Dockerfile에 비밀이 새고 있습니다 - 99%가 모르는 레이어 보안 함정
4분, 그리고 모든 게 끝났다
얼마 전 동료 하나가 금요일 하루를 통째로 날렸습니다. 프로덕션 자격 증명 14개를 손으로 교체하느라요. 원인은 Docker 이미지 안에 박제돼 있던 API 토큰이었는데, 정작 본인은 그 토큰을 '제대로' 지웠다고 끝까지 믿고 있었어요. Dockerfile에 rm 명령어가 분명히 들어가 있었거든요.
그게 함정입니다.
공개 Docker 이미지에 자격 증명이 노출되고 실제로 악용되기까지 걸리는 평균 시간이 4분이라는 통계가 있습니다. 4시간도 4일도 아니에요. 고작 4분. 스캐너가 레지스트리를 훑는 속도가 사람이 실수를 깨닫는 속도보다 훨씬 빠른 셈이죠.
문제의 뿌리는 단순합니다. 대부분의 개발자가 Docker 이미지를 zip 파일처럼 생각해요. 압축을 풀면 최종 상태만 들어있을 거라고요. 그런데 이미지는 그런 구조가 아닙니다. 투명한 필름을 여러 장 겹쳐놓은 것에 가까워요. 위에서 내려다보면 깨끗해 보여도, 한 장씩 떼어내면 그 아래 깔린 것들이 그대로 남아있습니다.

완벽해 보이는 실수
다음 Dockerfile을 보면 어디가 문제인지 바로 보이시나요?
FROM node:20-alpine
COPY . .
RUN npm install
RUN echo "$NPM_TOKEN" > ~/.npmrc && npm install && rm ~/.npmrc
CMD ["node", "server.js"]
언뜻 보면 흠잡을 데가 없습니다. 토큰을 만들고, 쓰고, 마지막에 지우기까지 했으니까요. 한 줄 안에서 생성과 삭제를 끝냈으니 흔적이 남을 리 없다고 생각하기 쉽죠.
하지만 Docker는 그런 식으로 동작하지 않습니다.
각각의 RUN 명령어는 새로운 불변 레이어를 만듭니다. .npmrc 파일이 존재했던 그 시점의 레이어는 이미지 안에 그대로 동결돼요. rm은 최종 파일시스템 뷰에서만 파일을 치울 뿐입니다. 그 아래 레이어, 그러니까 토큰이 살아있던 그 순간은 손도 못 대요. 한 줄 안에서 지웠다는 사실은 여기서 아무 의미가 없습니다. RUN 단위로 레이어가 잘리는 게 아니라, 명령 하나가 통째로 한 레이어가 되니까요.
30초 안에 확인하는 방법
말로 설득하는 것보다 직접 보는 게 빠릅니다. 지금 빌드해둔 이미지에서 이걸 돌려보세요.
docker history --no-trunc my-app:latest
아니면 더 노골적으로:
docker save my-app:latest | tar -xO | strings | grep -i "token\|secret\|password"
출력을 잠깐 들여다보길 권합니다. 마음이 편치 않을 수도 있어요. 레지스트리에 접근 권한이 있는 사람이라면 누구나, 표준 Docker 명령어만으로 당신의 자격 증명을 그대로 긁어갈 수 있다는 뜻이거든요. 별도의 해킹 도구도, 특별한 권한도 필요 없습니다.
레이어 구조를 이해하기
왜 이런 일이 벌어지는지 레이어를 펼쳐보면 명확해집니다.
Layer 0: 베이스 이미지 (깨끗함)
Layer 1: COPY . . (소스 코드)
Layer 2: RUN npm install (node_modules 생성)
Layer 3: RUN echo $TOKEN > .npmrc (토큰이 여기서 동결됨)
Layer 4: rm ~/.npmrc (삭제는 새로운 레이어에서)
-----------------------------------------------
최종 이미지에는 .npmrc가 보이지 않지만, Layer 3은 그대로 존재
삭제 작업은 비밀을 지우는 게 아니라, 비밀이 담긴 레이어 위에 '안 보이게 덮는' 레이어를 하나 더 쌓는 일입니다. Layer 3도 Layer 4도 둘 다 이미지에 영구히 박혀 있어요. 최종 뷰에서 안 보일 뿐, 과거는 통째로 보존됩니다.
Git 히스토리를 떠올리면 이해가 쉽습니다. 커밋에서 비밀번호를 지우고 다시 커밋해도, 이전 커밋을 들춰보면 그대로 나오잖아요. 이미지 레이어가 정확히 그렇게 동작합니다. 최신 상태만 깨끗하다고 안심할 수 없는 구조죠.

