12. 설치·설정·배포
구독·결제 API 서버를 로컬 개발부터 운영 배포까지 정리한다. 운영은 VM 1대에 다음 셋을 올리는 구조다 — ① 호스트 nginx(VM에 직접 설치, TLS 종단), ② docker A — PostgreSQL(db_server, DB 전용 컨테이너), ③ docker B — app·redis(docker-compose.prod.yml). 즉 DB도 같은 VM의 별도 docker로 띄우고, 앱은 그 DB에 접속한다.
🍎쉽게 말하면 서버 컴퓨터(VM) 한 대에 세 가지를 올린다 — ① 문지기(nginx: 외부의 https 접속을 받아 암호를 풀고 앱에 전달), ② 데이터 창고(PostgreSQL: 구독·결제 데이터 저장), ③ 앱 본체(FastAPI + Redis 캐시). 이 장은 그 설치·설정·운영 방법을 순서대로 안내한다.
함께 보기: 서비스 API
12.1 요구사항·구성 요소
12.1.1 요구사항
| 항목 | 버전·비고 |
|---|---|
| VM(Ubuntu) | 호스트 nginx + Docker를 올릴 단일 인스턴스 |
| Docker / Docker Compose | docker A(PostgreSQL)·docker B(app·redis) 기동 |
| 호스트 nginx | nginx + certbot(Let's Encrypt). VM에 apt로 설치 |
| PostgreSQL | 16 (db_server 컨테이너). asyncpg 드라이버로 접속 |
| Redis | 7 (app 스택에 포함 — 세션·nonce·레이트리밋·킬스위치 캐시) |
| Python | 3.13 (requires-python>=3.13, pyproject.toml). 운영 이미지도 python:3.13-slim |
| uv | 의존성·실행 관리(uv sync/uv run). 로컬 개발에서 앱을 직접 실행할 때 필요(운영은 컨테이너가 담당) |
12.1.2 구성 요소 — VM 1대에 셋
┌──────────────────── VM 1대 ────────────────────┐
인터넷 ─443/80─▶ 호스트 nginx(TLS) ─127.0.0.1:8000─▶ [docker B] app ──┐ │
│ redis ◀─┘ │
│ [docker B] app ─5432(host.docker.internal)─▶ [docker A] PostgreSQL │
└─────────────────────────────────────────────────┘
| 구성요소 | 설치 방식 | 외부 노출 | 설정 절 |
|---|---|---|---|
| 호스트 nginx | VM에 apt로 설치. TLS 종단·리버스 프록시, certbot 발급·자동 갱신 |
80 / 443 (유일하게 인터넷 공개) | 12.5.4 |
| docker A — PostgreSQL | db_server/run.sh로 postgres:16 컨테이너(payment-postgres) |
5432 (VM 내부/사설망만 — 인터넷 금지) | 12.5.2 |
| docker B — app·redis | docker-compose.prod.yml(app + redis) |
app 127.0.0.1:8000(루프백), redis 내부 6379 |
12.5.3 |
💡참고: 회사 클라우드는 Azure. 실제 VM·도메인·NSG는
docs/cloud/PAY-VM-ONBOARDING.md— stgapi-stg-pay.medisolveai.com(vm-pay-api-stg) / prodapi-pay.medisolveai.com(vm-pay-api-prod), NSG 인바운드 22·80·443만 개방(5432는 비공개).왜 호스트 nginx인가: VM에 이미 nginx가 설치돼 있으면 컨테이너 nginx와 80/443이 충돌한다. 그래서 nginx는 호스트가 맡고, compose는 app·redis만 띄우며 app을
127.0.0.1:8000으로만 노출한다.대안: DB를 자체 docker 대신 관리형(Azure Database for PostgreSQL)으로 쓸 수도 있다. 그 경우 12.5.2(docker A)를 건너뛰고
.env.prod의DATABASE_URL만 관리형 엔드포인트로 두면 된다(?ssl=require필요할 수 있음).
12.2 로컬 개발
호스트에서 앱을 uv run uvicorn(핫리로드)으로 직접 실행하고, Redis만 docker로 띄운다. DB는 별도 docker로 띄워 그 엔드포인트로 연결한다(운영과 동일 원칙).
12.2.1 명령
uv sync # 의존성 설치(Python 3.13 + 잠금된 deps)
# DB(별도 docker) — 간단 기동(개발용)
docker run -d --name payment-postgres \
-e POSTGRES_USER=payment -e POSTGRES_PASSWORD='XXXXXXXX' -e POSTGRES_DB=payment \
-p 5432:5432 postgres:16
docker compose up -d # 개발용 redis(payment-dev, 호스트 127.0.0.1:6380)
uv run alembic upgrade head # 스키마 적용
uv run python -m app.cli create-admin \
--email admin@medisolveai.com --password '<10자 이상>' # 최초 관리자 1회 생성
uv run uvicorn app.main:app --reload --port 8000 # 앱(핫리로드)
💡참고:
docker compose up -d(인자 없음)는 루트docker-compose.yml(프로젝트명payment-dev)을 띄운다 — Redis만 올리고 포트를 루프백(127.0.0.1:6380:6379)에만 바인딩한다. 앱까지 컨테이너로 띄우려면docker compose -f docker-compose.dev.yml up -d --build(또는./dev.sh)를 쓰며, 이때 app은host.docker.internal:5432로 호스트의payment-postgres에 접속한다.참고: 최초 관리자 계정은
uv run python -m app.cli create-admin으로 만든다. SYSTEM_ADMIN 권한으로 생성되며, 같은 이메일이 이미 있으면 거부된다.
12.2.2 .env.dev 핵심 값
cp .env.example .env.dev 후 값을 채운다. 로드 순서는 .env → .env.<APP_ENV>(뒤가 우선), APP_ENV 미지정 시 dev.
ENVIRONMENT=dev
BASE_URL=http://localhost:8000
DATABASE_URL=postgresql+asyncpg://payment:XXXXXXXX@localhost:5432/payment
REDIS_URL=redis://localhost:6380/0
# AES-256-GCM 키: python -c "import base64,os;print(base64.b64encode(os.urandom(32)).decode())"
ENCRYPTION_KEY=
💡팁:
ENCRYPTION_KEY는 위 한 줄로 새로 생성. 분실·변경 시 기존 암호화 데이터(카드 빌링키 등)를 복호화할 수 없다.토스 시크릿 키: 2026-06-23부터 전역
TOSS_SECRET_KEY환경변수가 제거됨. 서비스별 토스 시크릿은 앱 기동 후 어드민 콘솔 → 서비스 상세 → Toss 시크릿 키 카드에서 각 서비스마다 등록한다(AES 암호화 저장, 평문 미노출). 키 미등록 서비스에서 결제 시도 시 HTTP 422 (TOSS_KEY_NOT_CONFIGURED) 반환.
12.3 환경변수
전체 변수는 app/core/config.py의 Settings에 정의된다. .env(공통) → .env.<APP_ENV>(환경별, 뒤가 우선) 순으로 로드되며, APP_ENV 미지정 시 dev. 운영 일부 값은 compose environment가 다시 덮어쓴다(아래 마지막 참고). 선언되지 않은 변수는 extra="ignore"로 무시된다.
12.3.1 핵심·인프라
| 변수 | 설명 | 기본·예시 |
|---|---|---|
ENVIRONMENT / APP_ENV |
실행 환경(dev/test/stg/prod). prod·stg면 HSTS 부착·세션 쿠키 Secure(보안 S-1), prod면 docs 기본 비공개. APP_ENV가 단일 진실(신뢰성 R-4) — .env 파일 로드 선택뿐 아니라 실행 환경 판정(웹훅 fail-closed·HSTS·MINUTE 요금제 운영 차단)까지 APP_ENV를 우선한다. compose가 APP_ENV=prod를 강제하므로 .env.prod에 ENVIRONMENT가 누락돼도 안전하게 prod로 동작한다 |
dev / prod |
BASE_URL |
서버 공개 URL(이메일 링크 등). 운영은 https FQDN | https://api-stg-pay.medisolveai.com |
DATABASE_URL |
PostgreSQL 접속(반드시 asyncpg). 운영(같은 VM docker)은 host.docker.internal. 필수 — 미설정 시 기동 즉시 실패(코드에 기본값 없음, 보안 C-1) |
postgresql+asyncpg://payment:...@host.docker.internal:5432/payment |
REDIS_URL |
Redis 접속(운영은 compose가 redis://redis:6379/0으로 덮어씀) |
redis://localhost:6380/0 |
REDIS_SOCKET_TIMEOUT / REDIS_CONNECT_TIMEOUT |
Redis 응답·연결 대기 한도(초). Redis는 모든 인증 API 요청 경로에 있어, 값이 없으면 Redis가 조용히 먹통일 때 요청이 OS TCP 한계(약 15분)까지 매달린다. 호출 서비스의 타임아웃보다 짧게 유지 | 2.0 / 2.0 |
REDIS_HEALTH_CHECK_INTERVAL |
유휴 연결 점검 주기(초) — 끊긴 연결을 재사용하지 않도록 재연결 | 30 |
ENCRYPTION_KEY |
AES-256-GCM 키(base64 32바이트). 빌링키·HMAC secret 암호화에 사용. 필수 | (직접 생성) |
~~TOSS_SECRET_KEY~~ |
제거됨(T7 컷오버) — 서비스별 키는 어드민 콘솔에서 등록(AES 암호화 저장) | — |
DB_POOL_SIZE / DB_MAX_OVERFLOW |
DB 커넥션 풀(총 최대 = 합) | 10 / 20 |
DB_POOL_TIMEOUT / DB_POOL_RECYCLE |
풀 고갈 대기 한도(초) / 커넥션 재활용 주기(초) | 30 / 1800 |
12.3.2 토스·보안·프록시
| 변수 | 설명 | 기본·예시 |
|---|---|---|
TOSS_API_BASE_URL |
토스 API 베이스 URL | https://api.tosspayments.com |
TOSS_READ_TIMEOUT_SECONDS / TOSS_CONNECT_TIMEOUT_SECONDS |
토스 HTTP read/connect 타임아웃(초). 자동결제 승인 최대 60초 대비 | 65 / 5 |
TOSS_WEBHOOK_ALLOWED_IPS |
토스 발신 허용 IP(JSON 배열). .env에 두며 기본은 코드 내장 목록 |
["13.124.18.147", ...] |
WEBHOOK_IP_CHECK_ENABLED |
토스 발신 IP 외 웹훅 거부. prod/stg에서 false면 기동 거부(fail-closed, 보안 M-1) |
true |
TRUST_PROXY / TRUST_PROXY_HOPS |
프록시 뒤면 true + XFF hop 수(nginx 1단=1, LB 추가면 2) |
true / 1 |
SWAGGER_ID / SWAGGER_PW |
/docs·/openapi.json HTTP Basic 계정. 둘 다 설정해야 노출, 비우면 404 |
admin / (강력값) |
PUBLIC_SERVICE_LIST_ENABLED |
무인증 서비스 목록 API(GET /api/v1/services) 노출. 운영 노출 환경은 false 권장 |
true |
DEV_LOGIN_EMAIL / DEV_LOGIN_PASSWORD |
로그인 폼 기본값 자동 채움 — dev에서만 노출. 프리필 판정은 APP_ENV까지 반영(권위 판정)하므로 비-dev(stg·prod)에선 이 값이 남아 있어도 로그인 화면에 절대 노출되지 않는다. 비-dev엔 불필요한 값이라, 설정돼 있으면 기동은 정상 진행하되 경고 로그를 남긴다(보안 L-4 — .env.dev에만 두기를 권장) |
(비움) |
12.3.3 세션·HMAC·레이트리밋·계정 정책
| 변수 | 설명 | 기본 |
|---|---|---|
SESSION_TTL_SECONDS / SESSION_ABSOLUTE_TTL_SECONDS |
어드민 세션 유휴 만료(초) / 절대 수명(초) | 1800 / 43200 |
HMAC_TIMESTAMP_TOLERANCE_SECONDS / HMAC_NONCE_TTL_SECONDS |
요청 서명 타임스탬프 허용 오차(초) / nonce 재사용 차단 윈도우(초) | 300 / 600 |
RATE_LIMIT_PER_MINUTE / RATE_LIMIT_PAYMENT_PER_MINUTE |
일반 API / 결제 API 분당 허용 요청 수 | 120 / 20 |
ADMIN_LOGIN_RATE_LIMIT_PER_MINUTE |
어드민 로그인 분당 허용 횟수(무차별 로그인 방지) | 10 |
MAX_FAILED_LOGINS / ACCOUNT_LOCK_MINUTES / MIN_PASSWORD_LENGTH |
연속 실패 잠금 임계치 / 잠금 지속(분) / 비밀번호 최소 길이 | 5 / 15 / 10 |
PASSWORD_LINK_TTL_HOURS |
비밀번호 설정·재설정 링크 유효시간(시간) | 48 |
ONE_OFF_MAX_AMOUNT |
단건(일반) 결제 1회 최대 금액(원) | 100000000 |
12.3.4 스케줄러·구독 정책·내보내기
| 변수 | 설명 | 기본 |
|---|---|---|
SCHEDULER_ENABLED / SCHEDULER_INTERVAL_MINUTES |
자동 갱신 배치(APScheduler) 사용 여부·실행 주기(분) | true / 5 |
SCHEDULER_LOCK_TTL_SECONDS |
갱신 배치 Redis 전역 락 TTL(초, 데드맨 스위치). heartbeat 주기는 1/3로 파생 | 240 |
RENEWAL_BATCH_LIMIT |
갱신 배치 1회당 카테고리별 처리 상한(due 폭주 시 끊기) | 1000 |
RETRY_INTERVAL_HOURS / RETRY_LIMIT |
구독 결제 실패 재시도 간격(시간) / 최대 횟수 | 12 / 4 |
SUSPENDED_GRACE_DAYS |
SUSPENDED 후 EXPIRED까지 수동 결제 대기 일수 | 30 |
EXPORT_MAX_ROWS |
엑셀 내려받기 1건 최대 행 수(워커 OOM 방지) — 정산 서비스별 내려받기 포함 전 export에 적용 | 100000 |
LOG_LEVEL |
앱 로깅 레벨(DEBUG/INFO/WARNING/ERROR). 앱 로거(payment.*)를 이 레벨로 docker logs에 남긴다(신뢰성 R-2) |
INFO |
TEST_ERROR_INJECTION_ENABLED |
QA 에러 재현(요청 qa/r01). 요청 헤더 TossPayments-Test-Code를 토스 승인 호출로 중계할지. 운영(prod)에선 항상 무시되고, test 키 서비스에만 적용된다. STG QA에서만 true로 opt-in |
false |
12.3.5 이메일(SMTP)
| 변수 | 설명 | 기본 |
|---|---|---|
GMAIL_ID / GMAIL_PW |
Gmail 앱 비밀번호(16자). 둘 다 설정 시 실제 발송, 아니면 콘솔 출력 | (비움) |
SMTP_HOST / SMTP_PORT |
SMTP 호스트·포트 | smtp.gmail.com / 587 |
MAIL_FROM_NAME |
발신자 표시 이름 | 결제시스템 |
EMAIL_QUEUE_MAXSIZE |
인메모리 이메일 큐 상한(초과 적재 거부+경고, 신뢰성 R-7) | 1000 |
EMAIL_SEND_RETRIES / EMAIL_SEND_RETRY_BACKOFF_SECONDS |
일시적 SMTP 실패 재시도 횟수 / 재시도 간 선형 백오프(초) | 2 / 2.0 |
⚠️중요:
ENCRYPTION_KEY·DB 비밀번호·SWAGGER_PW·GMAIL_PW등 비밀값은 Git에 커밋하지 않는다..env.example만 추적한다.TOSS_SECRET_KEY는 2026-06-23부로 제거됨 —.env.example에서도 삭제 대상.참고: 운영에서
APP_ENV·REDIS_URL·TRUST_PROXY·TRUST_PROXY_HOPS는 composeenvironment가 고정 주입한다(.env.prod에 적어도 compose 값이 우선,docker-compose.prod.yml).dev/stgcompose도 동일하게APP_ENV·REDIS_URL·TRUST_PROXY를 고정하며, dev compose만DATABASE_URL도host.docker.internal로 덮어쓴다(stg·prod는.env.stg/.env.prod의DATABASE_URL을 그대로 쓴다).참고: 다중 인스턴스로 늘릴 때 마이그레이션 중복 적용을 막는
RUN_MIGRATIONS(엔트리포인트,0이면 건너뜀)는Settings가 아닌 컨테이너 환경변수다. 자세한 건 12.5.3·12.6 참조.
12.4 테스트
테스트도 DB를 쓴다. payment-postgres에 payment_test 데이터베이스를 만들어 둔다(운영 DB와 별개). 기본 접속값은 tests/conftest.py:18, 다르면 TEST_DATABASE_URL로 덮어쓴다.
docker exec -it payment-postgres createdb -U payment payment_test # 테스트 DB 1회 생성
uv run pytest # 전체 (unit/integration/security/e2e)
uv run pytest tests/integration/ # 통합만
uv run pytest --cov=app --cov-report=term-missing # 커버리지
12.5 운영 배포 — VM 1대에 셋 올리기
설치 순서: 공통 준비(12.5.1) → docker A: PostgreSQL(12.5.2) → docker B: app·redis(12.5.3) → 호스트 nginx·TLS(12.5.4) → 동작 확인(12.5.5). (Azure 실제 값은 docs/cloud/PAY-VM-ONBOARDING.md)
💡참고: 스테이징(stg)은
docker-compose.stg.yml(./stg.sh)로 동일한 구조를 띄운다 —APP_ENV=stg,.env→.env.stg로드. 운영은docker-compose.prod.yml(./prod.sh), 개발은docker-compose.dev.yml(./dev.sh). 아래 절차는 prod 기준이며 stg는 파일명·FQDN만 바꾸면 동일하다.
12.5.1 공통 준비 (Docker 설치 + 코드)
- Docker 설치 — Engine + Compose 플러그인 설치 후 현재 사용자를 docker 그룹에(이후 재로그인).
curl -fsSL https://get.docker.com | sudo sh sudo usermod -aG docker $USER # 재로그인 필요 docker --version && docker compose version - 코드 가져오기 — 저장소를 클론한다(배치 경로 예:
/opt/pay).git clone <이-저장소-URL> /opt/pay cd /opt/pay
12.5.2 docker A — PostgreSQL (db_server)
DB 전용 컨테이너(payment-postgres)를 db_server/run.sh로 띄운다. 데이터 영속(컨테이너를 지워도 데이터 보존)·헬스체크(상태 자가 점검)·재부팅 자동복구·멱등 실행(여러 번 실행해도 안전)을 제공한다.
- 설정 파일 작성 —
db_server/.env.example를.env로 복사하고 비밀번호를 반드시 변경한다(예시 기본값이면 스크립트가 거부).cd /opt/pay/db_server cp .env.example .env # .env 편집: POSTGRES_PASSWORD=강한값, (선택) TZ, DATA_DIR 등 - 기동 —
run.sh실행.chmod +x run.sh ./run.sh docker logs -f payment-postgres # 'ready to accept connections' 확인
db_server/.env 핵심 값:
POSTGRES_CONTAINER=payment-postgres
POSTGRES_IMAGE=postgres:16-alpine
POSTGRES_USER=payment
POSTGRES_PASSWORD=__강한_비밀번호로_변경__
POSTGRES_DB=payment
# 보안: 0.0.0.0이면 방화벽/NSG에서 5432를 '앱서버(같은 VM)만' 허용. 인터넷 노출 금지.
HOST_BIND=0.0.0.0
HOST_PORT=5432
DATA_VOLUME=payment_pgdata # 또는 DATA_DIR=전용디스크경로
TZ=Asia/Seoul
- 데이터 영속: 기본은 docker 명명 볼륨(
payment_pgdata). 전용 디스크를 쓰려면.env의DATA_DIR지정. - 빈
paymentDB만 있으면 된다 — 테이블(스키마)은 app이 자동 생성한다(12.5.3의 마이그레이션). - 운영 명령:
docker start/stop payment-postgres,docker exec -it payment-postgres psql -U payment -d payment.
⚠️주의(보안): DB(5432)는 인터넷에 노출 금지. Azure NSG 인바운드에 5432가 없으니 외부에서 막히고, VM 내부에서만 app이 접근한다. 별도 DB 서버를 둘 땐 사설망·방화벽으로 앱서버 IP만 허용.
12.5.3 docker B — app·redis (docker-compose.prod.yml)
app(FastAPI) + redis 2개 컨테이너. app은 127.0.0.1:8000(호스트 루프백 — 그 VM 안에서만 접속 가능)에만 노출되고, 같은 VM의 docker A(PostgreSQL) 에 host.docker.internal:5432로 접속한다(compose에 extra_hosts: host.docker.internal:host-gateway 설정됨).
.env.prod작성 —.env.example참고. 필수값은 아래.cd /opt/pay cp .env.example .env.prod python3 -c "import base64,os;print(base64.b64encode(os.urandom(32)).decode())" # ENCRYPTION_KEY 생성- 기동(+ 자동 마이그레이션) — 엔트리포인트가
alembic upgrade head로 docker A의 DB에 테이블을 만든 뒤 앱을 띄운다.docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml logs -f app curl -I http://127.0.0.1:8000/ # 200/3xx/405면 앱 도달(정상) - 최초 관리자(SYSTEM_ADMIN) 생성 — 앱 컨테이너에서 CLI로.
docker compose -f docker-compose.prod.yml exec app \ python -m app.cli create-admin --email admin@medisolveai.com --password '강력한비밀번호!'
.env.prod 최소 필수값:
ENVIRONMENT=prod
BASE_URL=https://api-stg-pay.medisolveai.com # prod: https://api-pay.medisolveai.com
# 같은 VM의 docker A(payment-postgres)에 접속 — 호스트 게이트웨이 경유
DATABASE_URL=postgresql+asyncpg://payment:__DB비밀번호__@host.docker.internal:5432/payment
ENCRYPTION_KEY= # 위 명령으로 생성한 값
# TOSS_SECRET_KEY 는 전역 설정에서 제거됨 — 어드민에서 서비스별 등록 필요
SWAGGER_ID=admin
SWAGGER_PW=강력한값
WEBHOOK_IP_CHECK_ENABLED=true
💬토스 시크릿 키 등록 순서(운영 전환 시 필수): 1.
alembic upgrade head(toss_secret_key_encrypted 컬럼 추가 마이그레이션 적용) 2. 어드민 콘솔 → 각 서비스 상세 → Toss 시크릿 키 카드에서 키 등록 3..env.prod에서TOSS_SECRET_KEY항목 제거 (이미 없으면 생략)주의:
DATABASE_URL을localhost/127.0.0.1로 두면 app 컨테이너 자신을 가리켜 접속 실패한다. 같은 VM의 DB docker에는 반드시host.docker.internal:5432(또는 VM 사설 IP)를 쓴다.
12.5.4 호스트 nginx + TLS
VM에 nginx·certbot을 설치하고 인증서를 먼저 발급한 뒤, 레포의 docker/nginx/host/payment-host.conf를 /etc/nginx/conf.d/payment.conf로 복사한다. 이 파일은 80→443 리다이렉트 + 443 TLS 프록시를 모두 담은 완성본이라 인증서 경로를 참조한다(그래서 발급이 먼저).
payment.conf 전체 — server_name 2곳과 ssl_certificate 2경로를 환경 FQDN으로 맞춘다(stg api-stg-pay / prod api-pay).
# /etc/nginx/conf.d/payment.conf — payment_system (stg) 리버스 프록시
# 인터넷 80/443 ──▶ 호스트 nginx(TLS 종단) ──▶ docker app(127.0.0.1:8000)
# ── 80: ACME 챌린지 + 나머지는 전부 https로 리다이렉트 ──
server {
listen 80;
listen [::]:80;
server_name api-stg-pay.medisolveai.com;
location /.well-known/acme-challenge/ { root /var/www/html; } # LE 갱신 경로
location / { return 301 https://$host$request_uri; } # 평문 전부 https로
}
# ── 443: 실제 서비스(HTTPS) ──
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name api-stg-pay.medisolveai.com;
ssl_certificate /etc/letsencrypt/live/api-stg-pay.medisolveai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api-stg-pay.medisolveai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1h;
client_max_body_size 16m; # 엑셀 업로드/다운로드 등
# 보안 헤더(앱도 일부 부착하지만 프록시단에서 한 번 더 보강)
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:8000; # docker B의 app(루프백 publish)
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 실제 클라이언트 IP를 XFF 맨 오른쪽에 append → app TRUST_PROXY_HOPS=1 과 정합
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 90s; # 토스 자동결제 승인(최대 65s) 대비
}
}
핵심: 80 블록의 return 301이 평문을 전부 https로 보낸다(이게 없으면 브라우저 "Not secure"). 443 블록은 인증서로 TLS를 종단하고 proxy_pass로 app(루프백 8000)에 전달하며, X-Forwarded-For/X-Forwarded-Proto로 app이 진짜 클라이언트 IP·https를 인식한다(TRUST_PROXY_HOPS=1 정합). HSTS로 이후 브라우저가 https를 강제한다.
💬8001(sample_service)은 nginx로 프록시하지 않는다. sample_service 컨테이너가 호스트
0.0.0.0:8001을 직접 publish 하므로, nginx에listen 8001server 블록을 두면 같은 포트를 두고 bind 충돌(98: Address already in use)이 나서 nginx가 기동 실패한다. 외부에서 샘플에 접근하려면 nginx를 거치지 않고 방화벽/보안그룹에서 8001 인바운드만 열면http://<도메인>:8001로 컨테이너에 직결된다(평문 http). 자세한 건sample_service/README.md의 "외부에서 접속하기" 참고.
설치·발급·적용 — 위 파일은 인증서 경로를 참조하므로 certbot 발급을 먼저 해야 nginx -t가 통과한다.
sudo apt-get update
sudo apt-get install -y nginx certbot python3-certbot-nginx
# 1) 인증서 먼저 발급(DNS A레코드가 VM 공인 IP, NSG 80 열림 전제)
sudo certbot certonly --nginx -d api-stg-pay.medisolveai.com # prod: -d api-pay.medisolveai.com
# 2) payment.conf 배치(server_name·인증서 경로를 환경에 맞게 수정)
sudo cp /opt/pay/docker/nginx/host/payment-host.conf /etc/nginx/conf.d/payment.conf
sudo rm -f /etc/nginx/sites-enabled/default # 기본 사이트(80 default_server) 충돌 방지
# 3) 검사 후 적용
sudo nginx -t && sudo systemctl reload nginx
💬대안: 80 블록만 둔 상태에서
sudo certbot --nginx -d <도메인>을 실행하면 certbot이 443 블록을 자동 생성한다(위 1·2단계를 한 번에). 결과 파일은 위와 동일하다.참고: 인증서는
/etc/letsencrypt/에서 certbot이 관리하고 systemd 타이머로 자동 갱신한다. 레포의docker/nginx/(컨테이너 nginx용 conf·certs)는 이 방식에선 쓰지 않는다.방화벽: Azure는 NSG 인바운드 22·80·443이 이미 열려 있다(온보딩). VM 자체 ufw를 쓸 때만
sudo ufw allow 80,443/tcp. 5432는 열지 않는다.
12.5.5 동작 확인
docker ps # payment-postgres + app + redis 모두 Up/healthy
curl -I http://127.0.0.1:8000/ # app 도달
curl -sI http://api-stg-pay.medisolveai.com/ | head -n1 # 301(https 리다이렉트)
curl -sI https://api-stg-pay.medisolveai.com/ | head -n1 # 200/3xx/405(앱 도달)
브라우저에서 https://<FQDN>/admin/login → 자물쇠(보안) + 로그인 화면. 만든 관리자 계정으로 로그인한다.
문서 정적 서빙도 함께 확인한다 — 이 매뉴얼은 /user-manual/, 구독·결제 정책 문서는 /policy/v0.1.0.html로 서빙된다(둘 다 Dockerfile의 COPY docs/...로 이미지에 포함되며, 매뉴얼 사이드바의 "구독·결제 정책" 링크가 후자를 가리킨다).
12.5.6 운영(로그·재배포·종료)
# app·redis
docker compose -f docker-compose.prod.yml logs -f app
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml down # 중지(redis 볼륨 유지)
git pull && docker compose -f docker-compose.prod.yml up -d --build # 재배포(새 마이그레이션 자동 적용)
# DB(docker A)
docker start payment-postgres / docker stop payment-postgres
# 헬스체크(신뢰성 R-3)
curl -s http://127.0.0.1:8000/health # 라이브니스 — 프로세스 생존만({"status":"ok"})
curl -s -w ' %{http_code}\n' http://127.0.0.1:8000/health/ready # 레디니스 — DB·Redis 실검사
# 200 {"status":"ready","checks":{"db":"ok","redis":"ok"}} 또는 503 {"status":"not_ready",...}
# 컨테이너 healthcheck가 /health/ready를 보므로, DB/Redis 다운 시 `docker ps`가 unhealthy로 표시된다.
12.5.7 문제해결 (배포 시 자주 겪는 것)
| 증상 | 원인·해결 |
|---|---|
app 로그 Connect call failed ('127.0.0.1', 5432) / DB 접속 실패 |
DATABASE_URL이 localhost로 됨 → host.docker.internal:5432로 변경. docker A(payment-postgres)가 떠 있는지 docker ps로 확인. |
nginx [emerg] cannot load certificate ... fullchain.pem |
인증서 미발급.sudo certbot --nginx -d <FQDN>로 발급. |
| 브라우저"Not secure"(인증서는 valid인데) | http로 접속 + 리다이렉트 미적용. 대개reload 누락. sudo systemctl reload nginx 후 curl -sI http://<FQDN>/가 301인지 확인, 브라우저는 https로 새 탭·강력 새로고침. |
nginx -t conflicting server name / http 200(리다이렉트 안 됨) |
기본 사이트 충돌.sudo rm -f /etc/nginx/sites-enabled/default 후 reload. |
| 502 Bad Gateway | app 컨테이너 미기동.docker compose -f docker-compose.prod.yml ps, curl -I http://127.0.0.1:8000/ 확인. |
| 로그인이 안 됨(쿠키 안 먹음) | prod·stg 세션 쿠키는 Secure라 https로만 동작. http면 위 리다이렉트부터 해결. |
클라이언트 IP가 127.0.0.1로 보임 |
nginx에 X-Forwarded-* 헤더 누락. payment-host.conf의 proxy 헤더 라인 확인(app은 TRUST_PROXY_HOPS=1). |
12.6 운영 주의사항
- 컴포즈 프로젝트명 분리: 운영 app 스택은
name: payment_system(app·redis 2개; nginx는 호스트), 개발 인프라는name: payment-dev(redis만). DB(docker A)는 compose가 아니라db_server/run.sh로 독립 기동한다. - DB는 별도 docker(docker A): app compose에 postgres가 없다. app은
host.docker.internal:5432로 docker A에 접속한다. 같은 VM이지만 컨테이너가 분리돼 있어 DB를 따로 재시작·백업·이전하기 쉽다. - 비밀값 관리:
ENCRYPTION_KEY(운영 전용 새 값)·SWAGGER_PW·POSTGRES_PASSWORD는 각.env에 두고 Git 커밋 금지.TOSS_SECRET_KEY는 제거됨 — 서비스별 토스 키는 어드민 콘솔에서 등록(DB에 AES 암호화 저장). - 마이그레이션: 엔트리포인트가 매 기동 시
alembic upgrade head. 다중 인스턴스가 동시에 부팅해도 안전하다(신뢰성 R-5) —alembic/env.py가 Postgres 세션 advisory lock으로upgrade를 직렬화해 두 번째 프로세스는 대기 후 no-op이 된다.RUN_MIGRATIONS=0(docker/entrypoint.sh)으로 특정 인스턴스만 건너뛰게 할 수도 있으나 필수는 아니다. - 클라이언트 IP: 호스트 nginx가
X-Forwarded-For/X-Forwarded-Proto를 세팅, 앱은TRUST_PROXY=true·TRUST_PROXY_HOPS=1로 읽는다(compose 고정). 앞단에 LB가 더 있으면2. - 인증서 갱신: 호스트 certbot이 systemd 타이머로 자동 갱신. 점검
sudo certbot renew --dry-run, 반영sudo systemctl reload nginx. - 세션 보안(추가 키 없음): 어드민 세션은 별도 쿠키 서명키 없이 Redis의 무작위 토큰(
secrets.token_urlsafe(32),app/services/auth.py)으로 동작.SECRET_KEY류 환경변수는 없으며, redis 비공개가 곧 세션 보호다.
12.7 백업·복구·롤백
DB는 docker A(payment-postgres)에 있다. 백업은 같은 VM에서 컨테이너의 pg_dump를 그대로 쓴다. (전용 백업 도구는 db_backup_sw/ 참고)
12.7.1 DB 백업 (pg_dump)
일회성(배포/마이그레이션 직전 권장):
docker exec payment-postgres pg_dump -U payment -Fc payment > payment_$(date +%Y%m%d-%H%M).dump
-Fc(커스텀 포맷)은pg_restore로 부분/선택 복구가 가능하다.
정기 백업은 db_backup_sw/backup.sh(신뢰성 R-6)를 쓴다 — pg_dumpall(롤 포함)을 임시 파일에 뜬 뒤 덤프 헤더 검증에 통과한 것만 최종 파일로 확정(0바이트 "성공" 백업 방지), gzip 압축·umask 077·보관 일수 초과분 자동 로테이션을 한다. cron 등록 예:
# 매일 03:10, 실패 시 메일. BACKUP_DIR·BACKUP_RETENTION_DAYS·BACKUP_OFFSITE_CMD 등은 환경변수.
10 3 * * * /path/db_backup_sw/backup.sh >> /var/log/pg_backup.log 2>&1 || \
echo "PG backup FAILED" | mail -s "[payment] backup failed" ops@example.com
💬덤프에는 PII·빌링키 암호문·롤 해시가 담긴다. 백업 파일은 소유자만 읽도록(스크립트가
umask 077) 보관하고, 호스트 유실 대비 오프사이트 복사(BACKUP_OFFSITE_CMD)를 반드시 구성한다. 기본 저장 경로(db_backup_sw/backups/)는.gitignore대상이라 커밋되지 않는다.
12.7.2 DB 복구 (pg_restore)
# 주의: 운영 DB에 덮어쓰기 전, 반드시 현재 상태를 먼저 백업한다.
cat payment_YYYYMMDD-HHMM.dump | docker exec -i payment-postgres \
pg_restore -U payment --clean --if-exists -d payment
12.7.3 마이그레이션 롤백 (alembic downgrade)
엔트리포인트는 기동 시 alembic upgrade head만 한다. 직전 배포의 스키마 변경을 되돌리려면 app 컨테이너에서 직접 downgrade한다.
docker compose -f docker-compose.prod.yml exec app alembic current # 현재 리비전
docker compose -f docker-compose.prod.yml exec app alembic downgrade -1 # 한 단계 롤백
- 데이터 손실 주의: 컬럼/테이블을 지우는 downgrade는 데이터도 지운다. 반드시 12.7.1 백업을 먼저 뜨고, 코드 롤백(이전 이미지 재배포)으로 해결 가능한지 먼저 검토한다.
- 스키마-이미지 정합: 새 이미지는 자동으로
upgrade head까지 올린다. 이전 이미지로 되돌릴 땐 그 이미지가 기대하는 리비전까지 먼저 downgrade해야 정합이 맞는다.
12.7.4 Redis 데이터
redis는 세션·캐시다. 명명 볼륨(redis-data)에 스냅샷이 남지만 유실돼도 재로그인 수준의 영향이며 결제·구독 데이터(DB)와 무관하다. 별도 백업 대상이 아니다.
🔗함께 보기: 서비스 API