Skip to content
Published on

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

공유하기
Authors

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

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로 들어오지 않는가, 그리고 최종 스테이지에 빌드 도구가 남아 있지 않은가. 이 다섯 개만 통과시켜도 운영에서 만나는 사고의 대부분이 사라집니다.