|-------------------|-------------------------------------------------|------------------------------|
| 章节 | 关键文件 | 适合解答 |
| 1. 定位与历史 | daemon/builder/builder.go 注释 | Builder 包为什么这么设计? |
| 2. 整体架构 | daemon/builder/... 目录 | Builder 由哪些子模块组成? |
| 3. 接口契约 | daemon/builder/builder.go | Daemon 应该给 Builder 提供什么能力? |
| 4. 构建入口 | dockerfile/builder.go | BuildManager.Build 干了什么? |
| 5. 上下文检测 | remotecontext/detect.go | tar/git/URL 三种 context 怎么分流? |
| 6. 评估器与分发 | dockerfile/evaluator.go、dispatchers.go | Dockerfile 指令如何被分派? |
| 7. 状态与生命周期 | evaluator.go::dispatchState | 一个 Stage 的状态如何演化? |
| 8. 容器与提交 | dockerfile/internals.go、containerbackend.go | RUN/COMMIT 的真实流程? |
| 9. 镜像缓存 | dockerfile/imageprobe.go | 缓存探测如何判定命中? |
| 10. COPY/ADD | dockerfile/copy.go | 文件复制 + chown 怎么做? |
| 11. BuildArgs | dockerfile/buildargs.go | --build-arg 与 ARG 的语义? |
| 12. 与 BuildKit 关系 | daemon/builder/backend/backend.go | 谁是 v1 谁是 v2? |
| 13. 调用链总览 | --- | 全图速查 |
1. 模块定位与历史
daemon/builder/builder.go 的包注释开门见山:
Historically, only server-side Dockerfile interpreters existed.
This package allows for other implementations of Docker builders.
也就是说,daemon/builder 这一层只定义 构建器的接口(contracts),具体实现可以有多种:
- 经典构建器 (Classic Builder / v1):
daemon/builder/dockerfile/包,Dockerfile 解析 → 逐条指令用临时容器执行 → 每步 commit 出一层镜像。
- BuildKit 构建器 (v2):
daemon/internal/builder-next/,更现代、并行、支持密钥挂载/SSH/多阶段并发等。
⚠️ 生产默认走 BuildKit;本模块保留是为了向后兼容 DOCKER_BUILDKIT=0 场景以及少量仍依赖 commit 模型的代码路径(例如 docker commit --change)。
daemon/builder/dockerfile/builder.go 内部的 Builder 注释也明确:
Builder is a Dockerfile builder.
It implements the builder.Backend interface.
注意区分:builder.Backend 是 "Daemon 提供给 Builder 的能力",而 dockerfile.Builder 是 "Builder 本身"。
2. 整体架构
Go
HTTP / API ─ POST /build
│
▼
daemon/builder/backend/backend.go::Backend.Build
│
┌───────────┴────────────┐
│ Version==BuilderBuildKit│
▼ ▼
builder-next (BuildKit) dockerfile.BuildManager
│ (本文重点)
▼
┌─────────────────────────┐
│ dockerfile.Builder │
│ - dispatchDockerfile... │
│ - dispatch() switch │
│ - commitContainer() │
└──────────┬──────────────┘
│ 通过 builder.Backend 回调
▼
┌─────────────────────────┐
│ Daemon (ImageService / │
│ ExecBackend / Cache) │
└─────────────────────────┘
关键目录与职责:
|-------------------------------------------------|--------------------------------------------------------------------|
| 目录/文件 | 角色 |
| daemon/builder/builder.go | 接口契约:Source、Backend、Image、ROLayer、RWLayer、ImageCache 等 |
| daemon/builder/dockerfile/ | 经典构建器的实现核心(解析、评估、分发、提交、复制) |
| daemon/builder/dockerfile/builder.go | BuildManager、Builder、Build() 主流程 |
| daemon/builder/dockerfile/evaluator.go | 评估器:dispatch() 大 switch、dispatchState 状态对象 |
| daemon/builder/dockerfile/dispatchers.go | 每条 Dockerfile 指令的执行体(ENV/RUN/COPY/...) |
| daemon/builder/dockerfile/internals.go | 内部工具:commit、create、probeCache、performCopy 的辅助 |
| daemon/builder/dockerfile/containerbackend.go | 临时容器生命周期管理(Create/Run/Wait/Remove) |
| daemon/builder/dockerfile/buildargs.go | --build-arg / ARG / meta-arg 三种参数 |
| daemon/builder/dockerfile/imageprobe.go | 缓存探测器接口与命中/失效逻辑 |
| daemon/builder/dockerfile/imagecontext.go | 镜像挂载缓存(imageSources/imageMount) |
| daemon/builder/dockerfile/copy.go | COPY/ADD 的源解析、通配符、远程下载、tarsum |
| daemon/builder/remotecontext/ | 构建上下文来源(archive/git/url/lazycontext) |
| daemon/builder/backend/backend.go | 面向 API 路由的薄壳:选择 v1/v2,处理 squash/tag |
3. 接口契约:daemon/builder/builder.go
这是整个 Builder 模块的"对外协议",只有 ~115 行,但是阅读优先级最高的文件之一。
3.1 常量与 Source
Go
const DefaultDockerfileName = "Dockerfile"
type Source interface {
Root() string // 构建上下文根目录
Close() error // 释放临时目录等资源
Hash(path string) (string, error) // 计算文件指纹,用于缓存键
}
Source 是 ADD/COPY 的统一来源:可能是 tar 解压后的目录、git clone 后的目录、或者镜像内层(用于多阶段 COPY --from)。
3.2 Backend 接口:Daemon 给 Builder 的能力
Go
type Backend interface {
ImageBackend // 镜像/层操作
ExecBackend // 容器执行操作
CommitBuildStep(context.Context, backend.CommitConfig) (image.ID, error)
ContainerCreateWorkdir(containerID string) error
CreateImage(ctx context.Context, config []byte, parent string,
contentStoreDigest digest.Digest) (Image, error)
ImageCacheBuilder // 构建一个有状态的缓存查询器
}
拆开看:
ImageBackend.GetImageAndReleasableLayer:根据 ref/id 拉镜像 + 拿到一个只读层(ROLayer)。是否拉取由PullOption控制(NoPull/ForcePull/PreferLocal)。
ExecBackend:4 个回调------ContainerAttachRaw、ContainerCreateIgnoreImagesArgsEscaped、ContainerRm、ContainerStart、ContainerWait,覆盖了"运行临时容器执行 RUN"所需的全部能力。
CommitBuildStep:把容器快照成新镜像(不触发事件、不更新指标,专门给 Builder 用)。
CreateImage:用配置 JSON 直接生成镜像(用于 COPY/ADD 这种"非容器路径"产生的层)。
ImageCacheBuilder.MakeImageCache(...)→ImageCache.GetCache(parentID, cfg, platform):核心缓存查询。
设计要点:Builder 不直接调用 containerd,也不直接操作镜像存储 ,所有副作用都通过 Backend 接口回调到 Daemon。这种"控制反转"让 Builder 可以独立测试(参见 mockbackend_test.go),也让 BuildKit 能用不同的 Backend 实现。
3.3 Image / ROLayer / RWLayer
Go
type Image interface {
ImageID() string
RunConfig() *container.Config
MarshalJSON() ([]byte, error)
OperatingSystem() string
}
type ROLayer interface {
Release() error
NewRWLayer() (RWLayer, error)
DiffID() layer.DiffID
ContentStoreDigest() digest.Digest
}
type RWLayer interface {
Release() error
Root() string // 挂载点(容器根 / 临时根)
Commit() (ROLayer, error) // 把可写层固化成新的只读层
}
这组接口把"镜像 = 配置 JSON + 一组只读层"的模型抽象出来:
- RUN 走 ROLayer → RWLayer → 容器跑命令 → CommitBuildStep(容器路径)。
- COPY/ADD 走 ROLayer → RWLayer → 直接写文件 → RWLayer.Commit() → CreateImage(非容器路径)。
4. 构建入口:dockerfile/builder.go
4.1 BuildManager:单例
Go
type BuildManager struct {
idMapping user.IdentityMapping // 用户/UID 映射(user namespace)
backend builder.Backend
pathCache pathCache // COPY 路径指纹缓存,避免重复算 hash
}
func (bm *BuildManager) Build(ctx, config) (*builder.Result, error) {
buildsTriggered.Inc() // 指标 +1
if config.Options.Dockerfile == "" {
config.Options.Dockerfile = "Dockerfile" // 默认文件名兜底
}
source, dockerfile, err := remotecontext.Detect(config) // ① 解析上下文
defer source.Close()
b, err := newBuilder(ctx, builderOptions{...}) // ② 装配 Builder
return b.build(ctx, source, dockerfile) // ③ 执行
}
BuildManager 在 daemon/command/daemon.go:603 通过 dockerfile.NewBuildManager(d.BuilderBackend(), d.IdentityMapping()) 创建,整个 Daemon 生命周期内只有一份 。pathCache 用 syncmap.Map 实现,跨构建共享路径指纹。
4.2 Builder:每构建一个产物一份
Builder 是 dispatchDockerfileWithCancellation 期间的临时对象,关键状态:
Go
type Builder struct {
options *buildbackend.BuildOptions
Stdout, Stderr io.Writer
Aux buildbackend.AuxEmitter
Output io.Writer
docker builder.Backend
idMapping user.IdentityMapping
disableCommit bool // docker commit --change 时为 true
imageSources *imageSources // 镜像挂载缓存
pathCache pathCache
containerManager *containerManager // 临时容器池
imageProber ImageProber
platform *ocispec.Platform
}
newBuilder 做三件事:创建 ImageProber、装配 imageSources 和 containerManager、解析 --platform。
4.3 Builder.build():解析 → 评估
Go
func (b *Builder) build(ctx, source, dockerfile) (*builder.Result, error) {
defer b.imageSources.Unmount() // 收尾:卸载所有挂载
stages, metaArgs, err := instructions.Parse(dockerfile.AST, nil) // ④ 语法树 → 指令对象
if b.options.Target != "" { // --target 只保留前 N 个 stage
stages = stages[:targetIx+1]
}
buildLabelOptions(b.options.Labels, stages) // --label 注入到末段
dockerfile.PrintWarnings(b.Stderr)
state, err := b.dispatchDockerfileWithCancellation(ctx, stages, metaArgs, dockerfile.EscapeToken, source)
if state.imageID == "" {
return nil, errors.New("No image was generated. Is your Dockerfile empty?")
}
return &builder.Result{ImageID: state.imageID, FromImage: state.baseImage}, nil
}
注意:
- 真正的解析器不在本仓 ,由
github.com/moby/buildkit/frontend/dockerfile/instructions与.../parser提供。Moby 共享 BuildKit 的前端解析器(语法树节点),但不复用它的执行引擎 ------执行(评估)由daemon/builder/dockerfile/evaluator.go自实现。这是 v1/v2 的分水岭。
EscapeToken来自 parser,决定续行符是\(Linux 风格)还是`(Windows 风格)。
4.4 dispatchDockerfileWithCancellation:评估主循环
伪代码(精简后):
Go
buildArgs := NewBuildArgs(b.options.BuildArgs)
totalCommands := len(metaArgs) + len(parseResult) + Σ(stage.Commands)
// ⑤ 处理 FROM 之前的 ARG(metaArgs),影响 FROM 镜像名解析
for i := range metaArgs {
printCommand("Step %d/%d : %v", ...)
processMetaArg(metaArgs[i], shlex, buildArgs)
}
stagesResults := newStagesBuildResults()
for _, stage := range parseResult {
request := newDispatchRequest(b, escapeToken, source, buildArgs, stagesResults)
printCommand(stage.SourceCode)
initializeStage(ctx, request, &stage) // ⑥ FROM ...
request.state.updateRunConfig()
fmt.Fprintf(b.Stdout, " ---> %s\n", truncateID(state.imageID))
for _, cmd := range stage.Commands {
select { // ⑦ 取消检查(每条指令之间)
case <-ctx.Done(): return canceled
default:
}
printCommand(cmd)
dispatch(ctx, request, cmd) // ⑧ 真正执行单条指令
request.state.updateRunConfig()
fmt.Fprintf(b.Stdout, " ---> %s\n", truncateID(state.imageID))
}
emitImageID(b.Aux, state) // ⑨ 推送 BuildKit aux 兼容消息
buildArgs.MergeReferencedArgs(state.buildArgs)
commitStage(state, stagesResults) // ⑩ 注册到 stagesResults,供后续 COPY --from 使用
}
buildArgs.WarnOnUnusedBuildArgs(b.Stdout) // ⑪ --build-arg 没被引用就告警
return request.state, nil
printCommand 输出的就是构建日志里经典的 Step 3/7 : RUN apt-get update。
5. 构建上下文:remotecontext/detect.go
Detect(config) 根据 Options.RemoteContext 分流:
|------------------|-----------------------------------|-----------------------------------------------|
| 输入 | 分支 | 返回 |
| 空(默认) | newArchiveRemote | 把 config.Source(HTTP 请求 body 里的 tar)解压成临时目录 |
| client-session | 报错 | v1 builder 已不支持 |
| git URL | newGitRemote → MakeGitContext | git clone 到临时目录 → 转 tar → 走 archive 路径 |
| http(s) URL | newURLRemote | downloadRemote → 检测 Content-Type |
| | ├ text/plain | 当作单文件 Dockerfile 解析(context 为空) |
| | └ tar/octet-stream | 走 archive 路径 |
withDockerfileFromContext 还会做这几件事:
- Dockerfile 大小写兜底 :找不到
Dockerfile就退而求其次找小写dockerfile(Linux 文件系统区分大小写,但保持兼容)。
.dockerignore处理 :读取忽略规则,如果规则把 Dockerfile 或.dockerignore本身匹配上,则把它们从上下文里删掉,避免进入镜像。
FullPath 用 symlink.FollowSymlinkInScope 把任意路径锁死在 Root() 之内,防止 ADD/COPY 通过软链接逃出构建上下文------这是一道重要的安全边界。
6. 评估器与分发器
6.1 dispatch():分发总开关
Go
func dispatch(ctx, d dispatchRequest, cmd instructions.Command) (retErr error) {
// 1) 平台检查(个别指令在 Windows 上不可用)
if c, ok := cmd.(instructions.PlatformSpecific); ok { c.CheckPlatform(state.operatingSystem) }
// 2) 收集环境(image ENV + 当前 stage 的 build-arg)
envs := shell.EnvsFromSlice(append(runConfig.Env, buildArgs.FilterAllowed(runConfig.Env)...))
// 3) 变量展开(指令实现了 SupportsSingleWordExpansion)
if ex, ok := cmd.(instructions.SupportsSingleWordExpansion); ok {
ex.Expand(func(word string) (string, error) {
newword, _, err := d.shlex.ProcessWord(word, envs); return newword, err
})
}
// 4) 容器清理策略(defer)
defer func() {
if options.ForceRemove { containerManager.RemoveAll(stdout) }
else if options.Remove && retErr == nil { containerManager.RemoveAll(stdout) }
}()
// 5) 类型分发大 switch
switch c := cmd.(type) {
case *instructions.EnvCommand: return dispatchEnv(ctx, d, c)
case *instructions.MaintainerCommand: return dispatchMaintainer(ctx, d, c)
case *instructions.LabelCommand: return dispatchLabel(ctx, d, c)
case *instructions.AddCommand: return dispatchAdd(ctx, d, c)
case *instructions.CopyCommand: return dispatchCopy(ctx, d, c)
case *instructions.OnbuildCommand: return dispatchOnbuild(ctx, d, c)
case *instructions.WorkdirCommand: return dispatchWorkdir(ctx, d, c)
case *instructions.RunCommand: return dispatchRun(ctx, d, c)
case *instructions.CmdCommand: return dispatchCmd(ctx, d, c)
case *instructions.HealthCheckCommand: return dispatchHealthcheck(ctx, d, c)
case *instructions.EntrypointCommand: return dispatchEntrypoint(ctx, d, c)
case *instructions.ExposeCommand: return dispatchExpose(ctx, d, c, envs)
case *instructions.UserCommand: return dispatchUser(ctx, d, c)
case *instructions.VolumeCommand: return dispatchVolume(ctx, d, c)
case *instructions.StopSignalCommand: return dispatchStopSignal(ctx, d, c)
case *instructions.ArgCommand: return dispatchArg(ctx, d, c)
case *instructions.ShellCommand: return dispatchShell(ctx, d, c)
}
return errors.Errorf("unsupported command type: %v", reflect.TypeOf(cmd))
}
注意 dispatchRequest 是值类型 ,每个 stage 重建一次;它把 state、shlex、builder、source、stages 拼成一个上下文,避免 dispatcher 函数签名单个参数爆炸。
6.2 dispatchState:一个 Stage 的运行时状态
Go
type dispatchState struct {
runConfig *container.Config // 当前镜像的 container.Config(CMD/ENV/...)
maintainer string
cmdSet bool
imageID string // 当前阶段最新提交出的镜像 ID
baseImage builder.Image // FROM 来的镜像
stageName string // AS <name>
buildArgs *BuildArgs // 本阶段的 ARG 副本
operatingSystem string
}
每条 dispatcher 几乎都会:
- 修改
runConfig(设置Env、Cmd、Entrypoint、Volume、Labels等);
- 调
b.commit(ctx, state, "<comment>")把当前 runConfig "提交成一层"。
commit 内部会构造一个 Cmd = [shell..., "#(nop) <comment>"] 的临时 config------这解释了为什么 docker history里非 RUN 的指令会显示为 #/nop)注释行。
6.3 指令清单速查
|-----------------------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------|-----------|
| 指令 | dispatcher | 落点 | 是否 commit |
| FROM | initializeStage(不是 dispatch,是 stage 入口) | state.imageID/baseImage/runConfig | 否(直接拿父镜像) |
| MAINTAINER | dispatchMaintainer | state.maintainer(写入 history.Author) | 是 |
| RUN | dispatchRun | 临时容器执行 → CommitBuildStep | 是(容器路径) |
| CMD/ENTRYPOINT | dispatchCmd/dispatchEntrypoint | runConfig.Cmd/Entrypoint | 是(配置变更) |
| ENV/LABEL/EXPOSE/USER/VOLUME/WORKDIR/STOPSIGNAL/SHELL/ONBUILD/HEALTHCHECK | 对应 dispatchXxx | runConfig 对应字段 | 是 |
| ARG | dispatchArg | BuildArgs.AddArg | 是 |
| COPY | dispatchCopy | performCopy → exportImage | 是(非容器路径) |
| ADD | dispatchAdd | performCopy → exportImage,允许本地解压 + 远程下载 | 是 |
重要差异 :BuildKit 才支持 RUN --mount、COPY --chmod、ADD --chmod 等。v1 builder 一遇到这些 flag 就直接报 "requires BuildKit"------见 dispatchRun 和 dispatchAdd 开头的检查。
7. 容器与镜像提交:internals.go + containerbackend.go
7.1 commit:把"配置变更"提交成镜像
很多指令(ENV/LABEL/USER 等)并不真正运行容器,但仍会生成一个新层------这就是 commit 干的事:
Go
func (b *Builder) commit(ctx, state, comment) error {
if b.disableCommit { return nil } // docker commit --change 时跳过
if !state.hasFromImage() { return errors.New("...") }
runConfigWithCommentCmd := copyRunConfig(state.runConfig,
withCmdComment(comment, state.operatingSystem)) // Cmd = [shell, "#(nop) ENV FOO=bar"]
id, err := b.probeAndCreate(ctx, state, runConfigWithCommentCmd)
return b.commitContainer(ctx, state, id, runConfigWithCommentCmd)
}
probeAndCreate = 先 probeCache,命中直接复用,没命中才创建容器。commitContainer 调 Daemon 的 CommitBuildStep。
7.2 dispatchRun:RUN 指令的真实流程
Go
func dispatchRun(ctx, d, c) error {
// 1) OS 校验、BuildKit flag 校验
// 2) 解析命令行(shell 形式 vs exec 形式),决定是否走 sh -c / cmd /S /C
cmdFromArgs, argsEscaped := resolveCmdLine(...)
// 3) 把 build-arg 拼到 cmd 前面(以 |<n> 标记,便于缓存命中判别)
saveCmd := cmdFromArgs
if len(buildArgs) > 0 {
saveCmd = prependEnvOnCmd(buildArgs, buildArgs, cmdFromArgs)
}
// 4) 构造探针 config(带 argsEscaped,清空 entrypoint,关掉 healthcheck)
runConfigForCacheProbe := copyRunConfig(stateRunConfig,
withCmd(saveCmd), withArgsEscaped(...),
withEntrypointOverride(saveCmd, nil), withoutHealthcheck())
// 5) 命中缓存就跳过
if hit, _ := b.probeCache(state, runConfigForCacheProbe); hit { return nil }
// 6) 真正创建 + 启动 + 等待容器
runConfig := copyRunConfig(stateRunConfig,
withCmd(cmdFromArgs), withArgsEscaped(argsEscaped),
withEnv(append(stateRunConfig.Env, buildArgs...)),
withEntrypointOverride(saveCmd, []string{""}), withoutHealthcheck())
cID, err := b.create(ctx, runConfig)
if err := b.containerManager.Run(ctx, cID, stdout, stderr); err != nil {
return &jsonstream.Error{...} // 把 exit code 包成 API 错误
}
return b.commitContainer(ctx, state, cID, runConfigForCacheProbe)
}
几个常被忽略的细节:
prependEnvOnCmd用|<n>前缀:把 build-arg 注入到 RUN 命令字符串里。这样相同的 RUN + 相同的 build-arg 才能命中缓存;改变 build-arg 会导致缓存失效------这是设计上期望的。
withoutHealthcheck:RUN 期间不要让父镜像的健康检查脚本干扰,临时禁掉。
withEntrypointOverride(..., []string{""}):清空 entrypoint,避免 RUN 被 ENTRYPOINT 干扰(ContainerCreate会把[""]翻成nil)。
7.3 containerManager.Run:临时容器执行体
Go
func (c *containerManager) Run(ctx, cID, stdout, stderr) error {
// ① Attach 容器 IO(stdout/stderr 接到 builder 的输出)
// ② 起一个 goroutine 监听 ctx.Done:取消就强制删除容器
// ③ ContainerStart
// ④ 阻塞读 IO
// ⑤ ContainerWait(WaitConditionNotRunning)
// 退出码 != 0 → *statusCodeError
}
tmpContainers 是一个 map[string]struct{},记录本次构建过程中创建的所有容器;构建结束(或出错、或 ForceRemove)由 RemoveAll 统一清理。这就是为什么经典构建日志里会看到:
---> Running in 1a2b3c4d5e6f
---> Removed intermediate container 1a2b3c4d5e6f
8. 镜像缓存:imageprobe.go
8.1 接口
Go
type ImageProber interface {
Reset(ctx) error
Probe(parentID string, runConfig *container.Config,
platform ocispec.Platform) (string, error)
}
Probe 返回非空 cacheID = 命中,返回空 = 未命中。Reset 在每个 stage 开始时调用,让缓存基于 --cache-from 重新建立。
8.2 命中与"熔断"
Go
func (c *imageProber) Probe(parentID, runConfig, platform) (string, error) {
if c.cacheBusted { return "", nil } // 一旦本次构建内 miss 过,后续直接放弃
cacheID, err := c.cache.GetCache(parentID, runConfig, platform)
if cacheID == "" {
c.cacheBusted = true // 第一次 miss 就置位
return "", nil
}
return cacheID, nil
}
经典构建器的缓存语义是线性、严格顺序的:从父镜像出发,一旦某条指令的 cache miss,后面所有指令都视为 miss(因为后面的层 hash 链已经断了)。这与 BuildKit 的"按内容寻址、并行重用"完全不同。
--no-cache 时 newImageProber 直接返回 nopProber{},所有 Probe 永远返回空,效果就是每次都重跑。
8.3 GetCache 在 Daemon 侧的实现
ImageService.MakeImageCache 在 daemon/containerd/cache.go:25 委托给 daemon/internal/image/cache:
- 没传
--cache-from:用父镜像的子层 history 做匹配;
- 传了
--cache-from:把这些镜像视作可选起点,遍历它们的 history 寻找父 ID + runConfig 完全匹配的层。
匹配 key 是 (parentID, runConfig),所以 ENV、RUN 等任何"改变 config"或"改变命令"的修改都会让 key 不同------这是缓存的基本原理。
9. COPY/ADD:copy.go + internals.go::performCopy
COPY/ADD 走的是非容器路径 :直接在 RWLayer 上写文件,然后 exportImage 创建新镜像。
Go
func (b *Builder) performCopy(ctx, req, inst) error {
// ① 计算源 hash(单源直取,多源用 sha256 拼接)
srcHash := getSourceHashFromInfos(inst.infos)
commentStr := fmt.Sprintf("%s %s%s in %s ", inst.cmdName, chownComment, srcHash, inst.dest)
// ② 缓存探针:把"源 hash + 目标"塞进 Cmd 当注释,参与 cache key
runConfigWithCommentCmd := copyRunConfig(state.runConfig, withCmdCommentString(commentStr, ...))
if hit, _ := b.probeCache(state, runConfigWithCommentCmd); hit { return nil }
// ③ 挂载目标镜像的可写层
imgMount, _ := b.imageSources.Get(ctx, state.imageID, true, ...)
rwLayer, _ := imgMount.NewRWLayer()
defer rwLayer.Release()
// ④ 解析目标路径(相对 WORKINGDIR)+ 解析 chown
destInfo, _ := createDestInfo(state.runConfig.WorkingDir, inst, rwLayer)
if inst.chownStr != "" {
id, _ = parseChownFlag(ctx, b, state, inst.chownStr, destInfo.root, b.idMapping)
}
// ⑤ 真正拷贝(performCopyForInfo:考虑通配符、tar 自动解压、保持属主等)
for _, info := range inst.infos {
performCopyForInfo(destInfo, info, opts)
}
// ⑥ RWLayer.Commit() → 新只读层 → CreateImage
return b.exportImage(ctx, state, rwLayer, imgMount.Image(), runConfigWithCommentCmd)
}
exportImage 的关键步骤:
Go
newLayer, _ := layer.Commit() // 固化可写层
newImage := image.NewChildImage(parentImage, ChildConfig{
Author: state.maintainer,
ContainerConfig: runConfig,
DiffID: newLayer.DiffID(),
Config: copyRunConfig(state.runConfig),
}, parentImage.OS)
config, _ := newImage.MarshalJSON()
exportedImage, _ := b.docker.CreateImage(ctx, config, state.imageID, newLayer.ContentStoreDigest())
state.imageID = exportedImage.ImageID()
ADD vs COPY 的差异
|--------------------|-----------------------------------|--------------------------|
| 维度 | ADD | COPY |
| 本地 tar 自动解压 | ✅(allowLocalDecompression=true) | ❌ |
| 远程 URL 下载 | ✅(newRemoteSourceDownloader) | ❌(errOnSourceDownload) |
| --from=<stage> 源 | ❌ | ✅ |
| --chown | ✅(v1 也支持) | ✅ |
| --chmod | ❌(要 BuildKit) | ❌ |
COPY --from 通过 getImageMount → stages.get(nameOrIndex) → imageSources.Get 链路,允许从前序 stage 的镜像里直接拷贝文件,是多阶段构建的基石。
10. BuildArgs:buildargs.go
BuildArgs 维护三类参数:
|--------------------|--------------------------|----------------------------------------|
| 字段 | 含义 | 例子 |
| argsFromOptions | --build-arg KEY=VAL | docker build --build-arg VERSION=1.2 |
| allowedMetaArgs | FROM 之前的 ARG(meta-arg) | ARG VERSION=1.0\nFROM img:${VERSION} |
| allowedBuildArgs | FROM 之后的 ARG | ARG NODE_ENV\nRUN echo $NODE_ENV |
| referencedArgs | Dockerfile 真正引用过的 ARG | 用于"未使用告警" |
builtinAllowedBuildArgs 把 HTTP_PROXY/HTTPS_PROXY/... 等代理变量视作透明:它们可以在 RUN 里被使用,但不会污染 history。
getBuildArg 的解析优先级:--build-arg 选项 > 当前 stage 内 ARG 默认值 > meta-arg 默认值。
FilterAllowed 把 ARG 转成 KEY=VAL 形式注入到 RUN 的环境变量(如果未在父镜像 ENV 中存在)。WarnOnUnusedBuildArgs 在构建末尾打印 [Warning] One or more build-args [foo] were not consumed。
11. imageSources:镜像挂载缓存
imageSources 是构建过程中所有用到的镜像挂载的注册表:
type imageSources struct {
byImageID map[string]*imageMount // 按 ID 去重
mounts []*imageMount // 顺序记录,便于 Unmount
getImage getAndMountFunc
}
Get(idOrRef, localOnly, platform):第一次访问时拉镜像 + 创建 ROLayer,后续命中缓存。
Add(im, platform):scratch 镜像(image == nil)补全为空镜像(OS=linux on Windows)。
Unmount():构建结束统一卸载,b.build用defer b.imageSources.Unmount()保证收尾。
每个 imageMount 持有一个 builder.Image + builder.ROLayer;需要写文件时通过 NewRWLayer() 拿到可写层(Cow),不再用就 Release()。
12. 与 BuildKit 的关系:daemon/builder/backend/backend.go
这一层是 API 路由和构建器之间的薄壳:
Go
func (b *Backend) Build(ctx, config) (string, error) {
useBuildKit := options.Version == build.BuilderBuildKit
var buildResult *builder.Result
if useBuildKit {
buildResult, err = b.buildkit.Build(ctx, config) // BuildKit (v2)
} else {
buildResult, err = b.builder.Build(ctx, config) // Classic (v1,本文主角)
}
if options.Squash {
imageID = squashBuild(...) // --squash 后处理
}
if imageID != "" && !useBuildKit {
fmt.Fprintf(stdout, "Successfully built %s\n", truncateID(imageID))
tagImages(...) // 经典模式这里才打 tag
}
return imageID, err
}
要点:
- 选择 v1/v2 的依据是
BuildOptions.Version,由客户端通过?version=决定(或环境变量DOCKER_BUILDKIT)。
Successfully built <id>这行日志只 v1 打,BuildKit 自己有 progress UI。
--squash不论哪种 builder 都支持,但走ImageComponent.SquashImage做扁平化。
PruneCache和Cancel当前只委托给b.buildkit------也就是说经典构建器并没有独立的 cache prune 接口,所有缓存清理都是清 BuildKit 的。
13. 调用链总览(一图速查)
HTTP POST /build
└── api/server/router/build → buildbackend.Backend (Backend)
└── daemon/builder/backend/backend.go::Backend.Build
├── if BuildKit: b.buildkit.Build(ctx, config)
└── else (v1): b.builder.Build(ctx, config)
└── dockerfile.BuildManager.Build
├── remotecontext.Detect
│ ├── newArchiveRemote (默认 tar)
│ ├── newGitRemote (git URL)
│ └── newURLRemote (http URL)
├── newBuilder
│ ├── newImageProber (cache/no-cache)
│ ├── newImageSources (镜像挂载注册表)
│ └── newContainerManager(临时容器池)
└── Builder.build
├── instructions.Parse (AST → 指令)
└── dispatchDockerfileWithCancellation
├── processMetaArg (FROM 前的 ARG)
└── for each stage:
├── initializeStage
│ ├── imageProber.Reset
│ ├── getFromImage (含 stage 引用解析)
│ └── dispatchTriggeredOnBuild (父镜像 ONBUILD)
└── for each cmd:
└── dispatch
├── dispatchEnv / Label / User / ...
│ └── b.commit → probeAndCreate → commitContainer
│ └── Backend.CommitBuildStep (Daemon)
├── dispatchRun
│ ├── probeCache (cache hit 跳过)
│ ├── b.create → ContainerCreateIgnoreImagesArgsEscaped
│ ├── containerManager.Run
│ │ ├── ContainerAttachRaw
│ │ ├── ContainerStart
│ │ └── ContainerWait
│ └── commitContainer → Backend.CommitBuildStep
├── dispatchCopy / dispatchAdd
│ ├── probeCache
│ ├── imageSources.Get → NewRWLayer
│ ├── performCopyForInfo (文件写入 + 可选解压/下载)
│ └── exportImage
│ ├── RWLayer.Commit
│ └── Backend.CreateImage
└── dispatchWorkdir
├── probeAndCreate
├── Backend.ContainerCreateWorkdir
└── commitContainer
14. 学习路径建议
- 先读 5 个文件 :
daemon/builder/builder.go→dockerfile/builder.go→dockerfile/evaluator.go→dockerfile/dispatchers.go(按需读 dispatchRun/dispatchCopy)→dockerfile/internals.go。这五者构成 v1 builder 的主干。
- 挑一个指令跟一遍 :建议从
RUN开始,因为它串起了缓存探测、临时容器、stdout 透传、错误退出码、commit 这条最复杂的链。
- 再看 COPY :理解"非容器路径"如何提交镜像(
RWLayer.Commit+CreateImage),与 RUN 形成"两条提交路径"的对比。
- 最后看 BuildArgs / imageSources / remotecontext:这些是周边支撑,掌握了主干再读会非常自然。
- 横向对比 BuildKit :当 v1 全图清楚了,再去
daemon/internal/builder-next/看同样的 Dockerfile 是怎么用 LLB/DAG 并行求解的------能更深刻理解"为什么 BuildKit 是必然"。
15. 关键设计要点小结
- 接口与实现分离 :
daemon/builder/builder.go只声明契约,让 Daemon 通过实现Backend反向注入能力;构建器本身不直接接触 containerd。
- 解析器复用、执行器分叉 :v1 和 v2 共享
buildkit/frontend/dockerfile/parser与instructions,但评估器是 v1 独有。
- 两套提交路径 :RUN → 容器路径(
CommitBuildStep);COPY/ADD → 非容器路径(RWLayer.Commit+CreateImage)。
- 缓存严格线性 :一旦 miss 即"熔断"(
cacheBusted),不再尝试后续命中,这是 v1 缓存语义的本质。
- 取消粒度 :在每条指令执行之间 检查
ctx.Done(),所以单条 RUN 一旦开始就不会立即响应取消,需要等它结束或被containerManager.Run内部的 goroutine 强删。
- 安全边界 :
symlink.FollowSymlinkInScope把 ADD/COPY 路径锁在上下文根下;FollowSymlinkInScope同样用于容器内拷贝防止逃逸。
- BuildKit 才能用的特性 :
RUN --mount、COPY --chown之外的所有--<flag>(如--chmod、--link、--parents)v1 都直接拒绝;这也是为什么社区强烈推荐切到 BuildKit。