도커 고급 강좌 #6 프로덕션 운영 — graceful shutdown, healthcheck, restart

8 분 소요

도커 고급 시리즈의 마지막 글입니다. 빌드 / 멀티 아키 / 보안 / 자원 제한 — 모두 한 컨테이너의 외형을 다뤘다면, 이번 글은 그 컨테이너가 운영 환경에서 잘 죽고 잘 다시 살아나는 잔주름을 모았습니다.

도커 고급 강좌 시리즈에서 이번 글의 위치:

docker stop이 일어나는 일 — 한 번 더 깊이 #

기초 #3에서 짧게 짚었던 주제입니다. 운영 시각으로 다시.

docker stop의 흐름
docker stop myapp
컨테이너의 PID 1에게 SIGTERM 전송
기본 10초 대기 (--time으로 조정 가능)
   ├─ PID 1이 깔끔히 종료 → exit code 그대로
   └─ 시간 초과 → SIGKILL

이 흐름의 모든 무게가 PID 1에 실립니다. PID 1이 SIGTERM을 받아 자식들에게 신호를 전파하고 자기 정리를 끝내야 — 컨테이너가 그레이스풀하게 죽습니다.

PID 1 문제 — 컨테이너 안에서 자주 깨지는 지점 #

리눅스에서 PID 1은 특별합니다.

  • 부모 잃은 자식(orphan) 들의 양부모가 됨 — 좀비를 reap 해야 함
  • 신호 전달 규칙이 다름 — 명시적으로 핸들러를 등록하지 않은 신호는 무시됨

일반 앱(파이썬, 노드, 자바)은 PID 1으로 돌도록 설계된 적이 없습니다. 그래서 컨테이너 안에서 PID 1이 되면 두 문제가 생깁니다.

문제 1 — SIGTERM을 못 받음 #

대부분 런타임은 SIGTERM 핸들러를 명시적으로 등록하지 않으면 그 신호를 무시합니다. 도커가 보낸 SIGTERM이 효과 없이 사라지고, 10초 뒤에 SIGKILL로 강제 종료됩니다.

진단:

앱이 SIGTERM을 받는지 확인
docker run --rm -d --name test myapp
docker stop test
# 종료까지 10초 정도 걸리면 SIGTERM 무시 가능성 큼
# 즉시(1~2초) 끝나면 OK

문제 2 — 좀비 프로세스 누적 #

앱 안에서 자식 프로세스를 띄우면(Node의 child_process.spawn, Python의 subprocess.Popen + 빠른 종료), 부모가 자식을 wait 안 하면 좀비가 됩니다. 일반 환경에선 init이 대신 청소해주지만, 컨테이너 안 PID 1이 그 일을 안 하면 좀비가 쌓입니다.

해결 — 작은 init을 PID 1으로 #

답은 PID 1에 작은 init을 두고, 그 아래에 앱을 두는 것. 도커가 친절하게 옵션 하나로 제공합니다.

--init 옵션
docker run --init -d myapp
compose
services:
  web:
    image: myapp
    init: true

--inittini 라는 작은 init을 PID 1으로 띄우고, Dockerfile의 CMD를 그 자식으로 실행합니다. tini가:

  • 받은 SIGTERM을 자식들에게 그대로 전파
  • 좀비 프로세스를 자동 reap

이 한 줄로 두 문제가 다 풀립니다. 운영 컨테이너에는 거의 항상 init: true.

dumb-init — Dockerfile 안에 추가하는 형태 #

또 다른 길은 dumb-init (Yelp)을 ENTRYPOINT로 두는 것.

