목차 › 9. 배포 및 운영
배포 및 운영
main 머지가 곧 프로덕션 배포다 — 서버가 2분마다 확인해 pull · build · up 한다 · 인프라 오너 수택 E 진수택 · 최종 갱신 2026. 09. 22.
1. 배포 구성
프론트는 Vercel, 백엔드와 AI 서비스는 AWS EC2 한 대 위의 docker compose다.
둘 다 트리거는 같다 — main 머지. 파일은 브라우저가 S3 로 직접 올려
서버를 거치지 않고, 메일은 API 가 n8n 웹훅으로 넘긴다.
| 무엇 | 주소 · 구성 | 비고 |
|---|---|---|
| 서비스(프론트) | https://seuk.suvisdev.cloud |
Vercel. 환경변수 VITE_API_BASE 로 API 를 직접 부른다 |
| API | https://api.seuk.suvisdev.cloud |
Swagger /docs — 라우트 107개. HTTPS만, 8000 직접 접근은 막혀 있다 |
| 컨테이너 | db api lie-detection n8n caddy — 5개 |
전부 restart: unless-stopped — 재부팅 자동 복구. DB 이미지는 pgvector/pgvector:pg16 |
| 인스턴스 | EC2 서울 · Elastic IP · EBS 29 → 50GB | t3.micro → t3.small → t3.medium(4GB, 월 +$17) → t3.large. 올린 이유는 매번 메모리였다 |
| 파일 | S3 presigned URL 직접 업로드 | 버킷 CORS 에 프론트 출처를 넣어야 한다 — 빠지면 브라우저에서만 조용히 실패한다 |
| 메일 | api → n8n 웹훅 → 지메일 SMTP | n8n 은 같은 EC2 self-host. n8n Cloud 는 쓰지 않는다 — 지원자 개인정보가 외부 SaaS 를 거친다 |
인스턴스를 세 번 올렸고, 이유는 매번 메모리였다
t3.small(2GB)에서 t3.medium(4GB)으로 올린 것은 거짓말 탐지 서비스가 분석 1건에 574MiB 를 써서
api 와 같이 못 돌았기 때문이다(월 +$17). 그 다음 t3.large 까지 간 것은 실시간 면접 STT 였는데,
원인은 인스턴스 크기가 아니라 코드 기본값 하나였다 — faster-whisper 가 CPU 에서
float32 로 잡혀 로드에만 3,835MB 를 썼다. int8 을 명시하자 891MB(1/4)로 내려갔고
전사도 2.3배 빨라졌다. 메모리를 다섯 번 올리고 인스턴스를 옮긴 뒤에야 찾은 값이다.
디스크는 빌드 캐시와 컨테이너 이미지 때문에 EBS 를 29GB 에서 50GB 로 온라인 확장했다(무중단, 여유 27GB).
현재 타입은 단정하지 않는다 — 운영 문서에 남은 기록이 t3.medium 과 t3.large 로
갈려 있어, 확인되는 것은 위의 증설 이력까지다.
GPU 는 구성도에 없다 — 켜고 끄는 전제이기 때문이다
운영 EC2 에는 GPU 가 없고, 상시 GPU 가 필요한 경로도 없다. 거짓말 탐지는 CPU 로 돌고
(MediaPipe · scikit-learn), 실시간 전사는 ADR-0038 로 기본이 OpenAI API 가 됐다.
GPU 는 로컬 STT 와 Qwen 어댑터를 실제로 돌려 볼 때만 켠다 — lie-detection 을
VIT_DEVICE=cuda 로 옮긴 T4 실측이 STT 275ms/3초 음성 · ViT 75ms/crop 이고,
CPU 에서 7.5초 걸리던 전사가 약 1초로 줄며 8명 동시 접속에도 대기줄이 생기지 않았다.
그런데도 상시로 두지 않은 이유는 예산이다(위 표 — 24시간이면 월 ≈$470).
그래서 이 장의 구성도에는 GPU 가 없다 — 심사 서빙에 항상 떠 있어야 하는 것만 그렸다.
인프라 이전 — 개인 명의에서 역할 기반으로
2026-09-02 팀장 이탈 시점에 AWS 루트·IAM·GitHub org·Vercel·도메인·API 키·서버 .env 의 AWS 키가 전부
한 사람 개인 명의였다. 그가 빠지면 배포·메일·업로드가 멎고 아무도 고칠 수 없는 구조였고, 실제로
09-02 저녁 팀장 개인 키가 폐기되자 AI 기능이 전면 중단됐다 — /health 는 200 이고 읽기 API 도 200 인데
요약 호출만 0초에 422 로 떨어졌다. 0초는 LLM 호출이 시작조차 안 됐다는 뜻이다.
09-04 에 개인 AWS(서울)로 전체를 이전하고 IAM 을 suvisdev(관리) · arda-viewers(팀 열람) ·
arda-server(서버 키) 세 갈래로 나눴다. 키가 새면 서버 유저 하나만 재발급하면 된다.
AWS 표면적을 8종에서 3종으로
예산이 $400 · 2026-10-27 까지라, 그 이후에도 서비스가 이어 돌 수 있어야 했다. ADR-0031 로 EC2 · S3 · IAM 세 개만 남기고 나머지는 self-host 대안 뒤로 물렸다. 폐기가 아니라 스위치로 둔 것이 요점이다 — 코드에 이미 갈림길이 있어 값 하나로 갈아탄다.
| 쓰던 것 | 대체 | 스위치 | 상태 |
|---|---|---|---|
| SES | n8n + SMTP(지메일 앱 비밀번호) | MAIL_DISPATCH=n8n | 이관 완료 09-08 |
| SQS + 워커 | (불필요 — 발송 경로가 n8n 으로 바뀜) | — | 폐기 09-14 (#213) |
| CloudWatch | ~/metrics.log 파일 append + ~/status.sh | push-metrics.sh 재작성 | 대안 준비 |
| SNS 경보 | 관측 스크립트가 임계 넘으면 SMTP 발송 | — | 대안 준비 |
| S3 | MinIO | S3_ENDPOINT_URL | 코드에 이미 있음 |
| GPU | g4dn.xlarge 켜고 끄기 · 또는 로컬 GPU PC | GPU_TARGET=aws|local | 켜고 끄기 전제 — 24시간 가동은 월 ≈$470 이라 불가(8시간×20일이면 ≈$105) |
앵커 게시만은 이전 대상이 아니다. 서명 개인키가 서버가 아니라 GitHub Actions Secret 에 있어서, AWS 를 떠나도 그 경로는 그대로 돈다(2절).
온프레미스 3대를 구축했고, 심사 직전에 닫았다
AWS 예산 만료에 대비해 09-16 ~ 09-18 학원 PC 3대로 온프레미스 스택을 실제로 구축해 운영했다 — Cloudflare Tunnel 로 외부 노출, Ollama 로 Qwen 어댑터 3갈래 서빙, S3 자리에는 MinIO. 읽고 끝낸 검토가 아니라 돌아가는 물건이었다. 그런데 AWS 에서는 나지 않던 오류가 심사 직전에 누적됐다.
| 드러난 것 | 내용 |
|---|---|
| 브라우저 ↔ 내부 MinIO 불가 | presign URL 이 minio:9000(컨테이너 내부 호스트)이라 브라우저에서 이름이 안 풀렸다.
공개 지원폼에서 아무도 이력서를 못 올렸다. 파일 본문을 Postgres 에 저장하고 1회용 티켓 API 로
스트리밍하는 경로를 따로 만들어 막았다 |
| 로컬 sLLM 다단계 도구 호출 | list_postings 로 공고 id 를 확인하지 않고 create_application 을 한 번에 부르며
posting_id 를 추측했다. 안내를 줘도 반복 — 폐쇄의 결정타 |
| 예시 숫자 베끼기 | 프롬프트의 출력 예시 70/40/60 을 소형 모델이 그대로 베껴 전 지원자가 64~65점으로 수렴, 변별력 상실. Claude 는 베끼지 않아 AWS 에서는 드러나지 않았다 |
| JSON 깨짐 · 답변 소실 | 문자열 안 이스케이프 안 된 따옴표로 요약 파싱 실패(56명 중 2명 점수 NULL) · 면접 중 컨테이너를 재기동하자 메모리에만 있던 발화가 사라졌다 |
결론 — 심사 서빙은 AWS + Claude 로 확정 (2026-09-18)
코드 프리즈가 09-20 이었다. 프롬프트와 데이터로 매번 땜질해야 하는 로컬 모델을 그 앞에 두는 것은 감당할 수 있는 위험이 아니라고 판단해 온프레미스를 닫고 검증된 클라우드 스택(실 S3 · Anthropic Claude)으로 심사를 확정했다. 온프레 코드 · Ollama 어댑터 · GPU 이미지는 지우지 않고 R&D 자산으로 보존했고, 구축과 오류와 폐쇄 경위를 종합보고 문서로 남겼다.
만들어 봤기 때문에 접을 수 있었다는 것이 이 항목의 값이다. "온프레미스도 됩니다"가 아니라 "해 봤고, 이 지점에서 안 돼서 접었습니다"가 남았다.
2. CI/CD 파이프라인
CI 와 CD 는 목적이 다르다. CI 는 깨진 것을 보이게 하고, CD 는 초록인 것을 사람 손 없이 운영에 올린다. 둘을 잇는 규칙이 하나다 — CI 빨간불이면 머지할 수 없고, 머지되면 2분 안에 배포가 시작된다.
CI — 게이트가 아니라 "깨졌다는 사실만 즉시 보이게"
도입 계기는 명확하다. 2026-09-01 하루에 깨진 main 을 두 번 발견했고 둘 다 우연이었다(d7fdd10 · d96749e).
사람 승인 게이트를 세우는 대신 깨진 사실만 즉시 보이게 하는 쪽을 택했다 —
도메인 오너제에서 리뷰 게이트는 곧 병목이기 때문이다. push 와 PR 마다 5개 잡이 돈다.
| 잡 | 무엇을 돌리나 | 왜 그렇게 했나 |
|---|---|---|
| 백엔드 린트 | uvx ruff@0.16.7 check --select F |
헥사고날 이동 뒤 미정의 이름 2건이 프로덕션 500 까지 갔다 — 그 줄을 타는 테스트가 없어 pytest 는 초록이었다. F821·F401 만 켠다. 스타일 규칙까지 켜면 기존 파일 수백 줄이 빨개져 아무도 안 보는 빨간 CI 가 된다. 버전을 못 박은 것은 새 규칙이 생겨 남의 커밋에서 터지는 것을 막기 위해서다 |
| 백엔드 테스트 | 실제 PostgreSQL + pgvector 서비스에 alembic upgrade head →
모델↔이행 결과 비교 → pytest 1,165건 |
SQLite 목이 아니라 운영과 같은 DB 다. 모델↔이행 비교(2026-09-17)는 CI 가 이행 누락을 못 잡던 틈을 막는다 —
인덱스 이름까지 비교한다. pytest-timeout 60초는 pytest 가 24분간 조용히 멈춘 사고 뒤에 붙였다 —
멈춤을 침묵이 아니라 실패로 만든다 |
| 프론트 | oxlint + tsc -b + vite build (Node 22) |
빌드까지 돌린다 — 타입이 맞아도 번들이 깨지는 것은 Vercel 에서야 드러나기 때문이다 |
| AI 면접 서버 | pytest 3파일 (Python 3.13) |
실시간 전사 · 판정 중계 · 화자 매칭. ai/ 는 백엔드와 런타임이 달라 잡을 나눴다 |
| 앱 | flutter analyze + flutter test (테스트 파일 30개) |
앱은 배포가 APK 빌드라 운영 반영이 늦다 — 깨진 것을 머지 시점에 잡아야 한다 |
같은 브랜치에 연달아 push 하면 앞의 실행을 취소한다(cancel-in-progress) — 무료 러너 분을 아끼기 위해서다.
머지 경로는 2026-09-04 에 하나로 좁혔다: main 직접 push 와 force push 는 GitHub 브랜치 보호가 차단하고,
브랜치 → PR → CI 초록 확인 → 자체 머지다. 사람 승인 게이트는 없다.
CI 빨간불 = 머지 불가가 오너제의 유일한 안전망이므로, 테스트를 주석 처리해 통과시키는 것은 금지돼 있다.
CD — main 머지가 곧 프로덕션 배포
서버의 systemd 타이머 arda-deploy.timer 가 2분마다 origin/main 을 확인하고,
새 커밋이면 deploy-arda.sh 를 돌린다. 빌드 포함 총 5~10분. 로그는 ~/deploy.log.
스크립트 전체가 set -euo pipefail 이라 중간에 실패하면 거기서 멈추고 돌던 컨테이너는 계속 산다.
"배포해 주세요"라는 요청이 사라진 것이 이 장치의 실제 효과다.
fetch · ff-only merge — 새 커밋이 없으면 아무것도 하지 않고 끝낸다.
선택적 빌드 (2026-09-14) — $LOCAL..$REMOTE diff 로 무엇이 바뀌었는지 판정한다.
backend/ 나 compose 가 바뀌면 api, ai/lie-detection/ 이 바뀌면 그 이미지만.
문서·프론트만 바뀌면 재빌드가 없다. 옛 스크립트는 매번 둘 다 재빌드해
프론트만 고쳐도 10분이 걸리고 lie-detection 이 torch 200MB 를 다시 받았다.
alembic upgrade head — 기동 전에 돈다. 아래 별도 항목.
up -d --remove-orphans — --remove-orphans 는 09-14 에 붙였다.
없으면 compose 에서 지운 서비스가 계속 산다 — SQS 워커를 폐기한 뒤에도 arda-worker-1 이 남아
SQS 를 계속 폴링했다.
헬스 확인 — 최대 60초 재시도(3초 간격 20회). 10초에 한 번만 보던 때는 api 가 뜨는 중이라 가짜 WARN 이 기록됐다(2026-09-09).
uvicorn 워커 1개 재확인 (2026-09-17) — 세션 방 · 티켓 · 잠금 · STT 모델이
프로세스 메모리에 있어, 워커가 둘이면 지원자와 담당자가 다른 프로세스에 앉아 서로를 못 본다.
compose 에 --workers 1 이 있어도 실제 프로세스 수를 한 번 더 센다.
image prune · builder prune --keep-storage 3g —
배포마다 청소한다. 이 두 줄이 없던 09-07 이전에는 빌드 캐시가 12GB 쌓여 있었다.
빌드와 up 을 분리한 것은 규칙이다. 빌드가 깨져도 돌던 컨테이너가 안 죽는다 —
up -d --build 한 번으로 가면 빌드가 깨질 때 서비스가 같이 내려간다.
디스크 고갈로 api 를 실제로 내려야 했던 배포(다운타임 약 6분)를 겪고 스크립트에 박았다.
시연 직전에는 sudo systemctl stop arda-deploy.timer 로 배포를 잠시 멈출 수 있다.
프론트는 별도로 Vercel 이 main 머지 후 1~2분 내 자동 배포하고, 앱은
flutter build apk --dart-define=API_BASE=<API 주소> 로 굽는다.
alembic 이 CD 에 들어간 과정
계기는 사고다. 리비전 0009 가 만든 컬럼이 운영 DB 에 없어 /integrity/* 가 500 났다.
스키마는 create_all 로 만들지만 create_all 은 기존 테이블에 컬럼을 못 붙인다 —
pull 로는 DB 가 따라오지 않는다. 그래서 09-04 에 배포 스크립트에 alembic upgrade head 단계를 넣었다.
현재 리비전은 26개이고, 스키마를 바꾸면 코드 · ERD 문서 · 리비전이 같은 커밋이어야 한다.
처음에는 호스트에 코드를 마운트해 alembic 을 돌렸다. 09-07 에 이미지가 alembic 을 직접 가지게 바꿔
마운트를 없앴다(PR #20) — 서버 호스트에 저장소 사본이 있어야 이행이 되는 구조를 끊은 것이다.
이행이 실패하면 set -euo pipefail 로 배포가 거기서 멈춘다. 깨진 스키마 위에 새 코드가 올라가지 않는다.
그 전사(前史)가 손 SQL 시대였고 2026-09-01 에 끝났다. 운영 DB 를 alembic 관리 아래로 넣을 때
인계받은 안내는 "운영은 이행을 이미 마쳤으니 stamp 만 하면 된다"였는데,
실측해 보니 ai_summary_model 컬럼 폭이 50 이었다. 그대로 찍었으면 alembic 이
"0002 를 적용했다"고 기록하고 다시는 안 고쳤다. 컬럼을 200 으로 넓힌 뒤에 stamp 했고,
실제로 저장된 태그는 91자였다. stamp 는 선언이지 확인이 아니다 — 이 순서가 뒤바뀌었으면
그 차이가 영구히 숨었다.
서명 개인키를 서버 밖(GitHub Actions)으로 뺀 결정
제출물 무결성 앵커(ADR-0028)는 이력서·자소서의 SHA-256 해시 사슬 머리를 매일 09:10 공개 체인에 게시한다.
게시에는 서명이 필요하고, 서명에는 개인키가 필요하다. 그 키를 서버 .env 에 두지 않기로 했다(PR #13 · #15) —
서버가 털리면 공격자가 자기가 고친 내용으로 새 앵커를 찍을 수 있기 때문이다. 키를 GitHub Actions Secret 에 두고
게시를 Actions 가 대신하면, 서버는 해시를 만들 뿐 서명할 수 없다. 시간 증명은 OpenTimestamps 를 병행한다.
부수 효과가 하나 더 있다. AWS 를 종료해도 앵커 경로는 이전 대상이 아니다 — 서버 밖에 있으니 옮길 것이 없다.
운영 중 두 번 손을 봤다. 앵커 cron 이 12시간 만료 토큰으로 죽어 이틀 동안 아무도 몰랐다(09-05~06) —
매 실행마다 새로 로그인하게 고치고 실패하면 GitHub 이슈를 열게 했다(PR #21 · #25 · #31).
또 CHAIN_NETWORK 이름표와 실제 RPC 체인 식별자가 어긋나면 전송 전에 멈추게 해 잘못된 체인 오염을 막았다.
체인 자체는 Polygon Amoy → Ethereum Sepolia 로 옮겼다(PR #29) — Amoy 무료 faucet 6곳이 전부 막혀
가스를 구할 수 없었기 때문이다.
3. 모니터링 및 로그 관리
설계가 아니라 사고에서 나왔다. 앵커 cron 이 이틀 죽었는데 아무도 못 봤고(09-05~06),
"주 1회 ~/status.sh 를 본다"는 습관은 까먹는다. 그래서 사람이 안 봐도 되게 만들었다 —
임계를 넘으면 메일이 온다. 전체 비용은 0원이다(무료 구간 안).
안에서 보는 눈 — CloudWatch + SNS
서버 cron 이 10분마다 push-metrics.sh 로 숫자를 CloudWatch 에 보낸다(네임스페이스 Arda,
차원 Host=arda-api). 관측 에이전트를 따로 설치하지 않았다 — 이미 있는 aws-cli 만 쓴다.
알람은 6개이고 전부 SNS 주제 arda-alerts 로 메일을 쏜다.
알람 정의는 CloudFormation 템플릿 파일 하나(infra/cloudwatch-alarms.yml)다 —
콘솔 클릭 30번 대신 파일 업로드 한 번이고, 지우려면 스택을 지운다.
| 지표 | 뜻 | 알람 조건 | 누락 데이터 |
|---|---|---|---|
DiskUsedPercent | / 사용률 | ≥ 85 (1회) | 무시 |
BackupAgeHours | 마지막 백업 파일 나이 | ≥ 30시간 (1회) | 무시 — 하루 1회인데 빠졌다는 뜻 |
ApiHealthy | localhost:8000/health 가 ok 면 1 | < 1 (2회 연속 = 20분) | 경보로 취급 — 인스턴스가 통째로 죽어 숫자가 안 와도 울린다 |
MemUsedPercent | (total−available)/total | ≥ 90 (2회) | 무시 — 거짓말 탐지가 같은 서버에 있다 |
N8nHealthy | n8n /healthz 가 200 이면 1 | < 1 (2회 연속 = 20분) | 무시 — 인스턴스가 통째로 죽은 것은 ApiHealthy 가 울린다 |
StatusCheckFailed_System | EC2 시스템 상태 검사 (AWS 기본 지표) | ≥ 1 (2분 연속) | AWS 가 인스턴스를 자동 복구한다 — 같은 IP·디스크 유지 |
비용이 0인 근거는 CloudWatch 상시 무료 구간(사용자 지정 지표 10개 · 알람 10개 · API 100만 건/월)과 SNS 메일 월 1,000통 무료 안에 전부 들어가기 때문이다. 비용 0
밖에서 보는 눈 — GitHub Actions 헬스체크
서버 안에서만 보면 서버는 멀쩡한데 밖에서 안 닿는 경우를 못 잡는다.
그래서 GitHub 러너가 15분마다 공개 주소 두 개(api…/health · 프론트)를 밖에서 찌른다.
30초 간격으로 두 번 실패해야 알린다 — 한 번 삐끗한 것으로 알리지 않기 위해서다.
실패하면 이슈를 열거나 열린 이슈에 코멘트하고, 복구되면 코멘트를 달고 자동으로 닫는다.
둘을 조합해서 읽는 법
둘 다 울리면 서버, 밖에서만 울리면 DNS · TLS · Caddy · 보안그룹이다. 경보 두 벌을 따로 둔 이유가 이 한 줄이다 — 울린 조합이 곧 어디를 볼지를 알려 준다.
자동 경보와 별개로 사람이 보는 계기판도 있다. ~/status.sh 는
디스크 · 컨테이너 · 마지막 배포 · 마지막 백업 · 헬스 응답을 한 화면에 낸다.
읽기 전용이고, 주 1회 눈으로 훑는 용도다.
로그 관리 — 디스크가 차면 배포가 멈춘다
로그 관리가 운영 과제가 된 것은 29GB 디스크에서 로그 하나가 수 GB 로 부푼 사고 때문이다.
compose 에 로그 드라이버 옵션을 걸어 서비스당 20MB × 3개 로 상한을 뒀다.
운영 로그는 목적별로 나뉜다 — 배포 ~/deploy.log · 백업 ~/backup.log ·
지표 ~/metrics.log(10분마다 disk=..% backup_age=..h api=1 이 찍힌다).
가장 컸던 디스크 사고는 로그가 아니라 컨테이너 쓰기 레이어였다. 재배포 빌드가
No space left on device 로 죽었는데(디스크 97%, 590MB 남음), 원인은
컨테이너가 뜰 때마다 venv 를 쓰기 레이어에 다시 설치하고 있던 것이었다. 이미지는 382MB 인데
컨테이너 둘이 10.8GB 를 먹고 있었고, 게다가 런타임에 받은 것은 CPU 가 아니라 CUDA torch 였다.
CMD 를 /app/.venv/bin/… 직접 호출로 바꾸자 — 기동이 곧 실행이라 재설치가 일어나지 않는다:
| 항목 | 전 | 후 |
|---|---|---|
| 컨테이너 쓰기 레이어 | api 5.62GB · worker 5.18GB | 각 4.1kB |
| 이미지 | 382MB (venv 없음) | 1.42GB (CPU torch 포함) |
| 디스크 사용률 | 97% | 48% |
| 기동 → 서비스 | 약 2분 | 15초 |
"배포 직후 502 가 계속되면 기다릴 게 아니라 로그를 본다" 가 이 수치에서 나온 규칙이다 — 2분을 기다리는 것이 정상이던 시절이 끝났기 때문이다.
백업 — 서버가 털려도 백업은 못 지우게
EC2 한 대, EBS 볼륨 하나다. 인스턴스 사고 한 번이면 지원자 개인정보와 무결성 원장이 같이 사라지고,
앵커 증명은 잃으면 다시 만들 수 없다. 그래서 매일 04:00 KST 에 pg_dump | gzip 으로 뜨고
gzip -t 로 검증한 뒤 전용 S3 버킷에 올린다. 로컬에는 최근 3개만 두고,
S3 수명주기로 30일 뒤 자동 삭제해 스토리지 비용에도 상한을 뒀다. n8n 볼륨도 같은 cron 이 함께 올린다.
권한을 쓰기 전용으로 좁혔다
서버 IAM 유저 arda-server 는 백업 버킷에 PutObject 하나만 있다 — 읽기도 삭제도 없다.
서버가 털려도 백업을 지우거나 내려받지 못한다. 백업은 사고를 막는 장치인데,
사고를 낸 쪽이 백업까지 닿을 수 있으면 장치가 아니다. 버킷은 퍼블릭 차단 · SSE-S3 암호화이고
복원은 관리자만 한다. 복원할 때도 기존 DB 를 덮지 않고 arda_restore 라는 새 DB 에 넣어
alembic current 로 리비전을 확인한 뒤에 교체한다.
다만 복원 리허설은 아직 하지 않았다. 절차는 위처럼 문서에 적혀 있지만 실제로 복원해 본 기록이 없고, W4 중간 점검 항목으로 남아 있다. 복원해 본 적 없는 백업은 아직 백업이 아니다 — 이 장에서 "했다"고 말할 수 없는 자리다.
사고가 남긴 장치
아래는 전부 먼저 터진 뒤에 생긴 것이다. 설계 단계에서 예상해 넣은 것이 아니라, 한 번 당하고 규칙으로 승격시킨 것들이다.
깨진 main 을 하루 두 번, 둘 다 우연히 발견
장치 — CI 5개 잡. 이후 브랜치 → PR → 자체 머지로 main 에 닿는 경로를 하나로.
디스크 고갈로 배포가 죽음
장치 — 컨테이너 로그 상한 20MB×3 · 배포마다 이미지·캐시 prune · EBS 29→50GB. 재발 0.
pytest 가 24분간 조용히 멈춤
장치 — pytest-timeout 60초. 멈춤을 침묵이 아니라 실패로 만든다.
미정의 이름 2건이 프로덕션 500
장치 — CI ruff --select F. pytest 가 못 잡던 부류를 1초에 잡는다.
앵커 cron 이 이틀 죽었는데 아무도 모름
장치 — CloudWatch 경보 + 실패 시 GitHub 이슈 자동 개폐. 사람 습관에 기대지 않는다.
n8n 이 웹훅만 받고 죽어 메일이 queued 로 잔류
장치 — API 가 5분마다 밀린 메일을 SMTP 로 재발송(최대 3회, 초과는 failed 로 접어 사람이 확인).
.env 를 고쳤는데 반영이 안 됨
장치 — docker compose restart 는 env_file 을 다시 읽지 않는다.
up -d --force-recreate 규칙화.
compose 를 infra/ 로 옮기자 컨테이너·볼륨이 한 벌 더 생김
장치 — compose 는 프로젝트 이름이 없으면 폴더명을 쓴다. name: arda 를 박았다.
브라우저에서만 S3 업로드가 조용히 실패
장치 — 버킷 CORS. CORS 는 브라우저만 검사해 서버 간 PUT 은 통과한다 — "프론트 주소가 늘면 여기에도"를 문서 규칙으로.