Skip to content

Split View: Dockerfile에서 반복되는 실수 — root 실행, PID 1, 이미지에 남는 시크릿

✨ Learn with Quiz
|

Dockerfile에서 반복되는 실수 — root 실행, PID 1, 이미지에 남는 시크릿

들어가며 — 빌드는 통과하는데 운영에서만 터지는 것들

Dockerfile의 문제는 빌드 단계에서 거의 드러나지 않습니다. 이미지는 만들어지고 컨테이너는 뜨고 로컬에서는 잘 돕니다. 문제는 그 다음에 나옵니다.

배포할 때마다 docker stop이 정확히 10초를 기다리다 컨테이너를 강제 종료하고, 종료 코드가 143이 아니라 137로 찍힙니다. 그 10초 동안 처리 중이던 요청은 그냥 끊깁니다. 몇 달 뒤에는 프로세스 테이블에 좀비가 수천 개 쌓여 있고, 보안 스캔은 이미지 히스토리에서 배포용 토큰을 찾아냅니다.

이 글은 그런 문제들을 증상이 아니라 원인 쪽에서 봅니다. 대부분은 Dockerfile 두세 줄로 끝나지만, 왜 그래야 하는지를 모르면 다음 프로젝트에서 똑같이 반복됩니다.

root로 실행 — 아무도 지정하지 않으면 0번이 된다

USER를 쓰지 않은 이미지는 UID 0으로 돕니다.

docker run --rm node:22-bookworm-slim id
uid=0(root) gid=0(root) groups=0(root)

"컨테이너는 격리되어 있으니 안에서 root여도 상관없다"는 말이 오래 돌았지만, 컨테이너의 root는 기본 설정에서 호스트의 root와 같은 UID입니다. 유저 네임스페이스를 켜지 않았다면 호스트 파일시스템을 볼륨으로 마운트한 순간 그대로 드러납니다.

docker run --rm -v /etc:/host-etc alpine:3.20 \
  sh -c 'echo "# injected" >> /host-etc/hosts && tail -1 /host-etc/hosts'
# injected

컨테이너 안에서 호스트의 /etc/hosts를 수정했습니다. 여기에 런타임 취약점 하나만 더해지면 탈출은 곧바로 호스트 루트 권한입니다.

고치는 방법은 정해져 있습니다. 사용자를 만들고, 필요한 경로의 소유권을 그 사용자에게 주고, USER로 전환합니다.

FROM node:22-bookworm-slim
WORKDIR /app

RUN groupadd --gid 10001 app \
 && useradd --uid 10001 --gid app --shell /usr/sbin/nologin --create-home app

COPY --chown=10001:10001 package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=10001:10001 . .

USER 10001:10001
EXPOSE 8080
CMD ["node", "dist/server.js"]

이름 대신 숫자 UID를 쓴 것이 의도적입니다. 쿠버네티스의 runAsNonRoot는 이미지 설정의 USER 값이 이름이면 그것이 root가 아닌지 판단하지 못하고 파드를 거부합니다.

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

USER app으로 써 두고 위 설정을 적용하면 이런 이벤트를 보게 됩니다.

Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root

비특권 사용자로 바꾸면 1024 미만 포트를 열 수 없습니다. 여기서 NET_BIND_SERVICE capability를 추가하는 선택지도 있지만, 애플리케이션 포트를 8080으로 바꾸고 서비스 계층에서 80으로 노출하는 편이 훨씬 단순합니다.

latest 태그와 재현성

FROM node:22는 편리하지만 시간에 따라 다른 것을 가리킵니다. 오늘의 빌드와 다음 주의 빌드가 다른 베이스 위에 올라가고, 장애가 났을 때 "지난주에는 됐는데"의 원인을 추적할 수 없습니다.

docker pull node:22-bookworm-slim
docker inspect --format '{{index .RepoDigests 0}}' node:22-bookworm-slim
node@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

다이제스트로 고정하면 어떤 시점에 빌드해도 같은 베이스가 보장됩니다.

FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

여기서 자주 나오는 반론이 "그러면 보안 패치를 못 받는다"입니다. 맞는 지적이고, 그래서 갱신은 사람이 아니라 봇에게 맡깁니다. Dependabot이나 Renovate가 다이제스트를 올리는 PR을 열면, CI가 그 조합을 검증한 뒤 병합됩니다. 자동으로 흘러 들어오는 것과 검증을 거쳐 들어오는 것의 차이입니다.

