목차
앞 글에서 이미지가 레이어로 쌓인다는 것까지 봤다. 이번엔 그 이미지를 실제로 만들어본다.
한 줄 고쳤는데 빌드가 처음부터 다시 돈다
오타 하나를 고쳤다. 이미지를 다시 만든다. 그런데 화면에 이런 게 지나간다.
=> [3/5] RUN npm ci
=> => # added 1284 packages in 3m 12s의존성은 건드리지도 않았는데 npm ci가 처음부터 다시 돈다. 커피를 마시고 오면 끝나 있다. 그리고 다음 오타를 고치면 또 그만큼 기다린다.
이게 왜 벌어지는지, 그리고 왜 Dockerfile에서 줄 순서를 바꾸는 것만으로 대부분 사라지는지가 이 글의 알맹이다.
Dockerfile은 이미지를 만드는 절차서다
이미지는 손으로 만들지 않는다. Dockerfile이라는 파일에 만드는 절차를 적어두면 docker build가 그대로 실행해서 이미지를 만든다.
FROM node:22
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]다섯 줄이 하는 말은 이렇다.
| 줄 | 뜻 |
|---|---|
FROM node:22 | Node 22가 깔린 이미지에서 출발한다 |
WORKDIR /app | 작업 디렉터리를 /app으로 |
COPY . . | 현재 폴더의 파일을 이미지 안으로 복사 |
RUN npm ci | 이미지 안에서 의존성 설치 |
CMD [...] | 컨테이너를 띄우면 실행할 명령 |
앞 글에서 문제로 삼았던 것을 떠올려보자. README에 적힌 세팅 절차는 코드와 함께 실행되지 않아서 조용히 낡았다. Dockerfile은 다르다. 저장소에 코드와 같이 들어 있고, 빌드할 때마다 실제로 실행된다. 틀리면 빌드가 실패한다. 그래서 낡을 수가 없다.
FROM도 눈여겨볼 만하다. 맨 밑바닥부터 만드는 게 아니라 이미 만들어진 이미지 위에서 시작한다. Node 런타임과 그 아래 시스템 라이브러리는 남이 만들어둔 레이어를 그대로 받아 쓰는 것이다.
한 줄이 한 층이 된다
여기서 앞 글의 레이어와 이어진다. Dockerfile의 각 줄이 레이어 하나를 만든다.
빌드할 때 도커는 각 줄마다 이렇게 묻는다. “이 줄을 전에도 똑같이 실행한 적이 있나?” 있으면 실행하지 않고 저장해둔 레이어를 그대로 쓴다. 이게 빌드 캐시다.
캐시가 맞으면 화면에 이렇게 나온다.
=> [2/5] WORKDIR /app CACHED
=> [3/5] COPY . .CACHED가 붙은 줄은 실제로 실행되지 않았다는 뜻이다.
캐시는 한 번 깨지면 아래로 전부 깨진다
그런데 아까 상황에서는 npm ci가 다시 돌았다. 의존성은 안 건드렸는데 왜일까.
이유는 캐시의 규칙 하나 때문이다. 어느 줄에서 캐시가 깨지면, 그 아래 줄은 전부 다시 실행된다.
당연한 규칙이다. 레이어는 “이전 상태에서 무엇이 달라졌는가”의 기록이니, 이전 상태가 달라지면 그 위에 쌓은 것도 다른 물건이 된다.
COPY . .는 소스 코드 전부를 복사한다. 오타 하나를 고쳐도 복사되는 내용이 달라지니 이 줄의 캐시가 깨진다. 그러면 그 아래 npm ci도 자동으로 다시 돈다. 의존성이 안 바뀌었다는 사실은 도커가 알 방법이 없다. 도커가 아는 건 “위 레이어가 달라졌다”뿐이다.
그래서 순서가 곧 빌드 시간이다
고치는 방법은 간단하다. 자주 바뀌는 것을 아래로 내린다.
의존성 목록(package.json, package-lock.json)은 가끔 바뀌고, 소스 코드는 매번 바뀐다. 그러니 의존성 파일만 먼저 복사해서 설치하고, 소스는 그 다음에 복사한다.
FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # 의존성이 안 바뀌면 이 줄은 CACHED
COPY . . # 소스는 여기서 복사
CMD ["node", "server.js"]이제 오타를 고쳐도 캐시가 깨지는 건 COPY . .부터다. npm ci는 그 위에 있으니 그대로 재사용된다.
Dockerfile을 다루는 요령은 사실상 이 한 문장으로 압축된다. 덜 바뀌는 것을 위에, 자주 바뀌는 것을 아래에. 명령어를 외우는 게 아니라 이 원리를 아는 것이 값이다.
.dockerignore 파일도 같은 맥락이다. node_modules나 .git처럼 이미지에 들어갈 필요가 없는 것들을 여기 적어두면 COPY . .에서 제외된다. 안 적어두면 로컬 node_modules가 통째로 복사되면서 이미지가 커지고, 그 안의 파일이 바뀔 때마다 캐시도 같이 깨진다.
이미지가 커지면 무엇이 나빠지나
캐시 다음으로 신경 쓰는 게 크기다. 크면 왜 나쁜지부터 보자.
- 배포가 느려진다. 새 서버에 컨테이너를 띄우려면 이미지를 먼저 받아야 한다. 이미지가 클수록 오래 걸리고, 이건 문제가 생겨서 급히 늘려야 할 때 그대로 대응 속도가 된다.
- 저장 공간과 전송량을 먹는다. 이미지는 버전마다 쌓인다.
- 공격 면이 넓어진다. 이미지 안에 컴파일러·패키지 매니저·셸 도구가 들어 있으면, 컨테이너가 뚫렸을 때 공격자가 쓸 도구도 같이 들어 있는 것이다.
그러면 무엇이 이미지를 키우나. 대개 빌드할 때만 필요했던 것들이다.
빌드에 필요한 것과 실행에 필요한 것은 다르다
이 구분이 이미지 크기 이야기의 전부다.
Java 앱을 생각해보자. 빌드하려면 JDK와 Gradle이 있어야 하고, 소스 파일과 빌드 캐시가 쌓인다. 그런데 다 만들고 나서 실행할 때 필요한 건 JAR 파일 하나와 JRE뿐이다. JDK도 Gradle도 소스도 필요 없다.
앞 글에서 봤듯이 레이어는 지워도 아래에 남으니, 나중에 RUN rm -rf 해봐야 이미지는 안 줄어든다.
그래서 도커는 멀티 스테이지 빌드를 준다. 빌드용 이미지와 실행용 이미지를 따로 두고, 결과물만 옮겨 담는다.
# 1단계: 빌드 (JDK, 소스, 빌드 도구가 여기 다 있다)
FROM gradle:8-jdk21 AS builder
WORKDIR /src
COPY . .
RUN gradle bootJar
# 2단계: 실행 (JRE만. 위 단계의 결과물 하나만 가져온다)
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/build/libs/app.jar app.jar
CMD ["java", "-jar", "app.jar"]봐야 할 건 COPY --from=builder 한 줄이다. 최종 이미지는 두 번째 FROM부터 시작하고, 첫 단계에서 가져오는 건 저 JAR 하나뿐이다. 첫 단계의 JDK와 소스와 빌드 캐시는 최종 이미지에 아예 들어가지 않는다.
컴파일 언어일수록 효과가 크다. Go처럼 단일 바이너리로 떨어지는 언어는 두 번째 단계에 거의 아무것도 없는 이미지를 써도 된다.
인터프리터 언어(Node, Python)도 멀티 스테이지가 쓸모 있다. 개발 의존성까지 설치해서 빌드·번들링을 한 뒤, 실행 단계에는 운영 의존성과 산출물만 옮기면 된다. 테스트 도구나 타입 정의가 운영 이미지에 갈 이유는 없다.
설정은 이미지 안에 넣지 않는다
여기서 앞 글에서 미뤄둔 이야기를 마무리한다. 이미지는 어디서 돌리든 똑같아야 하는 물건인데, DB 주소나 API 키는 개발과 운영이 달라야 하는 값이다.
이걸 이미지에 박으면 두 가지가 깨진다.
- 환경마다 이미지를 따로 만들어야 한다. 그러면 “개발에서 검증한 그 이미지를 운영에 올린다”가 불가능해진다. 운영에 올라가는 건 검증한 적 없는 다른 이미지다.
- 시크릿이 이미지에 영구히 남는다. 레이어는 지워도 아래에 남는다는 그 성질 때문에, 이미지를 가진 사람은 누구든 꺼내볼 수 있다.
그래서 설정은 컨테이너를 띄울 때 밖에서 주입한다. 환경변수로 넣는 게 가장 흔한 방식이다.
docker run -e DB_URL=postgres://prod-db:5432/app myapp:1.4.2같은 myapp:1.4.2 이미지가 개발에서도 운영에서도 돈다. 다른 건 주입되는 값뿐이다.
시크릿은 환경변수로도 조심스럽다. 프로세스 목록이나 로그에 찍혀 새기 쉽기 때문이다. 실무에서는 별도의 시크릿 저장소에서 받아오거나, 오케스트레이터가 파일로 마운트해주는 방식을 쓴다. 이 글의 요점은 방식보다 **“이미지 안에 두지 않는다”**는 원칙이다.
실무에서 이미지를 다룰 때 챙기는 것
베이스 이미지의 태그를 고정한다. FROM node:latest는 언제 빌드하느냐에 따라 다른 것이 깔린다. 앞 글에서 없애려 했던 “환경이 어긋난다”를 Dockerfile 첫 줄에서 다시 불러들이는 셈이다. 최소한 node:22, 재현이 중요하면 다이제스트까지 고정한다.
작은 베이스를 고른다. 같은 Node라도 -slim이나 알파인 기반 변형이 훨씬 작다. 다만 알파인은 표준 리눅스와 시스템 라이브러리 구현이 달라서 네이티브 모듈이 안 붙는 경우가 있다. 작다고 무조건 좋은 게 아니라, 앱이 뜨는지 확인하고 고른다.
루트로 돌리지 않는다. 컨테이너 안의 기본 사용자는 루트다. 격리가 있으니 괜찮아 보이지만, 격리가 뚫렸을 때 피해가 달라진다. 실행 단계에서 일반 사용자로 내려두는 게 기본이다.
만든 이미지는 레지스트리에 올린다. 이미지를 만든 곳과 돌리는 곳은 대개 다르다. 그 사이를 잇는 창고가 레지스트리(Docker Hub, GitHub Container Registry, 사내 레지스트리)다. 흐름은 늘 이 모양이다.
여러 컨테이너를 묶어 띄우는 건 따로 있다. 앱과 DB와 캐시를 같이 띄워야 할 때 docker run을 세 번 치는 대신, 구성을 파일에 적어두고 한 번에 올리는 도구(Docker Compose)를 쓴다. 로컬 개발과 작은 배포에는 이걸로 충분하다.
정리
| Dockerfile | 이미지를 만드는 절차서. 코드와 함께 있고 실제로 실행돼서 낡지 않는다 |
| 한 줄 = 한 층 | 각 줄이 레이어를 만들고, 도커는 줄마다 캐시를 확인한다 |
| 캐시 규칙 | 한 줄에서 깨지면 그 아래는 전부 다시 실행된다 |
| 그래서 순서 | 덜 바뀌는 것을 위에, 자주 바뀌는 것을 아래에 |
| 멀티 스테이지 | 빌드에 필요한 것과 실행에 필요한 것을 나눠 결과물만 옮긴다 |
| 설정과 시크릿 | 이미지에 박지 않고 띄울 때 주입한다 |
| 베이스 이미지 | 태그를 고정한다. latest는 재현성을 버리는 것 |
이미지를 잘 만드는 요령은 명령어 목록이 아니라 두 질문이다. 무엇이 자주 바뀌는가(캐시), 실행에 정말 필요한가(크기).
이제 어디서든 똑같이 도는 이미지가 생겼다. 그런데 이걸 서버에 띄워놓고 나면 새로운 질문이 남는다. 새벽 세 시에 컨테이너가 죽으면 누가 다시 띄우나. 다음 글이 그 이야기다.