올바른 해결책: BuildKit 비밀
Docker BuildKit은 정확히 이 문제를 겨냥한 --mount=type=secret 플래그를 제공합니다. 빌드 타임에만 비밀을 잠깐 마운트하고, 실행 중인 프로세스는 그걸 정상적으로 쓰지만, 어떤 레이어에도 흔적을 남기지 않습니다.
# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=secret,id=npm_token \
cp /run/secrets/npm_token ~/.npmrc && \
npm install && \
rm ~/.npmrc
CMD ["node", "server.js"]
빌드는 이렇게:
docker build \
--secret id=npm_token,src=.npmrc \
-t my-app:latest .
핵심은 /run/secrets 경로가 빌드 중에만 존재하는 임시 마운트라는 점입니다. 레이어로 커밋되는 파일시스템 바깥에 떠 있다가, 빌드가 끝나면 사라져요. 그래서 docker history를 다시 돌려보면 토큰이 완전히 자취를 감춥니다. 레이어 아래 숨겨진 게 아니라, 이미지 매니페스트 자체에서 아예 없는 거죠. 이 차이가 중요합니다. 숨기는 것과 존재하지 않는 것은 완전히 다른 보안 수준이니까요.
대안: 멀티스테이지 빌드
빌드 플래그를 손대고 싶지 않거나, 좀 더 직관적인 그림을 원한다면 멀티스테이지 빌드도 같은 문제를 풉니다.
FROM node:20-alpine AS builder
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
RUN npm install
FROM node:20-alpine AS runner
COPY --from=builder /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]
작동 원리는 이렇습니다. 최종 이미지는 runner 스테이지만으로 만들어져요. builder에서 토큰을 쓰고 .npmrc를 만들었지만, 그 스테이지의 레이어 히스토리는 최종 이미지에 단 한 줄도 상속되지 않습니다. runner가 가져오는 건 COPY --from으로 명시한 결과물 파일뿐이에요. 비밀도, 히스토리도, 과거도 따라오지 않습니다.
다만 한 가지 짚고 넘어가야 할 게 있습니다. ARG로 받은 토큰이 builder 스테이지 레이어에는 여전히 남아요. 최종 이미지에 안 따라올 뿐이지, 빌드 중간 산출물에는 박힙니다. 그래서 CI 캐시나 중간 이미지를 따로 푸시하는 파이프라인이라면 이쪽도 점검이 필요해요. 운영 환경에서 가장 깔끔한 건 BuildKit 비밀 쪽이고, 멀티스테이지는 빌드 구조를 바꾸기 부담스러울 때의 현실적인 타협안에 가깝습니다.

지금 당장 확인해야 할 것들
- 가장 자주 배포하는 Dockerfile부터 열어보세요
- "token", "key", "password", "secret"이 들어간 RUN 명령어를 찾아보세요
--mount=type=secret를 안 쓰고 있다면, 취약점이 있다고 보면 됩니다docker history --no-trunc로 이미 빌드된 이미지를 점검하세요
이건 이론상의 위험이 아닙니다. 2023년 CircleCI 침해 사건은 빌드 환경의 비밀을 대규모로 노출시켰고, 수천 개 팀이 일요일 밤에 긴급 자격 증명 교체에 끌려 나왔어요. 보안 연구원들은 지금도 정기적으로 공개 레지스트리를 스크랩해서 이미지 레이어에서 자격 증명을 긁어갑니다. 자동화된 스캐너 입장에서 노출된 레이어는 그냥 공짜 데이터예요.
Docker 23부터는 BuildKit이 기본 활성화됩니다. 이전 버전이라면 빌드 전에 DOCKER_BUILDKIT=1 환경 변수 하나만 잡아주면 되고요. 두 해결책 모두 적용에 10분이면 충분합니다. 그런데 이 10분이, 몇 달째 열려 있었을지 모르는 노출을 닫는 일이에요.
마지막으로 이 문제의 성격을 짚고 싶습니다. 앞의 Dockerfile은 잘못 짠 코드가 아니었어요. 삭제 로직도 있었고, 한 줄에 생성과 정리를 묶은 논리도 나름 타당했습니다. 그런데도 비밀이 샜어요.
원인과 결과를 분리해서 보면 이렇습니다. 코드가 틀린 게 아니라, 개발자의 머릿속 모델과 Docker가 이미지를 실제로 저장하는 방식 사이의 간극이 진짜 원인입니다. 우리는 파일시스템을 '현재 상태'로 생각하는데, Docker는 그걸 '변경 이력의 누적'으로 다루거든요. 이 간극은 Dockerfile을 아무리 꼼꼼히 들여다봐도 안 보입니다. 레이어를 직접 까보기 전까지는요.
결국 컨테이너 보안에서 가장 위험한 건 명백한 실수가 아니라, 작동하는 것처럼 보이는 잘못된 모델입니다. 초록색으로 잘 떠 있는데 안에서 조용히 새고 있는 거죠. 그래서 나는 새 이미지를 푸시하기 전에 docker history를 한 번 돌려보는 습관을 권합니다. 30초짜리 확인이 금요일 하루를 지켜줄 수도 있으니까요.