컨테이너의 PID 1 문제와 시그널 처리

컨테이너의 PID 1 문제와 시그널 처리

한눈에 보기

컨테이너를 중지하면 runtime은 main process에 종료 signal을 보내고 일정 시간 뒤에도 살아 있으면 강제 종료한다. Shell form CMD를 쓰면 application 앞에 shell이 PID 1로 놓여 signal이 전달되지 않을 수 있다. Exec form으로 application을 직접 PID 1로 실행하거나, 여러 child process를 만든다면 signal forwarding과 zombie reaping을 담당하는 작은 init을 둔다. 애플리케이션은 SIGTERM을 받으면 readiness를 내리고 새 요청을 받지 않은 뒤 진행 중 작업과 connection을 제한 시간 안에 정리해야 한다.

목차

컨테이너 종료는 프로세스 종료다

컨테이너는 작은 가상 머신이 아니라 격리된 process 집합이다. main process가 끝나면 컨테이너도 끝난다. 배포 중 교체, autoscaling, node drain, 수동 docker stop은 결국 main process를 종료하는 동작으로 이어진다.

정상적인 종료는 대략 다음 흐름이다.

sequenceDiagram
    participant R as Runtime
    participant P as PID 1
    participant A as Application

    R->>P: SIGTERM
    P->>A: handle or forward
    A->>A: stop accepting work
    A->>A: drain and cleanup
    A-->>R: exit code 0

종료가 제한 시간 안에 끝나지 않으면 runtime 또는 orchestrator가 SIGKILL을 보낼 수 있다. 그때는 cleanup handler를 실행할 기회가 없다.

sequenceDiagram
    participant O as Orchestrator
    participant P as Main process

    O->>P: SIGTERM
    Note over O,P: grace period
    P--xO: still running
    O->>P: SIGKILL
    P-->>O: forced termination

따라서 “종료 signal을 받는가”와 “제한 시간 안에 종료하는가”를 따로 확인해야 한다.

PID 1은 일반 프로세스와 동작이 다르다

Linux process namespace 안에서 첫 process가 PID 1이 된다. Container runtime은 이 process를 컨테이너의 생명주기 대표로 본다.

PID 1에는 두 가지 주의점이 있다.

  1. 기본 signal disposition이 terminate인 signal도 PID 1에서는 명시적인 handler가 없으면 기대와 다르게 무시될 수 있다.
  2. 부모를 잃고 PID 1에 재부모화된 child process를 wait 계열 호출로 회수해야 한다.

일반 application이 이미 SIGTERM handler를 구현하고 child process를 만들지 않는다면 직접 PID 1로 실행해도 충분할 수 있다. 반대로 worker를 spawn하거나 shell script가 여러 process를 관리한다면 init 역할을 누가 맡는지 명확히 해야 한다.

PID 1이라는 이유만으로 항상 문제가 생기는 것은 아니다

문제는 application이 PID 1의 signal과 process 관리 책임을 가정하지 않았는데 그 자리에 놓였을 때 나타난다.

실행 중 process tree를 확인해 보면 구조가 드러난다.

docker top sample-api
docker exec sample-api ps -o pid,ppid,stat,comm,args

최종 image에 ps가 없다면 host의 docker top이나 platform observability 기능을 사용한다.

Shell Form이 Signal을 가로막는 과정

Dockerfile의 shell form은 명령을 문자열로 작성한다.

CMD node dist/server.js

개념적으로 /bin/sh -c를 통해 실행된다.

PID 1  /bin/sh -c node dist/server.js
└─PID 7  node dist/server.js

Runtime이 PID 1인 shell에 SIGTERM을 보냈는데 shell이 child에게 전달하지 않으면 Node process는 종료 요청을 모른다. 결국 timeout 후 SIGKILL로 끝날 수 있다.

Shell form은 variable expansion, pipe, redirect 같은 shell 기능을 쓸 수 있다는 장점이 있다.

CMD node "dist/${SERVICE_ENTRY}.js" 2>&1

