我们团队使用K8S来编排Docker,而小的镜像的体积的好处不言而喻,它可以有更快的构建和部署速度、更少的存储空间、更快的镜像传输和下载速度以及更好的可移植性。
但在实际工作中,团队伊始使用的Rust运行时镜像体积非常大(114M),令我感觉不可思议(我之前做过一个Go项目,总共也就30来M),于是下决心优化:
运行时镜像
在编写Dockerfile时,选择合适的基础镜像非常重要。常用的Linux系统镜像包括Ubuntu、CentOS和Alpine。其中,Alpine是一个高度精简的轻量级Linux发行版,包含了基本工具,基础镜像只有4.41M。此外,各开发语言和框架都有基于Alpine制作的基础镜像。因此,强烈推荐使用Alpine作为基础镜像。
当然,还有更小的镜像,例如scratch和busybox,读者如果想做极致的优化,可以考虑使用它们。
制作镜像本身并不复杂,对于Rust项目而言,基本上只需要安装libgcc就可以了,镜像体积缩减到5.96M:
后来我为了修改时区,我又额外安装了tzdata,一个时区数据库包,体积稍微大了点,为9.46M:
镜像代码
以下是最终的Dockerfile代码:
FROM alpine:3.17.3
ENV TZ=Asia/Shanghai
RUN set -eux && sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
&& apk update
&& apk add --no-cache libgcc tzdata
&& echo "${TZ}" > /etc/timezone
&& ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime
&& rm /var/cache/apk/*
镜像使用
这是一个构建Rust镜像的Dockerfile,它分成2部分,上半部分构建出产物二进制文件,下半部分将之前的文件copy到运行时镜像里。
FROM rust:alpine3.16
# This is important, see https://github.com/rust-lang/docker-rust/issues/85
ENV RUSTFLAGS="-C target-feature=-crt-static"
RUN set -eux && sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
# if needed, add additional dependencies here
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
# set the workdir and copy the source into it
WORKDIR /app
COPY ./ /app
# do a release build
RUN cargo build --release
# RUN strip target/release/search
# use a plain alpine image, the alpine version needs to match the builder
FROM alpine
# if needed, install additional dependencies here
RUN apk add --no-cache libgcc
# copy the binary into the final image
WORKDIR /app
COPY --from=0 /app/target/release/search .
# set the binary as entrypoint
ENTRYPOINT ["./search"]
我们有了上面的镜像以后,后面部分修改为:
FROM dk.uino.cn/runner/rust-runtime-alpine:0.0.3
COPY --from=0 /app/target/release/search .
# set the binary as entrypoint
ENTRYPOINT ["./search"]
但我们不建议在GitLab流水线中这样使用,为什么呢?
上篇文章《提高GitLab CICD效率:Rust编译速度飙升秘籍》提到,Rust编译速度非常缓慢,为解决这个问题,我们在GitLab流水线中加入sccache、mold以及调整CPU来加速。而在流水线中构建镜像(我们用的Buildah,详见这篇探索Buildah:简化Docker镜像构建),并不能充分利用这个优势。
所以,最佳做法是将流水线任务拆分成两步,一步是构建产物,一步是构建镜像,后面这步只将上一步产物复制。比如.gitlab-ci.yml
文件:
build:
stage: build
script:
- cargo build --release
artifacts:
paths:
- target/release/$RUST_BIN
expire_in: 1 day
docker:
stage: docker
variables:
CI_DOCKERFILE: Dockerfile
CI_DOCKER_PROJECT: xx # 你有权限推送的方件夹
CI_DOCKER_REPO: xx-xx # 仓库名
script:
- docker_build --build-arg GIT_REVISION=${CI_COMMIT_SHA}
Rust减少二进制文件体积
在 Cargo.toml 文件中添加以下配置:
[profile.release]
codegen-units = 1
strip = true
lto = true
opt-level = "z"
这些配置是用于Rust的发布模式(release mode)的:
codegen-units = 1
:这个配置指定在编译期间生成代码的单元数量。它的值为1,表示只生成一个代码单元。通过减少代码单元的数量,可以提高编译速度和减小最终生成的可执行文件的大小。然而,这可能会导致一些性能损失。strip = true
:这个配置用于指定是否在编译完成后剥离(strip)可执行文件中的调试符号和其他不必要的信息。剥离可执行文件可以减小其大小,并且可以防止他人通过分析可执行文件来获取敏感信息。设置它,效果与在cargo build之后再显式执行strip -s
是一样的。lto = true
:这个配置用于启用链接时优化(Link-Time Optimization,简称LTO)。链接时优化是在链接阶段对整个程序进行优化,而不仅仅是单个源文件。通过LTO,编译器可以更好地优化代码,提高最终可执行文件的性能。然而,它会显著增加编译时间和内存占用,有时候对程序性能没有正面影响,所以默认是没有激活的。opt-level = "z"
:这个配置用于指定优化级别,通常有0、1、2、3、s、z几种。在这里,"z"表示最小化优化,这意味着编译器将尽可能地减小体积,但可能会降低性能。Debug模式,缺省使用0,Release模式缺省是3。这些配置可以根据你的需求进行调整,以平衡编译速度、可执行文件大小和性能。
以下是我验证过的一个项目的结果:
大小 | 耗时 | |
---|---|---|
原始 | 15_581_576 | 3m |
strip | 8_796_320 | 2m53s |
strip+codegen | 7_506_032 | 2m57s |
strip+codegen+lto | 6_797_320 | 3m52s |
strip+codegen+lto+opt-level | 5_548_040 | 2m59s |
使用strip后,体积减少的最多。而后面几个虽然也有效果,但各有缺点,codegen可能增加编译时间(本例中并没有,应该与机器配置有关,Mac上某项目正常编译是1分30秒,加了codegen后是2分钟),lto明显地增加了编译时间,对性能的影响不确定。奇怪的是最后4个一起时间居然又减少了。这个样本可能不太正常。
综上,我觉得开启strip的最简单有效,对性能没有影响,又减少了相当的体积,再往下优化的几M意义不大。
由于我们老的项目基本都没有开启这几项优化,所以我在.gitlab-ci.yml
文件里build这一步添加strip:
build
stage: build
script:
- cargo build --release
- strip -s target/release/$RUST_BIN
总结
我们通过使用alpine镜像作为基础镜像,减少了Rust的运行时镜像体积,又使用strip命令,减少了Rust构建产物的体积,将最终镜像体积从130M减少到18M左右,基本满足了我们的需求。