Dockerfile
FROM python:3.14-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init && \
    rm -rf /var/lib/apt/lists/*
COPY app.py .
ENTRYPOINT ["dumb-init", "--"]
CMD ["python", "app.py"]

dumb-init --이 PID 1으로 뜨고 그 아래 파이썬이 자식으로 붙습니다. tini와 비슷한 방식입니다. 도커의 --init 옵션이 더 가벼우니 그쪽을 선호하지만, 이미지가 어디서 띄워질지 모를 때(운영 환경마다 --init을 켤지 보장 안 됨) dumb-init을 이미지에 포함해두는 편이 안전합니다.

앱 자체의 SIGTERM 핸들러 #

init으로 신호 전달은 풀렸어도, 앱이 그 신호를 듣고 정리를 시작해야 그레이스풀이 완성됩니다. 언어별로 짧은 패턴.

Node.js #

server.js
const server = app.listen(3000);

const shutdown = () => {
  console.log('Received SIGTERM, draining connections...');
  server.close(() => {
    console.log('All connections drained, exiting.');
    process.exit(0);
  });

  // 30초 안에 깨끗이 안 끝나면 강제 종료
  setTimeout(() => {
    console.error('Force exit after 30s');
    process.exit(1);
  }, 30000).unref();
};

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

server.close()는 새 연결을 거부하고, 처리 중인 요청이 끝나면 콜백을 부릅니다. 그동안 SIGTERM의 10초 (또는 늘려준 시간) 안에 끝내야 SIGKILL이 안 떨어집니다.

Python (FastAPI / Django) #

uvicorn / gunicorn 같은 프로덕션 서버가 SIGTERM을 자동으로 처리합니다. 직접 구현할 일은 적습니다. 단 — 워커 프로세스가 처리 중인 요청을 끝낼 시간을 충분히 줘야 합니다.

gunicorn 옵션
gunicorn app:app \
  --workers 4 \
  --graceful-timeout 30 \
  --timeout 60 \
  --bind 0.0.0.0:8000

--graceful-timeout 30 — SIGTERM 후 30초 동안 요청을 마저 처리. 도커의 stop --time도 이에 맞춰:

compose
services:
  web:
    stop_grace_period: 35s   # gunicorn의 graceful-timeout보다 살짝 길게

Go #

main.go
srv := &http.Server{Addr: ":8000", Handler: mux}
go srv.ListenAndServe()

stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)

http.Server.Shutdown이 표준 패턴. context 타임아웃 안에서 진행 중인 요청을 끝내고 종료.

stop_grace_period — 시간 늘리기 #

기본 10초 안에 깔끔히 끝낼 수 없는 앱(예: 대용량 파일 업로드 처리 중)이라면:

compose
services:
  web:
    stop_grace_period: 60s
docker stop
docker stop --time 60 myapp

운영에서 자주 보는 설정입니다. 단 — 너무 길게 두면 배포가 느려지고, ELB 같은 로드밸런서가 더 일찍 백엔드를 unhealthy로 인식해버려서 의미 없을 수 있습니다.

Restart 정책 — 깊이 다시 #

중급 #4에서 짚은 표를 운영 시각으로 다시.

정책언제
no일회성 컨테이너 (마이그레이션, 시드, 빌드)
always항상 — 호스트 부팅 시에도
on-failure[:N]0이 아닌 종료 코드 + 횟수 한계
unless-stopped명시적으로 멈춘 게 아니면 항상

운영의 안전한 기본 — unless-stopped #

alwaysunless-stopped의 차이가 자주 헷갈립니다. 차이는 docker stop의 의미:

  • always 라면, docker stop으로 멈춰도 도커 데몬 재시작 시 다시 뜸
  • unless-stopped 라면, docker stop으로 멈춘 컨테이너는 데몬 재시작 시에도 안 뜸

운영자가 의도적으로 멈춘 컨테이너를 그대로 두는 게 합리적이라, unless-stopped가 운영 기본입니다.

Restart 무한 루프 — 백오프 #

앱이 시작 시점에 항상 죽는다면, restart: always는 무한 루프가 됩니다. 도커는 이걸 막기 위해 재시작 간격을 점차 늘리는 백오프를 둡니다.

restart backoff
1번째 실패 → 즉시 재시도
2번째 실패 → 100ms 대기
3번째 실패 → 200ms 대기
...
N번째 실패 → 최대 1분 대기

연속 실패가 많은 컨테이너는 로그를 추적해 원인을 봅니다 — docker logs --tail 200 <c>, OOMKilled 점검 (#5).

Healthcheck — 운영 시각으로 #

중급 #4에서 본 healthcheck를 운영 시각에서 한 번 더.

Liveness vs Readiness — 두 가지 다른 질문 #

K8s의 개념이지만 도커에도 그대로 적용되는 사고 도구.

LivenessReadiness
질문살아 있나?트래픽 받을 준비 됐나?
실패 시컨테이너 재시작트래픽 차단 (재시작은 X)
데드락에 걸림 → 재시작 필요DB 연결 일시 끊김 → 잠깐 트래픽만 막기

도커 자체에는 healthcheck 한 종만 있고, 둘을 구분하지 않습니다. 그래서 도커 단독 운영에선 두 개념을 한 healthcheck에 섞기 어렵습니다.

대안 — healthcheck는 liveness에 가깝게 정의하고, readiness는 앱 안에서 처리. 예: 시작 직후 N 초간 /health가 503을 반환했다가 준비되면 200.

좋은 healthcheck의 조건 #

좋은 healthcheck
□ 빠르게 응답 (1초 안)
□ 의존 서비스를 재귀적으로 점검하지 않음
□ side effect 없음
□ 별도 엔드포인트 (/health) — 일반 트래픽과 분리
□ 인증 없음 (공격 표면 늘리지 않게 내부에서만)
나쁜 healthcheck
✗ DB 쿼리를 실행 — DB 부하
✗ 비즈니스 로직을 통과 — 의존성 / 부하
✗ 외부 API 호출 — 외부 다운으로 자기 컨테이너가 unhealthy 됨
✗ 인증 — 트래픽 헬스 체커도 인증 정보 필요

healthcheck는 앱이 살아 있고 요청을 처리할 수 있는가만 확인하면 충분합니다. 의존성의 건강은 별도 모니터링이 잡습니다.

시작 그레이스 — start_period #

마이그레이션 / 워밍업이 있는 앱은 시작 직후 잠깐 unhealthy가 정상입니다.

start_period 사용
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
  interval: 10s
  timeout: 5s
  retries: 3
  start_period: 60s   # 처음 60초 동안의 실패는 retries에 안 셈

K8s 라면 startupProbe와 같은 개념입니다.

로깅 — 운영 잔주름 #

중급 #6의 stdout 원칙 + log driver를 한 번 더, 그러나 운영의 시각에서.

로그 회전 #

필수 옵션
services:
  web:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

이 옵션이 없으면 디스크 폭주 — 도커 사고의 단골입니다. 데몬 전역 기본값으로 설정해두는 편이 안전합니다.

/etc/docker/daemon.json
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

local driver는 json-file의 효율적 변형 (압축 + 회전 기본). 운영 환경에서 점차 표준이 되어가는 방식입니다.

외부 수집기로 #

운영이 자라면 stdout에서 끝나지 않고 외부 수집기로 흘러갑니다.

fluentd로 흘리기
services:
  web:
    logging:
      driver: fluentd
      options:
        fluentd-address: localhost:24224
        tag: web.{{.Name}}

이걸 받아 Loki / Elasticsearch / CloudWatch 어디로든 보낼 수 있습니다. 한 단락으로만.

모니터링 — 한 줄짜리 확장 #

#5의 cAdvisor + Prometheus + Grafana가 도커 단독 운영의 첫 모니터링 셋업입니다. 자주 보는 패널 한 묶음:

  • 컨테이너별 CPU / 메모리 / 네트워크
  • restart 횟수 (자주 재시작되는 컨테이너 알람)
  • OOMKill이벤트
  • healthcheck 실패율
  • 디스크 IO

알람의 첫 룰은 보통 “같은 컨테이너가 5분 안에 3번 이상 재시작” 정도입니다. 빈번한 OOMKill / 죽음을 빨리 알아채는 기준입니다.

운영 체크리스트 — 한곳에 #

이번 시리즈 전체에서 다진 내용을 한 컨테이너 점검 체크리스트로 모으면:

이미지 / 빌드
□ 멀티스테이지 — 빌드 도구 분리 ([중급 #1])
□ 베이스: slim 또는 distroless ([중급 #1], [#3])
□ 멀티 아키 — linux/amd64 + linux/arm64 ([#2])
□ Dockerfile: hadolint 통과 ([#3])
□ 이미지: Trivy HIGH/CRITICAL 클린 ([#3])
□ SBOM 첨부 + cosign 서명 ([#4])
□ 빌드는 buildx + 외부 캐시 ([중급 #2], [#1])
런타임 / compose.yaml
□ image: digest 또는 시맨틱 버전 (latest 금지)
□ restart: unless-stopped
□ init: true (PID 1 처리)
□ stop_grace_period 명시 (앱의 graceful 시간보다 길게)
□ healthcheck — 빠르고 가볍고 인증 없음
□ 자원: mem_limit + cpus + pids_limit ([#5])
□ 보안: read_only + tmpfs + cap_drop ALL + no-new-privileges ([#3])
□ 비밀: secrets: 또는 외부 매니저, 절대 ENV 박지 말 것 ([중급 #5], [#4])
□ 로그: max-size + max-file
□ DB / 내부 서비스: -p는 127.0.0.1 바인딩만
□ 환경별 값: .env / override 파일로 분리 ([중급 #4])
배포 / CI
□ 빌드 → 멀티 아키 → SBOM → 서명 → 푸시 한 워크플로우 ([#4])
□ 검증을 게이트로 — Trivy / cosign verify
□ 태그 전략: 시맨틱 + Git SHA + latest 동시 ([기초 #5])
□ 외부 캐시: type=gha 또는 type=registry ([중급 #2])

다음으로 — 도커 실전 시리즈 #

이번 시리즈는 도커 자체의 깊이였습니다. 다음 시리즈 — 도커 실전 — 에서는 지금까지 다진 도구를 실제 앱들의 배포 흐름에 얹습니다. 다룰 주제:

  • FastAPI 앱 컨테이너화 — 실제 운영급 Dockerfile
  • Django + PostgreSQL compose 셋업 — admin / static / migration까지
  • React/Next.js 빌드 컨테이너 — standalone, multi-stage
  • CI에서 이미지 빌드 — GitHub Actions의 풀 워크플로우
  • 레지스트리 푸시와 태그 전략 — 운영의 잔주름
  • 클라우드 배포 — Fly.io / Railway / ECS 중 한 흐름

기초 / 중급 / 고급에서 다진 모든 도구가 실전에서 한곳에 모이는 시리즈입니다.

정리 #

이번 글에서 잡은 그림:

  • docker stop = SIGTERM → 그레이스 시간 → SIGKILL. PID 1의 신호 처리가 핵심
  • PID 1 문제 — 일반 앱은 PID 1으로 설계되지 않음. init: true 또는 dumb-init으로
  • 앱 안에서 SIGTERM 핸들러로 진행 중 요청을 마저 처리 — Node server.close, gunicorn --graceful-timeout, Go srv.Shutdown
  • **stop_grace_period**로 충분한 정리 시간 확보
  • **restart: unless-stopped**가 운영 기본. 백오프가 무한 루프를 막음
  • Healthcheck는 빠르고 가볍게, liveness에 가깝게 정의. 의존성 점검은 별도
  • 운영 체크리스트 — 이미지 / 런타임 / 배포 세 묶음
X