大型 SDK 开发镜像工程化:BuildKit、Buildx 与项目脚本

摘要:以约 25 GiB 的交叉编译 SDK 为例,解释 BuildKit、Buildx、named context、镜像层,以及 Dockerfile、Compose 和配套脚本怎样组成完整开发环境。

@[toc]

在这里插入图片描述

普通 Docker 教程经常从一个几十 MB 的源码目录开始:写 Dockerfile、COPY . .、执行 docker build,几秒钟就能看到结果。但真实嵌入式开发环境可能完全不是这个规模。

假设已有一套厂商 Yocto SDK,展开后约 25 GiB,还需要固定版本的 CMake、host 侧代码生成工具以及一套 UID/GID 兼容逻辑。此时问题已经从“会不会写 Dockerfile”变成了:

  • 25 GiB SDK 怎样进入 Image;
  • 怎样避免为了构建镜像再制造一个巨大的 SDK 压缩包;
  • BuildKit 和 Buildx 分别负责什么;
  • 为什么使用额外 build context;
  • 为什么源码不和 SDK 一起做进 Image;
  • 为什么需要 entrypoint.sh、Compose 和多个脚本;
  • 怎样在镜像生成后自动确认它真的能交叉编译。

这一篇围绕这条完整链路展开。

1. 先看最终结构

脱敏后的工程可以理解为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
docker-dev/
├── Dockerfile
├── compose.yaml
├── .dockerignore
├── docker/
│ └── entrypoint.sh
├── scripts/
│ ├── prepare-assets.sh
│ ├── build-image.sh
│ ├── check-sdk-permissions.sh
│ ├── dev.sh
│ ├── export-image.sh
│ ├── import-image.sh
│ └── verify-env.sh
└── workspace/
└── .gitkeep

各文件按职责分成四组:

组别 文件 主要责任
镜像定义 Dockerfile.dockerignore Image 里装什么、主 context 传什么
构建入口 prepare-assets.shbuild-image.sh 检查输入、启动 Buildx、构建后验证
运行入口 compose.yamldev.shentrypoint.sh 挂载源码、映射 UID/GID、加载 SDK
验证/迁移 verify-env.shcheck-sdk-permissions.shexport-image.shimport-image.sh smoke test、权限回归、离线迁移

2. 为什么 25 GiB SDK 不适合先压成 tar.gz

最自然的第一版方案通常是:

1
2
3
4
5
6
7
8
9
Host SDK

tar -czf sdk.tar.gz

放进 Docker 工程

COPY sdk.tar.gz

RUN tar -xzf ...

对于几百 MB 文件没有大问题,但 SDK 展开后约 25 GiB 时,磁盘峰值会很难看:

1
2
3
4
原始 SDK          ~25 GiB
SDK 压缩包 额外若干 GiB
Docker build cache 额外空间
最终 Image ~25 GiB+

如果开发机根分区只有百余 GiB,构建过程中非常容易逼近磁盘上限。

所以更合理的思路是:

SDK 保持原目录不动,让 Builder 直接把该目录作为一个构建输入,而不是先复制到项目目录或先压成大包。

这就是 Buildx additional build context 发挥作用的地方。

3. BuildKit 和 Buildx 到底有什么区别

这两个名字经常一起出现,但不是同一个东西。

BuildKit:真正执行构建的后端

BuildKit 是现代 Docker 镜像构建后端。它负责解析 Dockerfile、建立依赖关系、执行 build step、管理 cache、处理 RUN --mount 等能力。

可以把它理解成:

1
2
3
4
5
Dockerfile

BuildKit

执行构建图、缓存、生成结果

Buildx:面向 BuildKit 的 Docker CLI 扩展

Buildx 是 docker CLI 的构建前端/扩展。常见命令:

1
docker buildx build ...

Docker 官方文档对它的描述很直接:docker buildx build 使用 BuildKit 启动构建。

Buildx 在日常工程里很有价值,因为它把这些能力暴露成稳定 CLI:

  • additional build context;
  • 多平台构建;
  • 多种 output;
  • cache import/export;
  • builder 实例管理;
  • --load--push 等输出控制。

