Featured image of post 构建应用时如何缩小容器镜像的体积

构建应用时如何缩小容器镜像的体积

多阶段构建和跨平台镜像

多阶段构建

我们都知道,容器的镜像是一种分层结构,这种结构可以让我们在构建容器镜像时,通过一些方法可以缩小镜像的体积。

体积庞大的困扰

# docker image ls
REPOSITORY                                                        TAG                    IMAGE ID       CREATED              SIZE
spring-boot                                                       latest                 c706f273de79   About a minute ago   284MB
<none>                                                            <none>                 97bd21dc4f53   2 minutes ago        596MB
eclipse-temurin                                                   17-jre-jammy           5e2265f6166a   5 weeks ago          266MB
eclipse-temurin                                                   17-jdk-jammy           9ef86393bfda   5 weeks ago          455MB
quay.io/cilium/cilium                                             <none>                 c34c54b31628   2 months ago         451MB
quay.io/metallb/speaker                                           v0.13.7                738c5d221d60   2 months ago         106MB
registry.k8s.io/pause                                             3.9                    e6f181688397   3 months ago         744kB
quay.io/cilium/cilium                                             <none>                 743cf6b60787   4 months ago         456MB
dyrnq/ingress-nginx-controller                                    v1.3.1                 b7c8e5e285c0   4 months ago         263MB
quay.io/cilium/cilium                                             <none>                 68413ce8a529   5 months ago         457MB
influxdb                                                          2.3.0                  b24266999a5d   6 months ago         445MB

从上面看出,有许多镜像体积有将近半个GB之多。有时我们使用不同的基础镜像,构建出来的体积也大有不同。镜像过大往往带来:

  • 上传或拉取镜像速度慢,减慢发布更新流程
  • 占用很多存储空间

等问题。一些情况下,我们可以通过多阶段构建,来缩减容器镜像的体积。

大白话说,多阶段构建就是:

  1. 先使用一个大的基础镜像,这个基础镜像里面有将源码编译成可执行文件的所有工具和依赖,利用这个大镜像把源码构建成可执行文件
  2. 再换一个小的基础镜像(它或许没有很多构建源码所需的依赖,但是它足以运行这个可执行文件),把这个应用的可执行文件放到这个小的基础镜像里,在小基础镜像中运行应用

虽然不够准确,但是多阶段构建容器镜像的思想就是如此,来看两个例子。

Java应用多阶段构建

将应用构建过程和启动过程分为两个阶段

FROM eclipse-temurin:17-jdk-jammy as builder
WORKDIR /opt/app
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline
COPY ./src ./src
RUN ./mvnw clean install

