빌드노트
빌드 단계에서는 있던 명령이 최종 Docker 이미지에는 없다
멀티스테이지 빌드가 성공해도 이전 단계의 도구와 ENV가 최종 이미지에 자동으로 남지는 않는다. 별도 FROM으로 시작한 두 단계에서 도구·산출물·환경 변수를 비교하고, 런타임에 필요한 파일과 설정을 명시하는 기준을 설명한다.
멀티스테이지 Dockerfile에서 빌드 단계의 RUN은 성공했는데, 완성한 이미지에서 같은 명령을 실행하면 not found가 나올 수 있다. 먼저 최종 단계의 FROM과 그 아래의 COPY --from을 본다. 최종 단계가 별도 베이스 이미지에서 시작했다면, 이전 단계에서 만든 도구나 ENV가 자동으로 합쳐지지 않는다. 이전 단계에서 가져오는 파일은 복사 대상으로 지정해야 한다. Docker의 멀티스테이지 빌드 설명 (새 탭에서 열림)
최종 이미지에 파일과 설정이 있는지 각각 확인한다
자신의 Dockerfile에서 문제의 명령을 설치하거나 만든 단계와 마지막 FROM을 찾는다. 예를 들어 첫 단계가 FROM alpine:3.22 AS build이고 마지막 단계도 FROM alpine:3.22 AS runtime이면, 둘은 같은 Alpine 베이스에서 따로 시작한다. 같은 베이스 이름을 썼다고 첫 단계의 변경이 마지막 단계에 누적되지는 않는다. FROM 규칙 (새 탭에서 열림)
다음 PowerShell 명령은 최종 이미지의 환경 변수와 도구 파일의 존재를 따로 확인한다. myapp:runtime은 실제 사용하는 최종 이미지 태그로, /usr/local/bin/synth-tool은 문제의 도구가 있어야 할 절대경로로 바꾼다. Docker CLI와 실행 중인 Linux 엔진이 필요하며, 파일 조회는 이미지에 /bin/sh와 test가 있는 경우에 쓴다.
$image = 'myapp:runtime'
docker image inspect --format '{{json .Config.Env}}' $image
docker run --rm --network none --entrypoint /bin/sh $image -c 'test -f /usr/local/bin/synth-tool'
$LASTEXITCODE
inspect 출력은 이미지에 저장된 환경 변수 목록이다. 파일 조회가 출력 없이 끝나면 바로 다음 $LASTEXITCODE를 읽는다. 0은 해당 경로가 일반 파일로 확인됐다는 뜻이고, 1은 일반 파일로 확인되지 않았다는 뜻이다. 셸 자체를 시작하지 못했다면 파일 존재를 검사한 결과로 해석하면 안 된다. --entrypoint는 앱의 기본 진입점 대신 조회용 셸을 실행하기 위한 옵션이다. 이미지 조회 (새 탭에서 열림), 컨테이너 실행 옵션 (새 탭에서 열림)
파일이 없고 최종 단계의 복사 대상에도 빠져 있다면, 그 파일이 런타임에도 필요한지 결정한다. 필요하다면 해당 파일을 명시적으로 복사하고, 필요한 기본 환경 변수도 최종 단계에 선언한다. 반대로 파일이 이미 있다면 PATH, 실행 권한, 인터프리터나 라이브러리 등 다른 원인을 확인해야 한다. not found 문구 하나만으로 단계 경계의 누락을 확정하지는 않는다.
산출물은 남고 도구는 빠진 두 단계
실제 앱과 분리한 작은 이미지로 이 경계를 비교했다. 아래 입력은 합성 명령 synth-tool을 첫 단계에서 만들고 실행한 뒤, 그 출력 파일만 마지막 단계로 복사한다. 직접 비교하려면 alpine:3.22를 받을 수 있는 환경에서 빈 폴더에 이 내용을 Dockerfile로 저장한다. 호스트에서 도구나 산출물을 따로 만들 필요는 없다.
FROM alpine:3.22 AS build
ENV LAB_STAGE=build-only
RUN mkdir -p /out \
&& printf '%s\n' '#!/bin/sh' 'echo tool-ok' > /usr/local/bin/synth-tool \
&& chmod +x /usr/local/bin/synth-tool \
&& synth-tool > /out/result.txt
FROM alpine:3.22 AS runtime
COPY --from=build /out/result.txt /app/result.txt
CMD ["cat", "/app/result.txt"]
이 Dockerfile이 있는 폴더에서 다음 명령으로 중간 단계와 최종 단계를 각각 이미지로 만든다. ms-stage-check는 다른 이미지에 쓰지 않는 실습용 이름으로 바꿔도 된다. 앞 빌드가 실패하면 다음 명령으로 넘어가지 않는다. --target build는 이름 붙인 중간 단계에서 빌드를 멈추게 한다. 특정 단계까지 빌드하기 (새 탭에서 열림)
docker build --network none --target build -t ms-stage-check:build .
docker build --network none -t ms-stage-check:runtime .
docker run --rm --network none ms-stage-check:runtime
직접 실행한 두 빌드는 모두 종료 코드 0이었다. 최종 이미지의 기본 명령도 tool-ok를 출력하고 정상 종료했다. 이 출력은 빌드 때 도구가 남긴 파일을 읽은 결과다. 최종 이미지에서 도구를 다시 실행한 결과가 아니다.
환경 변수 목록과 도구 실행을 비교하면 차이가 드러난다. 아래 마지막 명령은 도구를 복사하지 않은 최종 이미지에서 의도적으로 실패하는 조회다.
docker image inspect --format '{{json .Config.Env}}' ms-stage-check:build ms-stage-check:runtime
docker run --rm --network none --entrypoint /bin/sh ms-stage-check:build -c 'synth-tool'
docker run --rm --network none --entrypoint /bin/sh ms-stage-check:runtime -c '/usr/local/bin/synth-tool'
중간 이미지의 환경 변수 목록에는 LAB_STAGE=build-only가 있었고, 도구 실행은 tool-ok를 출력하며 종료 코드 0으로 끝났다. 최종 이미지의 환경 변수 목록에는 베이스의 PATH만 있었고 LAB_STAGE는 없었다. 같은 최종 이미지에서 도구를 절대경로로 실행하자 /bin/sh: /usr/local/bin/synth-tool: not found가 나왔고 종료 코드는 127이었다. 앞의 파일 조회도 이 최종 이미지에서는 1이었다.
두 결과는 함께 성립한다. /app/result.txt는 COPY --from=build로 가져왔으므로 읽을 수 있다. /usr/local/bin/synth-tool은 복사하지 않았으므로 없다. ENV는 파일 복사 대상이 아니므로 result.txt를 복사해도 함께 넘어오지 않는다. COPY --from (새 탭에서 열림), ENV의 유지와 상속 범위 (새 탭에서 열림)
런타임에도 필요한 것만 마지막 단계에 추가한다
이 예제에서 도구를 런타임에도 실행해야 한다면, 마지막 단계의 기존 COPY 뒤에 다음 두 줄을 추가한다. 첫 줄은 도구 파일을 가져오고, 둘째 줄은 런타임의 기본 설정을 지정한다.
COPY --from=build /usr/local/bin/synth-tool /usr/local/bin/synth-tool
ENV LAB_STAGE=runtime
이렇게 수정한 별도 이미지도 실제로 빌드했다. 환경 변수 조회에 LAB_STAGE=runtime이 나타났고, 컨테이너 안의 도구 실행은 tool-ok를 출력하며 정상 종료했다. 기존 산출물도 그대로 읽혔다. 이 도구는 /bin/sh로 실행되는 짧은 스크립트이고 최종 Alpine에도 그 셸이 있어서, 실행 파일 하나를 복사하는 것으로 충분했다. 실제 앱의 도구도 파일 하나만 옮기면 된다고 일반화할 수는 없다.
컴파일이나 압축에만 쓰는 도구라면 최종 이미지에 추가할 이유가 없다. 위 원본 예제처럼 결과 파일만 읽는 런타임은 도구 없이 이미 정상 동작한다. 필요한 산출물과 실행 의존성을 선택하고, 빌드용 환경 변수도 런타임에 필요한 값인지 판단한 뒤 선언한다.
또한 FROM build AS runtime처럼 이전 단계를 베이스로 삼으면 상속 관계가 생긴다. Docker 문서는 부모·조상 단계의 ENV를 상속한다고 설명한다. 따라서 “새 FROM 뒤에는 모든 설정이 없어진다”로 외우면 안 된다. 이 글에서 실행한 구성은 이전 단계 대신 별도 alpine:3.22에서 시작한 경우다. 이전 단계를 베이스로 삼는 구성은 이번에 실행하지 않았다.
비교는 2026년 10월 22일 Windows의 Docker Desktop 4.92.0, Linux Engine 29.8.0, BuildKit 0.33.0, Buildx 0.37.1과 alpine:3.22의 Linux amd64 이미지에서 수행했다. 확인한 범위는 합성 셸 실행 파일, 일반 텍스트 산출물, 이미지의 ENV와 새 컨테이너 실행이다. Windows 컨테이너나 실제 앱 바이너리의 실행 의존성은 검증하지 않았다.