서론
최근 저는 MSA를 공부중입니다. 각 마이크로서비스에 go, 특히 gin을 사용해서 개발하는것을 선호합니다. 그 이유는 다음과 같습니다.
- golang 너무 재밌음
- 컴파일 언어여서 바이너리 하나만 배포하면 됨
- grpc 를 활용하기 용이함
최근 자주 사용하는 DB인 PostgreSQL과 gin을 활용하는 템플릿을 구성했습니다.
이 템플릿을 구성하며 Docker 이미지 최적화를 했습니다.
그 과정에서 Docker 이미지 크기를 99.2%나 줄이게 되었습니다. 다시 원래의 사이즈로 돌리면 무려 12921%가 증가합니다.
(떡상)
Docker 이미지의 사이즈를 줄이면 다음의 장점이 있습니다.
- 컨테이너 레지스트리(컨테이너 이미지 저장소)의 용량 절감
- 배포 프로세스의 소요시간 감소
실제 예제를 보시려면 https://github.com/code-yeongyu/gin-psql-microservice-template 이곳으로 가셔서 Dockerfile을 확인하셔도 되고, 직접 README에 적힌대로 빌드를 진행하셔도 됩니다. 굳이 권장하진 않습니다. 글에서 모든 과정을 진행하고 설명하기 때문입니다.
그래서 어떻게 했는데요
본 게시글에서는 어떤 방법들을 통해 이미지 사이즈를 99.2%까지 경량화 할 수 있었는지 이야기 하고자 합니다. 먼저 인증부터 하겠습니다.
- unoptimized [경량화 전] - 726MB
- optimized [경량화 후] - 5.58MB
726MB에서부터, 순차적으로 크기를 줄여봅시다.
0. 최적화 전
최적화 전의 Dockerfile은 다음과 같습니다.
FROM golang:1.16.3-alpine3.13
RUN apk update
RUN apk add git
RUN apk add ca-certificates
WORKDIR /usr/src/app
COPY . .
ENV GO111MODULE=on
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o bin/main cmd/server/main.go
ENTRYPOINT ["./main"]
대부분 이해하시겠지만, 아래의 프로세스로 되어있습니다.
- golang:1.16.3-alpine3.13 이미지 사용
- apk update 진행
- go 빌드를 위한 필요 패키지 설치
- WORKDIR 설정
- 소스코드 복사
- GO111모듈 활성화
- 필요 모듈 설치
- 빌드
전부 필요한 과정 같아보입니다. 과연 저는 어떤 마법을 썼을까요?
726MB → 726MB, 0% 감소
1. 컴파일 단계 경량화 [디버그 정보 삭제]
golang 코드를 컴파일하면 스택 추적 같은 디버깅을 위한 정보기록기능이 내장됩니다.
그러나 production 빌드에는 필요 없습니다. 따라서 빌드 구문을 이렇게 수정해줍니다.
FROM golang:1.16.3-alpine3.13
RUN apk update
RUN apk add git
RUN apk add ca-certificates
WORKDIR /usr/src/app
COPY . .
ENV GO111MODULE=on
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w" -o bin/main cmd/server/main.go
ENTRYPOINT ["./main"]
밑에서 세번째 줄을 봅시다.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w" -o bin/main cmd/server/main.go
-ldflags 라는 인자값에 -s -w 를 주었습니다.
이 플래그는 상술한 기능을 삭제합니다. 빌드 진행하겠습니다.
현재 이미지 크기: 726MB → 721MB, 0.68% 감소
2. 멀티스테이징 빌드
이제 Docker의 기능을 적극적으로 활용할 타이밍입니다.
Docker에는 멀티스테이징 빌드라는 기능을 갖고 있습니다. 멀티스테이징은 이런 뜻을 갖고 있습니다.
- 멀티: 여러개의
- 스테이징: 단계
그래서, 멀티스테이징 빌드는 여러개의 단계로 빌드를 진행한다는 뜻입니다.
보통 다음과 같은 프로세스로 진행됩니다.
- 빌드용 이미지 생성
- 바이너리 빌드
- 최종본의 이미지로 바이너리 복사
백문이 불여일견, 이미지를 직접 보시겠습니다.
### Bulder
FROM golang:1.16.3-alpine3.13 as builder
RUN apk update
RUN apk add git
RUN apk add ca-certificates;
WORKDIR /usr/src/app
COPY . .
ENV GO111MODULE=on
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w" -o bin/main cmd/server/main.go
### Executable Image
FROM alpine
COPY --from=builder /usr/src/app/bin/main ./main
EXPOSE 8080
ENTRYPOINT ["./main"]
726MB → 721MB → 29.5MB, 95.9% 감소
드라마틱하게 감소했죠?
이제 최종 결과물로 나오는 이미지는 alpine 리눅스에 빌드된 바이너리만 복사되어 들어가있는 형태입니다.
그런데 여기서 더 줄이는게 가능합니다..!
3. 멀티 스테이징 빌드 실행 이미지 최적화
사실 생각해보면, alpine 리눅스가 아무리 가볍다고 해도 여전히 바이너리를 실행하는데 필요없는 패키지들도 갖고 있습니다.
작성일 기준으로 Docker Hub의 Alpine 리눅스 페이지를 보면, amd64의 이미지 크기는 2.64입니다.
우리는 이것마저도 제거하고, 진짜 필요한것들만 이미지 안에 넣을수도 있습니다. 바로 아무런 이미지도 사용하지 않는것입니다.
이를 위해서는 scratch 이미지를 사용하면 됩니다. scratch는 바닥이라는 뜻이죠. Docker 레이어의 맨 바닥, 그러니까 아무것도 없는 이미지입니다.
이 이미지에 바이너리만 넣어서 그대로 사용하는것이 권장 되지는 않습니다.
https를 사용할 경우 이에 필요한 ssl 관련 내용이 없기 때문입니다. 때문에 관련 내용도 복사를 진행해야 합니다.
726MB → 721MB → 29.5MB → 24.1MB, 96.6% 감소
3단계가 엄청 드라마틱해서 이제 막 와닿지는 않지만, 엄청 많은 양을 줄였습니다. 96.6% 라니!
그런데 아직, 끝나지 않았습니다. 우리는 upx 압축기를 안쓰고 있었거든요.
4. 바이너리 압축
이제 바이너리도 압축해봅시다. 여기에는 UPX라는 도구가 사용됩니다.
무료이며, portable하고, 확장가능한, 높은 성능의 실행 파일 packer라고 합니다.
upx 는 1996년에 출시된 매우 오래된 도구이지만, 현재까지 관리 및 유지되고 있는 프로젝트입니다.
압축이라 하니 zip, tar.gz 같은 내용들을 떠올리실텐데, UPX 는 좀 다릅니다.
- 바로 실행 가능
- 압축 효율 매우 좋음
- 매우 빠른 압축 해제
UPX는 실행 파일, 바이너리를 실행가능한 압축 파일로 packing 합니다. 그리고 실행시에 압축을 해제합니다.
서버는 항상 작동중이어야 하고, 재 실행이 되는 경우는 거의 없습니다. 따라서 upx를 사용하는것은 매우 적절합니다.
UPX는 다양한 압축 옵션을 지원하지만, 압축 효율이 가장 높은 옵션을 사용하도록 하겠습니다. 바이너리의 크기가 매우 크다면, 경우에 따라 다른 효율의 옵션을 사용하는것이 권장 될 수 있습니다. 실행 속도가 과도하게 느릴 수 있기 때문입니다.
아래는 그렇게 만든 Dockerfile입니다.
### Bulder
FROM golang:1.16.3-alpine3.13 as builder
RUN apk update
RUN apk add git
RUN apk add ca-certificates
RUN apk add upx
WORKDIR /usr/src/app
COPY . .
ENV GO111MODULE=on
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w" -o bin/main cmd/server/main.go
RUN upx --best --lzma bin/main
### Executable Image
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /usr/src/app/bin/main ./main
EXPOSE 8080
ENTRYPOINT ["./main"]
현재 이미지 크기: 5.58MB, 99.2% 감소, 전 이미지 대비 76.8% 감소
726MB → 721MB → 29.5MB → 24.1MB → 5.58MB 99.2% 감소
번외. 빌드시간 줄이기 및 레이어 체이닝
docker에는 layer 라는 개념이 있습니다.
이러한 layer(층)는 RUN, ADD, COPY 가 실행되면 쌓이고, 이는 캐싱되어 다음 빌드에 활용됩니다. 이미 작업한 내용은 캐싱돼 빌드 시간을 절약 할 수 있다는 것입니다.
밑으로 갈수록 layer가 쌓이는 구조여서, Dockerfile의 윗 부분, 즉 상위층은 변경사항이 적을수록 캐싱에 유리합니다.
그래서 Dockerfile에 들어가는 내용은, 변경이 적은 순으로 정렬하여 작성하면 됩니다.
저의 템플릿의 경우에는 다음과 같았습니다.
- apk 패키지
- go mod 의존성 모듈
- 빌드
지금은 개선되었지만, 예전에는 layer 가 성능상 문제를 일으켰었습니다.
지금은 layer가 많아도 유의미한 차이가 생기진 않지만, 가독성 개선을 위해 중복되거나 하나의 layer에서 끝내도 되는것들은 한 줄로 합쳤습니다. 이를 체이닝이라고 합니다.
그래서 최종적인 이미지는 다음과 같습니다.
### Bulder
FROM golang:1.16.3-alpine3.13 as builder
RUN apk update
RUN apk add git ca-certificates upx
WORKDIR /usr/src/app
COPY go.mod .
COPY go.sum .
RUN go mod tidy
# install dependencies
COPY . .
RUN GO111MODULE=on CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w" -o bin/main cmd/server/main.go; \
upx --best --lzma bin/main
# compile & pack
### Executable Image
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /usr/src/app/bin/main ./main
EXPOSE 8080
ENTRYPOINT ["./main"]
위에서도 언급했지만, 실제 적용한 사례는 https://github.com/code-yeongyu/gin-psql-microservice-template 에서 확인하실 수 있습니다. 더 나은 방법이 있다면 PR 주셔도 좋습니다 😁
Go와 Docker는 매우 궁합이 좋습니다. 사실 요즘 Docker가 사용되지 않는 분야가 없지만, go를 사용하고 있다면 더더욱 Docker를 추천드립니다!
참고
'Computer Science' 카테고리의 다른 글
파이썬 함수가 mocking(patching) 이 안돼요! (0) | 2021.08.25 |
---|---|
내 깃허브가 털렸다 (1) | 2021.04.09 |
이리치이고 저리치이고. 고난의 CI/CD 구축기 (0) | 2021.03.26 |
약빨고 22시간 개발 한 썰(부제: inssa.club 개발기) (0) | 2021.03.23 |
Docker Stack의 env-file 설정시 주의 사항 (0) | 2021.03.22 |