4、Dockerfile编写:Dockerfile指令详解、构建上下文、多阶段构建、最佳实践

好,咱们进入正题。Dockerfile 这东西,说白了就是一份「菜谱」。你把原材料(源码、依赖)放进去,Docker 照着步骤一步步做,最后端出一盘镜像。我在嵌入式项目里折腾了这么多年,发现很多团队栽就栽在 Dockerfile 写得不够讲究。今天我把压箱底的经验掏出来,咱们一条一条捋清楚。

4.1 Dockerfile 指令详解

先过一遍最常用的指令。别小看这些基础,我见过有人把 RUN 和 CMD 搞混,结果镜像跑起来啥也不干,干瞪眼。

FROM —— 一切的基础

每个 Dockerfile 必须以 FROM 开头。它指定了基础镜像。嵌入式环境里,我习惯用 ubuntu:20.04 或者 debian:bullseye-slim。为什么?因为交叉编译工具链对 glibc 版本有要求,太新的发行版反而容易出幺蛾子。

关键点:尽量选择官方镜像,别用那些来路不明的「精简版」。我曾经贪图省事用了一个第三方 ARM 交叉编译镜像,结果编译出来的二进制在目标板上段错误,查了两天才发现是基础镜像里的 libc 打了奇怪的补丁。

LABEL —— 给镜像贴个标签

很多人忽略这个。其实 LABEL 很有用,尤其是团队协作时。我习惯写上维护者、版本号、构建日期。

LABEL maintainer="zhangsan@company.com" \
      version="1.0.0" \
      description="ARM Cortex-M4 交叉编译环境"

RUN —— 干活的主力

RUN 用来执行命令。注意,每一条 RUN 都会产生一个新层。层多了镜像就臃肿。我的原则是:能合并的尽量合并

# 不推荐
RUN apt-get update
RUN apt-get install -y gcc-arm-none-eabi
RUN apt-get clean

# 推荐
RUN apt-get update && \
    apt-get install -y gcc-arm-none-eabi && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

你看,最后还把 apt 缓存清掉了。这一层就小了十几兆。积少成多嘛。

COPY 与 ADD —— 把文件弄进去

COPY 就是老老实实复制文件。ADD 除了复制,还能自动解压 tar 包、支持 URL 下载。但我建议你:能用 COPY 就别用 ADD。为什么?ADD 的行为太「聪明」了,有时候会出乎你的意料。比如你明明想复制一个 foo.tar.gz 进去,结果它自动解压了,你找谁说理去?

注意:COPY 的源路径是相对于构建上下文的。这个后面会细说。

CMD 与 ENTRYPOINT —— 容器启动后干啥

这两个容易搞混。简单说:CMD 提供默认参数,ENTRYPOINT 定义主程序。组合起来用最灵活。

ENTRYPOINT ["/usr/bin/make"]
CMD ["all"]

这样,你运行 docker run myimage clean 时,实际执行的是 /usr/bin/make clean。嗯,这个模式在嵌入式构建环境里特别实用。

WORKDIR —— 切换工作目录

每次用 RUN cd 切换目录,都会产生额外层。用 WORKDIR 就清爽多了。它会在目录不存在时自动创建。

WORKDIR /workspace
COPY . .
RUN make

ENV —— 环境变量

嵌入式编译经常需要设置 CCCXXPATH 等。用 ENV 一次性设好,后面就不用操心了。

ENV CROSS_COMPILE=arm-none-eabi- \
    CC=arm-none-eabi-gcc \
    PATH=/opt/toolchain/bin:$PATH

4.2 构建上下文 —— 别把整个硬盘打包进去

构建上下文,就是执行 docker build 时指定的那个目录。Docker 会把整个目录打包发送给守护进程。你想想看,如果你在 / 根目录下执行 docker build,那得传多少数据?

我遇到过最离谱的事:有个同事把构建上下文设成了 /home,里面有个几十 G 的虚拟机镜像。结果构建命令卡了半小时,他还以为是 Docker 坏了。

我的习惯:在项目根目录放一个 .dockerignore 文件,把 .gitbuild/*.onode_modules 这些没用的东西统统排除掉。这样构建速度快,镜像也干净。

# .dockerignore 示例
.git
build/
*.o
*.a
*.so
__pycache__/
*.pyc
.DS_Store

4.3 多阶段构建 —— 瘦身利器

这是 Docker 17.05 之后引入的特性。说白了,就是可以在一个 Dockerfile 里用多个 FROM,每个 FROM 是一个独立的阶段。前面的阶段用来编译、安装依赖,最后一个阶段只把需要的东西拷过来。

为什么需要这个?嵌入式交叉编译工具链动不动就几百兆,加上各种库,一个镜像轻松上 2G。但运行时你只需要编译好的二进制文件,那些工具链、头文件统统可以扔掉。

# 第一阶段:编译
FROM ubuntu:20.04 AS builder
RUN apt-get update && apt-get install -y gcc-arm-none-eabi
WORKDIR /src
COPY . .
RUN make

# 第二阶段:运行
FROM alpine:3.18
RUN apk add --no-cache libgcc
COPY --from=builder /src/build/firmware.hex /app/
WORKDIR /app
CMD ["/bin/sh"]

你看,最终镜像只有几十兆。我有个项目,用多阶段构建后,镜像从 1.8G 降到了 45M。部署的时候爽得不行。

避坑指南:我曾经在第二阶段忘了拷贝动态库,结果二进制跑不起来。后来我学乖了,用 ldd 检查一下依赖,确保所有 .so 都拷过来了。

4.4 最佳实践 —— 血泪教训总结

这些年踩过的坑,我总结成几条铁律:

  1. 层数越少越好。 每层都有开销。能用 && 合并的 RUN 就合并。但别走极端,把所有命令写在一行里,那样调试起来想死的心都有。
  2. 基础镜像选对版本。 嵌入式工具链对系统库版本敏感。我一般锁定 ubuntu:20.04 而不是 ubuntu:latest。latest 哪天更新了,你的构建可能就炸了。
  3. 利用构建缓存。 Docker 会缓存每一层。把不常变动的指令放在前面,比如安装工具链;把经常变动的 COPY 源码放在后面。这样改代码时不用重新下载工具链。
  4. 不要以 root 运行。 虽然容器里 root 权限有限,但安全习惯要养成。建一个普通用户,用 USER 切换过去。
  5. 清理临时文件。 apt 缓存、pip 缓存、临时下载的压缩包,用完就删。别让它们留在镜像里占地方。
# 一个完整的嵌入式构建 Dockerfile 示例
FROM ubuntu:20.04 AS builder

LABEL maintainer="dev@company.com"

# 安装工具链和依赖
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        gcc-arm-none-eabi \
        make \
        cmake \
        python3 \
        && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# 创建非 root 用户
RUN useradd -m -s /bin/bash builder
USER builder
WORKDIR /home/builder

# 复制源码
COPY --chown=builder:builder . .

# 编译
RUN mkdir -p build && \
    cd build && \
    cmake .. -DCMAKE_TOOLCHAIN_FILE=../toolchain.cmake && \
    make -j$(nproc)

# 第二阶段:精简运行镜像
FROM alpine:3.18

RUN apk add --no-cache libgcc

COPY --from=builder /home/builder/build/firmware.bin /app/

WORKDIR /app
CMD ["/bin/sh"]

嗯,差不多就这些。Dockerfile 写得好不好,直接影响到你的开发效率和 CI/CD 速度。别嫌麻烦,花点时间把 Dockerfile 打磨好,后面能省下大把时间。下次咱们聊聊怎么用 Docker Compose 编排多个构建环境,那个也很有意思。