Arda 개발 보고서
2026. 09. 29. github.com/Seuk-Team/Arda

목차 › 9. 배포 및 운영

배포 및 운영

main 머지가 곧 프로덕션 배포다 — 서버가 2분마다 확인해 pull · build · up 한다 · 인프라 오너 수택 E 진수택 · 최종 갱신 2026. 09. 22.

1. 배포 구성

프론트는 Vercel, 백엔드와 AI 서비스는 AWS EC2 한 대 위의 docker compose다. 둘 다 트리거는 같다 — main 머지. 파일은 브라우저가 S3 로 직접 올려 서버를 거치지 않고, 메일은 API 가 n8n 웹훅으로 넘긴다.

브라우저 ── https ──> Vercel (frontend/app · main 머지 시 자동 배포 · seuk.suvisdev.cloud) 브라우저/앱 ── https ──> Caddy(443, 인증서 자동) ──> FastAPI api:8000 ┐ PostgreSQL 16 + pgvector ├ EC2 · docker compose lie-detection (/ai/*) │ (api.seuk.suvisdev.cloud) n8n (/n8n/*) ┘ 파일: 브라우저 ── presigned URL ──> S3 (서버 미경유) 메일: api ── 웹훅 ──> n8n ──> SMTP (실패하면 api 가 5분마다 직접 재발송)
무엇주소 · 구성비고
서비스(프론트) 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 대안 뒤로 물렸다. 폐기가 아니라 스위치로 둔 것이 요점이다 — 코드에 이미 갈림길이 있어 값 하나로 갈아탄다.

쓰던 것대체스위치상태
SESn8n + SMTP(지메일 앱 비밀번호)MAIL_DISPATCH=n8n이관 완료 09-08
SQS + 워커(불필요 — 발송 경로가 n8n 으로 바뀜)—폐기 09-14 (#213)
CloudWatch~/metrics.log 파일 append + ~/status.shpush-metrics.sh 재작성대안 준비
SNS 경보관측 스크립트가 임계 넘으면 SMTP 발송—대안 준비
S3MinIOS3_ENDPOINT_URL코드에 이미 있음
GPUg4dn.xlarge 켜고 끄기 · 또는 로컬 GPU PCGPU_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 이라 중간에 실패하면 거기서 멈추고 돌던 컨테이너는 계속 산다. "배포해 주세요"라는 요청이 사라진 것이 이 장치의 실제 효과다.

1

fetch · ff-only merge — 새 커밋이 없으면 아무것도 하지 않고 끝낸다.

2

선택적 빌드 (2026-09-14) — $LOCAL..$REMOTE diff 로 무엇이 바뀌었는지 판정한다. backend/ 나 compose 가 바뀌면 api, ai/lie-detection/ 이 바뀌면 그 이미지만. 문서·프론트만 바뀌면 재빌드가 없다. 옛 스크립트는 매번 둘 다 재빌드해 프론트만 고쳐도 10분이 걸리고 lie-detection 이 torch 200MB 를 다시 받았다.

3

alembic upgrade head — 기동 전에 돈다. 아래 별도 항목.

4

up -d --remove-orphans — --remove-orphans 는 09-14 에 붙였다. 없으면 compose 에서 지운 서비스가 계속 산다 — SQS 워커를 폐기한 뒤에도 arda-worker-1 이 남아 SQS 를 계속 폴링했다.

5

헬스 확인 — 최대 60초 재시도(3초 간격 20회). 10초에 한 번만 보던 때는 api 가 뜨는 중이라 가짜 WARN 이 기록됐다(2026-09-09).

6

uvicorn 워커 1개 재확인 (2026-09-17) — 세션 방 · 티켓 · 잠금 · STT 모델이 프로세스 메모리에 있어, 워커가 둘이면 지원자와 담당자가 다른 프로세스에 앉아 서로를 못 본다. compose 에 --workers 1 이 있어도 실제 프로세스 수를 한 번 더 센다.

7

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회인데 빠졌다는 뜻
ApiHealthylocalhost:8000/health 가 ok 면 1< 1 (2회 연속 = 20분)경보로 취급 — 인스턴스가 통째로 죽어 숫자가 안 와도 울린다
MemUsedPercent(total−available)/total≥ 90 (2회)무시 — 거짓말 탐지가 같은 서버에 있다
N8nHealthyn8n /healthz 가 200 이면 1< 1 (2회 연속 = 20분)무시 — 인스턴스가 통째로 죽은 것은 ApiHealthy 가 울린다
StatusCheckFailed_SystemEC2 시스템 상태 검사 (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 은 통과한다 — "프론트 주소가 늘면 여기에도"를 문서 규칙으로.

← 목차로