요약
먼저 읽을 결론
개발에서 잘 돌던 Docker 컨테이너가 프로덕션에서 터지는 이유 15가지. 루트 실행, 헬스체크 누락, 시크릿 이미지 레이어 포함 등 실제 사고 사례와 체크리스트.
Docker 프로덕션 체크리스트: 개발 환경 컨테이너를 운영에 올리기 전에 반드시 확인할 15가지
Executive Summary - Topic: Docker 컨테이너 프로덕션 배포 전 보안·안정성 체크리스트 - Target: 백엔드 개발자, DevOps 엔지니어, 플랫폼 팀 - TL;DR 1: 개발 Dockerfile과 프로덕션 Dockerfile은 달라야 한다 - TL;DR 2: 루트 실행, 시크릿 레이어 포함, 무한 로그는 프로덕션 3대 사고 원인 - TL;DR 3: HEALTHCHECK와 리소스 제한 없는 컨테이너는 시한폭탄
"개발에서는 잘 됐는데 프로덕션에서 왜 이러죠?" — 이 말을 들을 때마다 Dockerfile을 먼저 봅니다.
대부분의 컨테이너 문제는 개발 편의를 위해 만든 설정이 프로덕션에 그대로 올라가서 발생합니다. 실제로 마주쳤던 사고들을 기반으로 15개 체크리스트를 정리했습니다.
1. 루트(root)로 실행하는 컨테이너
위험도: 🔴 Critical
기본 컨테이너는 루트로 실행됩니다. 컨테이너가 탈출(container escape)되면 호스트를 루트 권한으로 장악합니다.
1# 나쁜 예2FROM node:203WORKDIR /app4COPY . .5RUN npm ci6CMD ["node", "server.js"]7# root로 실행됨8 9# 좋은 예10FROM node:20-alpine11WORKDIR /app12 13# 의존성 먼저 설치 (캐시 최적화)14COPY package*.json ./15RUN npm ci --omit=dev16 17COPY --chown=node:node . .18 19# node 사용자로 전환 (uid 1000)20USER node21 22CMD ["node", "server.js"]2. latest 태그 사용
위험도: 🔴 Critical
1# 나쁜 예 - 오늘 잘 되어도 내일 다를 수 있음2FROM node:latest3FROM postgres:latest4 5# 좋은 예 - 정확한 버전 고정6FROM node:20.11.1-alpine3.197FROM postgres:17.2-alpine3.20latest 태그는 예고 없이 바뀝니다. 월요일 아침 재배포에서 갑자기 메이저 버전이 올라가 패키지 호환성이 깨지는 사고를 방지합니다.
3. 시크릿이 이미지 레이어에 포함
위험도: 🔴 Critical
1# 절대 하지 말 것2RUN apt-get install -y curl && \3 curl -H "Authorization: ${API_KEY}" https://api.example.com/config > config.json4# 다음 레이어에서 삭제해도 이전 레이어에 API_KEY가 남아있음!5 6# docker image history로 모든 레이어 내용 확인 가능올바른 방법:
1# BuildKit secrets 사용 (Docker 18.09+)2# syntax=docker/dockerfile:13FROM node:20-alpine4 5# 빌드 시에만 사용되는 시크릿 (레이어에 저장 안 됨)6RUN --mount=type=secret,id=api_key \7 API_KEY=$(cat /run/secrets/api_key) \8 curl -H "Authorization: $API_KEY" https://api.example.com/config > config.json1# 빌드 명령2docker build --secret id=api_key,src=./api_key.txt .4. HEALTHCHECK 미설정
위험도: 🟡 High
1# 나쁜 예 - 프로세스가 살아있어도 앱이 죽어있으면 모름2FROM node:20-alpine3CMD ["node", "server.js"]4 5# 좋은 예6FROM node:20-alpine7CMD ["node", "server.js"]8 9HEALTHCHECK --interval=30s \10 --timeout=10s \11 --start-period=40s \12 --retries=3 \13 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1Kubernetes에서는 livenessProbe와 readinessProbe로 대체하는 것이 일반적이지만, Docker Compose 환경에서는 HEALTHCHECK가 필수입니다.
5. 빌드 의존성이 프로덕션 이미지에 포함
위험도: 🟡 High
1# 나쁜 예 - 빌드 도구가 모두 포함됨 (이미지 크기 3GB 이상)2FROM node:203WORKDIR /app4COPY . .5RUN npm install # devDependencies까지 설치6RUN npm run build7CMD ["node", ".next/standalone/server.js"]8 9# 좋은 예 - 멀티스테이지 빌드10FROM node:20-alpine AS builder11WORKDIR /app12COPY package*.json ./13RUN npm ci14COPY . .15RUN npm run build16 17FROM node:20-alpine AS runner18WORKDIR /app19ENV NODE_ENV=production20 21# 프로덕션 파일만 복사22COPY --from=builder /app/.next/standalone ./23COPY --from=builder /app/.next/static ./.next/static24COPY --from=builder /app/public ./public25 26USER node27EXPOSE 300028CMD ["node", "server.js"]29# 결과: 3GB → 200MB6. 무제한 리소스 사용
위험도: 🟡 High
한 컨테이너가 메모리 누수로 호스트 전체를 먹어버리는 사고를 방지합니다.
1# docker-compose.yml2services:3 api:4 image: myapp:1.2.35 deploy:6 resources:7 limits:8 cpus: '1.0' # 1 CPU 코어 최대9 memory: 512M # 512MB 메모리 최대10 reservations:11 cpus: '0.25' # 최소 보장12 memory: 128M1# Kubernetes Deployment2resources:3 requests:4 memory: "128Mi"5 cpu: "250m"6 limits:7 memory: "512Mi"8 cpu: "1000m"7. 로그 관리 미설정
위험도: 🟡 High
기본 Docker 로그 드라이버는 JSON 파일을 디스크에 무한정 씁니다. 몇 주 만에 디스크가 가득 차는 사고를 막으려면:
1// /etc/docker/daemon.json2{3 "log-driver": "json-file",4 "log-opts": {5 "max-size": "10m",6 "max-file": "3"7 }8}또는 컨테이너별:
1services:2 api:3 logging:4 driver: json-file5 options:6 max-size: "10m"7 max-file: "3"8. 불필요한 포트 노출
1# 나쁜 예 - 내부 포트까지 노출2EXPOSE 3000 5432 6379 80803 4# 좋은 예 - 외부에서 접근이 필요한 포트만5EXPOSE 3000Docker EXPOSE는 문서화 목적이지만, --publish-all 옵션 사용 시 모두 바인딩됩니다.
9. .dockerignore 미설정
1# .dockerignore 없으면 node_modules, .git, .env 등이 모두 이미지에 포함2 3# .dockerignore4node_modules5.git6.env7.env.local8.env.*.local9*.log10.next11coverage12.nyc_output13dist14.DS_Store15Dockerfile*16docker-compose*17*.md18.github19test20tests21__tests__.env 파일이 이미지에 포함되면 레지스트리에 올라갔을 때 모든 시크릿이 노출됩니다.
10. 컨테이너 내부에서 데이터 저장
컨테이너는 stateless해야 합니다. 파일을 컨테이너 내부에 저장하면 재시작 시 모두 사라집니다.
1# 볼륨 마운트 필수2services:3 app:4 volumes:5 - uploads:/app/uploads # 업로드 파일6 - logs:/app/logs # 로그 파일7 8 db:9 volumes:10 - postgres_data:/var/lib/postgresql/data # DB 데이터11 12volumes:13 uploads:14 logs:15 postgres_data:11. 패키지 캐시가 이미지에 포함
1# 나쁜 예 - apt 캐시 포함 (이미지 크기 증가)2RUN apt-get update3RUN apt-get install -y curl4 5# 좋은 예 - 같은 레이어에서 캐시 삭제6RUN apt-get update && \7 apt-get install -y --no-install-recommends curl && \8 rm -rf /var/lib/apt/lists/*9 10# Node.js11RUN npm ci --omit=dev && \12 npm cache clean --force13 14# Python15RUN pip install --no-cache-dir -r requirements.txt12. 환경변수로 직접 시크릿 전달
1# 나쁜 예 - docker inspect로 노출됨2services:3 api:4 environment:5 DB_PASSWORD: my_super_secret_password6 7# 좋은 예 - Docker secrets 또는 외부 시크릿 관리자8services:9 api:10 environment:11 DB_PASSWORD_FILE: /run/secrets/db_password12 secrets:13 - db_password14 15secrets:16 db_password:17 external: true # Docker Swarm의 external secret또는 AWS Secrets Manager, HashiCorp Vault를 앱 코드에서 직접 참조합니다.
13. PID 1 문제 (좀비 프로세스)
1# 나쁜 예 - node가 PID 1이 됨2# 좀비 프로세스 리핑과 시그널 처리를 제대로 못 함3CMD ["node", "server.js"]4 5# 좋은 예 - tini 사용 (경량 init 프로세스)6FROM node:20-alpine7RUN apk add --no-cache tini8ENTRYPOINT ["/sbin/tini", "--"]9CMD ["node", "server.js"]10 11# 또는 docker run --init 플래그12# docker-compose에서: init: true14. 이미지 취약점 스캔 미실시
1# Trivy로 취약점 스캔 (CI/CD 파이프라인에 포함)2APT_OUTPUT=$(trivy image \3 --severity HIGH,CRITICAL \4 --exit-code 1 \5 myapp:1.2.3)6 7echo "$APT_OUTPUT"1# GitHub Actions 예시2- name: Scan Docker image3 uses: aquasecurity/trivy-action@master4 with:5 image-ref: 'myapp:${{ github.sha }}'6 format: 'sarif'7 exit-code: '1'8 severity: 'HIGH,CRITICAL'15. 재현 불가능한 빌드
1# 나쁜 예 - RUN 중에 외부에서 파일 다운로드2RUN curl -o /usr/local/bin/tool https://example.com/tool && chmod +x /usr/local/bin/tool3# 오늘은 v1.2, 내일은 v1.3이 받아질 수 있음4 5# 좋은 예 - 버전과 해시 고정6RUN curl -fsSL -o /tmp/tool.tar.gz \7 https://example.com/tool-1.2.3.tar.gz && \8 echo "abc123def456 /tmp/tool.tar.gz" | sha256sum -c - && \9 tar -xzf /tmp/tool.tar.gz -C /usr/local/bin/ && \10 rm /tmp/tool.tar.gz최종 Dockerfile 예시 (Next.js)
1# syntax=docker/dockerfile:12FROM node:20.11.1-alpine3.19 AS base3 4# 의존성 설치 스테이지5FROM base AS deps6WORKDIR /app7COPY package.json package-lock.json ./8RUN npm ci9 10# 빌드 스테이지11FROM base AS builder12WORKDIR /app13COPY --from=deps /app/node_modules ./node_modules14COPY . .15RUN npm run build16 17# 프로덕션 스테이지18FROM base AS runner19WORKDIR /app20ENV NODE_ENV=production21 22# tini 설치 (PID 1 문제 해결)23RUN apk add --no-cache tini && \24 addgroup --system --gid 1001 nodejs && \25 adduser --system --uid 1001 nextjs26 27COPY --from=builder /app/public ./public28COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./29COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static30 31USER nextjs32EXPOSE 300033 34HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \35 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 136 37ENTRYPOINT ["/sbin/tini", "--"]38CMD ["node", "server.js"]FAQ
Q. Docker Desktop의 Scout 기능으로 취약점 스캔이 되나요? A. 네, 로컬에서 빠르게 확인할 수 있습니다. CI/CD 파이프라인에는 Trivy나 Snyk을 사용하는 것이 더 자동화하기 좋습니다. 엔터프라이즈 환경에서는 Amazon ECR의 Enhanced Scanning (Snyk 통합) 또는 Google Artifact Registry의 On-demand scanning을 권장합니다.
Q. 멀티스테이지 빌드가 로컬 개발에도 필요한가요? A. 로컬은 일반적으로 빌드 속도와 편의성을 위해 단순 Dockerfile을 써도 됩니다. 그러나 docker-compose.yml에서 프로덕션 Dockerfile과 개발용 Dockerfile을 분리해 두는 것이 좋습니다: Dockerfile (프로덕션), Dockerfile.dev (개발).
Q. Kubernetes 환경에서도 이 체크리스트가 동일하게 적용되나요? A. 대부분 적용됩니다. HEALTHCHECK는 livenessProbe/readinessProbe로 대체하고, 리소스 제한은 Pod spec에서 설정합니다. 시크릿은 Kubernetes Secrets 또는 External Secrets Operator를 사용하세요.
질문
자주 묻는 질문
이 글(Docker 프로덕션 체크리스트: 개발 환경 컨테이너를 운영에 올리기 전에 반드시 확인할 15가지)의 핵심 메시지는 무엇인가요?
개발에서 잘 돌던 Docker 컨테이너가 프로덕션에서 터지는 이유 15가지. 루트 실행, 헬스체크 누락, 시크릿 이미지 레이어 포함 등 실제 사고 사례와 체크리스트.
docker를 우선 검토해야 하는 시점은 언제인가요?
수작업 예외 처리와 운영 병목이 반복되기 시작하면, 구현을 늘리기 전에 아키텍처 경계를 먼저 고정하고 지표로 검증해야 합니다.
devops 관점에서 가장 먼저 확인할 항목은 무엇인가요?
기능 확장 전에 폴백 경로, 로그/모니터링 기준, 책임 경계를 먼저 점검해야 운영 리스크를 줄일 수 있습니다.
