프로덕션 컨테이너 운영 패턴
개발 환경에서는 정상인데, 배포 순간 요청이 끊기거나 종료 신호를 무시해 강제 종료되는 문제가 반복됩니다. 원인은 대개 애플리케이션 코드보다 운영 패턴(PID 1, 신호 처리, 시크릿 주입, 버전 고정)의 빈틈에 있습니다. 프로덕션에서는 "돌아간다"보다 "예측 가능하게 멈추고 복구된다"가 더 중요합니다. 이 모듈은 장애를 줄이는 운영 기본기를 컨테이너 관점으로 정리합니다.
컨테이너가 개발 환경에서는 잘 작동하다가 프로덕션에서 갑자기 이상하게 동작하는 경우가 있습니다. 배포 중 요청이 끊기거나, 컨테이너가 멈추지 않거나, 이상하게 메모리를 누수합니다. 이 모듈은 프로덕션 운영에서 실제로 마주치는 문제들 — PID 1, 신호 처리, Graceful Shutdown, 시크릿 관리, 이미지 핀닝 — 을 다루고 각각의 올바른 해결 방법을 제시합니다.
프로덕션 환경에서 컨테이너를 안정적으로 운영하기 위한 패턴들을 다룹니다. 개발 환경과 프로덕션 환경의 차이를 이해하고, 실제 장애 상황에서 도움이 되는 설정 방법을 학습합니다.
- 1PID 1 문제와 좀비 프로세스 — tini/dumb-init이 필요한 이유
- 2SIGTERM 처리와 Graceful Shutdown 구현 (Node.js/Python 예시)
- 3환경별 설정 관리 — 환경변수 vs 설정 파일 vs Docker secrets
- 4컨테이너 이미지 버전 고정 — latest 금지와 다이제스트 핀닝
- 5리소스 요청/제한 설정 원칙과 docker update
- 6로그 드라이버와 로그 로테이션 설정
대부분의 실습은 Docker만 있으면 가능합니다. 신호 처리 코드 예제는 컨테이너 내에서 실행하므로 로컬에 Node.js 설치가 필요하지 않습니다.
docker run --rm --init alpine sh -c 'ps aux'mkdir -p ~/prod-patterns && cd ~/prod-patternsnode --version || echo 'Node.js 미설치 — Docker 컨테이너 내에서 실행'docker compose versionPID 1 문제와 Init 프로세스
docker stop을 실행했는데 컨테이너가 10초 기다렸다가 강제 종료됩니다. 로그에는 진행 중이던 요청이 갑자기 끊긴 흔적이 있습니다. 혹은 컨테이너를 오래 실행하고 나면 docker stats에서 프로세스 수가 계속 늘어납니다 — 좀비 프로세스가 쌓이고 있는 것입니다. 두 증상 모두 PID 1 문제에서 비롯됩니다. Node.js, Python 같은 애플리케이션 프로세스가 컨테이너 안에서 PID 1로 실행될 때, 신호 처리와 자식 프로세스 회수를 PID 1처럼 하지 않아서 생기는 문제입니다.

