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 —— 环境变量
嵌入式编译经常需要设置 CC、CXX、PATH 等。用 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 文件,把 .git、build/、*.o、node_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 最佳实践 —— 血泪教训总结
这些年踩过的坑,我总结成几条铁律:
- 层数越少越好。 每层都有开销。能用
&&合并的 RUN 就合并。但别走极端,把所有命令写在一行里,那样调试起来想死的心都有。 - 基础镜像选对版本。 嵌入式工具链对系统库版本敏感。我一般锁定
ubuntu:20.04而不是ubuntu:latest。latest 哪天更新了,你的构建可能就炸了。 - 利用构建缓存。 Docker 会缓存每一层。把不常变动的指令放在前面,比如安装工具链;把经常变动的 COPY 源码放在后面。这样改代码时不用重新下载工具链。
- 不要以 root 运行。 虽然容器里 root 权限有限,但安全习惯要养成。建一个普通用户,用 USER 切换过去。
- 清理临时文件。 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 编排多个构建环境,那个也很有意思。