들어가며
매일 시간 날 때마다 geeknews를 보는 편이다. 이런저런 글들 중에서 가장 관심 있는 글을 발견하였는데 그것은 바로 도커의 멀티 스테이지 빌드에 대해서 쓰인 글이었다.
- [geeknews] Docker multi-stage build로 컨테이너 이미지 크기 줄이기 : https://news.hada.io/topic?id=17794
해당 게시글을 보고 바로 링크된 원문을 확인하여 여러 번 읽어보았는데 주된 내용은 아래와 같았다. 멀티 스테이지 빌드(multi-stage build)를 통해서 이미지 크기를 줄이고, 보안성이 좋은 docker image를 빌드할 수 있다는 점이다. 주로 FROM을 통해서 베이스 이미지를 받아오는데, 빌드 타임과 런타임에서 각각의 이미지를 다르게 가져가서, 경량형 도커 이미지를 만드는 것에 대해 다룬다.
거의 모든 애플리케이션에는 빌드 타임과 런타임의 두 가지 유형의 종속성이 있고, 일반적으로 빌드 타임 종속성은 런타임 종속성보다 많고 노이즈가 많다고 한다. (원문에서는 CVEs라고 불리는 것이다) 따라서 대부분의 경우에 최종 이미지는 프로덕션 종속성만 포함시키면 되는데, 이를 나누어서 멀티 스테이지 빌드를 하는 것이 좋다는 내용이다. 원문 레퍼런스는 아래와 같다.
- How to Build Smaller Container Images : Docker Multi-Stage Builds : https://labs.iximiuz.com/tutorials/docker-multi-stage-builds
최종 이미지는 프로덕션 종속성만 포함시키면 된다는 게 무슨 뜻일까? 다양한 docker image를 확인해 보았는데, 결론적으로는 빌드 타이밍에 쓰는 이미지는 폭넓게 다양한 툴을 포함한다. JDK를 놓고 보면 컴파일러, 디버거 등 많은 것들이 있다. 하지만 런타임에서는 이러한 것들이 쓸모가 없고 그저 애플리케이션이 실행될 수 있기만 하는 환경이 필요하다는 뜻이었다.
위 포스팅이 조금 방대한 내용을 다루려고 하다 보니까 읽기 힘들었는데, 이번 포스팅을 통해서 간략하게 다룬 후 현재 진행 중인 프로젝트에서 어떻게 멀티 스테이지 빌드를 통해서 용량을 줄일 수 있었는지 확인해보려고 한다.
본론
그 베이스 이미지는 프로덕션 용이 아니다.
포스팅을 살펴보았을 때, 멀티 스테이지 빌드를 해야 하는 이유로 먼저, 보통의 베이스 이미지는 프로덕션 용이 아니라는 말을 한다. 아래의 예시를 보자.
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o binary
CMD ["/app/binary"]
첫 번째 FROM에서 받아오는 golang 이미지가 프로덕션 용이 아니라는 것이다. ( 참고 : golang ) 해당 이미지의 설명을 참고하면, 이는 golang을 빌드할 수 있는 환경과 함께 다양한 운영체제의 환경들을 포함하고 있다. 폭넓게 다룰 수 있는 이미지긴 하지만, 런타임을 생각하면 너무 부가적인 것들이 많은 이미지다.
그렇다면 위 golang의 도커 파일을 수정하면 어떻게 멀티 스테이지 빌드로 만들 수 있을까?
# Build stage
FROM golang:1.23 AS build
WORKDIR /app
COPY go.* .
RUN go mod download
COPY . .
RUN go build -o binary .
# Runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/binary /app/binary
ENTRYPOINT ["/app/binary"]
요점은 위와 같이 Build와 Runtime을 분리하여 각각 다른 base image를 사용하는 것이다. golang은 빌드되면 바이너리 파일이 생기기 때문에, 그저 ./main과 같이 바이너리를 실행하는 것만으로도 빌드된 golang을 실행할 수 있다.
그렇다면, 직접 실행하는 /app/binary 명령어를 사용하기에 다양한 의존성들이 들어있는 golang 베이스 이미지인 채로 실행하는 것이 적합한가?라는 것이다. 빌드 시에는 그러한 의존성이 있는 이미지가 필요하겠지만, 런타임 스테이지에서는 단순히 리눅스 계열의 가벼운 OS만 있어도 된다.
그렇기 때문에 위와 같이 debian 계열의 베이스 이미지를 가져와 이전 스테이지에서 빌드했던 파일을 COPY --from=build 파라미터를 통해서 가져와 실행하는 일만 한다. 이것이 멀티 스테이지 빌드이다. 이를 진행 중인 Spring 프로젝트에 도입해 보자.
Spring에서 멀티 스테이지 빌드
그렇다면, Spring를 실행하는 환경에서 멀티 스테이지 빌드는 어떻게 구현할 수 있을까? 한 프로젝트의 dockerfile은 다음과 같이 작성되어 있었다. 깊은 고민 없이 그저 인터넷에 떠도는 dockerfile을 다운로드했다.
FROM openjdk:17
ARG JAR_FILE_PATH=build/libs/*.jar
WORKDIR /apps
COPY $JAR_FILE_PATH app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
위 파일을 살펴보면 openjdk:17라는 베이스 이미지를 사용하여 런타임 스테이지를 실행한다. 위는 심지어 빌드 관련된 내용도 없는 dockerfile이다.
# build stage
FROM gradle:7.6.4-jdk17 AS build
WORKDIR /apps
COPY . .
RUN gradle clean build --no-daemon
# runtime stage
FROM openjdk:jre-alpine
WORKDIR /apps
COPY --from=build /apps/build/libs/*.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
멀티 스테이지 빌드를 사용하도록 변경하면 위와 같다. openjdk17을 사용하지 않게끔 변경하였다. 빌드 과정에서는 gradle이 필요하기 때문에 빌드 스테이지에서는 gradle 이미지를 사용하여 빌드하도록 하였고, 런타임 스테이지에서는 앞서 빌드된 파일을 COPY --from=build를 통해서 가져와 jre-alpine이라는 .jar 파일을 실행시키는데 초점이 맞춰진 경량화된 베이스 이미지로 실행시킨다.
여기서 주의해야 할 점은, FROM 절에 AS 뒤에 오는 build와 같은 부분은 참조할 때 쓰기 때문에, 두 번째 COPY에서 --from=build을 맞춰서 지정해줘야 한다는 점이다. 만약 위에서 AS builder로 지정했다면, --from=builder로 가져다 써야 한다.
멀티 스테이지 빌드의 전후는 어떻게 되었을까? 무려 572MB에서 149MB로 줄어들었다. 약 74%의 용량을 줄인 모습이다. 이러한 멀티 스테이지 빌드가 필수적일까? 하나의 도커 이미지를 사용하는 아키텍처라면 필수적인 선택지는 아닐 것이다. 예를 들어 Jenkins 하나가 돌아가, CI/CD 파이프라인 설계가 매번 최신의 main 브랜치를 clone 해 같은 docker image tag로 빌드한다고 생각하면, 하나의 도커 이미지만 존재할 것이기 때문에 큰 문제가 없을 것이다.
하지만, 일반적으로는 docker image를 저장할 때, 날짜나 git commit의 id를 통한 태깅을 통해서 ECR이나 harbor 등에 저장하게 된다. 그렇기 때문에 가능하면 엔간하면 multi-stage build를 통해서 용량을 압축해 저장하는 것이 좋다는 결론을 낼 수 있다. 위와 같은 방법을 통해 용량을 4분의 1로 줄일 수 있는 것이다.
대체 왜 그런 걸까?
가장 중요한 부분은 일단 base 이미지가 다르다는 점이다. openjdk:17 같은 경우에는 말 그대로 JDK(Java Developer Kit)를 전부 포함한다. 그로 인하여 JVM 런타임뿐만 아니라 컴파일, 디버깅, 빌드 도구와 같은 모든 것들을 가지고 있다. 이는 보통 어떤 이미지냐에 따라 다르지만 일반적으로 debian 계열의 리눅스를 베이스 이미지로 사용하는 것을 확인할 수 있다.
반대로, openjdk:jre-alpine은 JRE(Java Runtime Environment)로써, 자바를 실행시키기 위한 환경만 존재한다. 개발 도구가 없는 런타임이기 때문에 그대로 실행만 가능하며, alpine linux라는 경량화된 리눅스 운영체제 이미지를 베이스로 깔고 간다. 따라서 배포 환경에서 빠르고 가벼운 Java 런타임을 사용할 수 있다.
사진으로 나타내면 아래와 같다.
위와 같이 모든 걸 다 가지고 있는 openjdk:17은 깃허브를 살펴보면 일반적으로 debian을 기반으로 둔다. 따라서 위의 openjdk를 통해서 직접 다 실행한다면 런타임에서도 필요 없는 jdk를 가지고 있게 되는 셈이다.
이를 멀티 스테이지로 변경하면 어떻게 될까?
위와 같이 debian을 쓰는 빌드 스테이지와 alpine을 쓰는 런타임 스테이지가 나뉘어, 이전과 다르게 필요 없는 것들을 가지고 런타임에서 app.jar를 실행하지 않는다. 그저 jre라고 불리는 런타임에 필요한 환경이 들어있는 openjdk:jre-alpine 이미지를 사용하여 프로덕션 환경을 구성하기 때문에 엄청나게 많은 용량을 줄일 수 있는 것이다.
마치며
dockerhub에서 어떠어떠한 이미지가 있는 지 찾는 과정이 꽤나 고통스러웠지만, 직접 눈으로 75% 가까이 용량이 감소한 것을 봐버렸으니 직접 실천하지 않을 수가 없겠다.. 앞으로 dockerfile을 만들 때에는 용량을 조금이라도 더 깎기 위해서 dockerhub를 열심히 뒤져보자..
추가적으로, visual studio에서 docker 플러그인을 설치하고나서 FROM 절을 사용하고 이미지를 입력하면 링크가 생기는데, 이를 통해 보다 용이하게 어떤 버전의 이미지가 있는 지 자세히 알 수 있다. (강추!)
참고
- How to Build Smaller Container Images : Docker Multi-Stage Builds : https://labs.iximiuz.com/tutorials/docker-multi-stage-builds
- [velog] docker-멀티-스테이지 빌드 : https://velog.io/@wy9295/Docker-멀티-스테이지-빌드
감사합니다.
'DevOps > docker' 카테고리의 다른 글
[Jenkins] DinD vs DooD 그리고 DooD 설정법 (1) | 2024.10.30 |
---|---|
docker의 경량화 버전인 enroot (0) | 2024.07.13 |
docker를 사용하는 이유 (0) | 2023.09.03 |
dockerfile 스크립트를 작성하는 방법 (0) | 2023.09.01 |