리눅스 시스템에서 PID 1의 역할
일반 리눅스 시스템에서 PID 1은 systemd나 init이 담당하며, 두 가지 핵심 역할을 합니다:
- 좀비 프로세스 회수: 자식 프로세스가 종료되면 부모가
wait()를 호출해야 합니다. 부모 프로세스가 먼저 죽으면 고아 프로세스가 되고, PID 1이 입양하여 회수합니다. - 신호 전달: SIGTERM 같은 신호를 자식 프로세스에 전달합니다.
일반 시스템:
PID 1 (systemd) → PID 100 (nginx) → PID 101 (worker)
→ PID 102 (worker)
↑ 신호 전달 + 좀비 회수 담당
컨테이너 (tini 없이):
PID 1 (node server.js) → PID 50 (child process)
↑ 앱이 직접 PID 1 — init 역할 수행 불가
tini와 dumb-init
tini와 dumb-init은 수백 줄 코드의 경량 init 프로세스입니다. 앱을 직접 PID 1으로 실행하는 대신 이 init 프로세스를 PID 1으로 실행하고 앱을 자식 프로세스로 실행합니다.
# 방법 1: tini를 Dockerfile에 포함
FROM node:20-alpine
# Alpine에서 tini 설치
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
# 방법 2: Docker --init 플래그 (Docker에 내장된 tini 사용)
# Dockerfile 변경 불필요, 실행 시 플래그 추가
# docker run --init myapp
# docker-compose에서:
# services:
# app:
# init: true
# 방법 3: dumb-init (GitHub star 더 많음)
RUN apk add --no-cache dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server.js"]
좀비 프로세스 발생 확인
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part5/exam_27 && cd /tmp/docker/part5/exam_27
# tini 없이 실행 — 좀비 프로세스 시뮬레이션
docker run --rm --name zombie-test alpine sh -c '
# 자식 프로세스를 생성하고 부모가 wait() 호출 안 함
sleep 5 & sleep 5 & sleep 5 &
sleep 30
' &
# 다른 터미널에서 프로세스 상태 확인 (Z = 좀비)
docker exec zombie-test ps aux
# tini 사용 시
docker run --rm --init --name tini-test alpine sh -c '
sleep 5 & sleep 5 & sleep 5 &
sleep 30
' &
docker exec tini-test ps aux
# 좀비 없음 — tini가 자동으로 회수
SIGTERM 처리와 Graceful Shutdown
배포 중 docker stop으로 구 버전 컨테이너를 내립니다. 그 순간 몇 명의 사용자가 요청을 처리 중이었습니다. 처리 중이던 요청들이 갑자기 끊겨서 클라이언트에는 503이 반환됩니다. 10초 뒤 컨테이너가 강제 종료됩니다(SIGKILL). 애플리케이션이 SIGTERM을 받았지만 처리하지 않은 것이 원인입니다. Graceful Shutdown은 SIGTERM을 받으면 새 요청을 받지 않고 진행 중인 요청만 마무리한 뒤 종료합니다. 코드 수정이 필요하지만 패턴은 명확합니다.

