운영은 서버 한 대에 컨테이너 네 개를 띄운다. 밖으로 여는 포트는 웹 80 · 443뿐이다 (443은 인증서가 있을 때만 열린다, 아래 "HTTPS").
브라우저 ──▶ web (Nginx :80) ──┬─ / → 정적 파일 (Vite 빌드, 실제 모드)
├─ /cases/... → index.html (SPA fallback, 라우터가 화면 선택)
└─ /api/... → backend (Spring Boot :8080) ──┬─▶ PostgreSQL (AWS RDS :5432, 10/28~)
└─▶ redis (:6379, DB 0 캐시 · DB 1 토큰)
| 파일 | 내용 |
|---|---|
docker-compose.prod.yml |
운영 구성 (web · backend · postgres · redis). 기본은 스키마만 |
docker-compose.real-seed.yml |
서버 전용 덧붙임 파일. 실제 사건 seed 폴더를 백엔드에 읽기 전용으로 연결 |
docker-compose.monitoring-agent.yml |
서버 전용 덧붙임 파일. 모니터링 에이전트(Alloy · node_exporter)와 backend의 로그 볼륨. 호스트로 여는 포트는 없다 (COMMON-43 · 64). .env의 MONITORING_AGENT=1이면 deploy.sh가 자동으로 함께 쓴다 — 아래 "모니터링 에이전트 (Grafana Cloud)" |
alloy-agent.alloy |
위 Alloy 설정. 백엔드 · 429 로그와 backend · node 지표를 Grafana Cloud(Loki · Prometheus)로 보낸다 (COMMON-43 · 62 · 64) |
docker-compose.pipeline.yml |
서버 전용 덧붙임 파일. AI 판결 파이프라인 서비스(관리자 사건 등록 A-14)와 backend의 PIPELINE_SERVICE_URL (COMMON-50). .env의 PIPELINE_SERVICE=1이면 deploy.sh가 자동으로 함께 쓴다. 켜는 시점은 팀 합의 후(COMMON-52) — 아래 "AI 판결 파이프라인 서비스" |
.env.example |
환경변수 예시. .env로 복사해 쓴다 (.env는 커밋하지 않음) |
smoke-test.sh |
/ · 화면 주소 · /api · 캐시 정책 · 보안 헤더 · 모듈 스크립트(.mjs) MIME · Let's Encrypt 확인 경로 확인. CI와 서버에서 같은 스크립트를 쓴다(보안 헤더 · .mjs MIME · 확인 경로는 서버에서는 경고만, CI에서만 실패). .mjs 찾기는 조각 200개 · 120초까지(SMOKE_MODULE_SEARCH_SECONDS로 초 조정, CI 실패 전환은 SMOKE_REQUIRE_MODULE_MIME=1) |
verify-origin-headers.sh |
Nginx X-Forwarded-Proto 전달 · forward-headers-strategy: native가 실제로 적용됐는지, 재발급 · 로그아웃 Origin 검증(OriginVerificationFilter)으로 확인한다. BASE_URL에 지금 떠 있는 배포만 확인하므로 운영에서는 배포 직후마다(smoke-test.sh 다음 줄) 돌리고, 동일 TLS 종료 구성의 스테이징이 있으면 배포 전에도 미리 돌려볼 수 있다. https 주소만 받고, 로그인 없이도 쓸 수 있다 (BE-95) |
deploy.sh |
서버 배포 한 번: sha 이미지로 교체 → 준비 대기 → 스모크 테스트 → 실패하면 직전 이미지로 되돌림 (BE-60) |
backup-db.sh |
운영 DB 백업 한 번: 임시 파일로 pg_dump → pg_restore --list 확인 → 최종 이름으로 옮김 → 7일 지난 파일 정리 (COMMON-17, 아래 "운영 DB 백업") |
env-set.sh |
.env 값 한 줄씩 바꾸기 · 점검(--secret 비밀값 숨김 입력, --check). 붙여 넣기 신호(ESC) 제거 · 키별 형식 검사 · 같은 키 한 줄 · 권한 600 유지 (BE-59, 아래 ".env를 고칠 때") |
cd-agent.sh |
자동 배포 확인: GHCR의 main 이미지가 바뀌었으면 deploy.sh 실행 (BE-60) |
cd/lawnambul-cd.service · .timer |
cd-agent.sh를 2분마다 부르는 systemd 설정 (서버에 한 번 설치) |
../frontend/Dockerfile |
웹 이미지 (Vite 빌드 → Nginx). 커밋 해시를 /version.json에 넣는다 |
../frontend/nginx/default.conf |
Nginx 설정: Rate limit zone · 기본 http server (localhost · IP · 발급 전 도메인) |
../frontend/nginx/snippets/app.conf |
앱 location (/api 프록시 · SPA fallback · 캐시 · /version.json). http · https server가 함께 include |
../frontend/nginx/snippets/api-proxy.conf |
/api 두 location(^~ /api/ · = /api)이 함께 include하는 본문 (Rate limit · 프록시 · 헤더 · 타임아웃) |
../frontend/nginx/snippets/acme-challenge.conf |
Let's Encrypt 도메인 확인 경로 (http 전용) |
../frontend/nginx/https.conf.template · 40-enable-https.sh |
인증서가 있을 때만 컨테이너 시작 시 https server를 만든다: 도메인 · www를 https://<도메인> 하나로 이동, 모르는 이름의 TLS는 거절 (BE-75) |
https/reload-web.sh |
인증서 갱신 뒤 Nginx 다시 읽기 (certbot 갱신 훅, 서버에 한 번 설치) |
../.github/workflows/deploy-images.yml |
이미지 빌드 → 스모크 테스트 → GHCR 업로드 → (main) 운영 배포 확인 |
develop·main으로 가는 PR: Actions가 이미지를 빌드해 이 구성으로 띄우고smoke-test.sh를 실행한다. 업로드하지 않는다.developpush(머지): 스모크 테스트를 통과한 이미지를 GHCR에 올린다. 운영에는 배포하지 않는다.mainpush(develop → mainPR 머지): 같은 방식으로 올리고, 운영 서버가 2분 안에 스스로 받아 배포한다 (아래 "자동 배포(CD)").ghcr.io/prgrms-aibe-devcourse/lawnambul-web·ghcr.io/prgrms-aibe-devcourse/lawnambul-backend·ghcr.io/prgrms-aibe-devcourse/lawnambul-pipeline(파이프라인 이미지는 오버레이를 켠 서버만 쓰지만 항상 올린다 — 켜는 순간 그 태그가 GHCR에 있어야 한다, COMMON-50)- 태그:
sha-커밋7자리(배포 · 되돌릴 때 쓰는 고정 태그) + 브랜치 이름develop·main(그 브랜치의 최신) - 이미지 라벨
org.opencontainers.image.revision에 커밋 전체 해시가 붙는다 (서버가 배포할 sha 태그를 이걸로 정한다)
- 서버는 이미지를 받아서 실행만 한다. 서버에서 Gradle · npm 빌드를 하지 않는다 (메모리 절약).
cd deploy
cp .env.example .env
# .env에서 POSTGRES_PASSWORD · REDIS_PASSWORD를 아무 값으로 채운다. 80번 포트가 쓰이고 있으면 WEB_PORT=8081
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
# 백엔드가 뜰 때까지 10 ~ 20초 정도 걸린다. 바로 실행해 /api가 실패하면 잠시 뒤 다시 실행한다
./smoke-test.sh http://localhost # 기본 WEB_PORT=80
# ./smoke-test.sh http://localhost:8081 # .env에서 WEB_PORT=8081로 바꾼 경우
docker compose -f docker-compose.prod.yml down # 데이터까지 지우려면 down -v준비: Docker Engine · Compose 플러그인 설치, 보안 그룹은 22(내 IP) · 80 · 443만 연다.
# 저장소에서 deploy 폴더만 있으면 된다 (compose 파일 · 스모크 테스트)
cd deploy
cp .env.example .env
# .env 수정
# IMAGE_PREFIX=ghcr.io/prgrms-aibe-devcourse/lawnambul
# IMAGE_TAG=develop
# POSTGRES_PASSWORD=$(openssl rand -base64 24) 로 만든 값
# REDIS_PASSWORD=$(openssl rand -hex 24) 로 만든 값 (16진수: 값에 `$`가 있으면 Compose가 변수로 읽어 비밀번호가 잘린다)
chmod 600 .env
# 패키지가 비공개면 먼저 로그인한다 (read:packages 권한 토큰)
# echo <토큰> | docker login ghcr.io -u <GitHub 아이디> --password-stdin
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --no-build
# 백엔드 기동(Flyway 마이그레이션 포함)을 기다린 뒤 스모크 테스트한다 (CI와 같은 방식, 최대 2분)
for i in $(seq 1 60); do
[ "$(curl -s --max-time 5 -o /dev/null -w '%{http_code}' http://localhost/api/v1/cases)" = "200" ] && echo "ready after $((i * 2))s" && break
sleep 2
done
./smoke-test.sh http://localhost되돌리기: ./deploy.sh --rollback (직전 배포 이미지로, 자동 배포가 켜져 있으면 타이머를 먼저 멈춘다 — 아래 "운영 중 쓰는 명령"). 자동 배포를 켠 뒤에는 .env의 IMAGE_TAG를 손으로 고치지 않는다 — deploy.sh가 관리한다.
.env는 주인 ubuntu, 권한 600(-rw------- ubuntu ubuntu)이어야 한다. deploy.sh · cd-agent · cron 백업이 모두 ubuntu로 돌기 때문에, 주인이 바뀌면 .env를 못 읽는다(10/26 실제로 백업 1회 실패). 드러나는 방식은 둘이 다르다.
| 대상 | 주인이 바뀌면 | 어디서 보나 |
|---|---|---|
cron 백업(backup-db.sh) |
❌ …을 읽을 수 없다 — 권한을 확인한다를 남기고 종료 코드 2로 멈춘다 |
tail ~/backups/backup.log — 백업이 안 생기면 여기부터 본다 |
| 자동 배포(cd-agent) | 파일이 있는지만 확인하고 읽기 권한은 보지 않는다. 값을 빈 값으로 읽은 채 진행해 조용히 실패할 수 있다 | journalctl -u lawnambul-cd.service -n 50 |
값 하나를 바꿀 때는 env-set.sh를 쓴다(BE-59). 10/28 RDS 전환 때 nano로 비밀값을 붙여 넣다 터미널 신호가 섞여 인증이 실패했다 — 아래 "RDS 전환" 문제 표.
cd /opt/lawnambul/app/deploy
./env-set.sh EXTERNAL_DB_USERNAME lawnambul_app # 일반 값 (이름이 *PASSWORD · *_KEY · *SECRET · *TOKEN이면 거절 → --secret)
./env-set.sh --secret OPENAI_API_KEY # 비밀값: 실행 → 입력 칸이 뜬 뒤 값을 복사해 붙여 넣는다 (화면 · 셸 기록에 안 남음)
./env-set.sh --clear ADMIN_BOOTSTRAP_PASSWORD # 값 비우기 (줄은 남김)
./env-set.sh --check # 권한 600 · ESC 섞임 · 같은 키 두 줄 · 키 형식 (값은 출력하지 않음)- 있는 키는 그 자리에서 바꾸고, 없으면 끝에 붙이고, 같은 키가 여러 줄이면 하나만 남긴다. 같은 폴더 임시 파일(600)에 쓴 뒤 바꿔치기한다.
- 형식이 틀리면 저장하지 않는다:
EXTERNAL_DB_URL(jdbc ·sslmode=require이상) ·GEMINI_API_KEY(AIza…또는 새 형식AQ.…) ·OPENAI_API_KEY(sk-…) ·*PASSWORD(12자 이상,$·#· 따옴표 · 공백 ·\금지) ·PIPELINE_SERVICE등 표시값(1). 모든 값에 공백 · 따옴표 ·$· 제어 문자 금지. .env주인과 실행 사용자가 다르면 멈춘다(다른 계정이면sudo -u ubuntu ./env-set.sh …).
여러 줄을 한꺼번에 볼 때는 nano로 연다.
sudo -u ubuntu nano .env # 다른 계정으로 접속했어도 ubuntu로 연다 → 주인 · 권한이 그대로 남는다
./env-set.sh --check # 저장한 뒤 꼭 점검한다
ls -l .env # -rw------- 1 ubuntu ubuntu … 인지 확인sudo nano·sudo vi로 열지 않는다. 편집기가 새 파일로 바꿔 쓰면 주인이root로 바뀐다.chmod 666·777등으로 권한을 넓히지 않는다. DB 비밀번호 · API 키가 서버의 모든 계정에 보인다.- 다른 팀원 계정에 수정이 필요하면 그 계정에
sudo권한을 주고 위 명령으로 고치게 한다(파일 권한은 그대로). - 이미 바뀌었으면 되돌린다:
sudo chown ubuntu:ubuntu .env && sudo chmod 600 .env - 값을 고친 뒤 반영은
./deploy.sh --reapply(이미지가 같아 자동 배포는.env만 바뀐 것을 반영하지 않는다).
운영 서버는 main에 머지된 것만 배포한다. GitHub Actions가 서버에 들어가지 않고, 서버가 2분마다 GHCR을 확인해 스스로 배포한다.
22번(SSH)은 배포 담당 IP만 열려 있고 IAM(Session Manager)을 만들 권한이 없어서, 새로 여는 포트 · AWS 권한 없이 하는 방식을 골랐다 (결정 기록).
develop → main PR 머지
└▶ Actions: 빌드 · 스모크 테스트 → GHCR (sha-xxxxxxx, main)
└▶ 서버 cd-agent.sh (2분마다): web:main의 커밋 라벨이 지금 배포(.env IMAGE_TAG)와 다르면
1) 서버 저장소 사본을 그 커밋으로 옮김 (Compose · 스크립트도 같은 버전)
2) deploy.sh sha-xxxxxxx: 이미지 받기 → 교체 → 백엔드 준비 대기 → 스모크 테스트
3) 실패하면 직전 이미지로 되돌리고, 그 커밋은 다시 시도하지 않는다 (고친 커밋이 올라오면 그때 배포)
└▶ Actions verify-deploy: 운영 /version.json이 이 커밋이 될 때까지 기다림(최대 15분) → 운영 주소로 스모크 테스트
BE-60이 develop에 머지된 뒤 서버에서 한다. 설치해도 main 이미지가 생기기 전까지는 아무것도 바꾸지 않는다.
cd /opt/lawnambul/app && git pull -q # BE-60 파일(deploy.sh · cd-agent.sh · cd/) 받기
sudo cp deploy/cd/lawnambul-cd.service deploy/cd/lawnambul-cd.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lawnambul-cd.timer
systemctl list-timers lawnambul-cd.timer # 다음 확인 시각이 보이면 정상
# 바로 한 번 확인 (main 이미지가 아직 없으면 출력 없이 끝난다)
sudo systemctl start lawnambul-cd.service; journalctl -u lawnambul-cd -n 20 --no-pager- Actions 쪽 확인을 켜려면 저장소 Settings → Secrets and variables → Actions → Variables에
DEPLOY_URL을 넣는다(저장소 관리자). HTTPS 적용 후에는https://naelawnambul.today(http://<도메인>은 https로 이동해 확인이 실패한다, 아래 "HTTPS"). 없으면 확인 job이 건너뛰기만 하고, 배포는 서버가 그대로 한다.
journalctl -u lawnambul-cd -n 50 --no-pager # 자동 배포 기록 (배포한 때만 남는다. 바뀐 게 없으면 출력 없음)
curl -s http://localhost/version.json # 지금 떠 있는 커밋
cd /opt/lawnambul/app/deploy
sudo systemctl stop lawnambul-cd.timer # 자동 배포 잠시 멈춤 (점검 · 장애 대응 중). 다시 켜기: start
./deploy.sh --rollback # 직전 이미지로 되돌리기 — 먼저 위처럼 타이머를 멈춘다
./deploy.sh sha-1a2b3c4 # 특정 커밋 이미지로 (develop 이미지를 시험 삼아 올릴 때도) — 역시 타이머를 먼저 멈춘다
rm .deploy/failed_rev # 실패해서 건너뛰는 커밋을 다시 시도하게 할 때- 손으로 되돌리거나 다른 sha를 올릴 때는 타이머를 먼저 멈춘다. 켜 둔 채면 2분 뒤
cd-agent.sh가 "지금 배포가main과 다르다"고 보고 다시main으로 배포한다. 원인을 고친 커밋이main에 올라가면 타이머를 다시 켠다(start). - 예외:
main이미지가 아직 없을 때(첫develop → mainPR 전)는cd-agent.sh가 아무것도 하지 않으므로 타이머를 켜 둔 채 시험해도 된다.
기록 (journalctl) |
뜻 · 할 일 |
|---|---|
↩ 되돌림 성공 |
새 이미지가 준비 · 스모크 테스트를 통과하지 못해 직전 이미지로 돌아왔다. 서비스는 정상. 위에 찍힌 backend · web 로그로 원인을 보고, 고친 커밋을 main에 올린다 |
⚠ … DB 마이그레이션을 적용했다 |
앱은 되돌렸지만 스키마는 새 버전 그대로다. 이전 앱은 모르는 새 마이그레이션을 무시하고 뜬다(Flyway 기본값). 스키마를 되돌릴지는 사람이 판단한다 (데이터 손상 위험). 아래 "마이그레이션 규칙"을 지켰다면 이전 앱이 그대로 뜬다 |
❌ 되돌림도 실패 |
서비스가 내려가 있을 수 있다. 바로 docker compose -f docker-compose.prod.yml ps · logs로 확인 |
❌ … 받지 못했다 … 토큰 만료 |
GHCR 로그인 토큰이 만료됐다(지금 토큰은 11/14경, 최종 제출 11/04 이후). docker login ghcr.io를 새 토큰으로 다시 한다. 다음 확인 때 자동으로 다시 시도한다 |
Actions verify-deploy 실패 (15분) |
서버가 배포하지 못했다는 뜻. 위 기록을 본다 |
마이그레이션 규칙 (main으로 가는 Flyway, #161 리뷰): 되돌림은 앱만 직전 이미지로 바꾸고 스키마는 그대로 둔다. 백엔드는 ddl-auto: validate라 이전 앱이 새 스키마에서 뜨려면 새 마이그레이션은 추가만 해야 한다(테이블 · 컬럼 추가, 기본값 있는 컬럼).
- 컬럼 · 테이블 지우기 · 이름 바꾸기, 기본값 없는
NOT NULL추가는 한 배포에 같이 넣지 않는다: 먼저 새 것을 추가하고 코드를 옮긴 배포가 안정된 뒤, 다음 배포에서 지운다. - 이 규칙을 어기면 새 앱이 실패했을 때 이전 앱도 검증에서 떨어져 되돌림까지 실패(
❌ 되돌림도 실패)한다.
주의
- 배포 중 API가 20 ~ 40초 끊긴다 (서버 한 대라 무중단이 아니다). 화면은 기존 웹이 계속 내주지만, 그동안 API를 부르면 502라 프론트는 "서버에 연결할 수 없어요"를 보여 준다(
api/client.js의 5xx 안내). 시연 · 발표 중에는main에 머지하지 않는다. - 되돌릴 때 Compose · 스크립트는 새 커밋 것을 그대로 쓴다 (이미지 태그만 되돌린다). Compose를 크게 바꾸는 PR(예: RDS 전환)은 머지 직후 한 번 지켜본다.
- 서버는 이미지 라벨의 커밋이 지금
main의 최신 커밋일 때만 배포한다(그 커밋의 스크립트를 서버가 실행하므로).main이 더 앞서 있으면 새 이미지가 올라올 때까지 건너뛴다 — 연달아 머지하면 마지막 커밋만 배포될 수 있다. - 배포에 성공하면 지금 · 직전 sha 이미지만 남기고 옛 이미지를 지운다(디스크 30GB). 더 옛날로 되돌리려면
./deploy.sh sha-xxxxxxx가 GHCR에서 다시 받는다. - 배포 실패는 서버 로그(journald)와 Actions
verify-deploy실패(커밋한 사람에게 GitHub 알림)로만 보인다. Grafana · Loki에는 아직 안 잡힌다(Alloy는 백엔드 로그와 웹의 429 전용 로그만 수집, 배포 로그는 필요하면 C 후속). develop머지는 운영에 나가지 않는다. 운영에 올리려면develop → mainPR을 올린다.- 켠 뒤에는 서버에서
git pull· 파일 직접 수정을 하지 않는다. 저장소 사본은cd-agent.sh가 배포하는 커밋으로 옮긴다(브랜치가 아니라 커밋에 고정, detached). 고칠 게 있으면 PR로main에 올린다. - 반영까지 최대 2 ~ 3분(확인 주기) + 기동 시간이 걸린다.
도메인 naelawnambul.today(www 포함)에 Let's Encrypt 인증서를 붙여 https로 연다. 왜 이렇게 나눴는지는 결정 기록.
http://localhost · http://<탄력적 IP> → 기본 http server: 그대로 응답 (deploy.sh · CI 스모크 테스트용)
http://naelawnambul.today · http://www. → 301 → https://naelawnambul.today (인증서가 있을 때만)
https://www.naelawnambul.today → 301 → https://naelawnambul.today (주소를 하나로: 쿠키가 호스트 단위)
https://naelawnambul.today → 앱 (http와 같은 snippets/app.conf: /api 프록시 · Rate limit · SPA)
https://<탄력적 IP> · 다른 이름 → TLS 연결 거절 (인증서를 보내지 않음)
http://<도메인>/.well-known/acme-challenge/ → 인증서 확인 파일 (이동하지 않음, 발급 · 갱신에 필요)
- 인증서는 서버 호스트의 certbot이 발급 · 갱신한다. 웹 컨테이너는
/etc/letsencrypt의live/·archive/만 읽기 전용으로 붙여 읽기만 한다(certbot 계정 키가 있는accounts/는 넣지 않는다). - https는 컨테이너가 시작할 때 켜진다.
40-enable-https.sh가.env의SERVER_DOMAIN과 인증서 파일을 확인해, 둘 다 있으면 https server를 만들고 아니면 http만 띄운다. 그래서 CI · 로컬 · 발급 전 서버는 지금과 똑같이 동작한다. - 인증서가 없어도 배포가 실패하지 않는다. https만 안 켜지고, 로그에
⚠ … 인증서가 없어 http만 연다가 남는다.
BE-75가 main에 배포된 뒤 서버에서 한다. 준비: DNS A 레코드(@ · www → 탄력적 IP), 보안 그룹 80 · 443 열림.
cd /opt/lawnambul/app/deploy
# 1) .env에 네 값 추가 (IMAGE_TAG처럼 deploy.sh가 관리하는 값이 아니므로 손으로 넣어도 된다)
# SERVER_DOMAIN=naelawnambul.today
# LETSENCRYPT_LIVE_DIR=/etc/letsencrypt/live
# LETSENCRYPT_ARCHIVE_DIR=/etc/letsencrypt/archive
# CERTBOT_WEBROOT=/var/www/certbot
# 폴더를 먼저 만들어 둔다 (certbot 설치 전이라 아직 없다. 없는 경로를 붙이면 Docker가 대신 만들지만, 오타를 바로 알아채도록 직접 만든다)
sudo mkdir -p /var/www/certbot /etc/letsencrypt/live /etc/letsencrypt/archive
# 2) 웹만 다시 만들어 위 폴더를 붙인다. --no-deps: backend를 건드리지 않는다
# (overlay 파일은 backend만 바꾸므로 웹은 base 파일만으로 다시 만들어도 같다)
docker compose -f docker-compose.prod.yml up -d --no-build --no-deps web
# 3) 확인 경로가 열렸는지: 404가 나와야 한다 (200이면 SPA로 새는 중 → 이미지가 BE-75 이전인지 /version.json 확인)
curl -s -o /dev/null -w '%{http_code}\n' http://naelawnambul.today/.well-known/acme-challenge/check
# 4) certbot 설치 → 시험 발급(횟수 제한 없음) → 실제 발급
sudo apt-get update && sudo apt-get install -y certbot
sudo certbot certonly --webroot -w /var/www/certbot \
-d naelawnambul.today -d www.naelawnambul.today \
--email <팀 메일> --agree-tos --no-eff-email --dry-run
sudo certbot certonly --webroot -w /var/www/certbot \
-d naelawnambul.today -d www.naelawnambul.today \
--email <팀 메일> --agree-tos --no-eff-email
# 5) 웹을 다시 시작해 https를 켠다 → 로그에 "✅ https 활성화"가 보여야 한다
docker compose -f docker-compose.prod.yml restart web
docker compose -f docker-compose.prod.yml logs --tail 20 web | grep 40-enable-https
# 6) 갱신 훅 설치: 갱신 직후 Nginx가 새 인증서를 읽게 한다 (없으면 90일 뒤 만료 경고)
sudo install -m 755 https/reload-web.sh /etc/letsencrypt/renewal-hooks/deploy/lawnambul-reload-web.sh
systemctl list-timers certbot.timer # apt의 certbot이 하루 두 번 갱신을 확인한다 (만료 30일 전부터 실제 갱신)
sudo certbot renew --dry-run --run-deploy-hooks # 갱신 시험 + 훅(reload-web.sh)까지 실행 → "[reload-web] ✅ … 모두 다시 읽음"
# --dry-run만 쓰면 훅이 실행되지 않아 reload 경로가 검증되지 않는다(PR #181 리뷰).
# "unrecognized arguments: --run-deploy-hooks"(옛 certbot)이면 --dry-run만 실행한 뒤 훅을 직접 실행해 확인한다:
# sudo /etc/letsencrypt/renewal-hooks/deploy/lawnambul-reload-web.sh- 저장소 변수
DEPLOY_URL을https://naelawnambul.today로 바꾼다(저장소 관리자, 위 "자동 배포(CD)").
curl -sI http://naelawnambul.today | head -3 # 301 + Location: https://naelawnambul.today/
curl -sI https://naelawnambul.today | head -1 # 200
curl -sI https://www.naelawnambul.today | grep -i '^location' # 301 → https://naelawnambul.today/
curl -sk --max-time 5 https://<탄력적 IP>/ ; echo "exit=$?" # 연결 거절(exit 35)이 정상: 모르는 이름은 TLS를 끊는다
curl -s http://localhost/version.json # 서버 안: 이동 없이 그대로 (deploy.sh 스모크 테스트 경로)
./smoke-test.sh https://naelawnambul.today # https로 전체 스모크 테스트
./verify-origin-headers.sh https://naelawnambul.today # 재발급 · 로그아웃 Origin 검증 전제조건 (BE-95, 배포 전 1회)
echo | openssl s_client -connect naelawnambul.today:443 -servername naelawnambul.today 2>/dev/null \
| openssl x509 -noout -issuer -subject -ext subjectAltName -dates # 발급자 Let's Encrypt · 만료일
# ↑ subjectAltName에 DNS:naelawnambul.today 와 DNS:www.naelawnambul.today 가 **둘 다** 있어야 한다 (PR #181 리뷰)
docker compose -f docker-compose.prod.yml logs web 2>&1 | grep 40-enable-https | tail -3
# ↑ "✅ https 활성화: naelawnambul.today, www.naelawnambul.today" 여야 한다. "www.… 이 없다" 경고가 있으면 www를 넣어 다시 발급
sudo certbot certificates # 서버의 인증서 목록 · 남은 기간| 증상 | 원인 · 할 일 |
|---|---|
로그 ⚠ … 인증서가 없어 http만 연다 |
발급 전이면 정상. 발급했다면 .env의 LETSENCRYPT_LIVE_DIR · LETSENCRYPT_ARCHIVE_DIR가 빠져 빈 볼륨이 붙은 것 → 값을 넣고 위 2) 명령 (둘 중 archive만 빠져도 링크가 끊겨 "없음"으로 보인다) |
로그 SERVER_DOMAIN이 없어 http만 연다 |
.env에 SERVER_DOMAIN이 없다 |
로그 ⚠ https 설정 검사(nginx -t) 실패 |
인증서는 있지만 Nginx가 읽지 못했다(인증서 · 개인키 쌍 불일치, 깨진 파일 등). http는 정상으로 뜬다. 바로 아래 줄의 nginx 오류 메시지를 보고 sudo certbot certificates로 인증서 상태를 확인한 뒤, 필요하면 다시 발급하고 restart web |
certbot Timeout during connect |
보안 그룹 80이 닫혀 있거나 DNS가 다른 IP를 가리킨다 (dig +short naelawnambul.today) |
certbot unauthorized · 404 |
CERTBOT_WEBROOT가 빠져 확인 파일이 컨테이너에 안 보인다 → 위 2) · 3) 다시 |
certbot too many failed authorizations |
실패를 반복해 1시간 제한. 이후에는 --dry-run으로 먼저 확인한다 |
| https 접속 거절 · http만 됨 | 위 5) 로그 확인. 인증서를 발급한 뒤 웹을 다시 시작하지 않았거나 2) 전에 발급했다 |
| 90일 뒤 브라우저 만료 경고 | 갱신 훅(6) 누락. sudo certbot renew 후 sudo /etc/letsencrypt/renewal-hooks/deploy/lawnambul-reload-web.sh |
로그 ⚠ 인증서에 naelawnambul.today 이 없어 … http만 연다 |
다른 도메인용 인증서다. http는 정상. 아래 줄에 찍힌 인증서 이름을 보고 -d naelawnambul.today -d www.naelawnambul.today로 다시 발급 → restart web |
로그 ⚠ 인증서에 www.naelawnambul.today 이 없다 |
-d www…를 빠뜨리고 발급했다. 루트 https는 정상, https://www 직접 접속만 경고 → www를 넣어 다시 발급(--expand) → restart web |
훅 로그 ❌ … 실패 (<컨테이너 ID>) |
그 컨테이너는 옛 인증서를 쓰고 있을 수 있다(배포 중 교체와 겹친 경우 등). docker compose -f docker-compose.prod.yml restart web |
- 익명 ID 쿠키
NLNB_AID는 항상Secure다(AnonymousIdCookie.java, API 명세 1-2). 브라우저는 http에서Secure쿠키를 저장하지 않는다 (localhost만 예외). 그래서 https로 열어야 체험 시작 이후 흐름(익명 ID가 필요한 API)이 동작한다. 관리자 세션 쿠키(SESSION_COOKIE_SECURE)도 같다. - IP · http(
http://<탄력적 IP>)로는 여전히 위 흐름이 동작하지 않는다. 사용자에게는 도메인 주소만 안내한다. - HSTS는 1일(
max-age=86400)로 켜 두었다 (COMMON-67). 값은 Nginx 한 곳(frontend/nginx/default.conf의$hsts_header)에서만 정하고, https 응답(화면 · 정적 파일 ·/api)에만 붙는다. 백엔드(Spring Security)는 HSTS를 끈다 — COMMON-67 전에는 백엔드가 API 응답에 1년 HSTS를 붙이고 있었다. - 보안 헤더: Nginx가 직접 내려주는 응답(화면 · 정적 파일 ·
version.json· 429)에X-Content-Type-Options: nosniff·X-Frame-Options: DENY·Referrer-Policy: strict-origin-when-cross-origin을 붙인다(snippets/security-headers.conf)./api응답은 백엔드가 같은 헤더를 붙인다. 스모크 테스트 "[보안 헤더]"가 확인한다(CI는 실패로, 서버 되돌림 때는 경고만). - CSP (COMMON-70, 1단계): 같은 응답에
Content-Security-Policy-Report-Only를 붙인다 — 막지 않고 위반만 브라우저 콘솔에 남긴다. 운영 주요 화면에서 개발자 도구 콘솔에Content Security Policy위반이 없으면 헤더 이름만Content-Security-Policy로 바꿔 켠다(2단계). 출처 목록 · 화면 목록은docs/decisions/COMMON-70.md. 외부 출처(스크립트 · 이미지 · 글꼴)를 새로 쓰면snippets/security-headers.conf를 함께 고친다. 스모크 테스트 "[보안 헤더]"가 두 이름 중 하나를 확인한다. - HSTS 늘리기: 첫 인증서 자동 갱신이 문제없이 돈 뒤(아래 확인)
$hsts_header를 지금 값 1일(86400) → 1주(604800) → 1년(31536000)으로 차례로 늘린다. 늘릴 때includeSubDomains를 함께 판단한다.
# 갱신이 정상으로 돌았는지 (서버). 마지막 갱신 시각 · 새 만료일을 본다
sudo journalctl -u certbot.timer -u certbot.service --since "-90 days" | tail -20
sudo certbot certificates # Expiry Date가 발급 후 90일보다 늘었으면 갱신된 것
# 운영 헤더 확인
curl -sI https://naelawnambul.today/ | grep -iE "strict|x-frame|x-content|referrer"
curl -sI https://naelawnambul.today/api/v1/cases | grep -iE "strict" # 한 줄만 나와야 한다(중복 없음)- 카카오 로그인(BE-62) 운영 앱의 Redirect URI는
https://naelawnambul.today/...로 등록한다.
한 IP가 /api를 짧은 시간에 너무 많이 부르면 Nginx가 백엔드 앞에서 막는다(t3.small 한 대 보호). zone은 ../frontend/nginx/default.conf, 적용(limit_req)은 ../frontend/nginx/snippets/api-proxy.conf에 있고 웹 이미지에 함께 들어간다.
| 설정 | 값 | 이유 |
|---|---|---|
| 범위 | /api만 |
화면 · 정적 파일은 Nginx가 바로 주는 거라 부담이 거의 없다 |
| 기준 | IP ($binary_remote_addr) |
로그인 전 서비스라 사용자를 구분할 다른 값이 없다 |
| 속도 | 평균 초당 20번, 순간 40번까지 기다림 없이 처리 | 혼자 쓰면 초당 1 ~ 3번(동시 호출 · 폴링 없음). 발표장처럼 여러 명이 같은 와이파이(같은 IP)를 써도 막히지 않게 넉넉히 |
| 넘으면 | 429 + JSON {"code":"TOO_MANY_REQUESTS", ...} + Retry-After: 1(공통 제한 기준, 전용 제한 경로는 아래) |
API 명세 1-4 형식. Nginx 기본 429(HTML)는 프론트가 "서버 오류"로 잘못 보여 준다 |
# 막힌 기록 보기 (zone api_per_ip, client가 막힌 IP)
docker compose -f docker-compose.prod.yml logs web 2>&1 | grep "limiting requests" | tail- 값 조정: 정상 사용자가 막히는 기록이 보이면
default.conf의rate(zone) ·snippets/api-proxy.conf의burst를 고쳐 PR → 새 웹 이미지로 반영한다. 서버에서 컨테이너 안 파일을 직접 고치지 않는다(다음 배포 때 사라진다). - 백엔드 지표(Prometheus)에는 429가 잡히지 않는다. 백엔드까지 가지 않고 Nginx에서 끝나기 때문이다. 서버에서는 위 로그로, Grafana에서는 아래 "429 로그 수집"으로 본다.
Nginx가 막은 요청(429)만 JSON 한 줄씩 따로 남기고, 모니터링 에이전트(MONITORING_AGENT=1)의 Alloy가 Grafana Cloud Loki로 보낸다(COMMON-64). Grafana 서비스 대시보드의 "Rate limit 429 (제한별)" 패널에서 본다.
| 항목 | 내용 |
|---|---|
| 파일 | 웹 컨테이너 /var/log/nginx-ratelimit/rate-limited.log (default.conf의 log_format rate_limited, if=$rate_limited) |
| 내용 | time · status · route · method · path(쿼리 제외) · ip |
| Loki 라벨 | job="web", route = auth_token(재발급 · 로그아웃) · admin_login · oauth(카카오 시작) · member_auth(아이디 로그인 · 가입, BE-113) · api(그 밖) — 값 5개로 고정. 경로 기준이라 zone과 1:1이 아니다(전용 · 공통 어느 쪽에 걸려도 그 경로로 센다). ip · path는 라벨로 올리지 않는다(BE-71 카디널리티 규칙) |
| 공유 | docker-compose.monitoring-agent.yml의 web-ratelimit-logs 볼륨(웹 쓰기 · Alloy 읽기 전용). 에이전트를 안 쓰는 서버는 컨테이너 안에만 남는다 |
| 크기 상한 | 41-trim-rate-limit-log.sh가 1분마다 보고 10MB(.env의 RATE_LIMIT_LOG_MAX_BYTES, 비우면 기본값 · 1보다 작으면 기본값)를 넘으면 비운다. 공격 중에도 디스크를 채우지 않는다. 비우기 직전 1분 안의 일부 줄은 Loki에 빠질 수 있다 |
| 평소 접속 기록 | 그대로 stdout(docker compose logs web) |
# 막힌 IP 상위 (Grafana Explore, Loki)
topk(10, sum by (ip) (count_over_time({job="web"} | json [1h])))
# 관리자 로그인에서 막힌 요청만
{job="web", route="admin_login"} | json
-
시각: Loki 엔트리 시각은 줄의
time(Nginx가 막은 시각)이다(stage.timestamp). Alloy가 멈췄다 켜져도 그 사이 429가 복구 시각에 몰리지 않는다. -
Alloy가 멈춘 사이 파일이 비워지면 그 구간 줄은 빠진다. 다시 켜진 Alloy가 비우기 전 위치부터 읽기 때문이다(PR #236 리뷰에서 확인: 새 줄이 그 위치보다 짧으면 전부, 길면 앞부분이 빠진다). 앞이 잘린 조각 줄은 Alloy가 버린다(
stage.drop). 상한(10MB)에 닿을 만큼 막히는 중에 Alloy까지 멈춘 경우에만 생긴다 — 그때 정확한 개수는 서버 오류 로그(by zone)로 본다. -
대시보드 반영: 운영 대시보드는 Grafana Cloud에
monitoring/grafana/dashboards/*.json을 가져와(Import) 쓴다. 파일과 자동으로 맞춰지지 않으므로,service.json등을 고쳐 머지하면 Grafana Cloud에서 같은 파일을 다시 가져온다(COMMON-64). 로컬 Grafana는 지금처럼 파일을 바로 읽는다. -
Docker의 socket이나
/var/lib/docker/containers를 Alloy에 붙이지 않았다. 다른 컨테이너의 로그 · 설정(환경변수 = 비밀값)까지 읽을 수 있게 되기 때문이다. 파일 하나만 볼륨으로 나눈다.
Rate limit은 Nginx가 실제 사용자 IP를 본다는 전제로 동작한다. 서버 구성에 따라 모든 요청이 Docker 내부 IP(172.x) 하나로 보이면, 모든 사용자가 초당 20번을 나눠 쓰게 된다. 배포한 서버에서 한 번 확인한다.
# 내 PC에서 서버로 요청을 하나 보낸 뒤, 서버에서 마지막 접속 기록의 첫 칸(IP)을 본다
docker compose -f docker-compose.prod.yml logs --tail 3 web
# 첫 칸이 내 PC의 공인 IP(내 PC에서 curl ifconfig.me 결과)와 같으면 정상, 172.x면 문제 → 이 문서 담당(D)에게 알린다- 앞에 로드 밸런서 · CDN을 두면 그 장비의 IP로 묶인다. 그때는
set_real_ip_from(그 장비 대역) +real_ip_header X-Forwarded-For를 같이 설정한다. - HTTPS(BE-75)의 443 server도 http와 같은
snippets/app.conf를 include하고, 그 안의 두/apilocation은snippets/api-proxy.conf하나를 include하므로 http · https ·/api·/api/모두에 같은limit_req·error_page 429가 걸린다./api설정은api-proxy.conf에서만 고친다. zone도 하나라 한 IP의 http · https 요청을 합쳐 센다. - 백엔드가 보는 IP · Host: BE-62에서
server.forward-headers-strategy=native를 켰다. 백엔드는 Nginx가 넘기는X-Forwarded-For·X-Forwarded-Proto·X-Forwarded-Host·X-Forwarded-Port를 믿으므로,snippets/api-proxy.conf가Proto·Host·Port는 Nginx가 본 값으로 덮어쓰고(비워 두면 클라이언트가 보낸 값이 그대로 넘어간다),X-Forwarded-For는 클라이언트가 보낸 값 뒤에 Nginx가 본 접속 IP를 덧붙인다($proxy_add_x_forwarded_for). 백엔드(RemoteIpValve)는internal-proxies밖의 가장 오른쪽 IP를 쓰므로 앞쪽에 위조한 값은 쓰이지 않는다(PR #229 리뷰). 위 "사용자 IP" 확인은 그대로 필요하다 — Nginx가 172.x만 보면 백엔드도 그 값을 본다
카카오 로그인 시작(/api/v1/auth/oauth2/authorization/)에는 /api 공통 제한에 더해 전용 제한 두 개를 건다(default.conf의 oauth_per_ip · oauth_all, snippets/app.conf의 전용 location). 콜백(/api/v1/auth/oauth2/callback/)은 전용 제한 없이 /api 공통 제한(IP별 초당 20번 · 순간 40번)만 받는다 — 콜백은 Redis에 새로 쓰지 않고, 시작과 같은 zone에 묶으면 같은 와이파이에서 여러 명이 동시에 로그인할 때 시작 요청이 용량을 다 써서 카카오 인증을 끝낸 사용자가 429를 본다(PR #229 리뷰).
| 설정 | 값 | 이유 |
|---|---|---|
IP별 (oauth_per_ip) |
분당 30번, 순간 30번 | 시작만 센다. 같은 와이파이에서 30명이 동시에 로그인을 시작해도 막히지 않게 |
전체 합계 (oauth_all) |
초당 10번, 순간 50번 | 시작 요청마다 백엔드가 Redis에 state를 5분 보관한다. IP를 여러 개 써서 IP별 제한을 나눠 피해도, 대기 중 state가 많아야 약 3,000개(약 3MB)로 묶여 Redis(200MB, noeviction)를 채우지 못한다 |
| 넘으면 | /api와 같은 JSON 429 |
@api_rate_limited |
- 재발급 · 로그아웃 · 관리자 로그인 제한은 아래 "토큰 재발급 · 관리자 로그인 전용 제한"(BE-63 2차).
카카오 로그인 시작과 같은 방식으로 /api 공통 제한에 더해 건다(default.conf의 zone, snippets/app.conf의 전용 location). 넘으면 같은 JSON 429(@api_rate_limited).
| 경로 | zone | 값 | 이유 |
|---|---|---|---|
POST /api/v1/auth/token/reissue · POST /api/v1/auth/logout |
auth_token_per_ip (둘이 나눠 씀) |
IP별 분당 30번, 순간 30번 | 정상 화면은 액세스 토큰 만료(15분) · 새로고침 때만 재발급하고 동시 재발급은 프론트가 하나로 묶는다. 같은 와이파이에서 여러 명이 동시에 새로고침해도 막히지 않게 카카오 시작과 같은 값 |
POST /api/v1/admin/auth/login |
admin_login_per_ip |
IP별 분당 5번, 순간 5번 | 백엔드에 로그인 실패 횟수 잠금이 없어 비밀번호 대입을 늦춘다(처음 5번 뒤 12초에 1번 ≈ 시간당 300번). 요청마다 Argon2id 검증(BE-90)이 CPU를 쓰는 것도 막는다 |
- 재발급에는 전체 합계 zone을 두지 않는다. 재발급은 기존 토큰을 지우고(
getAndDelete) 새 토큰 하나를 쓰므로 Redis 키가 늘지 않고, 쿠키가 없거나 모르는 토큰은 401로 끝나 아무것도 쓰지 않는다(RefreshTokenStore). 카카오 시작(요청마다 state를 새로 씀)과 다르다. - 재발급 429는 로그아웃이 아니다. 프론트(
api/client.js)는 재발급이 401일 때만 로그인 표시를 지운다. 리프레시 토큰은 그대로라 다음 요청 때 다시 재발급된다. - 관리자 CSRF 토큰 조회 · 내 정보 · 로그아웃(
/api/v1/admin/auth/csrf·me·logout)은 전용 제한 없이/api공통 제한만 받는다. - 전용 zone은 POST만 센다(
default.conf의$post_only_ip, PR #236 리뷰). 같은 경로의 GET이 전용 한도를 써서 같은 IP의 정상 POST가 막히지 않게 하기 위해서다. GET 등도/api공통 제한은 받는다. Retry-After는 경로별이다(default.conf의$rate_limit_retry_after): 공통 1초 · 재발급 · 로그아웃 · 카카오 시작 2초 · 관리자 로그인 12초. zone의rate를 바꾸면 이 값도 같이 바꾼다. 같은 경로라도 공통 제한에 걸린 경우에는 실제보다 조금 길게 안내된다. 프론트는 429를 자동으로 다시 보내지 않는다.
# 전용 zone에서 막힌 기록
docker compose -f docker-compose.prod.yml logs web 2>&1 | grep -E 'zone "(auth_token_per_ip|admin_login_per_ip|oauth_per_ip|oauth_all|member_login_per_ip|member_signup_per_ip|member_auth_all)"' | tail아이디 로그인(M-6) · 회원가입(M-5)에도 /api 공통 제한에 더해 전용 제한을 건다(default.conf의 member_* zone, snippets/app.conf의 전용 location). POST만 센다.
| 경로 | zone | 값 | 이유 |
|---|---|---|---|
POST /api/v1/auth/login |
member_login_per_ip |
IP별 분당 20번, 순간 20번 | 백엔드에 계정별 실패 잠금이 없어(docs/decisions/BE-113.md 결정 6) 비밀번호 대입을 늦춘다. 관리자(분당 5번)보다 넉넉한 이유는 같은 와이파이에서 여러 명이 동시에 로그인하기 때문 |
POST /api/v1/auth/signup |
member_signup_per_ip |
IP별 분당 10번, 순간 10번 | 가입은 중복 아이디(409)로 아이디가 있는지 드러나므로 대량 조회를 늦춘다 |
POST /api/v1/auth/kakao/signup |
member_signup_per_ip (아이디 가입과 같은 zone) |
IP별 분당 10번, 순간 10번 | 카카오 첫 가입 약관 동의(M-12, BE-145). 회원이 만들어지는 요청이라 가입과 같이 늦춘다. 해시를 쓰지 않아 합계 제한은 없다 |
| 두 경로 합계 | member_auth_all |
초당 10번, 넘으면 순간 20번까지 기다렸다가 보냄(nodelay 없음) | 요청마다 Argon2 해시(m=19MiB)가 메모리를 쓴다. 여러 IP로 나눠 와도 백엔드에서 동시에 도는 해시 수를 묶는다(oauth_all과 같은 이유). 20번을 넘게 쌓이면 429 |
- 관리자 로그인 zone과 따로 센다. 회원 로그인이 관리자 로그인 한도(분당 5번)를 쓰지 않는다.
Retry-After: 로그인 3초 · 가입 6초. 429 로그route는 둘 다member_auth다.- 운영 이미지와 같은
nginx:1.28-alpine으로 확인: 같은 IP에서 로그인 POST 30번 중 약 21번 통과 · 나머지 429, 가입 15번 중 11번 통과, 그 직후에도 관리자 로그인 · 재발급은 영향 없음, GET은 전용 제한을 받지 않음. KAKAO_CLIENT_ID는 HTTPS(BE-75) 적용 뒤에만 넣는다. state 쿠키 ·NLNB_RT·NLNB_AID가 모두Secure라 http에서는 브라우저가 저장하지 않아 로그인이 끝나지 않는다(PR #229 리뷰). 넣는 즉시 카카오 로그인 경로가 켜진다. 운영에는 HTTPS 뒤에 넣어 화면/auth/callback과 함께 동작하고 있다.
한 인스턴스를 논리 DB 두 개로 나눠 쓴다. DB 0은 캐시(지워져도 다시 만든다), DB 1은 토큰 저장소(지워지면 로그인이 풀린다).
| 설정 | 값 | 이유 |
|---|---|---|
| 영속화 | AOF everysec |
재시작 · 배포 뒤에도 토큰이 남는다 (최대 1초 유실) |
| 메모리 | maxmemory 200mb, 컨테이너 256m |
t3.small 2GB를 백엔드와 나눈다(PostgreSQL 컨테이너는 10/28 RDS 전환 뒤 외부 DB 오버레이가 기본 실행에서 뺀다 — 아래 "외부 DB 모드"). 남는 56MB는 AOF 재작성 여유분이라, used_memory가 150MB를 넘으면 점검한다(재작성 중 컨테이너가 메모리 초과로 죽을 수 있다) |
| 축출 | noeviction |
꽉 차도 토큰을 지우지 않는다. 대신 모든 키에 TTL이 있어야 한다 |
| 접근 | requirepass, 포트를 밖에 열지 않음 |
컨테이너 네트워크 안에서만 쓴다 |
비밀번호가 보이는 곳: 실행 명령 · 서버 ps에는 보이지 않지만, docker inspect의 환경변수로는 보인다(PostgreSQL 비밀번호도 같다). docker 명령을 쓸 수 있는 계정은 root와 같으므로 .env는 chmod 600, docker 그룹에는 배포 담당만 넣는다.
# 정책 · 메모리 확인 (REDISCLI_AUTH가 컨테이너에 있어 비밀번호를 입력하지 않는다)
docker compose -f docker-compose.prod.yml exec redis redis-cli CONFIG GET maxmemory-policy # noeviction
docker compose -f docker-compose.prod.yml exec redis redis-cli INFO persistence | grep aof_enabled # 1
docker compose -f docker-compose.prod.yml exec redis redis-cli INFO memory | grep used_memory_human
# DB별 키 수 · TTL 없는 키 찾기 (noeviction이라 TTL 없는 키가 쌓이면 메모리가 찬다)
docker compose -f docker-compose.prod.yml exec redis redis-cli INFO keyspace
docker compose -f docker-compose.prod.yml exec redis redis-cli -n 0 --scan | head # 캐시 키 일부 확인 (KEYS * 는 운영에서 쓰지 않는다)- 캐시만 비울 때:
redis-cli -n 0 FLUSHDB. 다른 DB는 그대로다.FLUSHALL은 토큰까지 지우므로 쓰지 않는다. - 메모리가 찼을 때(쓰기가
OOM command not allowed로 실패): 먼저 TTL 없는 캐시 키를 찾아 원인 코드를 고치고, 급하면 DB 0만 비운다. - 데이터 위치: 볼륨
redisdata.docker compose down -v는 이 볼륨도 지워 모든 사용자가 로그아웃되니 운영에서 쓰지 않는다. - Redis가 꺼졌을 때 백엔드 상태(BE-61 실측, Boot 4.1.1):
/actuator/health(전체)는 DOWN(503),/actuator/health/liveness·/readiness는 UP, API는 정상(실측은 로그인 기능 전). 로그인 · 재발급처럼 토큰 저장소를 쓰는 요청은 설계상 500이다(docs/tech-stack.md3장 "사용자 인증 기반"). 그래서 백엔드 healthcheck(docker-compose.prod.yml)는 전체 상태가 아니라 readiness를 본다 — 전체 상태를 쓰면 Redis 장애 때 백엔드까지 unhealthy로 본다.
- 기본 실행(
docker-compose.prod.yml만)은 스키마만 넣는다(FLYWAY_LOCATIONS=classpath:db/migration,.env로 바꿀 수 없게 고정). 처음 띄우면 사건 목록이 비어 있다("사건 0건"). - 개발용 가상 사건 시드(
classpath:db/seed)는 운영(EC2)에 넣지 않는다. 파일은 jar 안에 있지만(공개 저장소 파일) 운영FLYWAY_LOCATIONS에 없어서 읽히지 않는다. - 실제 사건은 비공개 저장소(
backend/private-seed서브모듈의 원본)의seed/폴더를 서버에 따로 받아 두고, 컨테이너에 읽기 전용으로 붙여 넣는다. 공개 저장소 · 이미지 · jar · CI에는 들어가지 않는다(BE-17, 10/20 운영 이미지로 확인 —docs/decisions/COMMON-17.md). .env에REAL_SEED_DIR가 있으면deploy.sh(자동 배포)가docker-compose.real-seed.yml을 자동으로 함께 쓴다. 손으로 Compose를 실행할 때도 두 파일을 같이 준다.
비공개 저장소 하나만 읽을 수 있는 Deploy key를 쓴다(개인 토큰 · 쓰기 권한을 서버에 두지 않는다). seed/만 받고 원본 판결문(source/) 등은 받지 않는다.
# 1) 서버에서 키 만들기 → 공개키(.pub) 한 줄을 비공개 저장소 관리자에게 전달
ssh-keygen -t ed25519 -f ~/.ssh/private_seed_ro -N "" -C "lawnambul-ec2-private-seed-readonly"
cat >> ~/.ssh/config <<'EOF'
Host github-private-seed
HostName github.com
User git
IdentityFile ~/.ssh/private_seed_ro
IdentitiesOnly yes
EOF
chmod 600 ~/.ssh/config
cat ~/.ssh/private_seed_ro.pub
# 관리자: 비공개 저장소 Settings → Deploy keys → Add deploy key ("Allow write access" 체크하지 않음)
# 개인키(~/.ssh/private_seed_ro)는 서버 밖으로 꺼내지 않는다
# 2) 연결 확인 → "Hi <소유자>/<비공개 저장소>! You've successfully authenticated" 가 나오면 성공
ssh -T github-private-seed
# 3) seed/만 받고, 공개 저장소(운영에 배포된 main)가 서브모듈로 고정한 커밋으로 맞춘다
# 비공개 저장소 develop 최신이 아니라 "main이 검증한 커밋"을 쓴다 (detached HEAD가 정상)
git clone --filter=blob:none --sparse --branch develop \
github-private-seed:<소유자>/<비공개 저장소>.git /opt/lawnambul/private-seed
cd /opt/lawnambul/private-seed && git sparse-checkout set seed
PTR=$(git -C /opt/lawnambul/app ls-tree HEAD backend/private-seed | awk '{print $3}')
git checkout -q --detach "$PTR" && git log -1 --format='%h %s' # PTR 앞 7자리와 같아야 함
# 4) .env에 경로 넣기 (.env.example에서 복사한 빈 줄이 있으면 그 줄을 바꾼다)
cd /opt/lawnambul/app/deploy
sed -i 's#^REAL_SEED_DIR=$#REAL_SEED_DIR=/opt/lawnambul/private-seed/seed#' .env
grep -n '^REAL_SEED_DIR=' .env # 정확히 한 줄
docker compose -f docker-compose.prod.yml -f docker-compose.real-seed.yml config | grep -E 'FLYWAY_LOCATIONS|/real-seed'
# 5) 백업 → 타이머 정지 → backend만 다시 만들기 → Flyway 적재 확인 → 타이머 재개
./backup-db.sh before-real-seed # 실패하면 여기서 멈춘다 (아래 "운영 DB 백업")
# 괄호 안(서브셸)을 어떻게 빠져나가든(정상 종료 · Ctrl+C · 명령 실패 · SSH 끊김) 타이머를 다시 켠다.
# 손으로 start를 빼먹으면 이후 main 머지가 자동 배포되지 않는다
(
trap 'sudo systemctl start lawnambul-cd.timer; systemctl is-active lawnambul-cd.timer' EXIT
trap 'exit 1' HUP INT TERM # 신호로 끝날 때도 EXIT를 거치게 한다 (EXIT만으로는 SSH 끊김(HUP)에 실행되지 않는다)
set -e
sudo systemctl stop lawnambul-cd.timer
docker compose -f docker-compose.prod.yml -f docker-compose.real-seed.yml up -d --no-build --no-deps backend
docker compose -f docker-compose.prod.yml -f docker-compose.real-seed.yml logs -f backend 2>&1 \
| grep -E 'Migrating|Successfully applied|ERROR|Started ' # "Started …" 후 Ctrl+C
)
systemctl list-timers lawnambul-cd.timer --no-pager # NEXT가 보여야 함
curl -s https://naelawnambul.today/api/v1/cases | head -c 200; echo # total이 0이 아니면 성공실제 사건 시드(비공개 저장소)는 팀이 합의한 뒤에만 외부 DB에 올린다. .env에 경로(REAL_SEED_DIR)와 합의 표시(EXTERNAL_DB_ALLOWS_REAL_SEED=1)가 둘 다 있어야 deploy.sh가 올린다 — 경로만 넣은 실수로 비공개 데이터가 올라가지 않게 한다. 사용 불가 데이터(LBox Open 판례, 라이선스상 사용 불가 — BE-54에서 시드에서 지움)는 합의와 별개로 올리지 않는다. 다시 들어오면 deploy.sh가 'LBOX' 행을 보고 멈춘다.
외부 DB 모드에서는 위 5)의 손 Compose 명령을 쓰지 않는다. -f docker-compose.external-db.yml이 빠져 backend가 같은 서버의 옛 postgres로 붙는다. deploy.sh --reapply가 오버레이를 모두 맞춰 준다.
전제: 이 변경이 main에 배포돼 있어야 한다(지금 서버의 deploy.sh가 EXTERNAL_DB_ALLOWS_REAL_SEED를 알아야 함). 진행 중에는 팀에 main 머지를 잠시 멈춰 달라고 알린다(자동 배포가 중간에 끼지 않게).
cd /opt/lawnambul/app/deploy
# 1) 시드 사본을 main이 고정한 커밋으로 맞춘다 (처음이면 위 "처음 한 번" 1) ~ 3))
cd /opt/lawnambul/private-seed && git fetch -q origin
PTR=$(git -C /opt/lawnambul/app ls-tree HEAD backend/private-seed | awk '{print $3}')
git checkout -q --detach "$PTR" && git log -1 --format='%h %s' # PTR 앞 7자리와 같아야 함
find -L seed -type f -name '*.sql' | sort # SQL 목록(하위 폴더 포함 — Flyway도 하위 폴더까지 읽는다)
grep -Ril --include='*.sql' "'LBOX'" seed/ || echo "LBox 없음 ✔"
cd /opt/lawnambul/app/deploy
# 2) 백업 (실패하면 여기서 멈춘다)
./backup-db.sh before-real-seed
# 3) .env에 두 줄 — ubuntu 사용자로 연다(주인 · 권한 600이 그대로 남는다). REAL_SEED_DIR= 빈 줄이 있으면 그 줄을 채운다
sudo -u ubuntu nano .env
# REAL_SEED_DIR=/opt/lawnambul/private-seed/seed
# EXTERNAL_DB_ALLOWS_REAL_SEED=1
grep -cE '^(REAL_SEED_DIR|EXTERNAL_DB_ALLOWS_REAL_SEED)=' .env # 2
ls -l .env # -rw------- ubuntu ubuntu
# 4) 적용 — 로그에 "외부 DB에 실제 사건 시드를 함께 적용한다"와 ✅가 나와야 한다
sudo -u ubuntu ./deploy.sh --reapply확인:
C="docker compose -f docker-compose.prod.yml -f docker-compose.external-db.yml -f docker-compose.real-seed.yml"
$C exec -T backend printenv FLYWAY_LOCATIONS # …,filesystem:/real-seed
$C logs backend | grep -E 'Migrating schema|Successfully applied|ERROR'
curl -s http://localhost/api/v1/cases | head -c 200; echo- 가상 사건 시드도 그대로 남아 함께 보인다.
.env에서 두 줄을 빼도 이미 들어간 데이터는 지워지지 않는다.- 운영 RDS에는 10/28 전환 때 이 두 줄로 시드가 들어가 있다(아래 "RDS 전환"). 10/26 ~ 10/28 Supabase 공용 DB에 올렸던 실제 사건은 프로젝트 삭제(COMMON-59)와 함께 지워졌다.
비공개 저장소에 머지해도 운영에 자동으로 반영되지 않는다. 운영 시드는 공개 저장소 main이 서브모듈로 고정한 커밋을 따른다. 그래서 순서는 이렇다.
- 비공개 저장소에 머지 (머지 전에 로컬
bootRun으로 확인) - 공개 저장소에서 서브모듈 포인터를 올리는 PR →
develop→main머지 (cd backend/private-seed && git checkout develop && git pull && cd ../.. && git add backend/private-seed) main자동 배포가 끝난 뒤 서버에서 시드 사본을 그 커밋으로 맞추고 backend를 다시 만든다
cd /opt/lawnambul/private-seed && git fetch -q origin
PTR=$(git -C /opt/lawnambul/app ls-tree HEAD backend/private-seed | awk '{print $3}')
git log -1 --format='지금 %h %s'
git checkout -q --detach "$PTR" && git log -1 --format='바꾼 뒤 %h %s'
# 이어서 backend를 다시 만든다. 위 5)의 docker compose 명령은 같은 서버 DB 모드용이라 RDS 운영에서는 외부 DB 오버레이가 빠진다
cd /opt/lawnambul/app/deploy
./backup-db.sh before-seed-update
sudo systemctl stop lawnambul-cd.timer
./deploy.sh --reapply # .env대로 외부 DB · 시드 오버레이를 붙여 backend를 다시 만든다 + 스모크 테스트
sudo systemctl start lawnambul-cd.timer- 서버에서
git pull(비공개 저장소 develop 최신)을 쓰지 않는다.main이 검증하지 않은 시드가 운영에 들어간다. - 시드 SQL이 실패하면 백엔드가 뜨지 않는다. 자동 되돌림은 이미지만 바꾸고 시드 폴더는 그대로라 되돌린 이미지도 같은 이유로 실패한다. 그때는 시드 사본을 바꾸기 전 커밋(위 "지금" 줄)으로 돌리고(
git checkout -q --detach <직전 커밋>) backend를 다시 만든다. 브랜치가 아니라 커밋에 고정하는 방식이라 그대로 두면 된다 — 원인을 고친 포인터가main에 올라오면 위 명령을 다시 실행한다. - 시드는 반복 마이그레이션(
R__)이고 "같은 제목의 사건이 있으면 건너뛰기" 구조다. 새 사건 추가는 바로 반영되지만, 이미 들어간 사건의 내용 수정은UPDATE문을 함께 넣어야 반영된다(비공개 저장소 README). - 실제 사건 폴더를 붙였다가 빼고 실행해도 기동된다(
ignore-migration-patterns).
파이프라인이 만든 loads/<시각>-<name>.sql은 Flyway가 읽지 않는 한 번 실행하는 파일이다. 사건은 DRAFT, AI 판결은 비공개로 들어가 사용자에게 보이지 않고, 관리자 후검수(A-11 ~ A-13)로 공개한다.
10/28부터 운영 DB는 RDS(외부 DB 모드)라 같은 서버 postgres 컨테이너가 없다. postgres:17-alpine 클라이언트 컨테이너로 붙고, 접속 값은 .env에서 읽는다(비밀번호를 손으로 붙여 넣지 않는다 — 10/28 붙여 넣은 값이 달라 인증 실패).
# 0) 직전 백업 + RDS 수동 스냅샷 (콘솔 → RDS → 데이터베이스 → 작업 → 스냅샷 생성, 이름 예: before-loads-1028 → "사용 가능"까지 대기)
cd /opt/lawnambul/app/deploy
./backup-db.sh before-load
# 1) 대상 파일 (서버 작업 폴더에 받지 않고 develop에서 꺼낸다 — seed/ 고정 커밋을 건드리지 않음)
cd /opt/lawnambul/private-seed && git fetch -q origin develop
git log -1 --format='%h %s' origin/develop
git ls-tree --name-only origin/develop loads/ # 이번에 넣을 파일만 고른다 (예전 날짜 파일이 같이 있다)
FILES=$(git ls-tree --name-only origin/develop loads/ | grep '^loads/20261028-.*\.sql$') # ← 이번 날짜로 바꾼다
echo "$FILES" | wc -l
# 2) 접속 함수 (값은 .env에서 읽고 출력하지 않는다)
E=/opt/lawnambul/app/deploy/.env
# deploy.sh · backup-db.sh · 아래 "되살리기"와 같은 기준: 같은 키가 여러 줄이면 마지막 줄, 따옴표 · 공백 · CR 제거
envv() { grep "^$1=" "$E" | tail -1 | cut -d= -f2- | tr -d "\"' \r"; }
U="$(envv EXTERNAL_DB_URL)"; U="${U#jdbc:postgresql://}"; HP="${U%%/*}"; DBN="${U#*/}"; DBN="${DBN%%\?*}"
H="${HP%:*}"; P="${HP##*:}"; [[ "$P" == "$HP" ]] && P=5432 # 주소에 포트가 없으면 5432
export PGPASSWORD="$(envv EXTERNAL_DB_PASSWORD)"
q() { docker run --rm -i -e PGPASSWORD postgres:17-alpine psql -At -v ON_ERROR_STOP=1 \
"host=$H port=$P dbname=$DBN user=$(envv EXTERNAL_DB_USERNAME) sslmode=require" "$@"; }
echo "select status, count(*) from legal_case group by status order by 1;" | q # 적재 전
# 3) 실행 — 하나라도 실패하면 멈춘다
# pipefail: git show(파일 읽기)가 실패해도 멈춘다. 없으면 q(psql)의 종료 코드만 보여 빈 입력을 성공으로 센다
set -o pipefail
ok=0; n=$(echo "$FILES" | wc -l)
for f in $FILES; do
if git show "origin/develop:$f" | q -q > /dev/null; then ok=$((ok+1)); echo "✅ $f"; else echo "❌ $f — 중단"; break; fi
done
echo "성공: $ok / $n"
echo "select status, count(*) from legal_case group by status order by 1;" | q # DRAFT가 넣은 건수만큼 늘어야 함
set +o pipefail; unset PGPASSWORD H P; unset -f q envv-
10/28 실적:
20261028-21건 → 21/21 성공,PUBLISHED 1 · DRAFT 21. -
서버 사본은 partial clone(
--filter=blob:none)이라git show가 그때 파일을 받아 온다(remote: Enumerating objects …가 보이는 게 정상). 받은 내용은.git/objects에 압축돼 남는다 — 작업 폴더에 파일을 만들지 않을 뿐 서버에 남지 않는 것은 아니다. 그래서.git은 다른 계정이 못 읽게 막아 둔다:chmod -R o-rwx /opt/lawnambul/private-seed/.git(10/28 적용. 컨테이너는seed/만 읽어 영향 없음). -
실패한 파일의 오류를 보려면 그 파일만
q -q대신q로 다시 실행한다. -
파일 전체가 한 트랜잭션이다. 파이프라인이 파일을
BEGIN;…COMMIT;으로 감싸 만들고(tools/ai-judgment/pipeline.py),ON_ERROR_STOP=1이라 중간에 실패하면 psql이 멈추며 전부 취소된다. 그래서psql -1을 따로 주지 않는다(파일 안BEGIN과 겹친다). 실패하면 원인을 고친 뒤 같은 파일을 다시 실행하면 된다 — 같은 제목의 사건 · 같은 실행(runKey)의 AI 판결은 건너뛰어 중복되지 않는다. -
loads/파일에는 원본 판결문(사건번호 · 원문)이 들어 있다. 서버 밖으로 옮기지 않고, 공개 저장소 · PR · Jira에 내용을 쓰지 않는다. -
후검수 뒤의 공개 · 승인 상태는 DB에만 있다(파일에 없음). 그래서 아래 백업이 그 상태의 원본이다.
| 언제 | 방법 | 보관 |
|---|---|---|
| 하루 1번 (운영 시간 중 첫 정각+17분) | 서버 cron으로 deploy/backup-db.sh → ~/backups/lawnambul-YYYY-MM-DD.dump |
7일 |
평일 17:47 (퇴근 전, cron 1-5) |
서버 cron으로 deploy/backup-db.sh evening → ~/backups/lawnambul-YYYY-MM-DD-1747-evening.dump |
7일(같은 정리 규칙) |
시드 갱신 · loads/ 실행 · 위험한 마이그레이션 직전 |
./backup-db.sh <작업 이름>을 손으로 한 번 |
7일(같은 정리 규칙) |
| RDS(10/28 ~) | RDS 자동 백업(매일 1회, 백업 기간은 콘솔 설정) · 위 backup-db.sh(외부 DB 모드라 RDS를 덤프)도 그대로 돈다 |
7일 |
loads/ 실행 · 큰 데이터 변경 직전(RDS) |
RDS 수동 스냅샷(콘솔 → 작업 → 스냅샷 생성) | 지울 때까지 (안 쓰면 지운다 — 저장 용량 요금) |
# 09:17 ~ 17:17 매시 확인하고, 오늘 백업 파일이 없을 때만 한 번 만든다 (crontab -e 로 한 줄). 결과는 ~/backups/backup.log
# 서버는 매일 09:00 ~ 18:00에만 켜져 있다(처음엔 평일만, 지금은 주말 포함) — 새벽 시각으로 두면 한 번도 돌지 않는다(10/26 확인).
# 매시 확인하므로 아침에 늦게 켜지거나 9시대 백업이 실패해도 다음 시각에 다시 시도한다
# umask 077: 백업 폴더 · 로그를 처음부터 나만 읽게 만든다(덤프 파일은 backup-db.sh가 umask 077로 만든다)
17 9-17 * * * umask 077; mkdir -p $HOME/backups && { [ -f $HOME/backups/lawnambul-$(date +\%F).dump ] || /opt/lawnambul/app/deploy/backup-db.sh >> $HOME/backups/backup.log 2>&1; }
# 퇴근 전 한 번 더: 아침 백업은 "어제 상태"라, 그날 작업분이 다음 날 아침까지 백업에 없다(PR #282 리뷰).
# 이름 붙은 백업은 파일 이름이 달라 위 "오늘 파일" 확인에 걸리지 않고, 같은 7일 정리에 들어간다
47 17 * * 1-5 umask 077; mkdir -p $HOME/backups && /opt/lawnambul/app/deploy/backup-db.sh evening >> $HOME/backups/backup.log 2>&1-
crontab 안에서는
%를\%로 쓴다(그대로 두면 cron이 줄바꿈으로 읽어 명령이 잘린다). -
서버가 꺼져 있는 시간(18:00 ~ 다음 날 09:00)에는 백업이 없다. 그 사이에는 데이터도 바뀌지 않는다.
-
저녁 백업(17:47)은 cron이 평일(
1-5)만이다. 주말에도 서버를 켜게 되면서 주말 저녁 백업이 필요하면1-5를*로 바꾼다. -
확인:
ls -lh ~/backups/ && tail -3 ~/backups/backup.log— 오늘 날짜 파일이 있어야 하고, 평일 18시 이후에는-evening파일도 있어야 한다. -
저장 위치를 바꾸려고 crontab에
BACKUP_DIR=을 두면 첫 줄의 확인 경로($HOME/backups/…)도 같이 고친다.backup-db.sh는BACKUP_DIR(기본~/backups)에 저장하는데 확인 경로는 고정이라, 하나만 바꾸면 매시 백업을 새로 만든다. -
backup-db.sh는 임시 파일에 먼저 덤프하고,pg_dump가 성공하고pg_restore --list로 형식을 읽을 수 있을 때만 최종 이름으로 옮긴다. 실패하면 임시 파일을 지우고 실패 코드로 끝나서, 반쯤 만들어진 파일이 백업처럼 남지 않는다. 확인:tail ~/backups/backup.log -
백업 파일에는 관리자 비밀번호 해시 등 운영 데이터가 있으므로 권한 600, 서버 밖으로 옮기지 않는다.
-
한계:
backup-db.sh파일은 같은 서버 디스크에 둔다. 서버(EBS)가 통째로 사라지면 그 파일도 같이 사라진다. 10/28 RDS 전환 뒤에는 RDS 자동 백업 · 스냅샷이 서버 밖 보관 역할을 한다. -
되살리기: 타이머 정지 · 백엔드 중지 후 한 트랜잭션으로 복구한다. 중간에 실패하면 전부 취소돼 반쯤 복구된 상태가 남지 않는다. 덤프를 만든 곳으로 되살린다 —
.env에EXTERNAL_DB_URL이 있으면(10/28부터 운영)backup-db.sh는 RDS를 덤프했으므로 RDS로, 없으면 같은 서버 postgres 컨테이너로 되살린다.외부 DB(RDS) 모드 —
backup-db.sh와 같은 클라이언트 컨테이너로 RDS에 복원하고, backend는--reapply로 외부 DB 오버레이를 포함해 다시 올린다(그냥up하면 로컬 DB로 붙을 수 있다):cd /opt/lawnambul/app/deploy sudo systemctl stop lawnambul-cd.timer docker compose -f docker-compose.prod.yml -f docker-compose.external-db.yml stop backend # 접속 값은 .env에서 읽고 출력하지 않는다(deploy.sh와 같은 기준: 같은 키는 마지막 줄) envv() { grep "^$1=" .env | tail -1 | cut -d= -f2- | tr -d "\"' \r"; } U="$(envv EXTERNAL_DB_URL)"; U="${U#jdbc:postgresql://}"; HP="${U%%/*}"; DBN="${U#*/}"; DBN="${DBN%%\?*}" H="${HP%:*}"; P="${HP##*:}"; [[ "$P" == "$HP" ]] && P=5432 export PGPASSWORD="$(envv EXTERNAL_DB_PASSWORD)" docker run --rm -i -e PGPASSWORD postgres:17-alpine \ pg_restore --clean --if-exists --single-transaction --exit-on-error --no-owner --no-privileges \ -d "host=$H port=$P dbname=$DBN user=$(envv EXTERNAL_DB_USERNAME) sslmode=require" < ~/backups/<백업>.dump unset PGPASSWORD; unset -f envv ./deploy.sh --reapply # 같은 이미지로 외부 DB 오버레이까지 다시 적용 + 스모크 테스트 sudo systemctl start lawnambul-cd.timer
같은 서버 DB 모드(
EXTERNAL_DB_URL이 없을 때):sudo systemctl stop lawnambul-cd.timer docker compose -f docker-compose.prod.yml stop backend docker compose -f docker-compose.prod.yml exec -T postgres \ sh -c 'pg_restore --clean --if-exists --single-transaction --exit-on-error -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < ~/backups/<백업>.dump docker compose -f docker-compose.prod.yml -f docker-compose.real-seed.yml up -d --no-build --no-deps backend sudo systemctl start lawnambul-cd.timer
같은 서버의 postgres 컨테이너 대신 외부 PostgreSQL에 붙는다. 10/22 ~ 10/28 오전은 Supabase 공용 DB(테스트용, COMMON-59 — 종료 · 삭제)에 붙었고, 10/28 11:22부터 운영은 AWS RDS다(같은 구성에서 접속 값만 바꿈 — 아래 "RDS 전환"). postgres 컨테이너(512m)가 빠져 서버 메모리 여유가 생긴다.
| 항목 | 내용 |
|---|---|
| 스위치 | deploy/.env의 EXTERNAL_DB_URL · EXTERNAL_DB_USERNAME · EXTERNAL_DB_PASSWORD. URL이 있으면 deploy.sh가 docker-compose.external-db.yml을 자동으로 겹친다 |
| 오버레이가 하는 일 | backend 접속 값을 외부 DB로 바꾸고, depends_on에서 postgres를 뺀다(redis만 기다림). postgres는 profiles로 기본 실행에서 뺀다 — 정의 · pgdata 볼륨은 남겨 되돌릴 수 있다 |
| 적용 | .env만 바꾼 변경은 자동 배포가 하지 않는다(이미지 태그가 같다) → ./deploy.sh --reapply |
| 연결 수 | backend 기본 5개(EXTERNAL_DB_POOL_SIZE). 운영 backend 하나만 붙는다(Supabase 때는 팀원 로컬과 연결 수를 나눠 써서 작게 잡았다) |
| 백업 | backup-db.sh가 외부 DB의 public 스키마를 덤프한다(postgres:17-alpine 클라이언트 컨테이너, 처음 한 번 이미지를 받음) |
| 필요 버전 | Docker Compose 2.24.4 이상(!override). deploy.sh가 먼저 확인한다 |
deploy.sh가 배포 전에 멈추는 경우(종료 코드 4, 아무것도 바꾸지 않음): EXTERNAL_DB_USERNAME · PASSWORD가 없음 / URL에 sslmode=require(또는 verify-ca · verify-full)가 없음 / REAL_SEED_DIR이 있는데 EXTERNAL_DB_ALLOWS_REAL_SEED=1이 없음(팀 합의 표시 — 위 "외부 DB(RDS)에 실제 사건 시드 올리기") / REAL_SEED_DIR 폴더가 없거나 SQL 파일이 없음 / 하위 폴더를 끝까지 찾지 못함(권한 · 링크 순환) / 배포 계정이 못 읽는 SQL 파일이 있음 / 시드에 LBox 행('LBOX')이 있음 — 하위 폴더까지 검사한다(Flyway filesystem: 위치가 하위 폴더까지 읽기 때문) / Compose가 2.24.4 미만.
처음 켠 절차(10/22, Supabase 공용 DB)는 Supabase 종료(COMMON-59)와 함께 지웠다. 접속 값을 바꾸는 절차는 데이터를 옮기는지에 따라 다르다.
- 빈 DB로 시작(데이터를 옮기지 않음) — 아래 "RDS 전환"과 같다: 백업 ·
.env사본 →EXTERNAL_DB_*세 값 교체 →./deploy.sh --reapply→ 확인. 10/28 RDS 전환이 이 경우다(빈 RDS에 Flyway · 시드로 시작). - 기존 데이터를 유지 — 백업만으로는 새 DB에 아무것도 들어가지 않는다. 순서: 타이머 정지 · backend 정지(쓰기 막기) →
./backup-db.sh before-db-move(지금.env의 DB를 덤프) →.env사본 →EXTERNAL_DB_*를 새 DB 값으로 교체 → 위 "운영 DB 백업"의 외부 DB 되살리기 명령(접속 값을.env에서 읽으므로 새 DB로 복원된다) → 행 수 확인 →./deploy.sh --reapply→ 확인 → 타이머 재개.
옛 postgres 컨테이너는 전환해도 저절로 멈추지 않으므로(512m을 계속 쓴다) 확인 뒤 docker compose -f docker-compose.prod.yml stop postgres로 멈춘다. 데이터는 pgdata 볼륨에 남는다.
cd /opt/lawnambul/app/deploy
cp -p ~/backups/env-*-before-external-db.bak .env # 또는 .env에서 EXTERNAL_DB_* 세 줄 삭제
./deploy.sh --reapply # postgres가 다시 뜨고 backend가 원래 주소로 붙는다되돌리면 외부 DB에 쓰인 데이터는 같은 서버 DB에 없다(옮기지 않는다). --reapply가 실패해도 자동으로 되돌리지 않는다 — 되돌릴 대상이 이미지가 아니라 사람이 바꾼 .env이기 때문이다.
같은 오버레이에서 .env의 EXTERNAL_DB_*만 RDS 값으로 바꾸고 ./deploy.sh --reapply했다. Supabase 데이터는 옮기지 않았다 — 빈 RDS에 Flyway(스키마) · 비공개 시드 R__ 6개 · loads/ 실제 판례만 넣었다(실데이터만, 이유는 docs/decisions/BE-59.md "2차"). 엔드포인트 · 비밀번호 · 보안 그룹 ID는 저장소에 쓰지 않는다(서버 .env와 담당자 비밀번호 관리자에만).
| 항목 | 설정 |
|---|---|
| 엔진 | PostgreSQL 17 (vector 0.8 설치 가능 — 확장은 마스터로 미리 만든다) |
| 클래스 · 저장소 | db.t4g.micro(프리 티어 템플릿), 단일 AZ, gp3 20GiB(자동 확장 끔) |
| 연결 | 퍼블릭 액세스 없음. 콘솔 "EC2 컴퓨팅 리소스에 연결"이 만든 보안 그룹 한 쌍(RDS 쪽 · EC2 쪽)으로만 붙는다 — 둘 다 지우지 않는다 |
| 보호 | 저장 암호화, 삭제 방지 켬, 자동 백업 7일 |
| DB · 계정 | DB lawnambul. 마스터 lawnambul(관리용: 확장 · 계정) / 앱 lawnambul_app(backend · Flyway · 백업 · loads/) |
| 비밀번호 | 둘 다 Mac에서 openssl rand -hex 24(48자, $ 없음) → 비밀번호 관리자 |
R=<RDS 엔드포인트>
printf '\e[?1004l' # 창 전환 신호 끄기 (아래 문제 표 1번)
read -rs -p '마스터 비밀번호: ' PGPASSWORD && echo
PGPASSWORD="${PGPASSWORD//$'\e[O'/}"; PGPASSWORD="${PGPASSWORD//$'\e[I'/}"; PGPASSWORD="${PGPASSWORD//$'\e[200~'/}"; PGPASSWORD="${PGPASSWORD//$'\e[201~'/}"; export PGPASSWORD
echo "길이 ${#PGPASSWORD} / hex 아닌 글자 $(printf '%s' "$PGPASSWORD" | tr -d '0-9a-f' | wc -c)" # 48 / 0
read -rs -p '앱 계정 비밀번호: ' APPPW && echo # 같은 정리 · 길이 확인을 한다
APPPW="${APPPW//$'\e[O'/}"; APPPW="${APPPW//$'\e[I'/}"; APPPW="${APPPW//$'\e[200~'/}"; APPPW="${APPPW//$'\e[201~'/}"
echo "길이 ${#APPPW} / hex 아닌 글자 $(printf '%s' "$APPPW" | tr -d '0-9a-f' | wc -c)" # 48 / 0
# 확장은 마스터만 만들 수 있다 → 미리 만들면 V11의 CREATE EXTENSION IF NOT EXISTS가 권한 없이 넘어간다
# GRANT lawnambul_app TO lawnambul: 마스터가 스키마 주인을 앱 계정으로 바꾸려면 그 역할의 구성원이어야 한다
printf "%s\n" \
"CREATE EXTENSION IF NOT EXISTS vector;" \
"CREATE ROLE lawnambul_app LOGIN PASSWORD '${APPPW}';" \
"GRANT CONNECT, TEMP ON DATABASE lawnambul TO lawnambul_app;" \
"GRANT lawnambul_app TO lawnambul;" \
"ALTER SCHEMA public OWNER TO lawnambul_app;" \
| docker run --rm -i -e PGPASSWORD postgres:17-alpine psql -v ON_ERROR_STOP=1 "host=$R port=5432 dbname=lawnambul user=lawnambul sslmode=require"
# 앱 계정으로 확인 → lawnambul_app / public|lawnambul_app / vector|0.8.x / 생성 권한 OK
printf '%s\n' "select current_user;" \
"select nspname, pg_get_userbyid(nspowner) from pg_namespace where nspname = 'public';" \
"select extname, extversion from pg_extension where extname = 'vector';" \
"create table _perm_check(id int); drop table _perm_check; select '생성 권한 OK';" \
| docker run --rm -i -e PGPASSWORD="$APPPW" postgres:17-alpine psql -At -v ON_ERROR_STOP=1 "host=$R port=5432 dbname=lawnambul user=lawnambul_app sslmode=require"
unset PGPASSWORD APPPWcd /opt/lawnambul/app/deploy
./backup-db.sh before-rds # Supabase 마지막 백업
install -m 600 .env ~/.env.before-rds.$(date +%m%d) # 되돌리기용 사본은 저장소 폴더 밖(홈)에 둔다
./env-set.sh EXTERNAL_DB_URL 'jdbc:postgresql://<RDS 엔드포인트>:5432/lawnambul?sslmode=require'
./env-set.sh EXTERNAL_DB_USERNAME lawnambul_app
./env-set.sh --secret EXTERNAL_DB_PASSWORD # 앱 계정 비밀번호
./env-set.sh REAL_SEED_DIR /opt/lawnambul/private-seed/seed # 비공개 시드 R__ (위 "외부 DB에 실제 사건 시드")
./env-set.sh EXTERNAL_DB_ALLOWS_REAL_SEED 1
./env-set.sh ADMIN_BOOTSTRAP_EMAIL '<관리자 이메일>' # 새 DB에는 관리자가 없다 (확인 후 비운다)
./env-set.sh --secret ADMIN_BOOTSTRAP_PASSWORD
./env-set.sh --check
./deploy.sh --reapply # 빈 DB라 Flyway가 처음부터 돈다 (10/28: 38개, 약 2초)10/28에는 같은 .env 수정에서 파이프라인(PIPELINE_SERVICE=1 · PIPELINE_EXTRACT_MODEL · OPENAI_API_KEY · GEMINI_API_KEY, COMMON-52)도 함께 켰다. 키는 넣기 전에 무료 조회로 확인했다: curl -s -o /dev/null -w '%{http_code}' -H @<(printf 'x-goog-api-key: %s\n' "$k") https://generativelanguage.googleapis.com/v1beta/models → 200 (OpenAI는 https://api.openai.com/v1/models, Authorization: Bearer).
docker ps --format '{{.Names}}\t{{.Status}}\t{{.Ports}}' # backend가 방금 시작 · healthy
docker logs lawnambul-backend-1 2>&1 | grep -E 'Successfully applied|now at version|ERROR'
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/api/v1/cases # 200 (localhost:8080은 호스트에 안 열려 000이 정상)위 "loads/ 넣기" 2)의 q 함수로 DB를 직접 본다.
select count(*) from pg_stat_activity where usename = 'lawnambul_app' and pid <> pg_backend_pid(); -- backend 연결 풀 (10/28: 5)
select status, count(*) from legal_case group by status; -- 실데이터만 (10/28 시드 직후: PUBLISHED 1)
select count(*) from member; -- 0 (Supabase 회원은 옮기지 않음, 재가입)
select count(*) from admin_account; -- 1 (관리자는 member가 아니라 여기)사이트에서 관리자 로그인을 확인한 뒤 부트스트랩 값을 비운다.
./env-set.sh --clear ADMIN_BOOTSTRAP_EMAIL && ./env-set.sh --clear ADMIN_BOOTSTRAP_PASSWORD && ./deploy.sh --reapply되돌리기: install -m 600 ~/.env.before-rds.<MMDD> .env && ./deploy.sh --reapply → Supabase로 돌아가는 절차였다. Supabase를 지웠으므로(COMMON-59) 이 되돌리기는 더 쓸 수 없다 — RDS 스냅샷 · 자동 백업으로 복구한다.
| 증상 | 원인 · 조치 |
|---|---|
비밀번호가 맞는데 password authentication failed · 길이가 48이 아님(54 등) |
① 창 전환 신호: 비밀번호를 복사하러 다른 창에 다녀오면 터미널이 ESC[O · ESC[I를 입력에 끼운다. printf '%s' "$PGPASSWORD" | tr -d '0-9a-f' | od -c로 섞인 글자만 본다 → 위처럼 지우거나 printf '\e[?1004l'로 끈다. ② 붙여 넣기 신호 ESC[200~ · ESC[201~(bash가 프롬프트마다 다시 켠다) — bind 'set enable-bracketed-paste off'. ③ 클립보드가 바뀜: 값을 복사한 뒤 채팅에서 명령어를 복사하면 명령어가 붙여 넣어진다(28 · 15 · 247자 등). 명령을 먼저 실행하고 입력 칸이 뜬 뒤 값을 복사한다. env-set.sh --secret은 ①② 제거와 형식 검사를 한다 |
Mac 터미널에서 zsh: unknown file attribute · wc: #: open |
zsh는 대화형에서 # 뒤를 주석으로 보지 않는다. Mac에서는 주석 없이 실행한다 |
curl http://localhost:8080/... → 000 |
정상. backend 8080은 호스트에 열지 않는다. http://localhost/api/...(web 경유) |
member 0명인데 관리자 로그인은 됨 |
관리자는 admin_account 테이블이다. pg_stat_user_tables.n_live_tup은 통계 추정치라 새 DB에서 0으로 나올 수 있다 → count(*)로 본다 |
Gemini 키가 AQ.로 시작 |
AI Studio 새 형식이다. 헤더(x-goog-api-key) · 쿼리(key=) 방식 모두 200 확인(10/28) |
git show 때마다 remote: Enumerating objects |
서버 사본이 partial clone이라 정상(위 "loads/ 넣기") |
| 증상 | 확인 · 조치 |
|---|---|
--reapply 후 backend가 healthy가 되지 않음 · 로그에 password authentication failed |
EXTERNAL_DB_USERNAME이 앱 계정(lawnambul_app, 마스터 계정 아님)인지, 비밀번호가 맞는지 |
로그에 Max client connections reached 등 연결 수 오류 |
EXTERNAL_DB_POOL_SIZE를 줄이거나, 팀원 로컬 backend가 여러 개 떠 있지 않은지 본다 |
로그에 Schema-validation: missing … |
공용 DB가 main보다 뒤처졌다(담당자가 develop 기준 --migrate를 아직 안 함). 운영 backend의 Flyway가 맞추지만, 실패하면 담당자에게 알린다 |
| 서비스가 이상하다 · 원인을 못 찾겠다 | 위 "되돌리기"로 같은 서버 postgres로 돌아간다(데이터는 pgdata에 그대로) |
파일 업로드는 S3 버킷 하나에 저장한다. 버킷 · IAM 설정은 AWS 콘솔에서 인프라 담당이 관리하고, 이 저장소에는 비밀값을 두지 않는다.
- 버킷: 서울 리전(ap-northeast-2), 기본 암호화 SSE-S3. 퍼블릭 액세스는 차단하고, 사건 사진 경로(
case-photos/*)만 버킷 정책으로 공개 읽기를 연다(COMMON-74, 아래 "사건 사진 공개 읽기")- 백엔드는 서명 없는 객체 주소를 DB에 저장하고 브라우저가 그 주소로 바로 받는다. Presigned URL은 쓰지 않는다
- 인증: EC2 인스턴스에 연결한 IAM 역할로 한다. 역할의 정책은 이 버킷의 객체 읽기 · 쓰기 · 삭제 · 목록만 허용한다(최소 권한)
AWS_ACCESS_KEY_ID·AWS_SECRET_ACCESS_KEY를.env· Compose · 코드에 넣지 않는다. 넣으면 SDK가 역할보다 그 키를 먼저 쓴다
- 컨테이너에서 역할 받기: 인스턴스 메타데이터가 IMDSv2 필수라서, 응답 홉 제한이 1이면 컨테이너 안에서는 자격증명을 받지 못한다 → 2 이상이어야 한다
- 설정 이름: Compose가
S3_BUCKET_NAME·AWS_REGION을 넘기고,application.yml의cloud.aws.*가 읽는다 - 업로드 용량: Nginx
client_max_body_size(frontend/nginx/snippets/app.conf, 20m)와 Springspring.servlet.multipart.max-request-size(20MB)를 같게 둔다. 바꿀 때는 두 곳을 함께 고친다
cd /opt/lawnambul/app/deploy
# .env.example에서 복사한 빈 줄이 있으면 그 줄을 바꾼다 (IMAGE_TAG처럼 deploy.sh가 관리하는 값이 아니므로 손으로 넣어도 된다)
# S3_BUCKET_NAME=<운영 버킷 이름>
# AWS_REGION=ap-northeast-2
vi .env
# backend만 다시 만든다 (환경변수는 컨테이너를 새로 만들어야 반영된다)
# ⚠️ 지금 운영에서 쓰는 -f 조합을 그대로 쓴다 — real-seed · monitoring-agent overlay를 빼면 그 설정이 사라진다
# (위 "운영 데이터" 5)와 .env의 MONITORING_AGENT 참고)
docker compose -f docker-compose.prod.yml -f docker-compose.real-seed.yml up -d --no-build --no-deps backend관리자가 올린 사건 사진(대표 · 발단 · 결론)은 공개 화면에서 브라우저가 S3에서 직접 받는다. 이 설정이 없으면 S3가 403을 주고, 화면은 오류 없이 공용 컷으로 바꿔 끼워 **"사진을 올려도 기본 이미지로 나온다"**로 보인다. 왜 Presigned URL이 아니라 공개 읽기인지는 docs/decisions/COMMON-74.md.
- S3 → 버킷 → 권한 → 퍼블릭 액세스 차단(버킷 설정) → 편집에서 4개 중 버킷 정책 관련 2개만 끈다
- 끈다: "새 퍼블릭 버킷 또는 액세스 지점 정책을 통해 부여된 …" · "임의의 퍼블릭 버킷 또는 액세스 지점 정책을 통해 부여된 …"
- 켠 채로 둔다: ACL 관련 2개("새 ACL을 통해 …" · "임의의 ACL을 통해 …"). 공개는 아래 정책 한 곳에서만 연다
- 계정 수준 퍼블릭 액세스 차단(S3 왼쪽 메뉴)이 켜져 있으면 버킷 설정보다 우선한다. 다만 계정 수준 설정은 그 계정의 모든 버킷에 걸리므로, 함부로 끄지 않는다. 꺼야 한다면 먼저 계정의 다른 버킷 정책에 공개 문이 없는지 확인하고, 다른 버킷을 계속 막아야 하면 계정 수준은 그대로 두고 CloudFront + OAC(
docs/decisions/COMMON-74.md대안 C)로 간다
- 권한 → 버킷 정책 → 편집에 아래를 넣는다. 기존 정책 문(Statement)이 있으면 지우지 말고 배열에 이 문만 추가한다
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadCasePhotos",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::<운영 버킷 이름>/case-photos/*"
}
]
}- 여는 것은
case-photos/아래 객체 읽기(s3:GetObject) 하나뿐이다. 목록(s3:ListBucket) · 쓰기 · 삭제는 열지 않는다 — 목록이 닫혀 있어 주소를 모르면 다른 사진을 찾을 수 없다 - 로컬 실행(
S3_DIR_PREFIX=local)이 올린local/case-photos/*는 열지 않는다. 로컬에서도 사진을 확인해야 하면 그때Resource를 배열로 바꿔 추가한다 - 기본 암호화가 SSE-S3라 공개 읽기가 된다. SSE-KMS로 바꾸면 익명 요청이 KMS 키를 쓸 수 없어 다시 403이 난다
# 0) 사건 사진 공개 읽기 (COMMON-74) — 올린 사진 주소는 200, 버킷 목록과 경로 밖 객체는 403
# 사진 주소는 DB에서 하나 꺼낸다(값은 화면에서만 보고 공유하지 않는다):
# select thumbnail_url from legal_case where thumbnail_url like '%case-photos/%' limit 1;
curl -s -o /dev/null -w '%{http_code}\n' "<위에서 꺼낸 사진 주소>" # 200
curl -s -o /dev/null -w '%{http_code}\n' "https://<운영 버킷 이름>.s3.ap-northeast-2.amazonaws.com/" # 403 (목록은 닫혀 있다)
# 경로 밖: 실제로 있는 객체로만 확인한다. 목록 권한이 없어 없는 키도 403이 나므로, 지어낸 키의 403은 증거가 안 된다
# (예: 로컬 실행이 올린 local/case-photos/… 객체 → 403). 경로 밖 객체가 없으면 버킷 정책의 Resource가 case-photos/* 하나뿐인지 콘솔에서 본다
# 1) 컨테이너 안에서 IAM 역할 이름이 보이면 정상 (안 보이면 역할 연결 · 홉 제한 확인)
docker exec lawnambul-backend-1 sh -c '
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60");
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/iam/security-credentials/; echo'
# 2) 백엔드가 버킷 이름을 받았는지 (값이 비어 있으면 .env 확인)
docker exec lawnambul-backend-1 printenv S3_BUCKET_NAME
# 3) Nginx 업로드 상한이 20m인지
docker exec lawnambul-web-1 nginx -T 2>/dev/null | grep client_max_body_size| 증상 | 원인 · 조치 |
|---|---|
| 업로드가 413 | Nginx client_max_body_size가 작다 → 위 확인 3) |
| 1MB가 넘는 파일만 실패 (백엔드 오류) | Spring multipart 상한 → application.yml spring.servlet.multipart |
Unable to load credentials |
역할이 연결되지 않았거나 홉 제한이 1 → 위 확인 1) |
AccessDenied |
역할 정책의 버킷 이름 · 권한 확인 (인프라 담당) |
| 사진을 올렸는데 공개 화면 · 관리자 미리보기에 안 보임(공용 컷 · 깨진 이미지) | 브라우저 Network 탭에서 S3 요청이 403이면 공개 읽기 정책이 없거나 퍼블릭 액세스 차단이 정책을 막고 있다 → 위 "사건 사진 공개 읽기" · 확인 0) |
t3.small 2GB에 base 구성이 1.75GB를 할당해(backend 1g · postgres 512m · redis 256m) 순간 급증을 받을 자리가 없다. swap이 없으면 그때 커널 OOM이 가장 큰 프로세스(backend)를 바로 죽인다. swap은 "죽는 대신 잠깐 느려지는" 안전망이고, 용량을 늘리는 수단이 아니다.
swap을 켜도 backend · postgres · redis 자신은 여전히 swap을 쓰지 않는다(아래 표 "컨테이너"). 효과는 한 단계 건너서 온다 — 호스트 프로세스 · 파이프라인 쪽이 swap으로 밀려나 물리 메모리가 비는 덕분에, 서버 전체 OOM이 backend를 고를 일이 줄어든다. backend가 자기 mem_limit(1g)을 넘으면 지금처럼 재시작된다.
| 항목 | 값 | 이유 |
|---|---|---|
| 파일 | /swapfile 2GB (권한 600) |
메모리와 같은 크기. 디스크 30GB를 이미지(배포마다 약 700MB, 두 벌 유지) · DB · 백업과 나눠 쓴다 |
vm.swappiness |
10 (/etc/sysctl.d/99-lawnambul-swap.conf) |
평소에는 swap을 쓰지 않고, 메모리가 정말 모자랄 때만 쓴다(Ubuntu 기본 60은 여유가 있어도 미리 내린다) |
| 재부팅 | /etc/fstab에 한 줄 |
고칠 때 /etc/fstab.bak-* 사본을 남긴다(fstab이 깨지면 부팅이 막힌다) |
| 컨테이너 | backend · postgres · redis는 swap 안 씀(memswap_limit = mem_limit) |
값을 안 주면 Docker가 mem_limit만큼 swap을 더 허용한다. JVM 힙 · Redis가 swap으로 밀리면 죽지도 않고 수십 초씩 멈추는 "느린 서버"가 된다 → 지금과 같게(넘으면 OOM 재시작) |
| 파이프라인 | swap 허용(memswap_limit 없음) |
extract는 관리자가 기다리는 일괄 작업이라 느려져도 된다. 순간 급증 때 죽는 것보다 낫다 |
| web(Nginx) · 모니터링 에이전트 | 제한 없음 | web은 mem_limit이 없어 memswap_limit만 둘 수 없다(Docker가 메모리 제한 없는 swap 제한을 거부한다). 수십 MB라 급하지 않아 COMMON-52(파이프라인 반영)의 메모리 재조정 단계에서 mem_limit과 함께 본다. alloy(192m) · node-exporter(64m)도 열려 있지만 밀려나도 로그 · 지표 전송이 느려지는 정도다 |
순서: 이 변경(memswap_limit)이 main에 머지돼 자동 배포가 끝난 뒤에 켠다. 먼저 켜면 그 사이 backend · postgres · redis가 swap을 mem_limit만큼 더 쓸 수 있다(memswap_limit은 컨테이너를 새로 만들 때 적용된다). 배포가 끝났는지는 아래 "확인"에서 backend · postgres · redis 세 개가 모두 memory.swap.max 0인지로 본다(하나라도 미실행이거나 0이 아니면 아직 켜지 않는다).
cd /opt/lawnambul/app/deploy
./setup-swap.sh --check # 지금 상태만 본다 (메모리 · swap · swappiness · fstab · 디스크 · Docker swap 상한 지원)
sudo ./setup-swap.sh # 없으면 만들고 켠다. 이미 맞게 돼 있으면 바꾸지 않는다(다시 실행해도 안전)- 디스크 여유가 swap + 5GB보다 적으면 만들지 않고 멈춘다(
MIN_FREE_AFTER_GB). 옛 이미지 · 백업부터 정리한다. - 이미 켜진 swap이 있으면 새로 만들지 않고 swappiness만 맞춘다. 이때
/swapfile이 꺼진 채 남아 있어도/etc/fstab에는 등록하지 않는다(실제로 켠 파일만 등록). /etc/fstab에/swapfile줄이 이미 있는데 옵션이noauto면, 아무것도 바꾸기 전에 멈춘다. fstab이 깨지면 부팅이 막히므로 스크립트가 고쳐 쓰지 않는다 — 사본을 만든 뒤 그 줄의 옵션을sw로 직접 고치고 다시 실행한다./swapfile이 이미 있는데 크기가 요청(2GB)과 바이트 단위로 다르면 멈춘다(함부로 지우지 않는다).memswap_limit은 컨테이너를 새로 만들 때 적용된다. 이 변경이main에 머지되면 자동 배포(deploy.sh의up -d backend)가 backend와 함께 postgres · redis도 다시 만든다 — Compose는 설정이 바뀐depends_on서비스도 다시 만들기 때문이다(로컬 실측). 데이터는 볼륨에 있어 그대로다.- 이번 배포는 평소보다 오래 끊길 수 있다. 평소 배포는 backend만 다시 떠서 API가 20 ~ 40초 끊기지만, 이번에는 postgres 재기동 + healthcheck를 backend가 기다린 뒤 뜬다. 팀에 미리 알린다.
free -h # Swap: 2.0Gi
cat /proc/sys/vm/swappiness # 10
docker info 2>&1 | grep -i "swap limit" # 아무것도 안 나와야 한다 (나오면 memswap_limit이 무시된다 — 아래)
# 컨테이너 swap 상한: memswap_limit = mem_limit을 둔 세 서비스만 본다 — memory.swap.max가 0이면 swap을 안 쓴다 (cgroup v2)
# (web · 모니터링 에이전트 · 파이프라인은 swap 제한을 두지 않아 0이 아닌 게 정상이다 — 판단에 넣지 않는다)
for s in backend postgres redis; do
c=$(docker compose -f docker-compose.prod.yml ps -q "$s")
if [ -z "$c" ]; then echo "$s: 미실행 ⚠️"; continue; fi
echo "$s: $(docker exec "$c" cat /sys/fs/cgroup/memory.swap.max 2>/dev/null || echo '읽기 실패')"
done평소 free -h의 Swap used는 0에 가까워야 한다. 계속 수백 MB씩 쓰이면 swap이 안전망이 아니라 상시 메모리가 된 것이다 — 그때는 mem_limit을 다시 나누거나(COMMON-52 메모리 재조정 단계) RDS 전환(7.1.2)으로 postgres를 빼는 게 맞다. Grafana 인프라 대시보드의 메모리 패널(node_exporter)로도 본다.
| 증상 | 확인 · 조치 |
|---|---|
docker info에 WARNING: No swap limit support |
커널이 컨테이너 swap 상한을 지원하지 않아 memswap_limit이 무시된다(cgroup v1 등). Ubuntu 22.04 이상 기본(cgroup v2)에서는 나오지 않는다. 나오면 D에게 알린다 — 컨테이너가 swap을 쓸 수 있는 상태다 |
서버가 전체적으로 느리고 free -h의 Swap used가 계속 큼 |
docker stats --no-stream으로 누가 쓰는지 본다. backend · postgres · redis는 swap을 못 쓰므로 호스트 프로세스나 파이프라인 · 에이전트다 |
swapon이 실패한다(Invalid argument · skipping - it appears to have holes 등) |
파일 시스템이 fallocate로 만든 파일을 거부한 경우다(EC2 Ubuntu ext4에서는 드물다). 2GB 파일이 mkswap까지 된 채 남아 다시 실행해도 같은 자리에서 멈춘다 → sudo rm /swapfile 후 sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 status=progress로 만들고 다시 sudo ./setup-swap.sh |
❌ 모르는 인자 |
sudo ./setup-swap.sh(인자 없음) 또는 ./setup-swap.sh --check만 받는다. --dry-run 같은 인자는 없다 — 상태만 보려면 --check |
| swap 끄기 | sudo swapoff /swapfile → /etc/fstab의 /swapfile 줄 삭제 → sudo rm /swapfile → sudo rm /etc/sysctl.d/99-lawnambul-swap.conf && sudo sysctl -w vm.swappiness=60 |
관리자가 판결문을 넣고 사건 등록을 누르면(A-14) 백엔드가 같은 서버의 내부 컨테이너에 작업을 맡긴다.
오프라인 도구 tools/ai-judgment(BE-31)를 그대로 쓰고, HTTP 껍데기만 덧씌운 구조다 (tools/ai-judgment/service/README.md).
backend ──▶ pipeline (FastAPI :8000, 내부 네트워크 전용) ──▶ 외부 LLM API
│
└─ 볼륨: pipeline-out(작업 상태 · 생성 결과) · pipeline-cases(추출 산출물)
- 포트를 호스트로 열지 않고, backend만 있는 별도 네트워크(
pipeline)에 둔다. 기본 네트워크에 두면 web(Nginx) · redis · postgres · 모니터링 에이전트도pipeline:8000에 닿는다. 인증이 없는 서비스라 범위를 줄였다. 관리자 인증 · CSRF는 백엔드가 한다 (docs/decisions/COMMON-50.md). - 운영 DB에 직접 쓰지 않는다. 파이프라인의
load단계는 서비스에서 꺼 두고, 검수 · 등록은 백엔드가 한다. - 판결문 원문은 이미지가 아니라 볼륨에만 있고, 결과 응답에도 넣지 않는다 (FR-5-3). 검수가 끝나면 백엔드가 작업을 지운다.
t3.small은 2GB인데 RDS 전환 전 base 구성은 1.75GB를 할당했다(backend 1g · postgres 512m · redis 256m, 실측 여유 약 250MB, 모니터링 에이전트가 그중 100MB).
그래서 7.1.2 RDS 전환(postgres 컨테이너 512m이 빠진다) 뒤에 켜는 것을 전제로 했다. 10/28 전환으로 자리는 생겼다 — 빠진 postgres 512m과 파이프라인 mem_limit 512m이 같은 예산이다.
다만 켠 뒤의 실제 여유는 실측이 아니므로, 켜는 날 docker stats로 확인한다. 대본까지 만들게 하면 한 등록이 오래 걸리니 한 등록의 소요 시간도 같이 본다.
남은 조건은 팀 합의(COMMON-52) 다 — 운영에서 사건 등록을 열지는 팀이 정한 뒤 켠다.
그 전에 켜려 하면 deploy.sh가 배포를 시작하기 전에 멈춘다. 여유가 모자란 상태에서 이 컨테이너가 붙으면
커널 OOM이 가장 큰 프로세스(backend)를 먼저 죽일 수 있기 때문이다. 꼭 켜야 하면 backend의 mem_limit을
먼저 내리고 docker stats로 확인한 뒤, .env에 PIPELINE_MEMORY_CHECKED=1을 넣는다.
(외부 DB 모드(BE-59, 아래 "외부 DB 모드")에서는 이 검사를 타지 않는다 — 옛 postgres 컨테이너를 멈춘 뒤 켠다.)
위 "Swap"(COMMON-54)을 켰어도 이 전제는 그대로다. swap은 순간 급증 때 죽지 않게 하는 안전망이고, 파이프라인을 상시로 얹을 자리를 만들어 주지 않는다. 파이프라인 컨테이너만 swap을 쓸 수 있게 해 두었다(backend · postgres · redis는 막음).
# 1) .env에 값 추가 (deploy/.env.example "AI 판결 파이프라인 서비스" 참고)
# PIPELINE_SERVICE=1
# ANTHROPIC_API_KEY · GEMINI_API_KEY · OPENAI_API_KEY 중 하나 이상 (없으면 deploy.sh가 배포 전에 멈춘다)
# ※ 쓰려는 모델(PIPELINE_MODELS · PIPELINE_EXTRACT_MODEL 등)의 공급자 키가 있어야 한다. 키가 하나만 있어도 deploy.sh는 통과하므로,
# 다른 공급자 모델을 고르면 키가 비어 첫 등록에서 실패한다
# ※ 서비스를 켜는 것(PIPELINE_SERVICE=1)과 대본까지 만드는 것은 별개 스위치다(BE-129). 대본까지 만들려면 PIPELINE_SCRIPT_MODEL을 따로 넣는다
# (기본 꺼짐 — 비우면 대본 없이 등록). 켜면 등록 한 번에 분류 · 대본 · 검수까지 돌아 모델 호출과 소요 시간이 늘어난다.
# 분류 모델(PIPELINE_CLASSIFY_MODEL)은 대본 모델과 같은 값으로 적는다 — 자세한 이유는 .env.example
# PIPELINE_MEMORY_CHECKED=1 ← RDS 전환 전(postgres 컨테이너가 있는 서버)에만 필요. 위 "켜기 전: 메모리" 확인 후
# 2) 같은 이미지로 .env를 다시 적용한다. deploy.sh가 .env를 보고 외부 DB · 시드 · 파이프라인 오버레이를 함께 붙여
# pipeline을 올리고 backend를 다시 만든다(PIPELINE_SERVICE_URL 반영) + 스모크 테스트
# (docker compose -f docker-compose.prod.yml -f docker-compose.pipeline.yml 처럼 손으로 고르면 외부 DB 오버레이가 빠져
# RDS 운영에서 backend가 로컬 postgres를 찾는다)
sudo systemctl stop lawnambul-cd.timer
./deploy.sh --reapply
sudo systemctl start lawnambul-cd.timer이후 자동 배포(deploy.sh)는 .env의 PIPELINE_SERVICE=1을 보고 이 오버레이를 함께 쓰고,
pipeline 이미지도 같은 태그로 받아 올린다. 다만 파이프라인이 실패해도 배포를 되돌리지 않는다 —
관리자 전용 기능이라 사용자 화면 · API와 무관하기 때문이다. 대신 배포 로그에 ⚠ 줄이 남는다.
# 1) 호스트에 포트가 열려 있지 않아야 한다 (PORTS 칸이 비어 있어야 정상)
docker compose -f docker-compose.prod.yml -f docker-compose.pipeline.yml ps pipeline
# 1-2) 기본 네트워크에서는 이름조차 찾을 수 없어야 한다 (exit code 6 = 호스트를 못 찾음 → 정상)
docker run --rm --network deploy_default curlimages/curl:8.11.1 -s --max-time 5 http://pipeline:8000/health; echo $?
# 2) 서비스 응답 (컨테이너 안에서만 된다)
docker exec lawnambul-pipeline-1 python3 -c \
"import urllib.request; print(urllib.request.urlopen('http://localhost:8000/health').read().decode())"
# 3) 백엔드가 내부 주소를 받았는지 (값이 비어 있으면 overlay 없이 backend를 다시 만든 것)
docker exec lawnambul-backend-1 printenv PIPELINE_SERVICE_URL
# 4) 메모리 (위 "켜기 전" 참고 — backend가 밀려나지 않는지 본다)
docker stats --no-stream| 증상 | 원인 · 조치 |
|---|---|
배포 로그에 ⚠ 파이프라인 이미지를 받지 못했다 |
그 태그의 이미지가 아직 GHCR에 없다 → Actions 실행 확인. 파이프라인만 이전 이미지로 계속 돈다 |
배포 로그에 ⚠ 파이프라인 서비스를 띄우지 못했다 |
docker compose ... logs pipeline으로 원인 확인. 사용자 화면 · API는 정상이다 |
| 사건 등록만 안 된다 | 위 확인 3) — overlay를 빼고 backend를 다시 만들면 PIPELINE_SERVICE_URL이 사라진다 |
작업이 계속 FAILED |
LLM 키 문제가 가장 흔하다. `docker exec lawnambul-pipeline-1 printenv |
| 서버 메모리가 모자란다 | 위 "켜기 전: 메모리". 급하면 PIPELINE_SERVICE=로 비우고 docker compose ... stop pipeline |
배포가 PIPELINE_MEMORY_CHECKED 때문에 멈춘다 |
의도한 동작이다 (RDS 전환 전 서버). 위 "켜기 전: 메모리"를 확인하고 값을 넣는다 |
백엔드가 pipeline을 못 찾는다 |
backend가 pipeline 네트워크에 붙어야 한다 → overlay 없이 backend를 다시 만들지 않았는지 확인 |
메인 EC2에는 에이전트 두 개만 둔다. 수집 · 저장 · 대시보드 · 경보는 전부 Grafana Cloud다(서버가 죽어도 경보가 울리게, COMMON-62 · 63).
backend:8081 (Actuator) ─┐
node-exporter:9100 ─┼─ Alloy ── 지표 remote_write ──▶ Grafana Cloud Prometheus ─▶ 경보 → Slack
backend 로그 · 429 로그 ─┘ └─ 로그 push ─────────▶ Grafana Cloud Loki
(같은 Docker 네트워크 · 볼륨) (밖으로 나가는 https 443만 쓴다)
- 호스트로 여는 포트가 없다. Alloy가 같은 Compose 네트워크에서
backend:8081·node-exporter:9100을 직접 긁는다. 전용 모니터링 EC2(차선, 안 씀)를 전제로 열던 8081 · 9100과MAIN_PRIVATE_IP, 그 EC2의 Loki 주소LOKI_URL은 COMMON-64에서 지웠다 — 서버.env에 남아 있어도 쓰지 않는다. - 인증값은 환경변수로만 넘긴다(
docker-compose.monitoring-agent.yml의environment→alloy-agent.alloy의sys.env). 비밀값 파일을 컨테이너 사용자가 못 읽는 문제(PR #262)를 피하려고 파일 방식은 쓰지 않는다. env라벨: 지표 · 로그에env="prod"가 붙는다(MONITORING_ENV, 이 오버레이 기본값). 같은 Alloy 설정으로 로컬에서 Cloud 전송을 테스트하면env="local"이라 운영과 섞이지 않는다.- 보내는 지표는 걸러서 보낸다(
alloy-agent.alloy의cloud_filter— 대시보드 · 경보가 쓰는 지표군만). 무료 등급 시계열 한도 때문이다.
필요한 값: Grafana Cloud 포털 → 스택 → Prometheus · Loki → Details에서 주소 · ID, Access Policy 토큰(metrics:write · logs:write). 토큰은 저장소 · 채팅에 붙이지 않고 DM으로 받아 서버에서 직접 입력한다.
cd /opt/lawnambul/app/deploy
cp .env ~/backups/env-$(date +%F-%H%M)-before-monitoring.bak && chmod 600 ~/backups/env-*-before-monitoring.bak
# 주소 · ID (비밀값 아님)
printf '\nGRAFANA_CLOUD_PROM_URL=%s\nGRAFANA_CLOUD_PROM_USERNAME=%s\nGRAFANA_CLOUD_LOKI_URL=%s\nGRAFANA_CLOUD_LOKI_USERNAME=%s\n' \
'<remote_write 주소>' '<Instance ID>' '<Loki push 주소>' '<Loki User ID>' >> .env
# 토큰 — 화면 · 셸 기록에 남지 않게 read -rs로 받는다
read -rs GC_KEY && printf 'GRAFANA_CLOUD_PROM_API_KEY=%s\n' "$GC_KEY" >> .env && unset GC_KEY
# (로그용 토큰이 따로 있을 때만) read -rs GC_KEY && printf 'GRAFANA_CLOUD_LOKI_API_KEY=%s\n' "$GC_KEY" >> .env && unset GC_KEY
# 값을 다 넣은 뒤 맨 마지막에 켠다
printf 'MONITORING_AGENT=1\n' >> .env
chmod 600 .env
./deploy.sh --reapplydeploy.sh가 필수 값(주소는 https만)을 먼저 확인하고, 없으면 아무것도 바꾸지 않고 멈춘다(exit 4).
docker compose -f docker-compose.prod.yml ps -a | grep -E "alloy|node-exporter" # 둘 다 Up
docker logs --tail 30 lawnambul-alloy 2>&1 | grep -iE "error|401|403" || echo "오류 없음"
ss -ltnp | grep -E ":8081|:9100" || echo "호스트 포트 없음 (정상)"
docker stats --no-stream lawnambul-alloy lawnambul-node-exporter # 상한 192m · 64mGrafana Cloud → Explore에서 1 ~ 2분 뒤:
- 지표:
up{env="prod"}→job="backend"·job="node"둘 다1 - 로그:
{env="prod", job="backend"}최근 로그, 429가 있었다면{env="prod", job="web"} - 경보: Alerting → Alert rules에서
lawnambul규칙이 Normal로 돌아온다(Watchdog은 항상 Firing · 음소거)
| 증상 | 원인 · 조치 |
|---|---|
deploy.sh가 MONITORING_AGENT=1인데 .env에 … 가 없다로 멈춤 |
값 누락. 위 "켜기"의 값을 채우고 다시 --reapply |
Alloy 로그에 401 · 403 |
토큰 · ID 불일치. 지표는 Instance ID, 로그는 Loki User ID인지(둘은 다른 숫자), 토큰에 metrics:write · logs:write가 있는지 |
alloy · node-exporter 컨테이너가 없음 |
배포 로그(journalctl -u lawnambul-cd)에서 ⚠ 모니터링 에이전트(alloy · node-exporter)를 띄우지 못했다를 찾는다(journalctl -u lawnambul-cd --no-pager | grep '모니터링 에이전트'). deploy.sh가 backend · web 뒤에 up -d --no-deps alloy node-exporter로 따로 올린다(COMMON-64) — 실패해도 배포는 되돌리지 않는다 |
| Alloy가 계속 재시작 | docker logs lawnambul-alloy에서 설정 오류 확인. 대부분 환경변수가 비어 있는 경우다 |
| 지표는 오는데 로그만 없음 | GRAFANA_CLOUD_LOKI_* 확인. 로그 토큰을 비웠다면 지표 토큰에 logs:write가 있어야 한다 |
| 메모리가 모자람 | docker stats. 급하면 .env의 MONITORING_AGENT=를 비우고 --reapply → 아래 "끄기" |
cd /opt/lawnambul/app/deploy
sed -i 's/^MONITORING_AGENT=.*/MONITORING_AGENT=/' .env
./deploy.sh --reapply
docker rm -f lawnambul-alloy lawnambul-node-exporter # 오버레이를 빼면 compose가 이 둘을 더는 관리하지 않는다Grafana Cloud 경보가 no data로 울리기 시작하므로, 끄기 전에 Grafana Alerting → Silences로 기한을 걸어 둔다.
| 작업 | 바꿀 곳 |
|---|---|
| 7.1.2 RDS 전환 · 시크릿 | (BE-59, 10/28 RDS 전환 완료 — 위 "RDS 전환") Supabase 프로젝트 삭제 · 공용 DB 파일 정리(COMMON-59)도 끝났다. 남은 것: 비밀값을 시크릿 저장소(SSM 등)로 옮길지, sslmode=verify-full(RDS CA 인증서), deploy.sh의 마이그레이션 비교를 외부 DB로도 할지, 안정되면 docker-compose.prod.yml에서 postgres 정의를 지울지, DB 지표(CloudWatch · postgres_exporter) 경보 |
| CSP 2단계 (COMMON-70) | 운영 주요 화면에서 Report-Only 위반이 없음을 확인한 뒤 frontend/nginx/snippets/security-headers.conf의 Content-Security-Policy-Report-Only를 Content-Security-Policy로 바꾼다(docs/decisions/COMMON-70.md "다음 단계") |
| HTTPS 안정화 후 (BE-75 · COMMON-67 후속) | 첫 인증서 자동 갱신이 정상으로 돈 뒤 frontend/nginx/default.conf의 $hsts_header를 1일 → 1주 → 1년으로 늘린다(위 "HTTPS로 풀리는 것 · 남은 것") |