Table of contents
- 무엇을 막고 싶은가
- HEALTHCHECK 지시어
- Graceful shutdown — SIGTERM 처리
- tini / dumb-init — PID 1의 책임
- 로그 드라이버 — 기본값이 위험하다
- 리소스 제한 — 이웃에게 피해 안 가게
- 재시작 정책
- 시간 동기화와 타임존
- 최종 체크리스트
- 여기까지의 상태
무엇을 막고 싶은가
운영 사고 대부분은 다음 몇 가지 패턴으로 정리된다.
flowchart TB
PROD["프로덕션 이슈"] --> H["헬스 체크 부재"]
PROD --> S["종료 시 요청 유실"]
PROD --> L["로그 디스크 포화"]
PROD --> R["리소스 폭주 / OOM"]
PROD --> Z["좀비 프로세스 / 시그널 전달 실패"]
H -->|HEALTHCHECK / readiness| F1["라이브/레디 분리"]
S -->|SIGTERM 처리 + 드레인| F2["graceful shutdown"]
L -->|드라이버 + 로테이션| F3["로그 관리"]
R -->|--memory / --cpus| F4["리소스 제한"]
Z -->|tini / dumb-init| F5["PID 1 처리"]
하나씩 본다.
HEALTHCHECK 지시어
Dockerfile 안에 헬스 체크를 정의할 수 있다. 이건 Docker Engine이 주기적으로 실행해서 컨테이너의 health 상태를 유지한다.
FROM alpine:3.20
RUN apk add --no-cache curl
COPY --from=builder /out/server /server
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=3s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:8080/healthz || exit 1
ENTRYPOINT ["/server"]
--interval: 검사 주기--timeout: 응답 대기 시간--start-period: 앱 초기 기동 대기 시간 (이 시간 동안은 실패해도 unhealthy로 안 셈)--retries: 연속 실패 허용 횟수
docker ps에 (healthy) 표시가 붙고, Compose에서 depends_on: condition: service_healthy와 함께 쓰면 앞선 7편의 기동 순서 제어가 정상 동작한다.
Kubernetes는 조금 다르다
Kubernetes는 Dockerfile의 HEALTHCHECK를 무시한다. 대신 Pod spec에서 더 세분화된 probe를 제공한다.
containers:
- name: web
image: myapp:1.4.2
startupProbe:
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
- startup: 초기 기동이 끝날 때까지 liveness/readiness를 대신한다. 기동이 오래 걸리는 앱에 유용
- liveness: 살아있는가 — 실패하면 kubelet이 컨테이너를 재시작
- readiness: 트래픽 받을 준비가 됐는가 — 실패하면 Service에서 엔드포인트가 빠짐
liveness와 readiness를 엔드포인트 수준에서 구분하는 게 중요하다. liveness는 정말 죽었을 때만 실패해야 한다. DB 일시 불능 같은 것으로 liveness가 실패하면 재시작 루프에 빠진다.
Graceful shutdown — SIGTERM 처리
배포 시 구 컨테이너를 내리고 신 컨테이너를 띄운다. 이때 구 컨테이너가 처리 중인 요청을 끊지 않고 마무리한 다음 종료하도록 만드는 게 graceful shutdown이다.
sequenceDiagram
participant Orch as Orchestrator
participant Proxy as Load Balancer
participant App as 앱 컨테이너
Orch->>Proxy: 엔드포인트 제거 (readiness=false)
Orch->>App: SIGTERM
App->>App: 새 요청 거부 시작
App->>App: in-flight 요청 처리 완료
App->>App: DB/큐 연결 종료
App->>Orch: 정상 종료 (exit 0)
Orch->>App: (타임아웃 시 SIGKILL)
실제 구현은 언어별로 다르지만 공통 패턴은 이렇다.
Go 예시:
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
srv := &http.Server{Addr: ":8080", Handler: router}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, c := context.WithTimeout(context.Background(), 30*time.Second)
defer c()
_ = srv.Shutdown(shutdownCtx)
Node.js 예시:
const server = app.listen(8080);
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 30_000).unref();
});
오케스트레이터 쪽에서도 시간을 줘야 한다. Kubernetes에서는 terminationGracePeriodSeconds와 preStop 훅이 그 역할을 한다.
spec:
terminationGracePeriodSeconds: 60
containers:
- name: web
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"] # LB에서 빠질 시간
preStop의 sleep은 LB/Service가 엔드포인트 목록에서 이 파드를 제거하는 동안 유예 시간을 주기 위한 것이다. 너무 짧으면 이미 라우팅된 요청을 잃는다.
시그널이 앱까지 안 닿는 경우
흔한 실수 하나. Dockerfile에서 CMD나 ENTRYPOINT를 셸 형태로 쓰면 sh -c가 PID 1이 되고 앱은 자식 프로세스가 된다. SIGTERM이 sh로 가고 앱에는 전달되지 않는다.
# 나쁨 — 셸 형태
CMD "node dist/server.js"
# 좋음 — exec 형태
CMD ["node", "dist/server.js"]
exec 형태로 쓰면 앱이 직접 PID 1이 돼서 시그널을 직통으로 받는다.
tini / dumb-init — PID 1의 책임
PID 1은 Unix에서 특별하다. 좀비 프로세스를 수거(reap)해야 하고, 시그널 전달 동작이 일반 프로세스와 다르다. Node.js, Python 같은 런타임은 이런 책임을 잘 안 해준다. 자식 프로세스를 포크하는 앱이면 좀비가 쌓여 메모리를 갉아먹는다.
해결은 가볍다. tini(또는 dumb-init)를 init 프로세스로 쓰는 것이다.
FROM node:20-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY . .
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]
Docker 자체도 --init 옵션으로 같은 효과를 낸다.
docker run --init myapp:1.4.2
Compose에서는 init: true로 설정한다.
services:
web:
image: myapp:1.4.2
init: true
Kubernetes는 기본적으로 자식 프로세스가 많지 않은 컨테이너라면 크게 문제되지 않지만, 앱이 쉘 스크립트나 다중 프로세스를 포크한다면 shareProcessNamespace와 함께 init을 고려한다.
로그 드라이버 — 기본값이 위험하다
Docker의 기본 로그 드라이버는 json-file이다. 이름대로 로그를 JSON으로 로컬 파일에 쓴다. 아무 설정 없이 방치하면 디스크가 찰 때까지 계속 쌓인다.
로테이션 설정
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "5"
}
}
이 설정을 데몬 레벨에 넣고 systemctl restart docker로 반영한다. 파일 하나가 10MB를 넘으면 새 파일로 넘어가고, 최대 5개까지만 유지된다. 컨테이너별로도 오버라이드 가능하다.
docker run --log-driver json-file \
--log-opt max-size=10m --log-opt max-file=5 \
myapp:1.4.2
다른 드라이버
로그를 다른 목적지로 보내는 드라이버가 여럿 있다.
| 드라이버 | 목적 |
|---|---|
json-file | 로컬 파일 (기본) |
local | 로컬 + 압축 |
journald | systemd journal로 |
syslog | syslog 서버로 |
fluentd | fluentd 에이전트로 |
gelf | Graylog 등 GELF 수신기로 |
awslogs | CloudWatch Logs로 |
gcplogs | GCP Cloud Logging으로 |
일반적인 프로덕션 패턴은 컨테이너는 stdout/stderr로만 쓰고, 호스트에 Fluent Bit 또는 Vector 같은 수집기를 두고 외부로 전송하는 것이다. 이렇게 하면 컨테이너 레벨에서 드라이버를 바꿀 필요가 없고, 수집기에서 버퍼링/리라우팅이 가능해진다.
flowchart LR
APP["앱 컨테이너"] -->|stdout/stderr| FILE["/var/lib/docker/containers/<id>/<id>-json.log"]
FILE --> AGENT["Fluent Bit / Vector"]
AGENT --> ES["Elasticsearch / Loki"]
AGENT --> S3["S3 / GCS"]
리소스 제한 — 이웃에게 피해 안 가게
리소스 제한을 안 걸면 메모리 누수 한 번에 호스트 전체가 흔들린다.
docker run \
--memory=512m --memory-swap=512m \
--cpus=1.0 \
--pids-limit=200 \
myapp:1.4.2
--memory: 메모리 상한. 초과 시 OOM killer가 컨테이너를 종료--memory-swap: 스왑 포함 상한. 기본값은 메모리의 2배. 같은 값으로 두면 스왑 사용 금지--cpus: CPU 개수 (소수점 가능)--pids-limit: 포크 폭탄 방어
Compose에서는 deploy.resources 아래에 쓴다.
services:
web:
image: myapp:1.4.2
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
deploy.resources는 원래 Swarm용 키였지만 Compose v3 이후 일반 Compose에서도 해석된다.
Kubernetes는 resources.requests와 resources.limits로 같은 개념을 다룬다. 스케줄러가 requests 기준으로 배치하고, limits가 런타임 상한이 된다.
resources:
requests:
cpu: 250m
memory: 128Mi
limits:
cpu: 1
memory: 512Mi
JVM 앱은 주의
JVM은 예전엔 컨테이너 제한을 무시하고 호스트 전체 메모리를 기준으로 힙을 잡았다. Java 11 이후로는 기본적으로 컨테이너 제한을 인식하지만, 구버전이거나 -Xmx를 안 주면 여전히 문제가 된다.
java -XX:MaxRAMPercentage=75 -jar app.jar
-XX:MaxRAMPercentage로 컨테이너 상한의 몇 퍼센트를 힙에 쓸지 명시하는 패턴이 안전하다.
재시작 정책
호스트가 재부팅되거나 컨테이너가 죽으면 자동으로 다시 뜨도록 정책을 건다.
docker run --restart=unless-stopped myapp:1.4.2
no(기본): 재시작 안 함on-failure[:N]: 비정상 종료 시만, 최대 N회always: 항상 (수동 stop 이후에도 데몬 재시작 시 다시 뜸)unless-stopped: 항상 (수동 stop이면 데몬 재시작 시에도 안 뜸)
프로덕션 싱글 호스트 배포(Compose)에서는 unless-stopped가 무난하다. Kubernetes는 이미 Deployment가 재시작을 관리하므로 별도 정책이 필요 없다.
시간 동기화와 타임존
로그 타임스탬프가 UTC면 좋고 KST면 좋고 — 팀 표준을 정하는 게 중요하다. 이미지에 타임존을 박아둔다.
FROM alpine:3.20
RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul
alpine은 tzdata가 기본 포함이 아니니 명시적으로 설치한다. UTC가 좋으면 TZ=UTC로 둔다. 컨테이너마다 타임존이 뒤섞이면 로그 상관 분석이 악몽이 된다.
최종 체크리스트
프로덕션 배포 전에 다음을 확인한다.
-
CMD/ENTRYPOINT가 exec 형태로 쓰여 있다 - HEALTHCHECK 또는 Kubernetes probe가 설정돼 있다
- 앱이 SIGTERM을 처리하고 graceful shutdown을 구현했다
-
terminationGracePeriodSeconds또는 Compose 종료 타임아웃이 넉넉하다 - 로그가 stdout/stderr로 나가고, 드라이버 로테이션이 걸려 있다
-
--memory,--cpus또는 resources limits가 설정돼 있다 - 재시작 정책이 지정돼 있다
-
--init또는 tini를 써서 PID 1 문제를 피했다 - 10편에서 다룬 보안 설정이 같이 적용돼 있다
여기까지의 상태
이미지를 잘 굽고, 잘 유통하고, 잘 굴리기까지의 큰 축은 다 훑었다. 마지막 편에서는 일이 잘 안 풀릴 때 어디를 봐야 하는지, 그리고 Docker 말고 다른 옵션은 뭐가 있는지를 다룬다.
다음 편은 트러블슈팅과 대안이다. 흔한 에러 코드의 의미, logs/inspect/events/stats/top을 언제 써야 하는지, Podman/containerd 같은 대안과 그 차이까지 — 시리즈 마무리다.

Loading comments...