Docker 컨테이너 중지 신호 흐름
docker stop mycontainer
│
▼
SIGTERM 전송 → 앱이 처리할 시간 (기본 10초)
│
▼ (10초 후 응답 없으면)
SIGKILL 전송 → 강제 종료 (진행 중 요청 모두 끊김)
Graceful Shutdown의 목표는 SIGTERM 수신 후 다음 순서로 종료하는 것입니다:
- 새 요청 수신 중단 (로드 밸런서에서 트래픽 차단)
- 진행 중인 요청 완료 대기
- 데이터베이스 연결, 파일 핸들 등 리소스 정리
- 프로세스 종료
Node.js Graceful Shutdown 구현
// server.js — 프로덕션 수준 Graceful Shutdown
const http = require('http');
const server = http.createServer((req, res) => {
// 실제 요청 처리 로직 (예: DB 조회, 외부 API 호출)
setTimeout(() => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
}, 100); // 100ms 처리 시간 시뮬레이션
});
// 현재 진행 중인 연결 추적
let connections = new Set();
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
server.listen(3000, () => {
console.log('서버 시작: http://localhost:3000');
});
// Graceful Shutdown 함수
function gracefulShutdown(signal) {
console.log(`[${signal}] Graceful Shutdown 시작...`);
// 1. 새 연결 수락 중단
server.close((err) => {
if (err) {
console.error('서버 종료 오류:', err);
process.exit(1);
}
console.log('모든 요청 완료 — 프로세스 종료');
process.exit(0);
});
// 2. 기존 keep-alive 연결에 연결 종료 헤더 전송
for (const socket of connections) {
socket.end(); // 즉시 파괴하지 않고 현재 요청 완료 후 종료
}
// 3. 타임아웃 설정 — 8초 내 완료 안 되면 강제 종료
// (Docker의 10초 SIGKILL보다 2초 여유)
setTimeout(() => {
console.error('타임아웃 — 강제 종료');
process.exit(1);
}, 8000);
}
// 신호 핸들러 등록
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT')); // Ctrl+C 로컬 개발용
// 예상치 못한 오류 처리
process.on('uncaughtException', (err) => {
console.error('처리되지 않은 오류:', err);
gracefulShutdown('uncaughtException');
});
Python Graceful Shutdown 구현 (FastAPI)
# main.py — FastAPI Graceful Shutdown
import asyncio
import signal
import sys
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
# 진행 중인 요청 카운터
active_requests = 0
shutting_down = False
@asynccontextmanager
async def lifespan(app: FastAPI):
# 시작 시 초기화
print("서버 시작")
yield
# 종료 시 정리
print("서버 종료 중...")
# DB 연결 종료, 큐 플러시 등
await asyncio.sleep(0.1) # 정리 작업 시간
print("정리 완료")
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def track_requests(request, call_next):
global active_requests
if shutting_down:
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=503,
content={"detail": "서버 종료 중"}
)
active_requests += 1
try:
response = await call_next(request)
return response
finally:
active_requests -= 1
@app.get("/health")
async def health():
if shutting_down:
return {"status": "shutting_down"}
return {"status": "ok", "active_requests": active_requests}
def handle_sigterm(signum, frame):
global shutting_down
print(f"SIGTERM 수신 — Graceful Shutdown 시작")
shutting_down = True
async def shutdown():
# 진행 중인 요청 완료 대기 (최대 8초)
for _ in range(80): # 0.1초 * 80 = 8초
if active_requests == 0:
break
await asyncio.sleep(0.1)
print(f"종료 (잔여 요청: {active_requests})")
sys.exit(0)
asyncio.create_task(shutdown())
signal.signal(signal.SIGTERM, handle_sigterm)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
docker-compose에서 종료 타임아웃 설정
# docker-compose.yml
services:
api:
image: myapi:1.2.3
init: true # tini 활성화
stop_grace_period: 30s # 기본 10초에서 30초로 확장
# 위 설정은 docker stop -t 30과 동일
환경별 설정 관리
같은 이미지를 개발/스테이징/프로덕션에 배포합니다. 환경마다 DB 호스트, API 키, 로그 레벨이 다릅니다. 이를 Dockerfile에 하드코딩하면 환경마다 이미지를 따로 빌드해야 합니다. 반대로 설정 파일을 이미지에 넣으면 API 키가 이미지 레이어에 노출됩니다. 컨테이너 설정을 외부에서 주입하는 방법은 환경변수, 마운트 파일, Secrets 세 가지이며, 값의 민감도와 크기에 따라 골라 씁니다.

