chore(docker): 운영 배포 점검 반영 — 헬스체크·업로드 권한·빌드 컨텍스트·e2e 직렬 실행

운영 스택을 따로 띄워 확인하다 드러난 것들을 막는다.
- 업로드 디렉터리가 root 소유로 생기면 사진 업로드가 전부 500 인데 헬스체크(/)는 healthy 였다
  → 운영 헬스체크를 /api/health(DB) + 업로드 쓰기 권한으로, 이미지에 node 소유 디렉터리를 만들어 둔다
- 빌드 컨텍스트에 호스트(Windows) node_modules 와 backend/.env.* 가 들어가고 있었다 → .dockerignore
- CI e2e 는 스위트마다 빈 DB 에 마이그레이션을 동시에 돌려 충돌하고 재연결 타이밍으로만 통과했다 → --runInBand
- dev 헬스체크는 쿼리 로그가 쌓이지 않게 / 로 둔다
- 첫 배포 준비(env 두 파일·proxy 네트워크·업로드 디렉터리·백업·셸 APP_ENV 주의)를 docker 스킬에 적는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 23:56:24 +09:00
parent 0b978a9610
commit f60654bed6
10 changed files with 64 additions and 2 deletions
+15
View File
@@ -41,6 +41,21 @@ sudo docker compose --env-file .env.production up -d --build
sudo docker compose --env-file .env.production logs -f backend
```
첫 배포 전에 서버에서 한 번 준비한다.
- env 두 파일 — 루트 `.env.production`, `backend/.env.production` (`JWT_SECRET` 32자 이상, 소셜 로그인 값). placeholder 가 남으면 기동 실패
- 외부 네트워크 `proxy` — 리버스 프록시(TLS 종료)와 backend·frontend 가 이 네트워크로 만난다. 없으면 `docker network create proxy`
- 업로드 디렉터리 — backend 는 uid 1000 으로 돈다. `sudo install -d -o 1000 -g 1000 ./data/uploads` (`UPLOAD_DATA_DIR`).
root 소유로 생기면 업로드가 전부 실패하고 backend 가 unhealthy 가 된다
- 마이그레이션은 backend 가 기동할 때 자동 적용된다 (`migrationsRun`). 별도 명령이 없다
- 백업 — `DB_DATA_DIR` 와 `UPLOAD_DATA_DIR` 를 **함께** 백업한다 (DB 가 파일을 id 로 가리킨다)
- 셸에 `APP_ENV` 같은 변수를 export 하지 않는다 — compose 는 셸 변수를 `--env-file` 보다 우선한다.
`APP_ENV=development` 가 남아 있으면 운영 명령이 development 설정(개발용 로그인 열림)으로 뜬다.
`sudo docker compose --env-file .env.production config | grep NODE_ENV` 로 `production` 인지 확인한다
배포 후 `sudo docker compose --env-file .env.production ps` 에서 backend 가 `healthy` 인지 확인한다
(헬스체크는 `/api/health` — DB 연결과 업로드 디렉터리 쓰기 권한을 함께 본다).
---
## 프론트엔드는 호스트에서 (개발 환경)
+5
View File
@@ -76,6 +76,11 @@ COMPOSE_PROFILES=
# ==========================================
# 파일 업로드 (로컬 디스크)
#
# UPLOAD_DATA_DIR : 업로드 사진이 저장되는 호스트 경로 (컨테이너의 /app/uploads 에 연결)
# Linux 호스트라면 백엔드(비-root, uid 1000)가 쓸 수 있게 미리 만든다:
# install -d -o 1000 -g 1000 ./data/uploads
# (Docker Desktop 의 Windows·macOS 경로는 소유권과 무관하게 쓸 수 있다)
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
+6
View File
@@ -77,6 +77,12 @@ COMPOSE_PROFILES=
# ==========================================
# 파일 업로드 (로컬 디스크)
#
# UPLOAD_DATA_DIR : 업로드 사진이 저장되는 호스트 경로 (컨테이너의 /app/uploads 에 연결)
# - 백엔드는 비-root(node, uid 1000)로 돈다. 디렉터리가 없으면 docker 가 root 소유로 만들어
# 업로드가 전부 실패하므로(헬스체크 unhealthy) **첫 배포 전에 한 번** 만들어 둔다.
# sudo install -d -o 1000 -g 1000 ./data/uploads
# - DB 는 파일을 id 로 가리킨다. 백업은 DB_DATA_DIR 와 이 디렉터리를 **함께** 한다.
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
+4 -1
View File
@@ -57,8 +57,11 @@ jobs:
run: npm run test
# 표준 응답 래퍼·에러 코드·traceId 계약 검증 (.claude/rules/response-format.md)
# --runInBand: 스위트들이 같은 DB 를 쓰고, NODE_ENV=test 라 스위트마다 기동할 때 마이그레이션을 돌린다.
# 병렬로 돌면 빈 DB 에서 여러 스위트가 같은 테이블을 동시에 만들다 충돌한다(duplicate key ... pg_class).
# TypeORM 재연결(3초 뒤)로 대개 통과하지만 타이밍에 기대는 것이라 순서대로 돌린다.
- name: E2E test
run: npm run test:e2e
run: npm run test:e2e -- --runInBand
env:
NODE_ENV: test
# 서비스 컨테이너의 네트워크 별칭 (localhost 는 잡 컨테이너 자신을 가리킨다)
+13
View File
@@ -0,0 +1,13 @@
# 빌드 컨텍스트에서 제외한다 — 이미지 안에서 새로 만들거나, 이미지에 들어가면 안 되는 것
#
# node_modules : 호스트(Windows)에서 설치한 네이티브 바이너리(@swc/core-win32 등)가
# 이미지의 npm ci 결과를 덮어써 Linux 빌드가 깨진다
# .env* : 시크릿 — 값은 compose 의 env_file/environment 로만 주입한다 (.claude/rules/security.md)
# uploads : 사용자 파일 — 운영에서는 compose 볼륨(UPLOAD_DATA_DIR)에 있다
node_modules
dist
coverage
uploads
.env
.env.*
*.log
+2
View File
@@ -29,3 +29,5 @@ NAVER_CLIENT_ID=
GOOGLE_CLIENT_IDS=
# Apple 번들 ID·서비스 ID (콤마 구분) — id_token 의 aud
APPLE_CLIENT_IDS=
# 개발용 로그인(AUTH_DEV_LOGIN)은 운영에서 값과 무관하게 코드로 닫힌다 — 여기에 두지 않는다.
+5
View File
@@ -31,6 +31,11 @@ RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# 업로드 저장 위치(compose 의 UPLOAD_DIR) — node 가 쓸 수 있게 소유권을 넘겨 둔다.
# named volume 은 이 소유권을 이어받는다. 호스트 경로(bind mount)는 호스트 디렉터리 소유권을 따르므로
# 운영 서버에서 미리 uid 1000 소유로 만들어 둔다 (.env.production.example 의 UPLOAD_DATA_DIR 참조)
RUN mkdir -p /app/uploads && chown node:node /app/uploads
EXPOSE 3000
# 비-root 실행 (보안 규칙)
+4
View File
@@ -49,6 +49,10 @@ services:
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
- "${WEBSOCKET_PORT}:${WEBSOCKET_PORT}"
# dev 는 TypeORM 쿼리 로그가 켜져 있어 /api/health(DB ping)를 부르면 30초마다 SELECT 1 이 로그에 쌓인다
# → 프로세스가 떠 있는지만 본다 (운영은 base 의 /api/health)
healthcheck:
test: ["CMD-SHELL", "test -w /app/uploads && wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"]
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
+3 -1
View File
@@ -88,8 +88,10 @@ services:
condition: service_healthy
required: false
healthcheck:
# /api/health 는 DB 까지 확인한다 (루트 '/' 는 프로세스가 떠 있는지만 본다)
# 업로드 디렉터리에 쓸 수 없으면(호스트 경로 소유권 문제) 사진 업로드가 전부 500 이 되므로 unhealthy 로 드러낸다
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 Node 는 0.0.0.0(IPv4) 바인딩이므로 127.0.0.1 을 명시한다
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"]
test: ["CMD-SHELL", "test -w /app/uploads && wget -qO- http://127.0.0.1:${BACKEND_PORT}/api/health || exit 1"]
interval: 30s
timeout: 5s
retries: 5
+7
View File
@@ -0,0 +1,7 @@
# 빌드 컨텍스트에서 제외한다 — 이미지 안에서 npm ci · vite build 로 새로 만든다
# (호스트에서 설치한 node_modules 의 네이티브 바이너리가 Linux 빌드를 깨뜨리지 않게)
node_modules
dist
coverage
*.log
*.tsbuildinfo