问题从哪来
构建容器镜像,最常见的做法是写一个 Dockerfile(或 Containerfile),交给 BuildKit、Buildah、Kaniko 之类的构建器执行。Cloud Native Buildpacks(CNB)则不需要 Dockerfile:把源码或 jar 交给 builder,自动产出 OCI 镜像。
既然 Dockerfile 已经能构建任意镜像,为什么还会出现 CNB?它解决的是 Dockerfile 解决不了的问题,还是别的问题?本文把这几个问题放在一起整理。CNB 的概念与 Paketo 用法见 CNB: Cloud Native Buildpacks 与 Paketo,Dockerfile 写法见 Containerfile。
先说结论:CNB 主要解决的不是「单个镜像能不能构建出来」,而是「几百上千个应用的镜像由谁来写、怎么统一、基础镜像出漏洞后怎么批量修」。这是组织规模上的问题,不是 Dockerfile 语法能力上的问题。
BuildKit 是什么
BuildKit 是 Moby 项目下的构建引擎,Docker Engine 23.0 起在 Linux 上成为 docker build 的默认后端,docker buildx 也基于它。
一个常被忽略的点:BuildKit 本身并不依赖 Dockerfile。它的结构分两层:
- 前端(frontend):把某种构建描述翻译成 LLB(Low-Level Build definition,一种描述构建步骤的有向无环图)。Dockerfile 只是其中一个前端,Dockerfile 首行的
# syntax=docker/dockerfile:1指定的就是前端镜像。 - 求解器(solver):执行 LLB,负责并行、缓存、导出镜像。
flowchart LR DF[Dockerfile] OTHER[其他前端
Dagger / Railpack 等] FE[Frontend] LLB[LLB DAG] SOLVER[BuildKit Solver
并行 + 缓存] OUT[OCI 镜像 / 本地文件] DF --> FE OTHER --> LLB FE --> LLB --> SOLVER --> OUT
所以更准确的说法是:「Dockerfile 是一种构建描述语言,BuildKit 是执行引擎」。Dagger 的引擎、Railway 的 Railpack 等工具都直接生成 LLB,不写 Dockerfile 也能用 BuildKit。
不写 Dockerfile 时,基础镜像和步骤写在哪
基础镜像、编译步骤、多阶段构建这些信息不会消失,只是换了地方表达。Dockerfile 指令和 LLB 操作大致一一对应:
| Dockerfile 指令 | LLB 操作(Go SDK) |
|---|---|
FROM golang:1.23 |
llb.Image("golang:1.23") |
RUN go build |
.Run(llb.Shlex(...)) |
COPY --from=build |
llb.Copy(build, ...) |
不写 Dockerfile 的常见方式有三种。
第一种,用 BuildKit 的 Go SDK 直接生成 LLB,两阶段构建同样能表达,生成的定义交给 buildctl build 执行:
// build stage: compile in golang image
build := llb.Image("golang:1.23").
Dir("/src").
Run(llb.Shlex("go build -o /out/app .")).Root()
// runtime stage: copy binary into alpine
runtime := llb.Image("alpine:3.20").
File(llb.Copy(build, "/out/app", "/app"))
第二种,用更高层的工具,例如 Dagger,用 Go / TypeScript / Python 写构建流程,底层仍是 BuildKit:
// Dagger pipeline: base image and steps are defined in code
dag.container().from("node:22")
.withDirectory("/app", src)
.withExec(["npm", "ci"])
.withExec(["npm", "run", "build"])
第三种,由工具自动检测项目再生成 LLB,例如 Railpack:看到 package.json 就按 Node 项目处理,基础镜像和步骤来自工具的默认规则,用户在配置文件里覆盖。
也就是说,基础镜像和步骤要么写在代码里,要么写在另一种配置格式里,要么由工具约定。Dockerfile 只是其中一种写法。
需要说明的是,直接写 LLB 在实际项目里很少见,主要是构建工具作者在用,见下文 §认可度。
相比旧版 docker build,BuildKit 带来的主要能力:
| 能力 | 说明 |
|---|---|
| 并行构建 | 多阶段构建中互不依赖的 stage 并行执行 |
| 缓存挂载 | RUN --mount=type=cache 让 Maven、Go module 等缓存跨构建复用 |
| 密钥挂载 | RUN --mount=type=secret 构建时用密钥,不写进镜像层 |
| 远程缓存 | --cache-to / --cache-from 把缓存放到 registry,CI 节点之间共享 |
| 多架构 | buildx build --platform linux/amd64,linux/arm64 |
| 供应链元数据 | v0.11 起支持 SBOM 与 SLSA provenance attestation |
| 可复现 | 支持 SOURCE_DATE_EPOCH 固定时间戳 |
一个典型的 BuildKit 风格 Dockerfile:
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/app .
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]
# build multi-arch image with registry cache and attestations
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-to type=registry,ref=registry.example.com/app:cache,mode=max \
--cache-from type=registry,ref=registry.example.com/app:cache \
--sbom=true --provenance=true \
-t registry.example.com/app:1.0 --push .
Dockerfile 在规模化时的问题
单看一个项目,Dockerfile 几乎没有做不到的事:任意基础镜像、任意系统包、任意目录布局。问题出在「每个应用一份 Dockerfile」这件事放大之后。
1. 每个团队各写一份,质量参差
同样是 Spring Boot 服务,有人用 openjdk 全量 JDK 做运行镜像,有人以 root 运行,有人把 fat jar 整个 COPY 成一层导致依赖层无法缓存,有人忘了设置 JVM 内存参数。Dockerfile 是命令式脚本,正确性完全取决于写的人。平台团队很难在不读每一份 Dockerfile 的前提下保证基线。
2. 基础镜像出漏洞,要逐个重建
基础镜像(OS 层、JRE)发布安全补丁时,用 Dockerfile 的流程是:更新 FROM 标签或等待 tag 漂移 → 每个应用重新跑一遍构建 → 重新测试 → 重新发布。几百个应用就是几百次完整构建,其中大部分应用代码根本没变。
3. 构建逻辑与应用耦合
Dockerfile 放在应用仓库里,构建知识(用哪个 JRE、怎么分层、启动参数)散落在每个仓库。想统一升级 Java 版本或换基础镜像发行版,需要给每个仓库提 PR。
4. 缓存语义基于指令顺序
Dockerfile 的层缓存按指令顺序失效:某一行变了,后面全部重建。合理分层依赖写的人对缓存机制的理解;BuildKit 的缓存挂载缓解了很多,但仍要手动写对。
CNB 怎么回应这些问题
CNB 由 Pivotal 与 Heroku 在 2018 年发起,同年进入 CNCF sandbox,2020 年升为 incubating。它的思路来自 Heroku 和 Cloud Foundry 多年的 PaaS 实践:开发者只推代码,平台负责把代码变成可运行的东西。
| Dockerfile 的问题 | CNB 的做法 |
|---|---|
| 各写各的,质量参差 | 构建逻辑集中在 buildpack 里,由平台团队或社区(如 Paketo)维护;应用侧只配 BP_* 环境变量 |
| 基础镜像漏洞要逐个重建 | rebase:只替换镜像底部的 run image 层,应用层不动,不需要重新构建 |
| 构建逻辑与应用耦合 | 构建知识在 builder 镜像里,升级 builder 版本即可统一变更 |
| 缓存依赖手写顺序 | buildpack 按语义(依赖、JRE、应用)贡献独立层,并能在构建间恢复缓存层 |
| 安全基线 | 构建与运行默认非 root;镜像内带 SBOM;时间戳固定以便复现 |
rebase:CNB 最独特的能力
CNB 镜像的结构是固定的:底部是 run image(OS + 基础库),上面是 buildpack 贡献的层(JRE、依赖、应用)。CNB 规范要求 run image 的更新保持 ABI 兼容,因此当 run image 发布补丁时,可以直接在 registry 层面改写镜像 manifest,把底部几层换成新版本:
# swap the run image layers in place; app layers stay untouched
pack rebase registry.example.com/app:1.0 --run-image paketobuildpacks/run-noble-base:latest
这个操作只涉及 manifest 和 layer 引用,通常几秒完成,不跑构建、不下载应用依赖。配合 kpack 这类 K8s 控制器,可以在 run image 更新后自动对所有镜像执行 rebase。
Dockerfile 镜像理论上也能做类似操作(例如 crane rebase),但 Dockerfile 没有约定哪几层是「基础」、哪几层依赖基础层里的文件;RUN apt-get install 产生的层可能与旧基础层紧耦合,替换后是否还能运行没有保证。CNB 的 rebase 安全,是因为镜像结构和 ABI 兼容性由规范约束。
回到问题:Dockerfile 有解决不了的问题吗
单个镜像的层面上,没有。Dockerfile 能表达的东西比 CNB 多:CNB 能做出来的镜像,手写 Dockerfile 都能做出来,反之不成立。
CNB 解决的是另一类问题:
- 把「怎么构建镜像」从每个应用里抽出来,变成平台统一维护的能力
- 让基础镜像修补从「重建所有应用」变成「rebase 所有镜像」
- 让镜像结构可预测,从而可以被工具批量治理
这些问题用 Dockerfile 也能部分缓解(公司统一模板、共享基础镜像、Renovate 自动提 PR、BuildKit 的 SBOM),但需要自己搭体系;CNB 把这套体系标准化了。
另外要注意,Dockerfile 与 BuildKit 这几年也在补短板:SBOM、provenance、可复现时间戳、远程缓存都已经有了。两者在供应链元数据上的差距已明显缩小,CNB 剩下最主要的差异是 rebase 和「应用侧零构建脚本」。
不经过 BuildKit:直接组装镜像
还有一类工具连 BuildKit 都不用,典型是 Go 的 ko、Java 的 Jib、Chainguard 的 apko。
容器镜像本质上是几个 tar 包(层)加 JSON(manifest 与 config)。Dockerfile 需要容器,是因为 RUN 要在容器里执行命令;如果产物在宿主机上就能编出来,就不需要这一步。ko 的流程:
- 在本机执行
go build,得到静态二进制 - 把二进制打成一个 tar 层
- 叠在基础镜像上,生成 manifest,直接推送到 registry
整个过程没有容器、没有 BuildKit。基础镜像默认是 cgr.dev/chainguard/static,可在 .ko.yaml 中修改:
# .ko.yaml
defaultBaseImage: cgr.dev/chainguard/static:latest
# build and push without Dockerfile or container runtime
KO_DOCKER_REPO=registry.example.com/team ko build ./cmd/app
ko 能做到这么简单,前提是 Go 能编出不依赖其他文件的静态二进制。Jib 同理,它知道 Java 应用由依赖 jar、资源文件、class 文件组成,直接按这个结构分层。
前端项目怎么办
据我所知,前端领域没有像 ko 那样专门为前端打镜像、又被广泛使用的工具。原因是 JS 项目没有「一个静态二进制」:SSR 需要 Node 运行时和 node_modules,SPA 需要一个 Web 服务器托管静态文件。可选方案分两种情况。
React SPA
Vite 等构建后的产物只是 dist/ 静态文件,镜像就是 nginx 或 Caddy 加上这些文件。不写 Dockerfile 的选项:
- Paketo(CNB):Node.js buildpack 执行
npm run build,Web Servers buildpack 打进 nginx - Railpack:能识别 Vite 这类静态站点,自动用 Caddy 托管
# BP_WEB_SERVER_ENABLE_PUSH_STATE falls back all routes to index.html
pack build my-spa --builder paketobuildpacks/builder-jammy-base \
--env BP_WEB_SERVER=nginx \
--env BP_WEB_SERVER_ROOT=dist \
--env BP_WEB_SERVER_ENABLE_PUSH_STATE=true
BP_WEB_SERVER_ENABLE_PUSH_STATE=true 用于前端路由(history 模式),否则刷新非根路径会 404。
实际项目里这种场景更多人还是写一个很短的两阶段 Dockerfile,因为足够简单:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
Next.js SSR
最接近 ko「单一产物」思路的是 Next.js 自带的 standalone 输出:
// next.config.js
module.exports = { output: 'standalone' }
next build 后生成 .next/standalone/,包含 server.js 和裁剪过的最小 node_modules,运行时只需要 Node。之后两种做法:
- 写一个简短 Dockerfile,参考 Next.js 仓库的
examples/with-docker - 不写 Dockerfile:Paketo Node.js buildpack、Heroku Node.js CNB、Google Cloud buildpacks、Railpack 都能识别 Next.js 项目,自动执行
npm ci、next build、next start
这些都是通用的多语言工具,不是前端专用,镜像体积和启动方式通常不如基于 standalone 自己写的 Dockerfile 精细。
纯 Node 后端(非 Next.js)还可以用 Bun 的 bun build --compile 或 Node 的 SEA(single executable application)编出单个可执行文件,之后就能像 ko 那样直接组装镜像;Next.js SSR 目前不适合这条路。
各语言汇总
| 场景 | 不写 Dockerfile 的选择 | 常见实际做法 |
|---|---|---|
| Go | ko | ko |
| Java | Jib、CNB | Jib 或 bootBuildImage |
| React SPA | Paketo Web Servers、Railpack | 两阶段 Dockerfile + nginx |
| Next.js SSR | Paketo / Heroku / Google CNB、Railpack | output: 'standalone' + 短 Dockerfile |
各自的优势与劣势
Dockerfile + BuildKit
优势:
- 完全控制:任意基础镜像、系统包、文件布局、多阶段裁剪
- 通用:任何语言、任何奇怪的项目都能构建
- 透明:构建步骤就写在文件里,出问题容易定位
- 生态最大:几乎所有 CI、教程、工具默认支持
- 构建快且灵活:并行 stage、缓存挂载、多架构成熟
劣势:
- 每个应用要有人写、有人维护,质量依赖个人经验
- 基础镜像补丁需要重建全部应用
- 默认缺少安全基线(root 用户、大基础镜像都很常见)
- 规模大时治理成本高
CNB(Paketo 等)
优势:
- 应用侧零 Dockerfile,主流语言自动检测
- 构建逻辑集中维护,统一升级 JRE、基础镜像
- rebase 快速修补基础层漏洞
- 镜像结构一致、默认非 root、自带 SBOM
- 对 Spring Boot 等生态集成好(
bootBuildImage、分层 JAR)
劣势:
- 灵活度受限:需要额外系统包或特殊 OS 布局时,要自定义 run image、写自己的 buildpack 或用 image extension,成本比改一行 Dockerfile 高
- 黑盒感:出问题要理解 detect / build 顺序和 buildpack 的行为,调试门槛高
- builder 镜像体积大,首次构建要拉几百 MB 甚至更多
- 只覆盖 buildpack 支持的语言和项目形态,非常规项目不适用
- 多架构支持起步较晚,部分 builder 和 buildpack 对 arm64 的支持不如 Dockerfile 成熟
- 无 Docker daemon 环境下要直接调用 lifecycle(如 Tekton 的 buildpacks-phases),集成比 Kaniko / BuildKit 稍绕
选型对比
| 维度 | Dockerfile + BuildKit | CNB |
|---|---|---|
| 构建描述 | 命令式脚本,应用自带 | 声明式配置,逻辑在 buildpack |
| 灵活度 | 最高 | 中,受 buildpack 能力约束 |
| 适用对象 | 任何项目 | 主流语言的标准应用 |
| 基础镜像补丁 | 重建 | rebase |
| 维护方 | 应用团队 | 平台团队 / 社区 |
| 调试 | 直观 | 需要了解 lifecycle |
| 规模化治理 | 需自建体系 | 内建 |
认可度:各自在哪一层
「直接写 LLB」和「CNB」常被放在一起比较,但它们不在同一层:
| 方案 | 所在层次 | 定位 | 面向谁 |
|---|---|---|---|
| Dockerfile | 构建描述语言 | 事实标准 | 应用开发者 |
| LLB | 引擎中间格式 | 实现细节 | 构建工具作者 |
| CNB | 构建约定 / 规范 | CNCF 规范 | 平台团队 + 应用开发者 |
| BuildKit | 执行引擎 | 事实标准 | 所有上层工具 |
BuildKit 引擎:普遍认可
BuildKit 是 docker build / docker buildx 的默认后端,GitHub Actions 的 docker/build-push-action 和各类云构建服务底层也大多是它。但喂给它的几乎都是 Dockerfile。
直接写 LLB:小众
- LLB 是 BuildKit 的内部中间格式,只有 Go API,随 BuildKit 版本演进,没有独立规范,也没有多家实现
- 应用项目基本不会手写 LLB,上文的 Go 代码只用来说明原理
- 使用 LLB 的是一批构建工具:Dockerfile 前端本身、Dagger、Railpack、Microsoft 的 Dalec(用 YAML 描述构建的 BuildKit 前端)等。它们各有用户,但都不是行业标准;也有项目做了几年后停止维护,如 HLB。这类工具的命运取决于背后的公司,没有中立规范兜底
CNB:「免 Dockerfile」方向上更被认可
- 是 CNCF incubating 项目,buildpack API、platform API 有版本化规范
- 有多家独立实现(Paketo、Heroku、Google Cloud),镜像结构一致
- 有产品级采用:Spring Boot
bootBuildImage、Heroku 新一代平台、Google Cloud Run 源码部署 - 管的是镜像结构约定、rebase、SBOM 这类「构建约定」,而 LLB 只描述执行步骤
理论上 CNB 也能跑在 BuildKit 上(早年有实验性的 buildpacks 前端),但未成为主流;CNB 官方实现使用自己的 lifecycle,不依赖 BuildKit。
整体认可度大致是:
- Dockerfile + BuildKit:绝对主流
- CNB:免 Dockerfile 方向上最被认可的标准,Java/Spring 与 PaaS 场景最强;放到整个容器生态,使用率仍远低于 Dockerfile
- ko、Jib:在各自语言内被广泛接受
- 基于 LLB 的各类工具:小众,按工具单独评估
未来会朝 CNB 走吗
我的判断是:不会出现「CNB 取代 Dockerfile」,但「应用开发者少写或不写 Dockerfile」是一个明确的方向,CNB 只是这个方向上的一种实现。
从现状看:
- Dockerfile 仍是事实标准。开源项目、基础设施组件、需要定制系统环境的服务,几乎都用 Dockerfile,短期内不会改变。
- PaaS 和平台型产品倾向于隐藏构建细节。Heroku 新一代平台、Google Cloud Run 的源码部署、Spring Boot 的
bootBuildImage都基于 CNB。 - 「不写 Dockerfile」还有其他路线:Go 的 ko、Java 的 Jib 直接从构建工具产出镜像;Railway 的 Railpack 自动检测项目后生成 BuildKit LLB;Chainguard 的 apko 用声明式配置组装最小镜像。它们不走 CNB 规范,但解决的是同一个问题。
- BuildKit 作为执行引擎的地位反而更稳:不论上层用 Dockerfile 还是其他前端,很多工具最终都在 BuildKit 上执行。
比较可能的格局:
- 标准化的业务服务(Spring Boot、Node.js、Go Web 服务),由平台统一构建,CNB 或类似工具占比会上升
- 基础设施、特殊依赖、追求极致镜像体积的场景,继续用 Dockerfile
- 两者在同一个组织里并存,按项目选择,如 Tekton 里 Kaniko 和 buildpacks-phases 并存(见 Spring Boot Container Packaging)
选择的依据主要是组织规模:应用数量少、团队熟悉 Docker 时,Dockerfile 的总成本更低;应用数量多、需要统一安全基线和快速修补时,CNB 的收益才明显。
参考
- BuildKit
- Docker Build 文档
- Dockerfile frontend 语法
- Build attestations(SBOM / provenance)
- buildpacks.io
- CNB rebase
- kpack
- BuildKit LLB
- Dagger
- Railpack
- ko
- Jib
- Paketo Web Servers Buildpack
- Next.js output: standalone
- CNB: Cloud Native Buildpacks 与 Paketo
- Containerfile
- Tekton 与 Kaniko
- Podman, Buildah, Skopeo