설정 주입 방식 비교
컨테이너 설정을 주입하는 방법은 세 가지가 있으며, 각각 적합한 사용 사례가 다릅니다.
┌──────────────────┬─────────────────────┬──────────────────────┐
│ 방식 │ 적합한 용도 │ 주의사항 │
├──────────────────┼─────────────────────┼──────────────────────┤
│ 환경 변수 │ 비민감 설정값 │ docker inspect로 노출│
│ │ (포트, URL, 플래그) │ 가능 │
├──────────────────┼─────────────────────┼──────────────────────┤
│ 설정 파일 │ 복잡한 구조화 설정 │ 볼륨 마운트 필요 │
│ (볼륨 마운트) │ (nginx.conf 등) │ 파일 권한 관리 │
├──────────────────┼─────────────────────┼──────────────────────┤
│ Docker secrets │ 비밀번호, API 키, │ Swarm/Compose 전용 │
│ │ 인증서 │ K8s는 별도 시크릿 │
└──────────────────┴─────────────────────┴──────────────────────┘
Docker secrets 실습 구성
# docker-compose.yml — secrets 사용 예시
version: "3.8"
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myuser
# 비밀번호는 환경변수 대신 secrets 사용
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
api:
image: myapi:1.2.3
environment:
DATABASE_HOST: db
DATABASE_PORT: "5432"
DATABASE_NAME: myapp
DATABASE_USER: myuser
# 앱 코드에서 /run/secrets/db_password 파일을 읽어 사용
secrets:
- db_password
- jwt_secret
depends_on:
- db
secrets:
db_password:
file: ./secrets/db_password.txt # 로컬 파일에서 읽기
jwt_secret:
file: ./secrets/jwt_secret.txt
volumes:
pgdata:
// Node.js에서 Docker secrets 읽기
const fs = require('fs');
function readSecret(name) {
const secretPath = `/run/secrets/${name}`;
try {
// secrets는 파일로 마운트됨 — trim()으로 개행 문자 제거
return fs.readFileSync(secretPath, 'utf8').trim();
} catch (err) {
// 로컬 개발 환경 fallback: 환경변수 사용
const envVar = name.toUpperCase().replace(/-/g, '_');
const value = process.env[envVar];
if (!value) {
throw new Error(`시크릿 없음: ${name} (파일: ${secretPath}, 환경변수: ${envVar})`);
}
return value;
}
}
// 사용 예시
const dbPassword = readSecret('db_password');
const jwtSecret = readSecret('jwt_secret');
환경별 설정 파일 패턴
# 디렉토리 구조
config/
├── default.json # 모든 환경 공통 기본값
├── development.json # 개발 환경 오버라이드
├── staging.json # 스테이징 환경 오버라이드
└── production.json # 프로덕션 환경 오버라이드
# docker-compose.prod.yml
services:
api:
image: myapi:1.2.3
environment:
NODE_ENV: production
CONFIG_FILE: /etc/myapp/config.json
volumes:
# 프로덕션 설정 파일을 읽기 전용으로 마운트
- ./config/production.json:/etc/myapp/config.json:ro
secrets:
- db_password
이미지 버전 고정과 다이제스트 핀닝

latest 태그가 위험한 이유
# 오늘 배포
docker pull node:20-alpine
# SHA256: abc123...
# 3개월 후 동일 명령
docker pull node:20-alpine
# SHA256: def456... ← 다른 이미지!
# 새 Node.js 패치 포함, 예상치 못한 동작 변경 가능
다이제스트로 이미지 핀닝
# 현재 이미지의 다이제스트 확인
docker pull node:20-alpine
docker inspect node:20-alpine --format='{{index .RepoDigests 0}}'
# node@sha256:a1b2c3d4e5f6...
# 다이제스트로 Dockerfile 작성
FROM node@sha256:a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789
# 실무 권장 패턴: 태그 + 다이제스트 병기 (가독성 + 안전성)
FROM node:20-alpine@sha256:a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789
# 베이스 이미지 메타데이터 레이블 추가 (추적용)
LABEL base.image="node:20-alpine" \
base.digest="sha256:a1b2c3d4e5f6..."
docker-compose에서 이미지 버전 고정
# 나쁜 예
services:
api:
image: myapi:latest # 롤백 불가능
db:
image: postgres:latest # 언제든 메이저 업그레이드 될 수 있음
# 좋은 예
services:
api:
image: myapi:1.2.3 # 명시적 semver
db:
image: postgres:15.4-alpine # 패치 버전까지 고정
# 가장 안전한 예 (프로덕션 크리티컬 서비스)
services:
db:
image: postgres:15.4-alpine@sha256:abc123...
이미지 취약점 스캔
# Docker Scout로 취약점 스캔 (Docker Desktop 포함)
docker scout cves myapi:1.2.3
# 요약만 출력
docker scout quickview myapi:1.2.3
# Trivy 사용 (오픈소스 대안)
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image myapi:1.2.3
리소스 제한과 로그 관리

