🐳

Docker 멀티 스테이지 빌드

Created
Mar 17, 2024 07:09 AM
Tags
docker
docker는 OS 레벨에서 가상화를 지원해 개발자가 자신의 소프트웨어를 “컨테이너”라 불리는 패키지 형태로 묶어 제공할 수 있게 하는 서비스다.
 
마치 컨테이너 선의 컨테이너가, 개별 상품의 모양과 상관 없이 컨테이너만 채우면 컨테이너 선에 쉽게 선적할 수 있듯, 까다로운 환경설정 없이 컨테이너 단위로 쉽게 배포할 수 있게 돕는다.
 
이제는 필수품처럼 사용되는 docker에도 몇가지 주의점들이 있는데, 그 중 하나는 빌드된 ‘이미지’의 용량 이슈이다.
이러한 용량을 최적화 하기 위해 여러 방법들이 있고, 오늘 살펴볼 방법은 “멀티 스테이지 빌드(multi-stage build)”라는 방법이다.
 
실습은 편의를 위해 go 언어를 활용하지만, 멀티 스테이지 빌드의 기본 개념은 모든 언어 통틀어 유사하다.
 

실습에 사용한 코드

main.go
http://localhost:8080을 호출하면 “hello, world”를 응답하는 간단한 서버다.
package main import ( "net/http" "github.com/labstack/echo/v4" ) func main() { e := echo.New() e.GET("", func(c echo.Context) error { return c.String( http.StatusOK, "hello, world!", ) }) e.Logger.Fatal(e.Start(":8080")) }
 

일반적인 Dockerfile

멀티 스테이지 빌드를 하지 않는 일반적인 Dockerfile은 아래 코드와 유사할 것이다.
FROM golang:1.19-alpine as build WORKDIR /go/src/multi-stage COPY . . RUN mkdir -p bin %% \ go mod download && go mod verify && \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/ EXPOSE 8080 CMD ["./app"]
FROM 을 이용해 베이스 이미지를 가져오고
WORKDIR을 이용해 빌드를 진행할 디렉토리를 설정하며
COPY를 통해 소스코드를 도커 컨테이너로 복사해 오고
RUN을 통해 빌드를 진행한다.
 
서버라면 포트번호를 노출해 외부에서 접근 가능해야 하기 때문에,
EXPOSE를 이용해 서버의 포트를 노출시키고
CMD를 통해 빌드된 결과물을 실행시킨다.
 
참고로 golang:1.19-alpine 에서 alpine은 경량 이미지에 붙는 태그이다. 즉, golang:1.19 보다 기본적으로 경량인 이미지다.
 
자, 이제 빌드를 해보고 이미지의 사이즈를 확인해보자.
쉘에서 docker image ls 명령을 쳐서 이미지의 사이즈를 확인한다
 
REPOSITORY TAG IMAGE ID CREATED SIZE multi-stage_server latest dfbbe278c4ea 12 seconds ago 463MB
 
463MB의 용량을 차지하고 있음을 알 수 있다.
다음으로 멀티 스테이지 빌드를 적용해보자.
 

멀티 스테이지 빌드

멀티 스테이지 빌드란, 빌드를 하는 스테이지와 실행을 하는 스테이지를 구분하는 방법을 보통 말한다.
 
즉, FROM 구문을 2번 이용해 처음에 가져온 베이스 이미지에서 빌드를 하고, 두번째로 가져온 베이스 이미지에서는 실행만 하는 것이다.
 
멀티 스테이지 빌드를 적용한 코드이다.
 
scratch는 빈 껍데기에서 시작하라는 의미다. Go 언어라서 가능한 것이고, 다른 언어에서는 alpine처럼 작은 사이즈의 이미지를 활용해 다시 빌드된 파일을 넘겨받고 실행하게 하면 된다.
FROM golang:1.19-alpine as build WORKDIR /go/src/multi-stage COPY . . RUN mkdir -p bin %% \ go mod download && go mod verify && \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/ FROM scratch COPY --from=build /go/src/multi-stage/bin/multi-stage ./app EXPOSE 8080 CMD ["./app"]
이제 다시 docker image ls 명령어로 용량을 확인해보자.
REPOSITORY TAG IMAGE ID CREATED SIZE multi-stage_server latest 8c1a9b8b84a9 3 seconds ago 7.23MB
 
7.23MB? 눈을 의심하게 만드는 사이즈이다.
 

사실 이건 Go라서..

사실 모든 언어가 이런 드라마틱한 차이를 보여주진 않는다.
Go 언어는 빌드를 하면 OS에 맞는 실행파일을 바이너리 형태로 만들어준다. 즉, lspwd, mkdir 명령어처럼 다른 패키지를 설치하지 않고도 명령어처럼 바로 실행할 수 있다.
 
이는 Node나 Python과는 다르다. Node나 Python으로 파일을 실행하려면 Node나 Python이 설치되어 있어야 한다. 따라서 당연히 이 부분만큼 이미지 용량은 증가한다.
 
애초에 FROM scratch는 사실상 아무것도 없는 상태를 의미하며, alpine이 붙은 베이스 이미지들과는 비교도 되지 않을 정도로 용량이 작다. 따라서, 다른 언어로 멀티 스테이지 빌드를 할 경우, 용량이 이렇게 드라마틱하게 줄지는 않는다.
 

왜 멀티 스테이지 빌드는 용량이 더 작아지는걸까?

도커 문서에도 잘 나와 있듯이, ADD, RUN, COPY 와 같은 명령어들은 추가될 때마다 컨테이너에 레이어를 추가한다. 그래서 사실, 다음 명령을 실행하기 전에는 사용하지 않는 아티펙트를 클린해주는 작업을 해줘야 한다. 보통은 쉘 스크립트 등을 이용해 구현해야 하는데, 복잡하고 매번 작성해야 해서 부담이 된다.
 
멀티 스테이지 빌드는 간단하게 불필요한 아티펙트들을 제거하는 방법이다.
즉, 베이스 이미지 2개를 순차적으로 다운받는데, 첫 이미지에서는 의존성 설치, 빌드 등을 하고, 두번째 이미지에서는 빌드된 결과물만 옮겨와 실행을 한다.
 
불필요한 아티펙트나 레이어의 추가가 최소화되므로 빌드 용량은 당연히 줄어들게 된다.