infra
Platform

모듈 맵

[Docker] 무중단 배포와 스케일아웃을 위한 실전 컨테이너 아키텍처

0 / 27 완료

펼치기
0 / 27 완료0%

도커 & 컨테이너 · 27 / 27

[Docker] 무중단 배포와 스케일아웃을 위한 실전 컨테이너 아키텍처

신호 처리, Graceful Shutdown, Init 프로세스, 환경별 설정 관리로 프로덕션 수준 컨테이너를 완성합니다

프로덕션 컨테이너 운영 패턴

🚨INCIDENT ALERT
HIGH

개발 환경에서는 정상인데, 배포 순간 요청이 끊기거나 종료 신호를 무시해 강제 종료되는 문제가 반복됩니다. 원인은 대개 애플리케이션 코드보다 운영 패턴(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 설치가 필요하지 않습니다.

tini 설치 확인 또는 Docker --init 플래그 사용 가능 여부 확인
docker run --rm --init alpine sh -c 'ps aux'
실습용 디렉토리 생성
mkdir -p ~/prod-patterns && cd ~/prod-patterns
Node.js 설치 확인 (신호 처리 예제용)
node --version || echo 'Node.js 미설치 — Docker 컨테이너 내에서 실행'
Docker Compose 버전 확인
docker compose version
💡개념

PID 1 문제와 Init 프로세스

docker stop을 실행했는데 컨테이너가 10초 기다렸다가 강제 종료됩니다. 로그에는 진행 중이던 요청이 갑자기 끊긴 흔적이 있습니다. 혹은 컨테이너를 오래 실행하고 나면 docker stats에서 프로세스 수가 계속 늘어납니다 — 좀비 프로세스가 쌓이고 있는 것입니다. 두 증상 모두 PID 1 문제에서 비롯됩니다. Node.js, Python 같은 애플리케이션 프로세스가 컨테이너 안에서 PID 1로 실행될 때, 신호 처리와 자식 프로세스 회수를 PID 1처럼 하지 않아서 생기는 문제입니다.

PID 1 문제와 Init 프로세스 — 앱 프로세스가 컨테이너에서 PID 1로 실행되면 SIGTERM 기본 처리와 좀비 자식 회수를 하지 않아 docker stop이 10초 후 강제 종료되고 좀비가 쌓임. tini 같은 init를 PID 1로 두면 신호 전달과 자식 회수를 대신 처리확대

리눅스 시스템에서 PID 1의 역할

일반 리눅스 시스템에서 PID 1은 systemdinit이 담당하며, 두 가지 핵심 역할을 합니다:

  1. 좀비 프로세스 회수: 자식 프로세스가 종료되면 부모가 wait()를 호출해야 합니다. 부모 프로세스가 먼저 죽으면 고아 프로세스가 되고, PID 1이 입양하여 회수합니다.
  2. 신호 전달: 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으로 실행하고 앱을 자식 프로세스로 실행합니다.

Dockerfile
# 방법 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을 받으면 새 요청을 받지 않고 진행 중인 요청만 마무리한 뒤 종료합니다. 코드 수정이 필요하지만 패턴은 명확합니다.

Graceful Shutdown 흐름 — docker stop이 SIGTERM을 보내면 앱이 새 요청 수신을 멈추고 진행 중인 요청만 마무리한 뒤 정상 종료. 처리하지 않으면 10초 후 SIGKILL로 강제 종료되어 진행 중 요청이 끊기고 클라이언트에 503 반환확대

Docker 컨테이너 중지 신호 흐름

Graceful Shutdown의 목표는 SIGTERM 수신 후 다음 순서로 종료하는 것입니다:

  1. 새 요청 수신 중단 (로드 밸런서에서 트래픽 차단)
  2. 진행 중인 요청 완료 대기
  3. 데이터베이스 연결, 파일 핸들 등 리소스 정리
  4. 프로세스 종료

Node.js Graceful Shutdown 구현

JS
// 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)

Python
# 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에서 종료 타임아웃 설정

YAML
# 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 세 가지이며, 값의 민감도와 크기에 따라 골라 씁니다.

환경별 설정 관리 방식 — 같은 이미지를 개발·스테이징·프로덕션에 쓰면서 환경마다 다른 설정을 외부에서 주입하는 세 가지 방법: 환경변수(작고 비민감한 값), 마운트 파일(큰 설정 파일), Secrets(API 키 등 민감 값). 값의 민감도·크기에 따라 선택확대

설정 주입 방식 비교

컨테이너 설정을 주입하는 방법은 세 가지가 있으며, 각각 적합한 사용 사례가 다릅니다.

방식적합한 용도주의사항
환경 변수비민감 설정값 (포트, URL, 플래그)docker inspect로 노출 가능
설정 파일 (볼륨 마운트)복잡한 구조화 설정 (nginx.conf 등)볼륨 마운트 필요, 파일 권한 관리
Docker secrets비밀번호, API 키, 인증서Swarm/Compose 전용, K8s는 별도 시크릿

Docker secrets 실습 구성

YAML
# 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:
JS
// 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 — 프로덕션 환경 오버라이드
YAML
# 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나 가변 태그는 같은 명령이라도 시점마다 다른 이미지를 받아 배포가 비결정적이 됨. 버전 태그(node:20-alpine)로 고정하고, 더 엄격하게는 SHA256 다이제스트 핀닝(node@sha256:...)으로 정확히 같은 이미지를 보장확대

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
Dockerfile
# 실무 권장 패턴: 태그 + 다이제스트 병기 (가독성 + 안전성)
FROM node:20-alpine@sha256:a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789

# 베이스 이미지 메타데이터 레이블 추가 (추적용)
LABEL base.image="node:20-alpine" \
      base.digest="sha256:a1b2c3d4e5f6..."

docker-compose에서 이미지 버전 고정

YAML
# 나쁜 예
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, 롤백 불가, "테스트한 것과 다른 게 떴다" — 는 대부분 이 흐름의 특정 칸이 빠져서 생깁니다. 흐름으로 보면 어느 단계를 건너뛰었는지로 원인을 좁힐 수 있습니다.

TEXT
[빌드 서버]  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 급등 시 같은 호스트의 다른 서비스까지 영향을 줌. memory·cpus 제한으로 격리하고, 로그 드라이버에 max-size·max-file을 설정해 로그가 디스크를 가득 채우는 것을 방지확대

리소스 제한 설정 원칙

리소스 제한 없이 컨테이너를 실행하면 메모리 누수나 CPU 급등 시 같은 호스트의 다른 서비스까지 영향을 줍니다.

YAML
# 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

로그 드라이버 설정

YAML
# 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

기본 실습

1tini 적용 전후 좀비 프로세스 비교

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

로컬 터미널
# 실습 디렉토리 준비
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
2SIGTERM 핸들러 구현 및 테스트
로컬 터미널
# 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
3Docker secrets로 DB 비밀번호 관리
로컬 터미널
# 시크릿 파일 생성 (실제 환경에서는 비밀 관리 시스템에서 주입)
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
4이미지 다이제스트 핀닝
로컬 터미널
# 현재 사용 중인 이미지의 다이제스트 확인
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이 앱에 전달되지 않습니다.

Dockerfile
# 문제: 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 핸들러를 등록하지 않음 — 핸들러 없이는 시그널을 받아도 즉시 종료됩니다.

JS
// 확인 방법: 수동으로 신호 전송
// docker exec mycontainer kill -SIGTERM 1
// 앱 로그에 종료 메시지가 없으면 핸들러 없는 것

// 해결: 명시적 핸들러 등록
process.on('SIGTERM', () => {
  console.log('SIGTERM 수신 — 종료 중');
  server.close(() => process.exit(0));
});

원인 3: Graceful Shutdown이 10초 이상 소요 — Docker 기본 타임아웃(10초)을 초과하면 SIGKILL로 강제 종료됩니다.

YAML
# 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를 사용하면 이런 신호 전달 문제를 자동으로 처리합니다:

Dockerfile
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의 스로틀 지표를 봅니다.

Docker
# 컨테이너 안에서 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_throttledthrottled_usec이 계속 증가하면 CFS 쿼터에 걸린 것이 확정입니다.

해결: 지연 민감 서비스는 하드 CPU 제한을 올리거나, 하드 제한 대신 reservations(보장치)·CPU share로 두어 버스트를 허용합니다. 동시에 런타임을 cgroup-aware하게 맞춰 스레드풀·GC 스레드 수를 할당한 코어에 맞춥니다(예: 최신 JVM은 컨테이너 인식이 기본, Node는 UV_THREADPOOL_SIZE 등 조정). CFS 주기 자체를 늘리는 튜닝은 부작용이 커 최후에만 씁니다.


실무 맥락

💼
실무 맥락서비스 배포 중 in-flight 요청이 끊기는 문제 — graceful shutdown으로 해결
현업 패턴

배포 파이프라인이 완성되어 git push 시 자동으로 프로덕션에 새 컨테이너가 올라옵니다. 그런데 배포할 때마다 일부 사용자에게 "502 Bad Gateway" 에러가 발생한다는 모니터링 알림이 옵니다.

장애 원인 분석: 배포 과정에서 로드 밸런서는 여전히 구 컨테이너로 요청을 보내는데, 오케스트레이션 시스템은 이미 docker stop을 실행해 구 컨테이너를 강제 종료합니다. 5~10초 처리 시간이 필요한 주문 결제 API의 경우 이 시간 동안 들어온 요청이 모두 502로 응답합니다.

해결 순서:

  1. 앱에 SIGTERM 핸들러 추가: 새 요청 거부(503 응답) + 기존 요청 완료 대기 로직 구현
  2. stop_grace_period: 30s 설정: 결제 API의 최대 처리 시간(15초) × 2 여유로 30초 설정
  3. 헬스체크 엔드포인트 개선: 종료 중일 때 /health가 503을 반환하도록 수정하여 로드 밸런서가 빨리 트래픽을 차단하게 함
  4. 로드 밸런서 드레이닝 시간 확보: 새 컨테이너 시작 후 구 컨테이너 종료 전 최소 5초 대기

이 네 가지 변경 후 배포 중 502 에러가 완전히 사라졌습니다. Graceful Shutdown은 컨테이너 기술의 문제가 아니라 애플리케이션 코드 수준에서 구현해야 합니다.


핵심 요약

개념명령/설정설명
tini init 프로세스ENTRYPOINT ["/sbin/tini", "--"]PID 1 문제와 좀비 프로세스 방지
Docker 내장 tinidocker run --init / init: trueDockerfile 변경 없이 tini 적용
exec 형식 CMDCMD ["node", "server.js"]SIGTERM이 앱 프로세스로 직접 전달
Graceful Shutdown 타임아웃stop_grace_period: 30sSIGKILL 전 대기 시간 연장
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 --inittini를 PID 1로 주입(좀비·신호 처리)docker run --init myapp / compose init: true
docker stop -tGraceful 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 runsecrets 등 일회성 실행 검증docker compose run --rm app
cat cpu.statCPU 스로틀(꼬리 지연) 진단docker exec myapp cat /sys/fs/cgroup/cpu.statnr_throttled
dmesg / journalctl호스트에서 OOM 로그 확인dmesg | grep -i "out of memory"

이것으로 Docker 트랙의 마지막 모듈입니다. 이미지·컨테이너·네트워크·볼륨·Compose·CI/CD·운영 패턴까지 익혔습니다. 수십~수백 개의 컨테이너를 자동 관리하는 Kubernetes 오케스트레이션으로의 전환은 별도의 Kubernetes 트랙에서 다룹니다.

직접 오케스트레이터를 운영하는 대신 클라우드의 매니지드 컨테이너 서비스(ECS·EKS·Fargate)로 운영 부담을 더는 선택지는 매니지드 컨테이너(Cloud Engineering 트랙)에서 비교합니다.

지식 확인

퀴즈 — 8문제

Q1

컨테이너에서 PID 1 문제가 발생하는 이유는?

Q2

Graceful Shutdown에서 SIGTERM 이후 SIGKILL이 발생하는 Docker의 기본 타임아웃은 몇 초인가?

Q3

Docker secrets와 환경변수로 비밀 정보를 주입하는 방식의 가장 큰 차이점은?

Q4

컨테이너 이미지에 latest 태그 대신 다이제스트(digest)를 사용하여 핀닝하는 이유는?

Q5

컨테이너의 로그 드라이버를 json-file에서 다른 드라이버로 변경할 때 주의해야 할 점은?

Q6

프로덕션에서 dev·staging·prod마다 설정이 다를 때, 환경마다 이미지를 따로 빌드하지 않고 '같은 이미지'를 쓰면서 설정만 주입하는 방식이 권장되는 이유는?

Q7

[심화] 컨테이너에 cpus 1.0 제한을 걸었습니다. 평균 CPU 사용률은 낮은데 p99 응답 지연이 주기적으로 튑니다. 그 메커니즘으로 가장 정확한 것은?

Q8

[심화] 평균 CPU는 여유로운데 컨테이너의 p99 지연이 주기적으로 튀어 CPU 스로틀링을 의심합니다. 이를 확정하려면 무엇을 봐야 하나요?

0 / 8 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

docker-compose.yml로 nginx + 앱 컨테이너를 함께 정의하고, 서비스 간 통신과 볼륨 마운트를 구성한다.

35📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요