리소스 제한 설정 원칙
리소스 제한 없이 컨테이너를 실행하면 메모리 누수나 CPU 급등 시 같은 호스트의 다른 서비스까지 영향을 줍니다.
# docker-compose.yml 리소스 제한
services:
api:
image: myapi:1.2.3
deploy:
resources:
limits:
cpus: '1.0' # 최대 1 CPU 코어
memory: 512M # 최대 512MB
reservations:
cpus: '0.25' # 최소 0.25 CPU 보장
memory: 128M # 최소 128MB 보장
# 실행 중 컨테이너 리소스 제한 변경 (재시작 불필요)
docker update --memory 1g --memory-swap 1g mycontainer
docker update --cpus 2.0 mycontainer
# 현재 리소스 사용량 확인
docker stats mycontainer
# 컨테이너 메모리 한도 확인
docker inspect mycontainer --format='{{.HostConfig.Memory}}'
OOM Killer 대응
# 메모리 한도 초과로 컨테이너가 종료된 경우 확인
docker inspect mycontainer --format='{{.State.OOMKilled}}'
# true → OOM으로 종료됨
# 적절한 메모리 한도 설정을 위한 사용량 모니터링
docker stats --no-stream --format \
"table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" mycontainer
로그 드라이버 설정
# docker-compose.yml 로그 설정
services:
api:
image: myapi:1.2.3
logging:
driver: json-file # 기본값: 로컬 JSON 파일
options:
max-size: "10m" # 파일당 최대 10MB
max-file: "5" # 최대 5개 파일 로테이션
compress: "true" # 이전 파일 gzip 압축
# 로그 수집 서버 사용 시 (Fluentd/Loki 등)
worker:
image: myworker:1.0.0
logging:
driver: fluentd
options:
fluentd-address: "localhost:24224"
fluentd-async: "true" # non-blocking 모드 (권장)
fluentd-buffer-limit: "8MB"
tag: "myapp.worker"
# 로그 드라이버별 docker logs 명령 지원 여부
# json-file, journald: docker logs 사용 가능
# fluentd, syslog, splunk: docker logs 사용 불가
# → 로그는 해당 수집 시스템에서 조회해야 함
# 로그 파일 위치 확인 (json-file 드라이버)
docker inspect mycontainer \
--format='{{.LogPath}}'
# /var/lib/docker/containers/<id>/<id>-json.log
기본 실습
실습 전 디렉토리와 예제 파일을 먼저 준비합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part4/exam_6 && cd /tmp/docker/part4/exam_6
# 프로덕션 패턴 실습용 Dockerfile 생성 (tini 포함)
cat > Dockerfile << 'EOF'
FROM node:18-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
USER node
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
EOF
이제 실습을 진행합니다.
# 좀비 프로세스 생성 스크립트
cat > ~/prod-patterns/zombie-demo/spawn.sh << 'EOF'
#!/bin/sh
# 자식 프로세스를 생성하지만 wait() 호출 안 함
for i in $(seq 1 5); do
sleep 60 &
echo "자식 PID: $!"
done
echo "부모 PID: $$"
# 부모 프로세스 유지
exec sleep 300
EOF
chmod +x ~/prod-patterns/zombie-demo/spawn.sh
# tini 없이 실행 — PID 1이 직접 스크립트 실행
docker run -d --name zombie-without-tini \
-v ~/prod-patterns/zombie-demo/spawn.sh:/spawn.sh \
alpine sh /spawn.sh
sleep 2
echo "=== tini 없이: 프로세스 목록 ==="
docker exec zombie-without-tini ps aux
# 자식 프로세스들이 완료되면 좀비(Z) 상태로 남을 수 있음
# tini(--init)와 함께 실행
docker run -d --name zombie-with-tini --init \
-v ~/prod-patterns/zombie-demo/spawn.sh:/spawn.sh \
alpine sh /spawn.sh
sleep 2
echo "=== tini 사용: 프로세스 목록 ==="
docker exec zombie-with-tini ps aux
# tini가 PID 1 — 고아 프로세스 자동 회수
# 정리
<DangerBlock
title="실습 컨테이너 강제 삭제 전 대상 재확인"
command="docker rm -f zombie-without-tini zombie-with-tini"
risk="강제 삭제는 실행 중 프로세스를 즉시 종료하므로 추가 관찰/로그 수집 기회를 잃습니다."
safeCondition="좀비 프로세스 비교 관찰이 끝났고 실습 컨테이너만 대상으로 할 때 실행합니다."
checklist={[
'삭제 대상 이름이 실습 컨테이너 2개가 맞는가?',
'비교 결과(출력/스크린샷)를 이미 확보했는가?',
'운영 컨테이너 이름과 혼동되지 않는가?',
]}
/>
docker rm -f zombie-without-tini zombie-with-tini
mkdir -p ~/prod-patterns/zombie-demo# Node.js Graceful Shutdown 서버
cat > ~/prod-patterns/graceful-shutdown/server.js << 'EOF'
const http = require('http');
let activeRequests = 0;
let shuttingDown = false;
const connections = new Set();
const server = http.createServer((req, res) => {
if (shuttingDown) {
res.writeHead(503);
res.end('서버 종료 중');
return;
}
activeRequests++;
console.log(`요청 시작 (진행 중: ${activeRequests})`);
// 오래 걸리는 작업 시뮬레이션 (5초)
setTimeout(() => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', pid: process.pid }));
activeRequests--;
console.log(`요청 완료 (잔여: ${activeRequests})`);
}, 5000);
});
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
server.listen(3000, () => console.log('서버 시작 (PID:', process.pid + ')'));
function shutdown(signal) {
console.log(`\n[${signal}] Graceful Shutdown 시작`);
console.log(`진행 중인 요청: ${activeRequests}개`);
shuttingDown = true;
server.close(() => {
console.log('모든 요청 완료 — 종료');
process.exit(0);
});
for (const socket of connections) socket.end();
setTimeout(() => {
console.error('타임아웃 — 강제 종료');
process.exit(1);
}, 8000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
EOF
# Dockerfile
cat > ~/prod-patterns/graceful-shutdown/Dockerfile << 'EOF'
FROM node:20-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY server.js .
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
EOF
# 빌드 및 실행
docker build -t graceful-demo ~/prod-patterns/graceful-shutdown/
docker run -d --name graceful-test -p 3000:3000 graceful-demo
# 요청을 보내놓고 즉시 SIGTERM 전송
curl http://localhost:3000/ &
sleep 0.5
docker stop graceful-test # SIGTERM 전송
# 로그 확인 — 진행 중 요청 완료 후 종료되는지 확인
docker logs graceful-test
# 정리
docker rm graceful-test
mkdir -p ~/prod-patterns/graceful-shutdown# 시크릿 파일 생성 (실제 환경에서는 비밀 관리 시스템에서 주입)
echo -n "super-secret-password-123" > ~/prod-patterns/secrets-demo/secrets/db_password.txt
echo -n "jwt-secret-key-very-long-random-string" > ~/prod-patterns/secrets-demo/secrets/jwt_secret.txt
# 시크릿 파일 권한 설정
chmod 600 ~/prod-patterns/secrets-demo/secrets/*.txt
# docker-compose.yml 생성
cat > ~/prod-patterns/secrets-demo/docker-compose.yml << 'EOF'
version: "3.8"
services:
app:
image: node:20-alpine
command: >
sh -c "
echo '=== 마운트된 시크릿 확인 ===' &&
ls -la /run/secrets/ &&
echo '=== DB 비밀번호 ===' &&
cat /run/secrets/db_password &&
echo '' &&
echo '=== JWT 시크릿 (앞 10자만) ===' &&
cat /run/secrets/jwt_secret | cut -c1-10 &&
echo '...(생략)' &&
echo '=== 환경변수와 비교 ===' &&
echo DB_HOST=$DB_HOST
"
environment:
DB_HOST: postgres
DB_PORT: "5432"
# 비밀번호는 환경변수 대신 secrets 사용
secrets:
- db_password
- jwt_secret
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_secret:
file: ./secrets/jwt_secret.txt
EOF
# 실행 및 확인
cd ~/prod-patterns/secrets-demo
docker compose run --rm app
mkdir -p ~/prod-patterns/secrets-demo/secrets# 현재 사용 중인 이미지의 다이제스트 확인
docker pull node:20-alpine
DIGEST=$(docker inspect node:20-alpine --format='{{index .RepoDigests 0}}')
echo "다이제스트: $DIGEST"
# 다이제스트만 추출
SHA=$(echo $DIGEST | cut -d@ -f2)
echo "SHA256: $SHA"
# 다이제스트로 Dockerfile 생성
cat > ~/prod-patterns/pinned.Dockerfile << EOF
# 다이제스트 핀닝된 베이스 이미지
FROM node:20-alpine@${SHA}
LABEL base.image="node:20-alpine"
LABEL base.digest="${SHA}"
WORKDIR /app
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
EOF
echo "생성된 Dockerfile:"
cat ~/prod-patterns/pinned.Dockerfile
# 다이제스트로 직접 pull (동일한 이미지 보장)
docker pull node:20-alpine@$SHA
cd ~/prod-patterns- PID 1 문제와 init 사용 여부가 종료/좀비 처리에 미치는 차이를 확인했는가?
- SIGTERM 처리 경로에서 in-flight 요청이 안전하게 마무리되는지 검증했는가?
- 시크릿 주입/이미지 핀닝 등 운영 안전장치를 배포 기준으로 설명할 수 있는가?
트러블슈팅
docker stop 실행 후 10초가 지나도 컨테이너가 종료되지 않고 강제 종료되는 경우입니다. 이때 진행 중인 HTTP 요청이 모두 끊깁니다.
원인 1: Shell 형식 CMD — tini 없이 앱이 PID 1이 아닌 경우 — PID 1이 아니면 SIGTERM이 앱에 전달되지 않습니다.
# 문제: Shell 형식은 /bin/sh -c "node server.js"로 실행
# sh가 PID 1이 되고 SIGTERM을 node에 전달하지 않음
CMD node server.js
# 해결: Exec 형식 사용 (앱이 직접 신호 수신)
CMD ["node", "server.js"]
# 또는 tini 사용
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
원인 2: 앱이 SIGTERM 핸들러를 등록하지 않음 — 핸들러 없이는 시그널을 받아도 즉시 종료됩니다.
// 확인 방법: 수동으로 신호 전송
// docker exec mycontainer kill -SIGTERM 1
// 앱 로그에 종료 메시지가 없으면 핸들러 없는 것
// 해결: 명시적 핸들러 등록
process.on('SIGTERM', () => {
console.log('SIGTERM 수신 — 종료 중');
server.close(() => process.exit(0));
});
원인 3: Graceful Shutdown이 10초 이상 소요 — Docker 기본 타임아웃(10초)을 초과하면 SIGKILL로 강제 종료됩니다.
# docker-compose.yml에서 타임아웃 연장
services:
api:
stop_grace_period: 30s # 30초로 연장
# docker stop 타임아웃 수동 지정
docker stop --time 30 mycontainer
쉘 스크립트를 entrypoint로 사용할 때 자주 발생합니다. docker stop 시 스크립트(PID 1)는 종료되지만 스크립트가 실행한 앱 프로세스가 살아남는 현상입니다.
# 문제 있는 entrypoint.sh
#!/bin/sh
echo "앱 시작"
node server.js # 이 프로세스는 sh의 자식, SIGTERM 못 받음
# 해결 1: exec 사용 (셸이 앱 프로세스로 교체됨)
#!/bin/sh
echo "앱 시작"
exec node server.js # exec로 PID 1 자리를 node가 차지
# 해결 2: 신호 전달 함수 작성
#!/bin/sh
_term() {
echo "SIGTERM 수신 — 자식 프로세스에 전달"
kill -TERM "$child" 2>/dev/null
}
trap _term TERM INT
node server.js &
child=$!
wait "$child"
tini를 사용하면 이런 신호 전달 문제를 자동으로 처리합니다:
FROM node:20-alpine
RUN apk add --no-cache tini
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]
컨테이너가 메모리 한도를 초과하면 OOM Killer가 프로세스를 종료합니다. restart: always 설정이면 반복 재시작합니다.
# OOM 종료 여부 확인
docker inspect mycontainer --format '{{.State.OOMKilled}}'
# 호스트 시스템 OOM 로그 확인
dmesg | grep -i "out of memory" | tail -20
journalctl -k | grep -i "oom" | tail -20
# 메모리 사용 추이 모니터링
docker stats --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}"
해결 방법: 서비스가 실제로 요청을 처리할 수 있을 때만 트래픽을 받도록 설정합니다.
# 1. 실행 중 메모리 한도 증가 (임시 조치)
docker update --memory 1g --memory-swap 1g mycontainer
# 2. 메모리 누수 확인 (Node.js)
# 컨테이너 안에서 힙 스냅샷 수집
docker exec mycontainer node -e "
const v8 = require('v8');
const snapshot = v8.writeHeapSnapshot();
console.log('스냅샷:', snapshot);
"
# 3. 적절한 한도 설정 (평균 사용량 × 1.5 ~ 2 권장)
# docker stats으로 정상 부하 시 메모리 사용량 측정 후 설정
실무 맥락
배포 파이프라인이 완성되어 git push 시 자동으로 프로덕션에 새 컨테이너가 올라옵니다. 그런데 배포할 때마다 일부 사용자에게 "502 Bad Gateway" 에러가 발생한다는 모니터링 알림이 옵니다.
장애 원인 분석: 배포 과정에서 로드 밸런서는 여전히 구 컨테이너로 요청을 보내는데, 오케스트레이션 시스템은 이미 docker stop을 실행해 구 컨테이너를 강제 종료합니다. 5~10초 처리 시간이 필요한 주문 결제 API의 경우 이 시간 동안 들어온 요청이 모두 502로 응답합니다.
해결 순서:
- 앱에 SIGTERM 핸들러 추가: 새 요청 거부(503 응답) + 기존 요청 완료 대기 로직 구현
stop_grace_period: 30s설정: 결제 API의 최대 처리 시간(15초) × 2 여유로 30초 설정- 헬스체크 엔드포인트 개선: 종료 중일 때
/health가 503을 반환하도록 수정하여 로드 밸런서가 빨리 트래픽을 차단하게 함 - 로드 밸런서 드레이닝 시간 확보: 새 컨테이너 시작 후 구 컨테이너 종료 전 최소 5초 대기
이 네 가지 변경 후 배포 중 502 에러가 완전히 사라졌습니다. Graceful Shutdown은 컨테이너 기술의 문제가 아니라 애플리케이션 코드 수준에서 구현해야 합니다.
핵심 요약
| 개념 | 명령/설정 | 설명 |
|---|---|---|
| tini init 프로세스 | ENTRYPOINT ["/sbin/tini", "--"] | PID 1 문제와 좀비 프로세스 방지 |
| Docker 내장 tini | docker run --init / init: true | Dockerfile 변경 없이 tini 적용 |
| exec 형식 CMD | CMD ["node", "server.js"] | SIGTERM이 앱 프로세스로 직접 전달 |
| Graceful Shutdown 타임아웃 | stop_grace_period: 30s | SIGKILL 전 대기 시간 연장 |
| Docker secrets | /run/secrets/<name> 파일 마운트 | 환경변수보다 안전한 시크릿 주입 |
| 이미지 다이제스트 핀닝 | image@sha256:abc... | 이미지 내용 변경 방지 |
| 메모리 한도 | docker update --memory 1g | 실행 중 리소스 제한 변경 |
| 로그 로테이션 | max-size: 10m, max-file: 5 | 디스크 풀 방지 |
| OOM 확인 | docker inspect --format '{{.State.OOMKilled}}' | 메모리 초과 종료 여부 확인 |