所以可以简化成:

1
2
Buildx = 你怎样请求一次高级构建
BuildKit = 后面真正执行这次构建的引擎

4. 如果不用 docker buildx build 会怎样

这里不能简单说“不用 Buildx 就不能构建”。现代 Docker 中,普通:

1
docker build .

本身通常也会使用 BuildKit,而且 Docker 当前 CLI 文档甚至把 docker build 列为 docker buildx build 的别名之一。

真正区别在于这个工程明确依赖:

1
--build-context sdk=/path/to/large-sdk

以及 Dockerfile 里的:

1
RUN --mount=type=bind,from=sdk,...

使用 docker buildx build 是为了把构建能力和参数写得明确、可审查,并确保 --build-context--load 等行为一致。

如果坚持只用“单一主 context”的思维,那么通常要选择其他方案:

  1. 把 SDK 移到 Docker 工程目录下;
  2. 把整个 SDK 放进主 build context;
  3. 先生成一个大型 tar,再 COPY
  4. 改用其他外部下载/挂载方案。

对 25 GiB 本地 SDK 来说,这几种方式不是绝对不可行,而是更容易制造额外磁盘占用、破坏目录边界,或者让项目仓库和本机 SDK 强耦合。

5. Build context 到底是什么

执行:

1
docker buildx build /home/dev/docker-dev

最后这个路径就是主 build context。

Dockerfile 中普通:

1
COPY docker/entrypoint.sh /usr/local/bin/entrypoint

读取的是主 context 里的文件。

但 SDK 在:

1
/opt/vendor-sdk/6.1-release

并不属于项目目录。

于是构建命令可以增加:

1
--build-context sdk=/opt/vendor-sdk/6.1-release

得到:

1
2
3
4
5
6
7
主 context:/home/dev/docker-dev
├── Dockerfile
├── scripts/
└── docker/

额外 context:sdk
└── /opt/vendor-sdk/6.1-release

Dockerfile 便可以通过 from=sdk 使用它。

6. 正式 build 命令逐项解释

一个脱敏后的构建命令:

1
2
3
4
5
6
docker buildx build \
--progress=plain \
--build-context "sdk=/opt/vendor-sdk/6.1-release" \
--load \
-t "arm64-dev:20.04" \
/home/dev/docker-dev

docker buildx build

使用 Buildx 发起 BuildKit 构建。

--progress=plain

使用纯文本日志,便于大型 SDK 构建时保存和定位具体 step。

--build-context "sdk=..."

注册额外 build context,名字叫 sdk

它是构建期输入,不是运行 Container 时的 Volume,也不是日常 bind mount。

--load

把单平台构建结果加载进当前 Docker Engine 的本地 Image store。

这样构建完成后可以直接:

1
2
docker image ls arm64-dev:20.04
docker run arm64-dev:20.04

官方文档把 --load 定义为 --output=type=docker 的快捷方式。

-t arm64-dev:20.04

给最终 Image 设置 repository 和 tag。

最后的项目目录

1
/home/dev/docker-dev

才是主 context。Dockerfile、Compose 附近脚本、小型资产都从这里来。

7. Dockerfile 怎样把 SDK 写进 Image

关键结构可以抽象成:

1
2
3
4
5
6
7
8
9
# syntax=docker/dockerfile:1.7
FROM ubuntu:20.04

ENV SDK_ROOT=/opt/vendor-sdk/6.1-release

RUN --mount=type=bind,from=sdk,source=/,target=/mnt/sdk,ro \
mkdir -p "$SDK_ROOT" \
&& cp -a --no-preserve=ownership /mnt/sdk/. "$SDK_ROOT"/ \
&& chmod -R a+rX "$SDK_ROOT"

这段代码有三个重点。

构建期 bind mount 不是运行期 bind mount

1
RUN --mount=type=bind,from=sdk,...

只在这个 RUN 执行期间存在。

流程是:

1
2
3
4
5
外部 SDK context
↓ 临时挂到 /mnt/sdk
RUN step
↓ cp
最终 Image 中的 /opt/vendor-sdk/...