애플리케이션 이미지 태그도 같습니다. myapp:latest로 배포하면 롤백할 대상이 없습니다. 커밋 SHA를 태그로 쓰고, 필요하면 latest를 추가로 붙이는 정도가 안전합니다.

ADD와 COPY — 기본값은 COPY다

두 명령은 비슷해 보이지만 ADD가 훨씬 많은 일을 합니다. 그리고 그 추가 동작이 대부분 문제입니다.

ADD는 로컬 tar 아카이브를 자동으로 풉니다.

ADD release.tar.gz /opt/app/

이 한 줄은 release.tar.gz를 복사하는 것이 아니라 압축을 해제합니다. 아카이브 안에 ../ 경로나 심볼릭 링크가 들어 있으면 의도하지 않은 위치에 파일이 쓰입니다. 파일 그대로 두고 싶었다면 COPY를 써야 합니다.

ADD는 URL도 받습니다.

ADD https://example.com/tool.tar.gz /tmp/
RUN tar -xzf /tmp/tool.tar.gz -C /opt && rm /tmp/tool.tar.gz

체크섬 검증이 없고, 압축 파일이 레이어에 남으며, 앞선 RUN에서 지워도 크기는 그대로입니다. 원격 파일이 필요하면 RUN 안에서 받고 검증하고 지우는 것이 낫습니다.

RUN set -eux \
 && curl -fsSL -o /tmp/tool.tar.gz https://example.com/tool-1.4.2.tar.gz \
 && echo "3f0a91c2e4d7b5a8f16c9d02e3b7a41f8c5d29e06b3a7f1c4d8e2b0a95f3c716  /tmp/tool.tar.gz" | sha256sum -c - \
 && tar -xzf /tmp/tool.tar.gz -C /opt \
 && rm -f /tmp/tool.tar.gz

ADD가 정당한 경우는 두 가지뿐입니다. 로컬 tar를 의도적으로 풀 때, 그리고 BuildKit에서 깃 저장소를 직접 가져올 때입니다. 그 외에는 항상 COPY입니다.

PID 1이라는 자리 — SIGTERM이 앱에 도달하지 않는 이유

가장 비싼 실수가 여기 있습니다. 증상은 배포할 때마다 반복되는 10초의 지연입니다.

FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
CMD npm start
docker run -d --name shellform demo:shell
time docker stop shellform
docker inspect -f '{{.State.ExitCode}}' shellform
shellform

real    0m10.412s
137

10초는 도커의 기본 종료 유예 시간이고, 137은 SIGKILL입니다. 정상 종료였다면 즉시 끝나고 143(SIGTERM)이 나왔어야 합니다.

원인은 두 겹입니다. shell form으로 쓴 CMD npm start는 실제로 /bin/sh -c "npm start"로 실행됩니다. 그러면 PID 1은 셸이 됩니다. 그리고 PID 1은 커널에서 특별 취급을 받아, 명시적으로 핸들러를 등록하지 않은 신호의 기본 동작이 적용되지 않습니다. 즉 SIGTERM이 와도 그냥 무시됩니다.

여기서 흔히 도는 설명은 "셸이 신호를 자식에게 전달하지 않는다"인데, 절반만 맞습니다. 데비안의 dash나 알파인의 busybox ash는 sh -c에 단순 명령 하나만 있으면 fork 없이 exec으로 대체하는 최적화를 합니다. 그러면 셸은 사라지고 대상 프로세스가 PID 1이 됩니다. 문제는 그 최적화가 조건부라는 점입니다. 명령에 파이프나 &&, 변수 확장, 리다이렉션이 들어가는 순간 셸이 남습니다. 셸에 의존한 동작은 언젠가 조용히 깨집니다.

그리고 두 번째 겹이 더 흔합니다. npm, yarn, pnpm 같은 래퍼는 셸이 exec으로 대체되더라도 그 자체가 자식 프로세스를 띄우는 관리자입니다. npm은 오랫동안 SIGTERM을 자식에게 전달하지 않았습니다. 그래서 셸을 없애도 앱은 신호를 못 받습니다.

고치는 방법은 exec form으로 쓰고 래퍼를 걷어내는 것입니다.

