Dockerfile 한 줄 한 줄은 무엇을 하는가
Dockerfile을 처음 보면 셸 스크립트처럼 생겨서 익숙하게 느껴집니다. 그런데 몇 줄만 써보면 셸 스크립트와 다르게 동작하는 지점들이 나오고, 그걸 모르고 넘어가면 이미지가 쓸데없이 커지거나 예상과 다르게 동작합니다. 실제로 자주 쓰는 명령어들을 하나씩 뜯어보겠습니다.
FROM — 시작점을 고르는 게 생각보다 중요합니다
FROM node:20이 한 줄이 이미지 크기의 대부분을 결정합니다. node:20 은 Debian 기반 풀 이미지로 900MB를 넘습니다. 반면 같은 Node.js 20을 Alpine Linux 기반으로 빌드한 node:20-alpine 은 150MB 안팎입니다. 대부분의 웹 서비스는 alpine으로 충분합니다.
다만 alpine은 만능이 아닙니다. Alpine은 glibc가 아니라 musl libc를 쓰기 때문에, 네이티브 바이너리를 포함한 일부 npm 패키지(예: 특정 버전의 node-sass, 일부 이미지 처리 라이브러리)가 alpine에서 빌드 실패하거나 런타임에 이상하게 동작하는 경우가 있습니다. "왜 로컬(Debian 기반)에서는 되는데 alpine 이미지에서만 깨지지"라는 질문의 상당수는 이게 원인입니다. 의심되면 일단 -slim 태그(Debian 기반이지만 불필요한 패키지를 뺀 버전)로 바꿔서 원인을 좁혀보는 게 빠릅니다.
RUN — 한 줄이 곧 레이어 하나
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean이렇게 세 줄로 나누면 레이어가 세 개 생깁니다. 문제는 apt-get clean 으로 캐시를 지워도, 그 이전 레이어(apt-get update, apt-get install)에 이미 기록된 용량은 줄어들지 않는다는 겁니다. 레이어는 diff의 누적이라 뒤에서 지워도 앞 레이어의 크기는 그대로 남습니다. 그래서 실무에서는 이렇게 한 줄로 합칩니다.
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*세 동작이 하나의 레이어 안에서 끝나기 때문에, 그 레이어의 최종 diff에는 설치된 curl만 남고 apt 캐시는 아예 기록되지 않습니다. 레이어 수를 줄이려는 목적이 아니라, 레이어 하나의 최종 결과물을 작게 만들려는 목적입니다.
COPY와 ADD — 거의 항상 COPY를 쓰세요
COPY package.json .
ADD package.json .둘 다 파일을 이미지 안으로 복사합니다. 차이는 ADD 가 URL을 다운로드하거나 tar 압축 파일을 자동으로 풀어주는 부가 기능을 갖고 있다는 겁니다. 이 "자동으로 해준다"는 점이 오히려 문제입니다. 리모트 URL을 압축 없이 그냥 받아오는 걸 원했는데 tar 파일이면 자동으로 풀려버리는 식의 예상 밖 동작이 생깁니다. Docker 공식 문서도 특별한 이유가 없으면 COPY 를 쓰라고 권합니다. 이 책에서도 원격 파일이 필요한 경우가 아니면 전부 COPY 를 씁니다.
CMD와 ENTRYPOINT — 초보자가 가장 많이 헷갈리는 두 줄
ENTRYPOINT ["python3"]
CMD ["app.py"]이렇게 써두면 docker run my-image 는 python3 app.py 를 실행합니다. 그런데 docker run my-image other.py 를 실행하면 python3 other.py 가 됩니다. CMD 는 "기본값"이고, docker run 뒤에 붙이는 인자로 덮어쓸 수 있습니다. 반면 ENTRYPOINT 는 고정된 실행 명령이고, 뒤에 붙는 인자는 여기에 덧붙습니다.
CMD 만 쓰면 (ENTRYPOINT 없이) 통째로 덮어쓸 수 있는 기본 실행 명령이 되고, ENTRYPOINT 만 쓰면 항상 그 프로그램이 실행되면서 뒤의 인자만 바뀝니다. 두 개를 조합하면 "이 이미지는 항상 이 프로그램을 실행하되, 어떤 파일/옵션으로 실행할지는 바꿀 수 있다"는 형태를 만들 수 있습니다. CLI 도구를 이미지로 배포할 때(docker run my-cli --help 처럼 인자를 바꿔가며 쓰고 싶을 때) 이 조합이 유용합니다.
한 가지 함정: CMD 와 ENTRYPOINT 를 대괄호 없이(CMD python3 app.py) 쓰면 셸 형태(shell form)로 동작합니다. 이 경우 Docker가 /bin/sh -c 로 감싸서 실행하는데, 그러면 그 프로세스가 PID 1이 아니라 /bin/sh 가 PID 1이 됩니다. 컨테이너를 멈추라는 신호(SIGTERM)가 PID 1에게 가는데, 셸이 이걸 애플리케이션에 제대로 전달하지 않는 경우가 있어서 docker stop 이 느려지거나(강제 종료 타임아웃까지 기다림) 애플리케이션이 정상 종료 로직을 못 타는 문제가 생깁니다. 대괄호를 쓰는 exec form(CMD ["python3", "app.py"])이 기본값이어야 하는 이유입니다.
WORKDIR — cd 대신 이걸 쓰세요
RUN cd /app && npm install이렇게 쓰면 안 됩니다. RUN 은 각 줄이 독립된 셸 세션이라, cd 로 이동한 디렉터리는 그 RUN 줄이 끝나면 사라집니다. 다음 RUN 줄은 다시 이미지의 기본 디렉터리에서 시작합니다.
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .WORKDIR 은 그 이후 모든 명령(RUN, COPY, CMD 등)의 기준 디렉터리를 바꿔주고, 이 설정은 레이어를 넘어 계속 유지됩니다.
지금까지의 예시를 하나로
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]COPY package.json 을 COPY . . 보다 먼저 하는 이유는 다음 장(레이어 캐싱)에서 이어서 설명하겠습니다. 이 순서 하나가 빌드 시간을 몇 분에서 몇 초로 줄여줍니다.