真正进入 Image 的是 cp 结果,不是 mount 本身。

cp -a --no-preserve=ownership

-a 尽量保留目录结构、符号链接、时间戳和可执行属性;--no-preserve=ownership 不把 Host 上某个开发者的 UID/GID 原样固化进 Image。

SDK 在 Image 中由 root 持有是合理的,因为它属于只读开发环境,而不是普通用户日常编辑的源码。

chmod -R a+rX

这里的 X 非常重要:

  • 目录会获得 traverse/execute;
  • 原本就是 executable 的工具继续可执行;
  • 普通 header/library 不会因为一个粗暴 chmod -R 777 全变成 executable。

最终权限策略是:

1
2
3
4
root 拥有 SDK
普通开发用户可以读取全部 SDK 文件
普通开发用户可以进入全部 SDK 目录
已有 SDK 工具仍可执行

这正好对应后续真实排障中发现的 SDK 权限问题。

9. .dockerignore 在这里控制什么

当前工程的 .dockerignore 类似:

1
2
3
4
5
6
7
8
9
10
.git
.gitignore
logs
*.log
workspace/*
!workspace/.gitkeep
*.zip
*.tar
*.tar.gz
!assets/cmake-3.20.6-linux-x86_64.tar.gz

它控制的是主 build context

几个规则很有代表性:

  • .git:镜像构建不需要把整个 Git 数据库发给 Builder;
  • workspace/*:开发源码运行时用 bind mount,不进入 Image;
  • *.zip*.tar.gz:避免历史归档包不小心进入 context;
  • !workspace/.gitkeep:重新包含一个占位文件;
  • !assets/cmake-...tar.gz:虽然全局排除了 *.tar.gz,但这个固定 CMake 资产是 Dockerfile 必须使用的构建输入,所以重新包含。

需要注意:.dockerignore 不负责过滤 named context sdk 的内容。主 context 与 additional context 是两条输入通道。

11. prepare-assets.sh:构建前把错误尽早暴露

这个脚本不是“真正 build Image”,而是准备阶段。

它主要做:

1
2
3
4
5
6
7
8
9
10
11
12
13
检查 SDK 目录

检查 environment-setup 可读

在子 shell source SDK 并检查交叉编译器

确认固定 CMake 版本

生成小型 CMake asset

确认 docker / buildx / compose 可用

打印磁盘信息

关键点是 vendor SDK 环境放在子 shell 中加载:

1
2
3
4
5
(
set -e
. "$SDK_ENV"
command -v aarch64-poky-linux-gcc
)

这样 Yocto 脚本导出的变量不会污染当前 Host shell。

如果 SDK 路径、权限、CMake 版本或 Docker 插件有问题,最好在传输 25 GiB 之前就失败。

12. build-image.sh:为什么要有唯一正式构建入口

核心逻辑是:

1
2
3
4
5
6
docker buildx build \
--progress=plain \
--build-context "sdk=$SDK_DIR" \
--load \
-t "$DEV_IMAGE" \
"$PROJECT_ROOT"

脚本在调用前还会检查:

  • Docker 是否存在;
  • Buildx 是否可用;
  • SDK 是否存在且环境脚本可读;
  • 小型 CMake asset 是否准备好;
  • 当前用户不是 root。

为什么不鼓励每个人手写 build 命令?

因为长命令很容易产生微小差异:

1
2
3
4
有人忘记 --load
有人 SDK 路径不同
有人 tag 不同
有人把 context 写错

把正式入口写成脚本后,工程只需要维护一份真实构建方式。

镜像完成后脚本立即运行 verify-env.sh,这是另一个重要设计:build 成功只是“文件写进 Image”,不代表环境能工作。

13. verify-env.sh:为什么不能只检查 gcc --version

gcc --version 只能证明程序存在,不能证明交叉构建链可用。因此验证脚本还会检查普通用户身份、固定 CMake、host Thrift、SDKTARGETSYSROOT、目标 header/library,并实际生成 ARM64 C/C++ 文件、执行 -lthrift 链接和最小 CMake build。

这类 smoke test 的价值是:把真实工程踩过的环境坑提前变成自动门禁。

14. check-sdk-permissions.sh:为什么单独做全量权限扫描

完整 SDK 约 25 GiB,没必要每次进入 Container 都扫描全部文件。

所以快速验证只检查关键文件,而独立脚本可以执行全量审计:

1
2
find "$SDK_ROOT" -type d ! -perm -0001 -print -quit
find "$SDK_ROOT" -type f ! -perm -0004 -print -quit

第一条找“other 没有 execute/traverse”的目录,第二条找普通用户无法读取的文件。

-print -quit 找到第一个错误就停止,便于快速给出明确失败点。

这和 Dockerfile 的 chmod -R a+rX 正好形成前后对应:

1
2
3
构建时建立权限策略

审计脚本验证策略没有回退

15. compose.yamldev.shentrypoint.sh 怎样配合

镜像构建好以后,日常开发不应该继续手写一大串 docker run

Compose 可以保存稳定的运行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
dev:
image: "${DEV_IMAGE:-arm64-dev:20.04}"
environment:
HOST_UID: "${HOST_UID:-1000}"
HOST_GID: "${HOST_GID:-1000}"
volumes:
- type: bind
source: "${WORKSPACE_DIR:-./workspace}"
target: /workspace
working_dir: /workspace
stdin_open: true
tty: true

这里:

  • image 选择开发镜像;
  • environment 把 Host UID/GID 交给入口脚本;
  • volumes 使用 bind mount 暴露真实源码;
  • working_dir 进入 /workspace
  • stdin_open + tty 对应交互式开发 shell。

dev.sh 则负责读取当前 Host 的动态信息:

1
2
HOST_UID=$(id -u)
HOST_GID=$(id -g)

并检查 workspace 真正可读、可写、可进入,再:

1
exec docker compose -f compose.yaml run --rm dev "$@"

这样使用者只需要:

1
./scripts/dev.sh /home/dev/work/demo-project

而不用记住 Compose 的所有参数。

16. entrypoint.sh 为什么是运行时核心

Image 中可以预建用户 devuser,但不同 Host 的 UID/GID 可能是 1000、1001 或其他值。

入口脚本先以 root 启动,做几件只能 root 完成的事:

  1. 校验 HOST_UID/HOST_GID
  2. 调整 Container 开发用户的数字 UID/GID;
  3. 只修正 /home/devuser 的 ownership;
  4. source Yocto SDK 环境;
  5. 把固定 CMake 放回 PATH 前部;
  6. 根据已验证工程契约正规化 CC/CXX
  7. 使用 setpriv 降权;
  8. 最终 exec 真正的 Bash/CMake/Make。

核心思想是:

1
2
让 Container 用户适应 Host 文件身份
而不是让 Container 去改 Host 源码所有权

所以入口脚本明确不能:

1
chown -R /workspace

因为 /workspace 是 bind mount。

最后:

1
exec setpriv ... -- "$@"

让真正命令替换 entrypoint shell,退出码和信号传播也更自然。

17. export-image.shimport-image.sh 为什么也属于开发环境工程

大镜像构建耗时较长,新 Host 可以直接迁移已经验证的 Image。export-image.shdocker save 导出、gzip 压缩并生成 SHA256;import-image.sh 解压后调用 docker load。脚本刻意把保存和压缩拆成独立步骤,便于在 POSIX sh 下准确判断失败。

注意:docker save/load 迁移的是 Image,不会把 bind-mounted Git 源码一起打包。

20. 把整个工程串起来

最终链路是:prepare-assets.sh 检查输入 → build-image.sh 调用 Buildx → Dockerfile 生成 Image → verify-env.sh 做 smoke test → dev.sh + compose.yaml + entrypoint.sh 提供日常开发环境;权限审计和 Image 导入导出则用于回归与迁移。

这套结构把旧开发机上的隐式环境变成了显式、可验证、可迁移的工程。下一篇继续讨论真实工程构建时暴露的 CMakeCache、host/target、权限、flags 与链接问题。

参考资料