CMD ["node", "dist/server.js"]
docker run -d --name execform demo:exec
time docker stop execform
docker inspect -f '{{.State.ExitCode}}' execform
execform

real    0m0.284s
143

애플리케이션 쪽에서도 신호를 받아 정리하는 코드가 필요합니다. 도커는 신호를 배달할 뿐 우아한 종료를 대신해 주지 않습니다.

const server = app.listen(8080)

for (const sig of ['SIGTERM', 'SIGINT']) {
  process.on(sig, () => {
    server.close(() => process.exit(0))
    setTimeout(() => process.exit(1), 15000).unref()
  })
}

엔트리포인트 스크립트가 꼭 필요한 경우에는 마지막 줄에서 반드시 exec을 씁니다. 이것을 빼면 스크립트가 PID 1로 남아 같은 문제가 재현됩니다.

#!/bin/sh
set -e
./wait-for-db.sh
exec "$@"
COPY --chmod=0755 entrypoint.sh /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "dist/server.js"]

종료 유예 시간도 함께 조정해야 합니다. 15초가 필요한 앱에 10초 기본값을 두면 매번 잘립니다.

docker run -d --stop-timeout 30 api:v312
# 쿠버네티스
spec:
  terminationGracePeriodSeconds: 30

PID 1의 다른 책임 — 좀비 수확

신호 전달을 고쳤다고 PID 1 문제가 끝난 것은 아닙니다. 리눅스에서 부모를 잃은 프로세스는 PID 1에게 입양되고, PID 1은 wait으로 그 종료 상태를 거둘 책임을 집니다. init 시스템은 이 일을 합니다. Node나 파이썬 프로세스는 하지 않습니다.

컨테이너 안에서 자식 프로세스를 자주 만드는 애플리케이션이라면 좀비가 쌓입니다.

docker exec api ps -eo pid,stat,comm | grep -c 'Z'
1847

프로세스 테이블이 차면 새 프로세스 생성이 실패하고, 그 증상은 애플리케이션 로그에 fork: Resource temporarily unavailable로 나타납니다.

해결책은 진짜 init을 1번에 두는 것입니다. 가장 간단한 방법은 도커의 내장 옵션입니다.

docker run -d --init api:v312
docker exec api ps -eo pid,comm
    PID COMMAND
      1 docker-init
      7 node

이미지 자체에 넣으려면 tini를 씁니다.

FROM node:22-bookworm-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]

쿠버네티스에는 --init에 해당하는 파드 옵션이 없으므로 이미지 쪽에서 처리해야 합니다. 다만 자식 프로세스를 만들지 않는 단일 프로세스 서버라면 굳이 필요하지 않습니다. 하위 프로세스를 띄우는지부터 확인하고 결정하면 됩니다.

이미지 히스토리에 남는 시크릿

사설 레지스트리에서 패키지를 받으려면 빌드 중에 토큰이 필요합니다. 여기서 두 가지 방법이 자주 쓰이고 둘 다 틀렸습니다.

# 안티패턴 1: ENV
ENV NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ
RUN npm ci

# 안티패턴 2: ARG
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc \
 && npm ci \
 && rm ~/.npmrc

첫 번째는 이미지 설정에 영구히 남습니다.

docker inspect -f '{{json .Config.Env}}' bad:v1 | tr ',' '\n'
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
"NODE_VERSION=22.14.0"
"NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ"]

두 번째는 ~/.npmrc를 지웠지만 앞 절에서 본 것처럼 하위 레이어에는 그대로 남고, 클래식 빌더에서는 히스토리에도 남습니다.

docker history --no-trunc bad:v2 | grep -o 'NPM_TOKEN=[^ ]*' | head -1
NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ

레지스트리에 푸시했다면 그 토큰은 이미 유출된 것으로 간주하고 폐기해야 합니다. 이미지를 지우는 것으로는 되돌릴 수 없습니다.

올바른 방법은 BuildKit의 secret mount입니다. 시크릿은 해당 RUN이 실행되는 동안에만 tmpfs로 마운트되고 레이어에 기록되지 않습니다.

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,mode=0400 \
    npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > /tmp/npmrc
docker buildx build --secret id=npmrc,src=/tmp/npmrc -t api:v312 .
shred -u /tmp/npmrc

환경 변수에서 바로 넘길 수도 있습니다.