하지만 시작 명령에 이런 동적 shell 기능이 정말 필요한지 먼저 묻는다. 환경 변수 해석은 application 안에서 하고, log는 stdout/stderr로 직접 쓰는 편이 단순한 경우가 많다.

Exec Form으로 애플리케이션을 직접 실행하기

Exec form은 JSON array로 executable과 argument를 구분한다.

CMD ["node", "dist/server.js"]

이제 Node process가 PID 1이 되어 runtime의 signal을 직접 받는다.

PID 1  node dist/server.js

JSON 문법이므로 작은따옴표가 아니라 큰따옴표를 사용해야 한다.

# 잘못된 JSON 형태
CMD ['node', 'dist/server.js']

고정된 executable과 변경 가능한 기본 argument를 나누려면 ENTRYPOINTCMD를 함께 쓸 수 있다.

ENTRYPOINT ["node", "dist/server.js"]
CMD ["--port", "8080"]
# CMD 기본 인자를 대체한다.
docker run --rm sample-api --port 9090

이 패턴이 항상 필요한 것은 아니다. 운영자가 다른 diagnostic command로 image를 실행해야 한다면 고정 ENTRYPOINT가 방해가 될 수 있다. image의 사용 계약에 맞춰 선택한다.

Exec form도 handler를 대신 만들지는 않는다

application이 SIGTERM을 처리하지 않거나 종료할 수 없는 작업을 기다리면 여전히 강제 종료된다.

Entrypoint Script의 마지막에는 exec가 필요하다

환경 검사나 파일 권한 설정 때문에 startup script가 필요한 경우가 있다.

#!/bin/sh
set -eu

node scripts/check-config.js
node dist/server.js

마지막 줄도 child process로 실행되므로 shell은 계속 PID 1로 남는다. exec는 현재 shell process를 application process로 교체한다.

#!/bin/sh
set -eu

node scripts/check-config.js
exec node dist/server.js "$@"

Dockerfile은 script 자체를 exec form으로 실행한다.

COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["--port", "8080"]

보다 범용적인 wrapper는 전달받은 command를 그대로 실행한다.

#!/bin/sh
set -eu

if [ -z "${APP_CONFIG_PATH:-}" ]; then
  echo "APP_CONFIG_PATH is required" >&2
  exit 64
fi

exec "$@"

startup script가 migration을 무조건 실행하게 만들면 여러 replica가 동시에 migration을 시도할 수 있다. 초기화와 main process 실행의 lifecycle이 같은지 검토하고, cluster-wide 작업은 별도 job으로 분리하는 편이 안전하다.

Child Process와 Zombie Reaping

Child가 종료하면 parent가 wait로 exit status를 회수할 때까지 process table에 zombie entry가 남는다. Parent가 먼저 죽으면 orphan child는 namespace의 subreaper 또는 PID 1에 재부모화된다.

PID 1 application
└─PID 23 worker
  └─PID 41 short-lived helper

worker가 먼저 비정상 종료하고 application이 재부모화된 helper를 회수하지 못하는 구조가 반복되면 zombie가 쌓일 수 있다.

상태의 Z를 확인한다.

ps -eo pid,ppid,stat,comm

단일-thread server가 child를 만들지 않는다면 reaping 문제가 거의 없을 수 있다. 다음 경우에는 확인이 필요하다.

Application이 child lifecycle을 직접 소유한다면 spawn handle을 보관하고 종료·wait까지 책임지는 것이 가장 명확하다.

작은 Init Process를 사용해야 할 때

Application을 PID 1 책임에 맞게 바꾸기 어렵거나 여러 child process를 만들면 tini 같은 작은 init을 앞에 둘 수 있다. Docker CLI의 --init도 init process를 삽입하는 방법이다.

docker run --rm --init sample-worker:latest

개념적인 process tree는 다음과 같다.

PID 1  init
└─PID 7  application

Init은 signal을 child에게 전달하고 종료된 descendant를 회수한다. 이것이 application의 graceful shutdown을 대신하지는 않는다. Application은 여전히 전달된 SIGTERM을 처리해야 한다.

