Docker Multi-Stage Builds

Docker 的 multi-stage 技术可以把不同的构建阶段分离开,方便修改和复用,同时还能更好地把缓存层组合在一起,避免重复劳动,还能保证最后运行时的干净纯粹,不会带入前边阶段的副产品。不过我在实际场景中遇到的问题比单纯的构建更加复杂一些——我不仅需要不同的构建阶段(build stage),还需要在不同的环境里构建同一个镜像,比如在本地和在 CI/CD 中。CI/CD 的特殊之处在于在前边的阶段中基本上已经完成了 dependency download, build, package 等一系列阶段,因此直接把 build 的结果拷贝到 Docker context 里执行 Dockerfile 的最后几步就可以了。如果复用一个 Dockerfile,那么基本上要做前边的很多重复工作,如果用两份 Dockerfile,则又会造成配置上的冗余,迟早会出幺蛾子。

为了解决这个问题,我打算使用 Docker 的 multi-stage 来做,本意是把 package 之后的几步合并到一个 stage 里,在前边的 build 阶段来区分不同的环境。就像下边的配置一样,设想中这个流程非常完美,Docker 可以根据 ENVIRONMENT 来区分不同的环境,并且在最后的 runtime 阶段中从不同的环境构建阶段中把二进制内容拷贝出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM ubuntu:22.04 AS build-base
FROM ubuntu:22.04 AS runtime-base

# 当前环境
ARG ENVIRONMENT=dev

# 本地构建阶段
FROM build-base AS build-dev
RUN build
RUN package
RUN cp . /app

# CI/CD 构建阶段
FROM build-base AS build-prod
COPY . /app

# 最终阶段
FROM runtime-base AS runtime
COPY --from=build-${ENVIRONMENT} /app /app
ENTRYPOINT [ "/app/main" ]

但是这样做有个非常大的问题就是 Docker 的 build arguments 是只在单个阶段之内管用的,因此倒数第二行的 COPY 压根不认识 ENVIRONMENT 这个变量,进而这个 Dockerfile 根本无法正常构建。找了很多解决方案,最终看到虽然在 Docker 某一个阶段内不认识其他阶段的 build arguments,但是在定义阶段本身的时候是可以复用的,也就是 FROM runtime-base AS runtime 这一行,在这里是可以用 ENVIRONMENT 这个变量的。因此思路就很清晰了,可以直接改成下边这种情形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FROM ubuntu:22.04 AS build-base
FROM ubuntu:22.04 AS runtime-base

# 当前环境
ARG ENVIRONMENT=dev

# 本地构建阶段
FROM build-base AS build-dev
RUN build
RUN package
RUN cp . /app

# CI/CD 构建阶段
FROM build-base AS build-prod
COPY . /app

# 最终阶段
FROM build-${ENVIRONMENT} AS runtime
ENTRYPOINT [ "/app/main" ]

但这样有个最大的问题,就是最后的 runtime 阶段直接继承前边 build 阶段,会把这些阶段运行过程中的副产品、垃圾文件和系统包带到最后,这显然极大地违背了 multi-stage 的初衷。继承还是要继承的,关键是不能从 build 阶段继承,那么自然而然地就想到再加上一层干净的 ready 阶段来过渡,保证 ready 阶段中的内容是纯粹的二进制,每个环境都有自己对应的 ready 阶段,然后分别把自己的二进制拷贝进去就行,如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
FROM ubuntu:22.04 AS build-base
FROM ubuntu:22.04 AS runtime-base

# 当前环境
ARG ENVIRONMENT=dev

# 本地构建阶段
FROM build-base AS build-dev
RUN build
RUN package
RUN cp . /app

# CI/CD 构建阶段
FROM build-base AS build-prod
COPY . /app

# 本地准备阶段
FROM runtime-base AS ready-dev
COPY --from=build-dev /app /app

# CI/CD 准备阶段
FROM runtime-base AS ready-prod
COPY --from=build-prod /app /app

# 最终阶段
FROM ready-${ENVIRONMENT} AS runtime
ENTRYPOINT [ "/app/main" ]

这样就可以完美地解决上边的所有问题,除了多出两个几乎一样的 build stages 以外没有什么成本。只要在执行 docker build 的时候传进入对应的 ENVIRONMENT 参数就行。还要注意的是,一定要使用 Docker Buildkit 来执行,这样才会根据最后的 runtime 阶段有挑选地执行需要的阶段,否则 Docker 会傻乎乎地执行所有的阶段,这样就得不偿失了。