第4章 Dockerfile编写实战:多阶段构建,减小镜像体积,嵌入式场景下的最佳实践

好,咱们直接进入正题。Dockerfile怎么写,网上教程一大堆。但嵌入式场景下,讲究的是「小」和「快」。你想想看,一个交叉编译工具链动不动就几个G,要是全塞进镜像里,那还怎么玩?

我刚开始搞嵌入式容器化的时候,就犯过这个错。一个镜像打包完,1.2个G。部署到边缘设备上,传输就花了半小时。嗯,从那以后,我彻底拥抱了多阶段构建。

4.1 为什么嵌入式场景必须用多阶段构建?

说白了,多阶段构建就是「用完就扔」。编译阶段用大镜像,运行阶段用小镜像。两个阶段互不干扰。

嵌入式设备有几个特点:

  • 存储空间有限:很多设备就几百MB的Flash
  • 网络带宽窄:OTA升级传个大镜像,用户会骂娘的
  • CPU性能弱:镜像解压和启动都要时间

所以,我们的目标很明确:运行镜像只包含可执行文件和运行时依赖。编译工具、头文件、中间产物,统统不要。

核心原则:编译环境 = 大而全,运行环境 = 小而精。

4.2 多阶段构建的典型结构

来看一个实际例子。假设我们要编译一个基于ARM的MQTT客户端程序。

# 第一阶段:编译阶段
FROM arm64v8/gcc:13.2 AS builder

WORKDIR /app

# 只复制依赖文件,利用缓存
COPY src/CMakeLists.txt .
COPY src/*.c .
COPY src/*.h .

# 交叉编译
RUN cmake -DCMAKE_BUILD_TYPE=Release . && \
    make -j$(nproc)

# 第二阶段:运行阶段
FROM arm64v8/alpine:3.19

# 安装运行时库,不装开发包
RUN apk add --no-cache libcrypto3 libssl3

# 只复制编译好的二进制
COPY --from=builder /app/mqtt_client /usr/local/bin/

# 非root用户运行,安全
RUN adduser -D appuser
USER appuser

CMD ["mqtt_client"]

你看,第一阶段用了gcc镜像,里面什么都有。第二阶段换成了alpine,只有5MB。最终镜像大小?从1.2G降到了12MB。我在项目中遇到过类似的情况,当时团队都惊呆了。

4.3 嵌入式场景下的最佳实践

光会写多阶段还不够。嵌入式有嵌入式的小九九。我总结了几条经验:

4.3.1 选择合适的基础镜像

基础镜像 大小 适用场景
alpine ~5MB 纯C/C++程序,无复杂依赖
debian-slim ~80MB 需要glibc,或依赖较多
distroless ~2MB 极致精简,无shell无包管理器
scratch 0MB 静态编译的Go/Rust程序

我个人习惯,嵌入式首选alpine。但要注意,alpine用的是musl libc,不是glibc。如果你的程序依赖glibc特有的行为,那就得用debian-slim。

小技巧:用 ldd 命令查看你的二进制依赖哪些动态库。然后只复制这些库到运行镜像,而不是整个系统。

4.3.2 利用构建缓存加速

Docker构建是有缓存的。但缓存失效的时机很关键。我建议:

  1. 先复制依赖描述文件(如CMakeLists.txt、requirements.txt)
  2. 再安装依赖
  3. 最后复制源码

为什么这样?因为依赖文件很少变,源码经常变。这样能最大化利用缓存。

# 不好的写法:源码变了,所有层都重跑
COPY . /app
RUN pip install -r requirements.txt

# 好的写法:依赖缓存独立
COPY requirements.txt /app/
RUN pip install -r requirements.txt
COPY . /app/

4.3.3 清理编译中间产物

编译过程中会产生很多临时文件。比如.o文件、.a静态库、测试二进制等。这些东西在运行阶段完全没用。

# 在编译阶段末尾清理
RUN make clean  # 或者
RUN rm -rf /app/build/*.o /app/build/tests/

我曾经见过一个镜像,里面还留着编译时的单元测试二进制。足足多了50MB。嗯,这种低级错误,咱们别犯。

4.3.4 处理动态库依赖

嵌入式场景下,动态库的版本管理是个坑。我建议:

  • 静态编译:如果可能,尽量用静态链接。省心,镜像更小。
  • 精确复制:只复制需要的.so文件,不要整个/lib目录。
  • 版本锁定:在alpine上用 apk add 时指定版本号。

注意:静态编译虽然方便,但会增大二进制体积。而且有些库(如OpenSSL)的静态链接有安全风险。需要权衡。

4.4 一个完整的嵌入式多阶段构建示例

来,咱们看一个更真实的例子。这是一个基于ARM的传感器数据采集程序。

# ===== 第一阶段:编译 =====
FROM arm64v8/ubuntu:22.04 AS builder

# 安装交叉编译工具链
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        gcc-aarch64-linux-gnu \
        libc6-dev-arm64-cross \
        cmake \
        make && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /build

# 复制依赖描述
COPY CMakeLists.txt .
COPY lib/ ./lib/

# 编译依赖库(静态链接)
RUN cmake -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
          -DBUILD_SHARED_LIBS=OFF \
          -DCMAKE_BUILD_TYPE=MinSizeRel \
          . && \
    make -j$(nproc) libs

# 复制源码并编译主程序
COPY src/ ./src/
RUN make -j$(nproc) sensor_collector && \
    aarch64-linux-gnu-strip sensor_collector

# ===== 第二阶段:运行 =====
FROM arm64v8/alpine:3.19

# 只安装运行时需要的库
RUN apk add --no-cache \
    libgpiod=2.1.2-r0 \
    libi2c=4.3-r2

# 复制二进制和配置文件
COPY --from=builder /build/sensor_collector /usr/bin/
COPY config/ /etc/sensor/

# 创建运行用户
RUN adduser -D -H -s /sbin/nologin sensor
USER sensor

ENTRYPOINT ["/usr/bin/sensor_collector"]
CMD ["--config", "/etc/sensor/config.yaml"]

这个例子有几个亮点:

  • 用了 --no-install-recommends 减少编译阶段的包
  • 编译完用 strip 去掉调试符号
  • 运行阶段只装了两个运行时库
  • 用了非root用户,安全

4.5 避坑指南

最后,分享几个我踩过的坑:

我曾经... 在alpine上跑一个用glibc编译的程序,结果运行时疯狂报段错误。后来发现是musl和glibc的线程实现有差异。解决方案:要么用debian-slim,要么在编译时链接musl。

还有几个常见问题:

  • 时区问题:alpine默认没有时区数据。如果程序需要,记得装 tzdata 包。
  • DNS解析:alpine的DNS解析依赖 libc.musl,有时候需要手动配置 /etc/resolv.conf
  • 信号处理:scratch镜像没有init进程,信号处理要自己搞定。

好了,这一章的内容就到这里。多阶段构建是嵌入式容器化的基本功。你想想看,一个12MB的镜像和一个1.2G的镜像,部署体验天差地别。下一章咱们聊聊镜像分层和缓存优化,那又是另一门学问了。