Image에 init을 명시적으로 포함한다면 공급망과 버전을 관리한다.

ENTRYPOINT ["/usr/bin/tini", "--", "node", "dist/server.js"]

base image에 해당 binary가 실제로 존재하는지 확인해야 한다. 문서 예제만 보고 경로를 가정하면 container가 시작되지 않는다.

Process supervisor를 무조건 넣지 않는다

한 container 안에서 여러 장기 실행 daemon을 관리하면 health, log, restart 책임이 결합된다. 특별한 이유가 없다면 process별 container로 나누는 편이 단순하다.

애플리케이션의 Graceful Shutdown 순서

종료 handler에서 곧바로 database pool부터 닫으면 진행 중인 HTTP handler가 query를 수행하다 실패한다. 일반적인 순서는 다음과 같다.

flowchart TD
    A[SIGTERM received]
    B[Mark not ready]
    C[Stop accepting new work]
    D[Wait for in-flight work]
    E[Close background consumer]
    F[Close DB and external clients]
    G[Flush bounded telemetry]
    H[Exit]
    A --> B --> C --> D --> E --> F --> G --> H

각 단계에는 제한 시간이 필요하다. 외부 API call 하나가 무기한 걸리면 전체 grace period를 소진한다.

종료 예산을 예로 나누면 다음과 같다.

단계 예산 예시 초과 시
load balancer 전파 5초 신규 요청 유입 가능
HTTP drain 15초 남은 요청 취소
worker checkpoint 5초 lease 만료 후 재처리
connection/telemetry 정리 3초 best effort 후 종료
안전 여유 2초 orchestrator 강제 종료 전 확보

숫자는 가상 예시다. 실제 request latency와 platform grace period로 정한다.

Node.js HTTP 서버 종료 예제

다음 코드는 실제 서비스가 아닌 종료 흐름 설명용 예시다.

import http from "node:http";

let shuttingDown = false;
const activeSockets = new Set();

const server = http.createServer(async (request, response) => {
  if (shuttingDown) {
    response.writeHead(503, { Connection: "close" });
    response.end("server is shutting down");
    return;
  }

  if (request.url === "/ready") {
    response.writeHead(200);
    response.end("ready");
    return;
  }

  await handleRequest(request, response);
});

server.on("connection", (socket) => {
  activeSockets.add(socket);
  socket.on("close", () => activeSockets.delete(socket));
});

server.listen(8080);

종료 handler는 여러 signal이 와도 한 번만 시작되어야 한다.

async function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;

  console.info("shutdown_started", { signal });

  const forceTimer = setTimeout(() => {
    console.error("shutdown_deadline_exceeded", {
      openSockets: activeSockets.size,
    });
    process.exit(1);
  }, 25_000);
  forceTimer.unref();

  server.close(async (error) => {
    try {
      if (error) throw error;

      await stopMessageConsumer();
      await databasePool.end();
      await flushTelemetry({ timeoutMs: 1_000 });

      clearTimeout(forceTimer);
      console.info("shutdown_completed");
      process.exit(0);
    } catch (shutdownError) {
      console.error("shutdown_failed", { shutdownError });
      process.exit(1);
    }
  });
}

process.once("SIGTERM", () => void shutdown("SIGTERM"));
process.once("SIGINT", () => void shutdown("SIGINT"));

이 코드를 그대로 production에 붙이기 전에 사용하는 Node 버전의 server.close semantics, keep-alive connection 처리, framework adapter의 shutdown API를 확인한다. 강제로 socket을 모두 끊으면 쓰기 요청의 response가 유실될 수 있다. 반대로 idle keep-alive가 종료를 막지 않도록 runtime API를 이용할 수 있다.

Signal handler 안에서 무조건 process.exit(0)부터 호출하면 event loop에 남은 비동기 cleanup이 수행되지 않는다. 완료 또는 deadline 시점에 exit한다.