docker buildx build --secret id=npmtoken,env=NPM_TOKEN -t api:v312 .
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci --omit=dev

프라이빗 깃 저장소를 받아야 한다면 SSH 에이전트를 전달하는 방법도 있습니다.

RUN --mount=type=ssh \
    git clone git@github.com:org/private-lib.git /opt/lib
docker buildx build --ssh default -t api:v312 .

검증은 습관으로 만들어야 합니다. 푸시 전에 히스토리와 설정을 한 번 훑는 것으로 대부분의 사고가 걸러집니다.

docker history --no-trunc api:v312 | grep -Ei 'token|secret|password|key='
docker inspect -f '{{json .Config.Env}}' api:v312

HEALTHCHECK, 공격 표면, 그리고 파일 소유권

남은 항목들은 개별적으로는 작지만 함께 모이면 운영 품질을 바꿉니다.

HEALTHCHECK는 이미지에 상태 판정 기준을 심습니다.

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

여기서 오해가 둘 있습니다. 첫째, 헬스체크가 실패해도 docker run은 컨테이너를 재시작하지 않습니다. 상태가 unhealthy로 표시될 뿐이고, 자동 조치는 Swarm이나 오케스트레이터의 몫입니다. 둘째, 쿠버네티스는 이미지의 HEALTHCHECK를 완전히 무시합니다. 파드에서는 livenessProbereadinessProbe를 따로 정의해야 합니다. 그리고 distroless 이미지에는 wgetcurl도 없으므로, 헬스체크용 소형 바이너리를 함께 넣거나 애플리케이션에 상태 확인 서브커맨드를 만드는 편이 낫습니다.

공격 표면은 최종 이미지에 무엇이 남았는지의 문제입니다. 빌드에 쓴 curl, git, gcc가 런타임 이미지에 남아 있으면 침입자에게 도구를 쥐여 주는 셈입니다. 멀티스테이지로 분리하는 것이 첫 번째 답이고, 그럴 수 없다면 최소한 설치 범위를 좁힙니다.

RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 ca-certificates \
 && rm -rf /var/lib/apt/lists/*

--no-install-recommends 하나로 데비안 계열에서 수십 MB와 수십 개의 패키지가 빠집니다.

파일 소유권은 크기와 직결됩니다. 복사한 뒤 소유권을 바꾸는 패턴은 파일 전체를 새 레이어에 다시 씁니다.

# 안티패턴: 레이어가 두 배가 됨
COPY . /app
RUN chown -R app:app /app
COPY . /app          286MB
RUN chown -R app:app  286MB

--chown을 쓰면 복사하면서 소유권이 정해지므로 레이어가 하나입니다.

COPY --chown=10001:10001 . /app

읽기 전용 루트 파일시스템으로 운영한다면 쓰기가 필요한 경로만 따로 열어 줍니다.

docker run -d --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /run:rw,noexec,nosuid,size=8m \
  api:v312
실수나타나는 증상고치는 방법
USER 미지정컨테이너가 root로 실행, k8s 정책 위반숫자 UID로 USER 10001:10001
FROM node:22어제 빌드와 오늘 빌드가 다름다이제스트 고정 + Renovate
ADD 남용아카이브 자동 해제, 검증 없는 다운로드COPY 또는 RUN에서 체크섬 검증
shell form CMDdocker stop이 10초, 종료 코드 137JSON 배열 exec form
래퍼로 실행(npm start)SIGTERM이 앱에 미도달런타임 바이너리 직접 실행
init 없음좀비 누적, fork 실패--init 또는 tini
ENV/ARG로 토큰 전달docker history에 평문 노출RUN --mount=type=secret
RUN chown -R이미지 크기 두 배COPY --chown
HEALTHCHECK에 의존k8s에서 무시되어 무동작liveness/readiness 프로브

마치며 — Dockerfile은 실행 계약서다

이 글의 항목들은 서로 달라 보이지만 뿌리가 하나입니다. Dockerfile은 파일을 모으는 스크립트가 아니라 이 프로세스가 누구로, 어떤 권한으로, 어떻게 시작하고 어떻게 끝나는지를 적은 계약서입니다.

새 Dockerfile을 리뷰할 때 볼 것은 다섯 줄입니다. FROM이 고정되어 있는가, USER가 숫자 UID로 지정되어 있는가, CMDENTRYPOINT가 JSON 배열인가, 시크릿이 ARGENV로 들어오지 않는가, 그리고 최종 스테이지에 빌드 도구가 남아 있지 않은가. 이 다섯 개만 통과시켜도 운영에서 만나는 사고의 대부분이 사라집니다.

Mistakes That Keep Repeating in Dockerfiles — Running as root, PID 1, and Secrets Left in the Image

Introduction — The Things That Pass the Build and Only Break in Production

Dockerfile problems barely surface during the build stage. The image gets created, the container comes up, and it runs fine locally. The problems show up after that.

On every deploy docker stop waits exactly 10 seconds before force-killing the container, and the exit code is 137 rather than 143. The requests in flight during those 10 seconds are simply cut off. Months later there are thousands of zombies in the process table, and the security scan finds a deployment token in the image history.

This article looks at those problems from the cause side rather than the symptom side. Most of them come down to two or three lines of Dockerfile, but without knowing why, you repeat them identically on the next project.

Running as root — If Nobody Specifies, You Get UID 0

An image that does not use USER runs as UID 0.

docker run --rm node:22-bookworm-slim id
uid=0(root) gid=0(root) groups=0(root)

"Containers are isolated, so being root inside does not matter" circulated for a long time, but under default settings the container's root is the same UID as the host's root. If you have not enabled the user namespace, this becomes plain the moment you mount a host filesystem as a volume.

docker run --rm -v /etc:/host-etc alpine:3.20 \
  sh -c 'echo "# injected" >> /host-etc/hosts && tail -1 /host-etc/hosts'
# injected

The host's /etc/hosts was modified from inside the container. Add a single runtime vulnerability to that and an escape immediately means host root privileges.

The fix is well established. Create a user, give that user ownership of the paths it needs, and switch with USER.

FROM node:22-bookworm-slim
WORKDIR /app

RUN groupadd --gid 10001 app \
 && useradd --uid 10001 --gid app --shell /usr/sbin/nologin --create-home app

COPY --chown=10001:10001 package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=10001:10001 . .

USER 10001:10001
EXPOSE 8080
CMD ["node", "dist/server.js"]

Using a numeric UID rather than a name is deliberate. Kubernetes' runAsNonRoot cannot determine whether the image config's USER value is non-root when it is a name, and it rejects the pod.

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

Write USER app and apply the setting above and you will see an event like this.

Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root

Switching to an unprivileged user means you cannot open ports below 1024. Adding the NET_BIND_SERVICE capability is one option here, but changing the application port to 8080 and exposing it as 80 at the service layer is far simpler.

The latest Tag and Reproducibility

FROM node:22 is convenient but points at different things over time. Today's build and next week's build sit on different bases, and when an incident hits you cannot trace the cause of "but it worked last week."

docker pull node:22-bookworm-slim
docker inspect --format '{{index .RepoDigests 0}}' node:22-bookworm-slim
node@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

Pinning to a digest guarantees the same base no matter when you build.

FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

The objection that comes up here is "then we never get security patches." That is a fair point, which is why updates are handed to a bot rather than a person. Dependabot or Renovate opens a PR bumping the digest, CI validates that combination, and then it merges. That is the difference between something flowing in automatically and something coming in after validation.

The same goes for application image tags. Deploy as myapp:latest and there is nothing to roll back to. Using the commit SHA as the tag, and additionally attaching latest if you need it, is about as safe as it gets.

ADD and COPY — the Default Is COPY

The two commands look similar, but ADD does far more. And most of that extra behavior is a problem.

ADD automatically extracts local tar archives.

ADD release.tar.gz /opt/app/

That single line does not copy release.tar.gz, it unpacks it. If the archive contains ../ paths or symbolic links, files get written to unintended locations. If you wanted the file left as it is, you should use COPY.

ADD also accepts URLs.

ADD https://example.com/tool.tar.gz /tmp/
RUN tar -xzf /tmp/tool.tar.gz -C /opt && rm /tmp/tool.tar.gz

There is no checksum verification, the archive stays in the layer, and deleting it in a later RUN does not change the size. If you need a remote file, it is better to download, verify, and delete it inside a RUN.

RUN set -eux \
 && curl -fsSL -o /tmp/tool.tar.gz https://example.com/tool-1.4.2.tar.gz \
 && echo "3f0a91c2e4d7b5a8f16c9d02e3b7a41f8c5d29e06b3a7f1c4d8e2b0a95f3c716  /tmp/tool.tar.gz" | sha256sum -c - \
 && tar -xzf /tmp/tool.tar.gz -C /opt \
 && rm -f /tmp/tool.tar.gz

There are only two cases where ADD is legitimate: deliberately unpacking a local tar, and fetching a git repository directly on BuildKit. Everything else is always COPY.

The Seat Called PID 1 — Why SIGTERM Never Reaches the App

The most expensive mistake is here. The symptom is a 10-second delay that repeats on every deploy.

FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
CMD npm start
docker run -d --name shellform demo:shell
time docker stop shellform
docker inspect -f '{{.State.ExitCode}}' shellform
shellform

real    0m10.412s
137

Ten seconds is Docker's default termination grace period, and 137 is SIGKILL. A clean shutdown would have finished immediately and produced 143 (SIGTERM).

The cause has two layers. CMD npm start written in shell form actually executes as /bin/sh -c "npm start". Then PID 1 is the shell. And PID 1 gets special treatment from the kernel: the default action for signals with no explicitly registered handler does not apply. In other words, SIGTERM arrives and is simply ignored.

The explanation that commonly circulates here is "the shell does not forward signals to its child," which is only half right. Debian's dash and Alpine's busybox ash optimize a sh -c containing a single simple command by replacing themselves with exec instead of forking. Then the shell disappears and the target process becomes PID 1. The problem is that the optimization is conditional. The moment the command contains a pipe, &&, variable expansion, or a redirect, the shell stays. Behavior that depends on a shell will break silently one day.

And the second layer is more common. Wrappers like npm, yarn, and pnpm are themselves managers that spawn child processes, even if the shell was replaced via exec. npm did not forward SIGTERM to its children for a long time. So even with the shell removed, the app does not receive the signal.

The fix is to write exec form and strip out the wrapper.

CMD ["node", "dist/server.js"]
docker run -d --name execform demo:exec
time docker stop execform
docker inspect -f '{{.State.ExitCode}}' execform
execform

real    0m0.284s
143

You also need code on the application side that receives the signal and cleans up. Docker only delivers the signal; it does not perform a graceful shutdown for you.

const server = app.listen(8080)

for (const sig of ['SIGTERM', 'SIGINT']) {
  process.on(sig, () => {
    server.close(() => process.exit(0))
    setTimeout(() => process.exit(1), 15000).unref()
  })
}

When an entrypoint script is genuinely necessary, always use exec on the last line. Leave it out and the script stays as PID 1 and the same problem reproduces.

#!/bin/sh
set -e
./wait-for-db.sh
exec "$@"
COPY --chmod=0755 entrypoint.sh /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "dist/server.js"]

The termination grace period needs adjusting too. Leave the 10-second default on an app that needs 15 seconds and it gets cut short every time.

docker run -d --stop-timeout 30 api:v312
# Kubernetes
spec:
  terminationGracePeriodSeconds: 30

PID 1's Other Duty — Zombie Reaping

Fixing signal delivery does not end the PID 1 problem. On Linux, a process that loses its parent is adopted by PID 1, and PID 1 bears the duty of collecting its exit status with wait. An init system does this job. A Node or Python process does not.

If your application frequently creates child processes inside the container, zombies pile up.

docker exec api ps -eo pid,stat,comm | grep -c 'Z'
1847

Once the process table fills, creating new processes fails, and the symptom appears in the application log as fork: Resource temporarily unavailable.

The solution is to put a real init at number 1. The simplest way is Docker's built-in option.

docker run -d --init api:v312
docker exec api ps -eo pid,comm
    PID COMMAND
      1 docker-init
      7 node

To bake it into the image itself, use tini.

FROM node:22-bookworm-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]

Kubernetes has no pod option equivalent to --init, so it has to be handled on the image side. That said, a single-process server that never creates child processes does not really need it. Check whether it spawns subprocesses first, then decide.

Secrets Left in the Image History

Pulling packages from a private registry requires a token during the build. Two approaches are commonly used here and both are wrong.

# Anti-pattern 1: ENV
ENV NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ
RUN npm ci

# Anti-pattern 2: ARG
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc \
 && npm ci \
 && rm ~/.npmrc

The first one stays permanently in the image config.

docker inspect -f '{{json .Config.Env}}' bad:v1 | tr ',' '\n'
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
"NODE_VERSION=22.14.0"
"NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ"]

The second deletes ~/.npmrc, but as seen in the previous section it remains in the lower layer untouched, and on the classic builder it remains in the history as well.

docker history --no-trunc bad:v2 | grep -o 'NPM_TOKEN=[^ ]*' | head -1
NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ

If you pushed to a registry, treat that token as already leaked and revoke it. Deleting the image does not undo it.

The correct approach is BuildKit's secret mount. The secret is mounted as tmpfs only while that RUN executes and is never written into a layer.

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,mode=0400 \
    npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > /tmp/npmrc
docker buildx build --secret id=npmrc,src=/tmp/npmrc -t api:v312 .
shred -u /tmp/npmrc

You can also pass it straight from an environment variable.

docker buildx build --secret id=npmtoken,env=NPM_TOKEN -t api:v312 .
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci --omit=dev

If you need to fetch a private git repository, you can forward the SSH agent.

RUN --mount=type=ssh \
    git clone git@github.com:org/private-lib.git /opt/lib
docker buildx build --ssh default -t api:v312 .

Verification has to become a habit. Skimming the history and the config once before pushing filters out most incidents.

docker history --no-trunc api:v312 | grep -Ei 'token|secret|password|key='
docker inspect -f '{{json .Config.Env}}' api:v312

HEALTHCHECK, Attack Surface, and File Ownership

The remaining items are individually small but together they change operational quality.

HEALTHCHECK embeds the health verdict criteria in the image.

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

There are two misconceptions here. First, even when the health check fails, docker run does not restart the container. The status is merely marked unhealthy, and automatic action is the job of Swarm or an orchestrator. Second, Kubernetes ignores an image's HEALTHCHECK entirely. In a pod you have to define livenessProbe and readinessProbe separately. And a distroless image has neither wget nor curl, so it is better to include a small health-check binary alongside it or add a status-check subcommand to the application.

Attack surface is a question of what remains in the final image. If the curl, git, and gcc used during the build are left in the runtime image, you are handing an intruder a toolkit. Splitting with multi-stage is the first answer; when that is impossible, at least narrow the install scope.

RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 ca-certificates \
 && rm -rf /var/lib/apt/lists/*

--no-install-recommends alone removes tens of MB and dozens of packages on Debian derivatives.

File ownership is directly tied to size. The pattern of copying and then changing ownership rewrites all the files into a new layer.

# Anti-pattern: doubles the layer
COPY . /app
RUN chown -R app:app /app
COPY . /app          286MB
RUN chown -R app:app  286MB

With --chown, ownership is set during the copy, so there is a single layer.

COPY --chown=10001:10001 . /app

If you run with a read-only root filesystem, open up only the paths that need to be writable.

docker run -d --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /run:rw,noexec,nosuid,size=8m \
  api:v312
MistakeSymptom that appearsHow to fix it
No USERContainer runs as root, violates k8s policyUSER 10001:10001 with a numeric UID
FROM node:22Yesterday's build differs from today'sPin the digest + Renovate
Overusing ADDAutomatic archive extraction, unverified downloadsCOPY, or checksum verification in RUN
shell form CMDdocker stop takes 10s, exit code 137JSON array exec form
Running via a wrapper (npm start)SIGTERM never reaches the appRun the runtime binary directly
No initZombies accumulate, fork fails--init or tini
Passing tokens via ENV/ARGPlaintext exposure in docker historyRUN --mount=type=secret
RUN chown -RImage size doublesCOPY --chown
Relying on HEALTHCHECKIgnored on k8s, does nothingliveness/readiness probes

Closing — A Dockerfile Is an Execution Contract

The items in this article look unrelated, but they share one root. A Dockerfile is not a script that gathers files, it is a contract stating who this process runs as, with what privileges, how it starts, and how it ends.

There are five lines to look at when reviewing a new Dockerfile. Is FROM pinned, is USER specified with a numeric UID, are CMD and ENTRYPOINT JSON arrays, do secrets avoid coming in through ARG or ENV, and are build tools absent from the final stage. Passing just these five makes most of the incidents you meet in production disappear.