项目背景

前端微服务,使用angular框架,代码开发后要经过依赖下载、node编译、docker镜像产出几个步骤,进而部署到各个环境。

多阶段构建

项目根目录的Dockerfile如下所示:重要的提示信息已经注释在Dockerfile中

################# 第一阶段:依赖获取 ################### 
FROM  node:12.16.2-alpine3.10 as resolver

# 由于需要联网,在公司内网环境需要配置代理
ARG HTTP_PROXY=""
ARG HTTPS_PROXY=""

WORKDIR /source/

# 从项目代码根目录拷贝依赖需求到本阶段容器
COPY ./yarn.lock .
COPY ./package.json .

# 换yarn源,为yarn设置代理
RUN yarn config set registry https://registry.npm.taobao.org/ && \
    yarn config set proxy "${HTTP_PROXY}" && \
    yarn config set https-proxy "${HTTPS_PROXY}" && \
    yarn config set sass_binary_site https://npm.taobao.org/mirrors/node-sass/

RUN yarn

################# 第二阶段:编译 ################### 
FROM node:12.16.2-alpine3.10 as builder

WORKDIR /source/

# 合并源代码和依赖
COPY . .
COPY --from=resolver /source/ .

# 构建
RUN node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --prod --configuration=production

################# 第三阶段:将静态产物打包成镜像 ################### 
FROM nginx:alpine as app

# 中间其他指令与本文无关,略

# 把编译完成的产物拷贝至本阶段容器
COPY --from=builder /source/dist/${build_result_folder_name} /apps/portal/dist

EXPOSE 80

使用多阶段构建最为明显的好处是,最终打出的镜像只包含编译产物而不包含编译过程中的依赖等冗余文件,可大大减少最终镜像的体积。

除此之外,相比于传统的宿主机打包编译,完成后再将编译产物打包至docker镜像的做法,上面的做法相当于将除了源代码以外的所有发布流程都容器化了。这样使得发布环境的准备更加简单:只需要有docker和几个基础镜像,便可以完成code to image的流程,而不需要关心node、yarn等各类工具的安装配置。

除了以上优点,本文更加关心的是使用了多阶段构建后,其中的不变部分可以被制成缓存,使得后续构建避免重复拉取依赖,效率更高。

使用外部缓存

准备外部缓存

通过以上的多阶段构建分解,我们发现只要项目的依赖不改变,那么第一阶段的容器内容实际上应该是不变的,所以可以将其制成缓存,以供后续使用,命令如下:

docker pull $(repo)/cache:latest
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from=$(repo)/cache:latest \
--target resolver \
-t $(repo)/cache:latest \
--build-arg=HTTP_PROXY=$(proxy) \
--build-arg=HTTPS_PROXY=$(proxy) \
.
docker push $(repo)/cache:latest

和本文有关系的是docker build参数里的以下几个:

  • --build-arg BUILDKIT_INLINE_CACHE=1:加了这个build arg打出的镜像,会写入缓存元数据,该镜像才可以被指定为外部缓存源,即稍后在正式构建阶段将会看到的--cache-from选项。
  • --target:本次构建截止到dockerfile中的哪个阶段。本例中,我们只需要biuld出第一个阶段,即拉取依赖完成即可,所以传入了dockerfile中的第一阶段的名称resolver。
  • 另外,缓存本身的构建也可以依赖缓存,即缓存本身的构建也不需要重复,因此在上面展示的完整的外部缓存准备过程中,会先pull缓存,然后build时指定--cache-from,在build完后再push,使得缓存的构建也只需要一次。

最终产物的构建

    docker build \
    -t $(repo)/app:$(tag) \
    --build-arg=BUILDKIT_INLINE_CACHE=1 \
    --cache-from=$(repo)/cache:latest \
    --target app \
    --build-arg=branch=$(branch) \
    --build-arg=commit=$$(git rev-parse --short HEAD) \
    --build-arg=build_time=$$(date +"%Y-%m-%dT%H:%M:%S%Z") \
    --build-arg=HTTP_PROXY=$(proxy) \
    --build-arg=HTTPS_PROXY=$(proxy) \
    .

缓存构建完成后,进行最终app镜像的构建,在上面的docker build命令中,和本文提到的外部缓存有关的参数是如下几个:

  • --cache-from=$(repo)/cache:latest:指定上一步构建的缓存镜像地址
  • --target app:指定要构建哪个阶段的镜像。这里是最后一个阶段,其实可以省略。

效果展示

从图中可以看到,在builder阶段之前的resolver阶段的每一步都使用了cache,如此一来,每次构建不必再下载依赖,可以节约一定的时间。

image-20210723160553299

局限性

在Makefile中将完整的构建过程串起来后,其大致顺序为:

  1. 从harbor拉取缓存镜像
  2. 尝试构建缓存镜像(开启缓存支持),此时如果缓存命中,则会快速跳过;如果缓存失效,会开始构建最新的缓存镜像
  3. 将最新的缓存镜像push到harbor
  4. 开始构建app镜像(其中的依赖拉取部分开启缓存支持,会直接利用缓存)

但是在实践中发现,在linux的服务器中构建的依赖缓存镜像推送到harbor后,在我的开发机器mac运行上述流程后,并不会命中缓存,会重新构建缓存,目前猜测可能是和操作系统有关系。

参考

Use multi-stage builds | Docker Documentation

docker build | Docker Documentation

加速Docker多阶段构建的方法 - jingsam

linuxea:Docker多阶段构建与- target和--cache-from - LinuxEA

文章目录