Background Job과 중복 처리 방지

HTTP server는 새 connection을 차단하면 되지만 queue consumer는 message lease와 acknowledgement가 있다.

안전한 종료 순서는 보통 다음과 같다.

  1. 새 message polling을 중단한다.
  2. 이미 받은 작업의 lease가 grace period 안에 끝날지 판단한다.
  3. 완료한 작업만 acknowledgement한다.
  4. 끝내지 못한 작업은 lease를 반환하거나 만료시켜 재처리한다.
  5. handler는 중복 실행되어도 결과가 망가지지 않도록 멱등하게 만든다.
async function stopWorker() {
  consumer.pause();

  const drained = await inFlight.waitUntilEmpty({
    timeoutMs: 15_000,
  });

  if (!drained) {
    await inFlight.releaseRemainingLeases();
  }

  await consumer.close();
}

releaseRemainingLeases는 가상 API다. 사용하는 queue가 visibility timeout, nack, lease extension을 어떻게 제공하는지 확인해야 한다.

종료를 완벽하게 구현해도 process crash와 SIGKILL, host 장애는 남는다. 따라서 graceful shutdown은 중복 방지의 유일한 장치가 될 수 없다.

Docker Stop과 종료 제한 시간

docker stop은 main process에 stop signal을 보내고 timeout을 기다린 뒤 강제 종료한다. Image의 STOPSIGNAL로 기본 signal을 바꿀 수 있다.

STOPSIGNAL SIGTERM
CMD ["node", "dist/server.js"]

대부분의 web application은 SIGTERM 계약으로 충분하다. 특별한 server가 다른 signal을 공식 shutdown signal로 사용하는 경우에만 바꾼다.

테스트에서는 시간을 측정한다.

docker run --rm --name sample-api sample-api:test &
docker stop --time 30 sample-api

Runtime과 Compose 버전에 따라 flag spelling과 기본 timeout이 다를 수 있으므로 현재 도구의 도움말을 확인한다.

종료 exit code도 본다.

docker inspect sample-api \
  --format '{{.State.ExitCode}} {{.State.OOMKilled}}'

Container가 이미 --rm으로 삭제됐다면 inspect할 수 없으므로 자동화 test에서는 삭제 시점을 조정한다.

Kubernetes의 Pod 종료 흐름

Kubernetes에서 Pod가 terminating 상태가 되면 grace period countdown이 시작된다. preStop hook이 있으면 먼저 실행되고, 그 뒤 container main process에 stop signal이 전달된다. 전체가 terminationGracePeriodSeconds 예산을 공유한다.

sequenceDiagram
    participant K as Kubelet
    participant H as preStop
    participant P as PID 1

    K->>H: run hook
    Note over K,P: grace period already counting
    H-->>K: complete
    K->>P: SIGTERM
    P-->>K: graceful exit

preStop에서 25초를 쓰고 application에 10초가 필요한데 전체 grace period가 30초라면 강제 종료될 수 있다.

spec:
  terminationGracePeriodSeconds: 40
  containers:
    - name: sample-api
      image: registry.example.invalid/sample-api@sha256:example
      lifecycle:
        preStop:
          httpGet:
            path: /internal/drain
            port: 8080

Image digest는 가상 값이다. Hook은 중복 호출 가능성과 실패를 고려해 멱등하게 만들고 가볍게 유지한다. 단순 sleep은 endpoint 전파 시간을 벌 수 있지만 실제로 readiness가 내려갔는지 보장하지 않는다.

Readiness와 Connection Draining

SIGTERM을 받았는데 readiness endpoint가 계속 성공하면 load balancer가 종료 중인 Pod로 새 요청을 보낼 수 있다.

if (request.url === "/ready") {
  response.writeHead(shuttingDown ? 503 : 200);
  response.end(shuttingDown ? "draining" : "ready");
  return;
}

Readiness 실패가 endpoint와 load balancer에 전파되기까지 시간이 걸린다. Application이 listen socket을 닫는 시점, existing keep-alive connection, proxy retry 정책을 함께 본다.

