多阶段构建和跨平台镜像
多阶段构建
我们都知道,容器的镜像是一种分层结构,这种结构可以让我们在构建容器镜像时,通过一些方法可以缩小镜像的体积。
体积庞大的困扰
# 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之多。有时我们使用不同的基础镜像,构建出来的体积也大有不同。镜像过大往往带来:
- 上传或拉取镜像速度慢,减慢发布更新流程
- 占用很多存储空间
等问题。一些情况下,我们可以通过多阶段构建,来缩减容器镜像的体积。
大白话说,多阶段构建就是:
- 先使用一个大的基础镜像,这个基础镜像里面有将源码编译成可执行文件的所有工具和依赖,利用这个大镜像把源码构建成可执行文件;
- 再换一个小的基础镜像(它或许没有很多构建源码所需的依赖,但是它足以运行这个可执行文件),把这个应用的可执行文件放到这个小的基础镜像里,在小基础镜像中运行应用。
虽然不够准确,但是多阶段构建容器镜像的思想就是如此,来看两个例子。
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
- 创建一个 builder 构建器。
$ docker buildx create --name builder
builder
- 告诉 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/amd64
和linux/arm64
两个架构下的镜像了。
本部分简单给出构建多平台镜像的思路,具体细节请看相关文档:
补充 如何选择基础镜像
先看一个情况:
# 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 作为第二阶段的镜像也可以正常运行程序了。
因此,不同阶段的基础镜像如何选择,还是需要根据具体的场景,再尽可能的缩小镜像体积。从易用性、安全性、跨平台等多方面综合考虑。