프로덕션 컨테이너 운영 패턴
개발 환경에서는 정상인데, 배포 순간 요청이 끊기거나 종료 신호를 무시해 강제 종료되는 문제가 반복됩니다. 원인은 대개 애플리케이션 코드보다 운영 패턴(PID 1, 신호 처리, 시크릿 주입, 버전 고정)의 빈틈에 있습니다. 프로덕션에서는 "돌아간다"보다 "예측 가능하게 멈추고 복구된다"가 더 중요합니다. 이 모듈은 장애를 줄이는 운영 기본기를 컨테이너 관점으로 정리합니다.
컨테이너가 개발 환경에서는 잘 작동하다가 프로덕션에서 갑자기 이상하게 동작하는 경우가 있습니다. 배포 중 요청이 끊기거나, 컨테이너가 멈추지 않거나, 이상하게 메모리를 누수합니다. 이 모듈은 프로덕션 운영에서 실제로 마주치는 문제들 — PID 1, 신호 처리, Graceful Shutdown, 시크릿 관리, 이미지 핀닝 — 을 다루고 각각의 올바른 해결 방법을 제시합니다.
프로덕션 환경에서 컨테이너를 안정적으로 운영하기 위한 패턴들을 다룹니다. 개발 환경과 프로덕션 환경의 차이를 이해하고, 실제 장애 상황에서 도움이 되는 설정 방법을 학습합니다.
- 1PID 1 문제와 좀비 프로세스를 이해하고 tini/dumb-init이 필요한 이유를 설명할 수 있다
- 2SIGTERM을 처리해 Node.js/Python에서 Graceful Shutdown을 구현할 수 있다
- 3환경변수, 설정 파일, Docker secrets를 비교해 환경별 설정을 관리할 수 있다
- 4latest를 금지하고 다이제스트 핀닝으로 컨테이너 이미지 버전을 고정할 수 있다
- 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 컨테이너 중지 신호 흐름
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 세 가지이며, 값의 민감도와 크기에 따라 골라 씁니다.
확대
설정 주입 방식 비교
컨테이너 설정을 주입하는 방법은 세 가지가 있으며, 각각 적합한 사용 사례가 다릅니다.
| 방식 | 적합한 용도 | 주의사항 |
|---|---|---|
| 환경 변수 | 비민감 설정값 (포트, URL, 플래그) | docker inspect로 노출 가능 |
| 설정 파일 (볼륨 마운트) | 복잡한 구조화 설정 (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
이미지가 운영에 배포되는 전체 흐름 — 빌드부터 롤백까지
다이제스트 핀닝·헬스체크·graceful shutdown은 따로 배운 조각들이지만, 실제 배포는 이 조각들이 한 줄로 이어지는 파이프라인입니다. 빌드 → push → pull → 헬스체크 → 트래픽 전환 → 롤백 대비. 배포 사고 — 배포 중 502, 롤백 불가, "테스트한 것과 다른 게 떴다" — 는 대부분 이 흐름의 특정 칸이 빠져서 생깁니다. 흐름으로 보면 어느 단계를 건너뛰었는지로 원인을 좁힐 수 있습니다.
[빌드 서버] docker build -t app:1.4.2 . (latest 금지, 불변 태그)
│
① 빌드 semver 태그 + 다이제스트로 아티팩트 확정
│
② push 레지스트리에 업로드 app:1.4.2@sha256:...
│
③ pull 배포 대상 호스트가 그 태그/다이제스트를 당김
│
④ 기동 + 헬스체크 대기 컨테이너 시작 → healthcheck 통과할 때까지 트래픽 보류
│
⑤ 트래픽 전환 LB가 새 컨테이너로 라우팅 · 구 컨테이너는 드레이닝 후 종료
│
⑥ 롤백 대비 직전 태그(app:1.4.1)를 남겨둬 문제 시 즉시 되돌림
▼
[운영] 새 버전이 트래픽을 받고, 이전 버전은 롤백 카드로 대기
각 단계에서 하는 일과, 이 단계가 빠지면:
| 단계 | 하는 일 | 이 단계가 빠지면 |
|---|---|---|
| ① 빌드 | semver 태그와 다이제스트로 이미지를 불변 아티팩트로 확정한다 | latest로 빌드하면 같은 태그가 시점마다 다른 내용을 가리켜 "테스트한 이미지 ≠ 배포된 이미지"가 됨 |
| ② push | 레지스트리에 태그·다이제스트로 업로드해 어디서든 같은 이미지를 받게 한다 | push를 건너뛰고 대상 호스트에서 로컬 빌드하면 노드마다 이미지가 갈려 이미지 불일치 |
| ③ pull | 배포 대상이 태그가 아니라 @sha256: 다이제스트로 당겨 내용을 못 박는다 | 태그만으로 당기면 캐시·재푸시로 다른 이미지가 내려올 수 있어 공급망 변조에 취약 |
| ④ 헬스체크 대기 | 컨테이너를 띄운 뒤 HEALTHCHECK가 healthy가 될 때까지 트래픽을 주지 않는다 | 헬스체크 없이 바로 전환하면 아직 준비 안 된 컨테이너로 트래픽이 가 502·503 |
| ⑤ 트래픽 전환 | LB가 새 컨테이너로 라우팅하고 구 컨테이너는 드레이닝(진행 요청 마무리) 후 종료 | 드레이닝·graceful shutdown 없이 구 컨테이너를 즉시 죽이면 처리 중 요청이 끊겨 502 |
| ⑥ 롤백 대비 | 직전 버전 태그·다이제스트를 보존해 문제 시 그 이미지로 즉시 되돌린다 | latest만 쓰거나 이전 태그를 안 남기면 되돌릴 대상이 없어 롤백 불가 |
즉 무중단 배포는 "새 이미지를 올리는 일"이 아니라 불변 태그(①③)·준비 확인(④)·안전한 교체(⑤)·되돌릴 카드(⑥)가 한 줄로 이어진 흐름입니다. 배포 중 502가 나면 대개 ④(헬스체크 생략)나 ⑤(드레이닝 없는 종료)에서, "되돌릴 수 없다"는 ①⑥(가변 태그)에서 끊긴 것이므로, 어느 칸이 비었는지부터 확인하면 됩니다.
리소스 제한과 로그 관리
확대
리소스 제한 설정 원칙
리소스 제한 없이 컨테이너를 실행하면 메모리 누수나 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- docker stop 실행 후 컨테이너 종료 시간을 먼저 확인 — 10초 이내 종료이면 SIGTERM 정상 수신, 10초 후 강제 종료(SIGKILL)이면 CMD가 shell 형식이거나 tini 없이 PID 1이 sh인 경우
- docker stats --no-stream 출력에서 PIDS 수 기준: 컨테이너 실행 초기 대비 지속 증가(예: 5분마다 +1 이상)이면 좀비 프로세스 누적 — tini/dumb-init 미설치 상태에서 자식 프로세스가 생성되는 구조일 때 발생
- restart_policy=always + healthcheck 통과 후 트래픽 수신 흐름 확인: health check가 healthy 되기 전에 로드밸런서가 트래픽 보내면 503 발생; memory limit 미설정이고 OOM 종료 반복이면 restart count가 지속 증가 — docker inspect 출력의 RestartCount가 5 이상이면 즉시 원인 파악 필요
트러블슈팅
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으로 정상 부하 시 메모리 사용량 측정 후 설정
심화 — cgroup이 자원을 제한하는 실제 방식
심화: cgroup CPU 스로틀링 — 제한은 평균이 아니라 '주기'로 걸린다
앞서 cpus·memory 제한을 '컨테이너가 쓸 수 있는 상한'으로 배웠습니다. 하지만 이 상한이 커널의 cgroup에서 어떻게 강제되는지를 모르면, '평균은 여유로운데 왜 느리지?' 같은 증상 앞에서 길을 잃습니다.
- 메모리 제한은 RSS + 페이지 캐시로 회계됩니다: cgroup의 메모리 한도는 앱이 직접 할당한 메모리(RSS)뿐 아니라, 파일을 읽을 때 커널이 채우는 페이지 캐시까지 함께 셉니다. 대용량 파일을 많이 읽는 컨테이너가 힙은 작은데도 한도에 닿아 OOM으로 죽는 일이 여기서 생깁니다(캐시는 회수 가능하지만 회수보다 할당이 빠르면 넘칩니다).
- CPU 제한은 CFS 쿼터/주기로 강제됩니다:
cpus: '1.0'은 '평균 1코어'가 아니라 기본 100ms 주기마다 100ms의 CPU 시간을 허용한다는 뜻입니다. 이 주기 안에서 할당량을 다 쓰면, 주기가 끝날 때까지 프로세스가 강제로 멈춥니다(throttling). GC나 요청 폭주처럼 순간적으로 여러 코어를 쓰려는 버스트가 있으면 스로틀에 걸려 지연이 튀는데, 스로틀된 시간은 '사용률'로 잡히지 않아 평균 CPU는 낮게 보입니다. - 많은 런타임은 cgroup을 모릅니다: JVM이나 Node의 일부 라이브러리는
nproc나 호스트의 총 코어 수를 보고 스레드풀·GC 스레드를 정합니다. 컨테이너에 1코어를 줬는데 런타임이 호스트의 32코어 기준으로 스레드를 만들면, 컨텍스트 스위칭과 스로틀링이 겹쳐 더 느려집니다.
그래서 '제한을 걸었으니 안전하다'가 아니라, 어떤 방식으로 회계·강제되는지를 알고 지표(스로틀 횟수, 페이지 캐시 포함 메모리)를 봐야 합니다. 지연 민감 서비스는 하드 CPU 제한 대신 reservation/share로 두는 편이 나을 때가 많습니다.
상황: cpus: '1.0' 제한을 건 API 컨테이너입니다. docker stats의 CPU%는 늘 30~40%로 한가해 보이는데, p99 지연이 수백 ms씩 주기적으로 튀어 사용자 항의가 들어옵니다. 앞의 OOM(메모리)이나 종료 신호 문제와는 증상이 다릅니다.
원인: CPU 제한은 CFS(Completely Fair Scheduler)의 쿼터/주기로 구현됩니다. 기본 100ms 주기 안에서 허용된 CPU 시간을 다 쓰면 그 주기가 끝날 때까지 스레드가 멈춥니다(스로틀링). 이 앱은 요청이 몰릴 때 순간적으로 1코어를 초과하는 버스트가 있어 스로틀에 걸리는데, 멈춰 있던 시간은 사용률로 집계되지 않아 평균은 여유로워 보이지만 꼬리 지연은 튀는 전형적 패턴입니다.
진단: 평균 CPU% 대신 cgroup의 스로틀 지표를 봅니다.
# 컨테이너 안에서 cgroup CPU 통계 확인 (cgroup v2)
docker exec myapp cat /sys/fs/cgroup/cpu.stat
# nr_periods 12000
# nr_throttled 3400 ← 주기의 약 28%가 스로틀됨
# throttled_usec 210000000 ← 누적 스로틀 시간이 계속 증가
# cgroup v1 환경이면 경로가 다르다
# cat /sys/fs/cgroup/cpu,cpuacct/cpu.stat
nr_throttled와 throttled_usec이 계속 증가하면 CFS 쿼터에 걸린 것이 확정입니다.
해결: 지연 민감 서비스는 하드 CPU 제한을 올리거나, 하드 제한 대신 reservations(보장치)·CPU share로 두어 버스트를 허용합니다. 동시에 런타임을 cgroup-aware하게 맞춰 스레드풀·GC 스레드 수를 할당한 코어에 맞춥니다(예: 최신 JVM은 컨테이너 인식이 기본, Node는 UV_THREADPOOL_SIZE 등 조정). CFS 주기 자체를 늘리는 튜닝은 부작용이 커 최후에만 씁니다.
실무 맥락
배포 파이프라인이 완성되어 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}}' | 메모리 초과 종료 여부 확인 |
명령어·단축키 빠른 참조
프로덕션 운영에서 신호·리소스·진단에 실제로 쓴 명령을 실전 옵션과 함께 모았습니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
docker run --init | tini를 PID 1로 주입(좀비·신호 처리) | docker run --init myapp / compose init: true |
docker stop -t | Graceful Shutdown 대기 시간 조정 | docker stop -t 30 myapp (기본 10초 연장) |
docker exec ... kill | 신호 핸들러 동작 수동 검증 | docker exec myapp kill -SIGTERM 1 |
docker inspect(OOM) | OOM 종료 여부 확인 | docker inspect ctr --format '{{.State.OOMKilled}}' (true=OOM) |
docker inspect(다이제스트) | 다이제스트 핀닝값 확인 | docker inspect node:20-alpine --format '{{index .RepoDigests 0}}' |
docker inspect(로그 경로) | json-file 로그 파일 위치 확인 | docker inspect ctr --format '{{.LogPath}}' |
docker update | 실행 중 리소스 제한 변경 | docker update --memory 1g --memory-swap 1g ctr / --cpus 2.0 |
docker stats | 메모리·CPU 사용 추이 관찰 | docker stats --no-stream --format "table {{.Name}}\t{{.MemPerc}}" |
docker scout | 이미지 CVE 취약점 스캔 | docker scout cves myapi:1.2.3 / docker scout quickview |
docker compose run | secrets 등 일회성 실행 검증 | docker compose run --rm app |
cat cpu.stat | CPU 스로틀(꼬리 지연) 진단 | docker exec myapp cat /sys/fs/cgroup/cpu.stat → nr_throttled |
dmesg / journalctl | 호스트에서 OOM 로그 확인 | dmesg | grep -i "out of memory" |
이것으로 Docker 트랙의 마지막 모듈입니다. 이미지·컨테이너·네트워크·볼륨·Compose·CI/CD·운영 패턴까지 익혔습니다. 수십~수백 개의 컨테이너를 자동 관리하는 Kubernetes 오케스트레이션으로의 전환은 별도의 Kubernetes 트랙에서 다룹니다.
직접 오케스트레이터를 운영하는 대신 클라우드의 매니지드 컨테이너 서비스(ECS·EKS·Fargate)로 운영 부담을 더는 선택지는 매니지드 컨테이너(Cloud Engineering 트랙)에서 비교합니다.