종료 직전 요청이 다른 replica로 retry될 수 있다면 쓰기 API에는 idempotency key가 필요할 수 있다. Connection draining만으로 exactly-once 실행을 보장할 수 없다.

Liveness와 readiness를 구분한다

종료 중 readiness는 실패해야 하지만 process가 정상적으로 drain 중이라는 이유로 liveness가 즉시 재시작을 유발해서는 안 된다.

SIGKILL은 처리할 수 없다

SIGTERM은 handler가 정리할 기회를 주지만 SIGKILL은 catch하거나 무시할 수 없다. 다음 상황에서는 cleanup 없이 멈출 수 있다.

그러므로 correctness를 shutdown handler에만 두지 않는다.

Graceful shutdown은 불필요한 실패를 줄이는 최적화이자 사용자 경험 장치다. 데이터 무결성의 최후 방어선은 durable protocol이어야 한다.

종료 동작을 자동으로 테스트하기

Container를 시작하고 readiness를 확인한 뒤 느린 요청 중에 stop signal을 보낸다.

set -eu

name="sample-api-shutdown-test"
docker run -d --name "$name" -p 18080:8080 sample-api:test

curl --fail --retry 20 --retry-delay 1 \
  http://127.0.0.1:18080/ready

curl http://127.0.0.1:18080/testing/slow-request &
request_pid=$!

docker stop --time 30 "$name"
wait "$request_pid"

docker inspect "$name" \
  --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
docker rm "$name"

Endpoint는 test build에서만 제공하는 가상 경로다. 실제 CI에서는 cleanup trap을 추가하되 trap이 test 실패 원인을 덮지 않게 한다.

추가 시나리오도 필요하다.

시나리오 기대 결과
idle server에 SIGTERM 빠르게 exit 0
진행 중 read request 제한 시간 안에 response 후 종료
진행 중 write request commit/rollback 상태가 명확함
queue job 처리 중 ack 또는 재처리 가능 상태
dependency close가 hang 내부 deadline 후 non-zero 종료
signal 두 번 수신 cleanup 한 번만 수행
child process 존재 signal 전달과 reaping 확인

관측할 종료 지표와 로그

종료 문제는 새 container가 떠서 가려지기 쉽다. 최소한 다음 event를 구조화해 남긴다.

{
  "event": "shutdown_completed",
  "signal": "SIGTERM",
  "durationMs": 3842,
  "inFlightAtStart": 17,
  "forcedClosures": 0
}

운영 지표 예시는 다음과 같다.

로그에 request body나 credential을 넣지 않는다. 종료 단계 이름과 count, duration만으로도 병목을 찾을 수 있다.

구현 체크리스트

Process 구조

종료 흐름

장애 내성

마무리

컨테이너의 종료는 추상적인 lifecycle event가 아니라 PID 1에 signal을 보내는 process 동작이다. Shell form CMD가 앞에 있으면 application이 signal을 받지 못할 수 있으므로, 기본은 exec form으로 main application을 직접 실행하는 것이다.

Startup script가 필요하면 마지막에 exec로 자신을 application과 교체한다. 여러 child를 만들거나 application이 reaping 책임을 수행하기 어렵다면 작은 init process로 signal forwarding과 zombie 회수를 맡길 수 있다.

Signal을 잘 받는 것만으로 종료가 완성되지는 않는다. Readiness를 내리고 새 요청을 차단한 뒤, 진행 중 작업과 worker lease를 정리하고, 마지막에 database와 telemetry client를 닫아야 한다. 이 모든 단계는 orchestrator의 grace period 안에 끝나야 한다.

마지막으로 SIGKILL과 host crash는 항상 가능하다. Graceful shutdown은 정상 교체의 실패율을 낮추지만, transaction·idempotency·lease 같은 복구 가능한 protocol을 대신하지 않는다. 안전한 종료는 process wiring, application lifecycle, durable correctness를 함께 설계할 때 만들어진다.

관련 노트

참고 자료