FROM eclipse-temurin:17-jre-jammy
WORKDIR /opt/app
EXPOSE 8080
# 从 构建应用使用的镜像 eclipse-temurin:17-jdk-jammy 中
# 复制可执行文件到 eclipse-temurin:17-jre-jammy
COPY --from=builder /opt/app/target/*.jar /opt/app/*.jar

将应用构建延迟到启动时

FROM eclipse-temurin:17-jdk-jammy

WORKDIR /app

COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:resolve

COPY src ./src

CMD ["./mvnw", "spring-boot:run"]

我们来看下,两种方式构建的容器镜像的大小:

# docker image ls | grep spring
spring-boot-big                                                   latest                 a24cbb0ff94d   4 minutes ago    525MB
spring-boot                                                       latest                 c706f273de79   20 minutes ago   284MB

很明显,第2种将应用构建延迟到启动时的容器镜像体积几乎是第一种方式的2倍。

但是,对于应用程序的运行来说,显然用于编译代码、构建可执行程序的JDK等工具是不需要的。

Golang应用多阶段构建

# 第一阶段
# 非通用的 多用于编译 golang 的镜像 golang:1.16-alpine
FROM golang:1.16-alpine AS builder

RUN go env -w GO111MODULE=on
RUN go env -w GOPROXY=https://goproxy.cn,direct

COPY . /go/src/server

WORKDIR /go/src/server
RUN go install ./server/...

# 第二阶段
FROM alpine:3.13
COPY --from=builder /go/bin/server /bin/server

ENV ADDR=:9090
ENV WS_ADDR=:8080

EXPOSE 9090
EXPOSE 8080

# 设置服务入口
ENTRYPOINT [ "/bin/server" ]

我们看这两个阶段使用的基础镜像:

$ docker image ls | grep alpine
alpine                                                   latest    042a816809aa   3 days ago      7.05MB
$ docker image ls | grep golang
golang                                                            1.19-alpine            feb4bbda921c   2 days ago       354MB

二者的体积相差有50倍之多。而在生产环境中,运行一个golang程序,可能使用体积7M左右的alpine就够了,而在构建过程,还是需要完整的300多M的golang镜像去构建。一些编译型语言往往需要一些工具链去实现源码的编译,但是这些工具在生产环境中会过多占用不必要的空间。通过多阶段构建,刚好解决了这个问题。

补充:构建镜像时,尽量复用 docker 构建缓存。

跨平台构建容器镜像

Golang的跨平台编译可以做到在单一平台构建不同架构下的可执行文件。对于容器来说,也有这种功能。Docker提供了buildx,来实现这个能力。我们看看如何使用。

为 docker 配置 buildx

  1. 创建一个 builder 构建器。
$ docker buildx create --name builder
builder
  1. 告诉 docker 使用这个构建器。然后初始化并启动 buildkit 容器。
$ docker buildx use builder
$ docker buildx inspect --bootstrap
[+] Building 106.0s (1/1) FINISHED
 => [internal] booting buildkit                                                                                                                        106.0s
 => => pulling image moby/buildkit:buildx-stable-1                                                                                                     103.7s
 => => creating container buildx_buildkit_builder0                                                                                                       2.3s
Name:   builder
Driver: docker-container

Nodes:
Name:      builder0
Endpoint:  unix:///var/run/docker.sock
Status:    running
Buildkit:  v0.11.0
# 这里看到 支持以下平台
Platforms: linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/386

使用 buildx 构建多平台镜像

# BUILDPLATFORM 		TARGETOS      TARGETARCH
# 平台 如 Linux/amd64    系统 如 Linux  架构 如 amd64
# 这些是内置变量
# --platform=$BUILDPLATFORM 强制使用不同平台的基础镜像 默认本平台基础架构对应的镜像
FROM --platform=$BUILDPLATFORM golang:1.18 as build
# 声明 系统和平台
ARG TARGETOS TARGETARCH
WORKDIR /opt/app
COPY go.* ./
RUN go mod download
COPY . .
# 将 上述信息告知 golang 的编译工具
RUN --mount=type=cache,target=/root/.cache/go-build \
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /opt/app/example .

FROM ubuntu:latest
WORKDIR /opt/app
COPY --from=build /opt/app/example ./example
CMD ["/opt/app/example"]

通过这个 Dockerfile,执行命令:

$ docker buildx build --platform linux/amd64,linux/arm64 -t demo:latest .

docker就会构建linux/amd64linux/arm64两个架构下的镜像了。

本部分简单给出构建多平台镜像的思路,具体细节请看相关文档:

https://github.com/docker/buildx

补充 如何选择基础镜像

先看一个情况:

# Step 1: build golang binary
FROM golang:1.17 as builder
WORKDIR /opt/app
COPY . .
RUN go build -o example

# Step 2: copy binary from step1
FROM alpine
WORKDIR /opt/app
COPY --from=builder /opt/app/example ./example
CMD ["/opt/app/example"]

如果go程序中我们使用了 CGO,或是包含 C 的底层代码。那么启动这个镜像时容器会报错。

原因在于:默认情况下,golang 编译成的可执行文件不是真正的 ”静态“,一些 C 语言的库会在执行之前动态链接。而 alpine 这个基础镜像中,没有 C 语言的 glibc 标准库,这个标准库正是 golang:1.17 编译时使用的。

如果我们在编译时让其禁用 CGO,在 Dockerfile 中使用:RUN CGO_ENABLED=0 go build -o example,go 的编译工具就会实现真正的”静态“,将链接编译好的 C 相关的代码打包到可执行程序中。这样一来,使用 alpine 作为第二阶段的镜像也可以正常运行程序了。

因此,不同阶段的基础镜像如何选择,还是需要根据具体的场景,再尽可能的缩小镜像体积。从易用性、安全性、跨平台等多方面综合考虑。

自认为是幻象波普星的来客
Built with Hugo
主题 StackJimmy 设计