┌────────────────────────────────────────────────────────────────────────┐ │ 用户在 shell 里敲: $ dockerd │ └──────────────────────────────┬─────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ cmd/dockerd/main.go: main() │ │ 1. reexec.Init() ← 判断是不是被"自身重执行"拉起的子进程 │ │ 2. signal.Ignore(SIGPIPE) │ │ 3. term.StdStreams() │ │ 4. command.NewDaemonRunner(stdout, stderr) ────┐ │ │ 5. r.Run(ctx) │ │ └────────────────────────────────────────────────────┼───────────────────┘ │ ┌────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ daemon/command/docker.go: NewDaemonRunner / daemonRunner.Run │ │ - 设置日志格式 (text) │ │ - initLogging │ │ - newDaemonCommand() → cobra 命令树 + 注册 flag │ │ - configureGRPCLog │ │ - cmd.ExecuteContext(ctx) ────┐ │ └─────────────────────────────────┼──────────────────────────────────────┘ │ ┌─────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ daemon/command/docker.go: cobra RunE 闭包 │ │ - newDaemonCLI(opts): 合并默认值 + daemon.json + flag │ │ - if --validate: 打印 "configuration OK" 返回 │ │ - runDaemon(ctx, cli) ────┐ │ └──────────────────────────────┼────────────────────────────────────────┘ │ ┌──────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ daemon/command/docker_unix.go (Linux/macOS) / docker_windows.go │ │ runDaemon → cli.start(ctx) │ └──────────────────────────────┬─────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ daemon/command/daemon.go: cli.start() ★ 主戏台 ★ │ │ 13 个阶段(详见第 4 章) │ │ 阻塞在 httpServer.Serve,等信号 │ └────────────────────────────────────────────────────────────────────────┘第 1 章 整体架构:三层洋葱
dockerd启动逻辑的代码组织,可以想成三层洋葱,从外向内逐层装配:
层 | 文件位置 | 角色 | 行数级别 |
进程层 |
| 进程入口、信号屏蔽、reexec 判定 | ~40 行 |
CLI 装配层 |
| cobra 命令树、flag 注册、Runner 接口 | ~150 行 |
服务层 |
| 真正的 daemon 启动流程(13 阶段) | ~300+ 行 |
设计原则
1. 入口保持极简。Moby 故意把main.go控制在几十行内:只做"进程级最小准备"(信号、终端、reexec),然后立刻交给daemon/command包。这让入口逻辑便于跨平台、便于测试。
2. 装配与执行分离。NewDaemonRunner()只负责"装配"(构造 cobra 对象),不执行任何业务。执行发生在调用r.Run(ctx)时。这种分离让 main.go 可以用统一接口(Runner)启动 daemon,便于在测试里替换。
3. "一个二进制 + 多种执行模式"。Docker 通过reexec.Init()实现这个技巧:同一个dockerd二进制,可以作为主 daemon 启动,也可以作为容器内的 init 进程、或作为 runc 调用者被自己拉起。判据就是argv[0]或环境标记。
4. 业务逻辑与可执行入口解耦。真正复杂的启动逻辑(cli.start())写在daemon/command包里,不写在cmd/dockerd下。这意味着同样一份启动代码可以被测试代码、其他工具复用,不需要 fork 整个 main。
第 2 章 进程入口:main.go
位置:cmd/dockerd/main.go
func main() { if reexec.Init() { return } // [1] 自身重执行判定 ctx := context.Background() // [2] 根 context signal.Ignore(syscall.SIGPIPE) // [3] 屏蔽 SIGPIPE _, stdout, stderr := term.StdStreams() // [4] 终端适配 r, err := command.NewDaemonRunner(stdout, stderr) // [5] 装配 Runner if err != nil { /* ... os.Exit(1) */ } if err := r.Run(ctx); err != nil { // [6] 执行 /* ... os.Exit(1) */ } }关键点逐条解释
[1]reexec.Init()—— 自身重执行机制
这是 Moby 自己的github.com/moby/sys/reexec包提供的。
为什么需要?Docker 在容器生命周期中会"把自己作为子进程再执行一次",比如:
- 容器 init 进程(容器内的 PID 1 由 dockerd 派生)
- 某些 runc 调用路径
- 嵌套容器场景
怎么区分身份?通过argv[0](程序名)来标记。每次 fork+exec 自己时设置一个特殊名字,子进程启动时调用reexec.Init(),它会用argv[0]去查注册表:
- 命中已注册的子命令 → 执行它,返回
true→main直接return,不走 daemon 启动流程。
- 没命中(用户直接敲
dockerd)→ 返回false,继续后面。
这就是为什么 main 的第一行就是它:必须最早判断,否则后面创建文件、绑定端口等动作都不对。
[2] 顶层 context
context.Background()作为整个 daemon 的根 context。这里没有显式 cancel 或超时——真正接管它的是后续 cobra 的ExecuteContext,再后面由cli.start派生出多个子 context 给后台 goroutine。
[3] 屏蔽 SIGPIPE
注释里的 issue #19728:当 dockerd 在 systemd 下运行、journald重启时,往已关闭的日志管道写会触发 SIGPIPE,默认处理是终止进程。这会让 dockerd 被无辜干掉,所以这里signal.Ignore掉。
[4] 终端适配
term.StdStreams()在 Windows 上做 ANSI 转义到 Win32 控制台的转换;Unix 上几乎透传。返回的 stdout/stderr 后面用作日志和错误输出。
[5][6] 装配 + 执行
NewDaemonRunner(stdout, stderr) → Runner 接口 r.Run(ctx) → 真正启动为什么用接口而不直接*cobra.Command?解耦。main.go不直接依赖 cobra,便于在测试里 mock 一个 Runner。
错误处理为什么用fmt.Fprintln + os.Exit(1)而不是log.Fatal?因为此时日志系统可能还没初始化(NewDaemonRunner内部才初始化)。
代码索引
函数/符号 | 文件 |
|
|
Windows 资源嵌入 |
|
|
|
第 3 章 CLI 装配层:docker.go
位置:daemon/command/docker.go
这一层的产物是一个 cobra 命令对象。它做完三件事就把控制权交还给 main:
NewDaemonRunner() ──▶ 设置日志格式 ──▶ initLogging(把 logger 接到 stderr/stdout) ──▶ newDaemonCommand() ← cobra 命令树 + flag 注册返回的Runner是个包装了*cobra.Command的daemonRunner结构。
3.1newDaemonCommand做了什么
cmd := &cobra.Command{ Use: "dockerd [OPTIONS]", RunE: func(cmd, args) error { cli, err := newDaemonCLI(opts) // ← 合并配置 if opts.Validate { return nil } // ← --validate 模式 return runDaemon(ctx, cli) // ← 进入下一层 }, } SetupRootCommand(cmd) flags := cmd.Flags() opts.installFlags(flags) // 注册 --debug / --host / TLS 等 installConfigFlags(opts.daemonConfig, flags) // 把 daemon.json 字段也作为 flag 暴露 installServiceFlags(flags) // Windows 服务相关cobra 的RunE闭包是关键——它定义了"用户敲 dockerd 之后到底执行什么"。注意它捕获了opts,这是 flag 注册和执行之间共享数据的桥梁。
3.2 配置三层合并
newDaemonCLI(opts)里调用的loadDaemonCliConfig实现了 Moby 的"配置三层合并":
默认值 (config.New()) │ 被覆盖 ▼ daemon.json (--config-file, 默认 /etc/docker/daemon.json) │ 被覆盖 ▼ 命令行 flag (最高优先级)为什么这么设计?三层都允许配置同一件事,让运维既能写默认配置文件,又能在调优时临时用 flag 覆盖。Moby 把所有 daemon.json 字段都镜像成了 flag(installConfigFlags),用户两种风格都能用。
--validate模式值得一提:它只是校验配置文件能否正确解析(类似nginx -t),打印 "configuration OK" 后退出,不启动 daemon。这是给运维和 CI 用的安全网。
3.3daemonRunner.Run—— 执行入口
func (d daemonRunner) Run(ctx context.Context) error { configureGRPCLog(ctx) // 抑制 grpc 的噪声日志 return d.ExecuteContext(ctx) // cobra 接管 }ExecuteContext是 cobra 的方法,它会:
- 解析
os.Args
- 触发对应命令的
RunE
- 把
ctx传下去
代码索引
函数 | 文件 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
第 4 章 真正的启动:cli.start 的 13 个阶段
位置:daemon/command/daemon.go中的(*daemonCLI).start()
这是整个启动流程的重头戏,~300 行的方法。按执行顺序划分为 13 个阶段。每一阶段的"做什么 / 为什么这一步在这里"如下。
流程速查表
阶段 | 做什么 | 关键产物 / 副作用 |
1 | 启动前置检查 + 环境/日志配置 | 内核/cgroup 自检;日志格式设置 |
2 | 文件系统准备 |
|
3 | 建立 API 监听器 | 每个 |
4 | containerd 初始化 | 复用系统 containerd 或自起一个 |
5 | HTTP Server 框架 + 信号 Trap |
|
6 | 可观测性(OTel) | tracer provider、systemd notify |
7 | 插件与设备(CDI/GPU) | CDI driver、GPU hooks |
8 | API 中间件 | experimental / version / authz |
9 | 核心:daemon.NewDaemon | 容器/镜像/网络/卷状态机全部还原 |
10 | metrics + Swarm 集群 |
|
11 | BuildKit 初始化 | builder backend |
12 | 组装 HTTP 路由 + Handler | REST 路由 + gRPC + httpServer.Handler |
13 | 实际对外服务 + 等待关闭 | 阻塞在 |
阶段 1:启动前置检查 + 环境/日志配置
daemon.CheckSystem() // 内核 / cgroup / OS 版本检查 configureProxyEnv(...) // 把 daemon.json 里的代理设置写回环境变量 configureDaemonLogs(...) // 设置日志格式 (text/json) 和级别这一阶段还做几个"轻量但致命"的检查:
--debug模式开启内置 debug 服务器(pprof)
- RootlessKit 自检(如果检测到 RootlessKit 但配置没开 rootless,直接报错)
- Linux 上:非 root 又不在 rootless 模式 → 友好错误
- 重置 umask,避免从父进程继承到奇怪的掩码
为什么把日志配置放在这么早?后面所有步骤都依赖日志能正常输出。
阶段 2:文件系统准备
daemon.CreateDaemonRoot(cli.Config) // /var/lib/docker,设 ACL(Windows 尤为重要) os.MkdirAll(cli.Config.ExecRoot, 0o700) // /var/run/docker if cli.Pidfile != "" { pidfile.Write(cli.Pidfile, os.Getpid()) // PID 文件 defer os.Remove(cli.Pidfile) // 退出时清理 }注意顺序:CreateDaemonRoot必须在所有其他文件创建之前做,因为 Windows 上要给目录设 ACL。
PID 文件的作用是给 systemd 之类的进程管理器追踪 dockerd,也防止 dockerd 多开(启动时会失败)。
阶段 3:建立 API 监听器
lss, hosts, err := loadListeners(cli.Config, cli.apiTLSConfig)为每个-H选项创建对应的监听器:
unix:///var/run/docker.sock→ Unix domain socket
tcp://0.0.0.0:2375→ TCP(如果没启用 TLS,会输出大量安全告警 + 强制 sleep 15s 防呆)
npipe:////./pipe/docker_engine(Windows)→ Named pipe
TCP 没 TLS 时为什么会强制 sleep?因为这是一个严重的安全风险——任何能访问该端口的人都能拿到 root 权限。Moby 用这种方式强迫用户注意到这个问题。
阶段 4:containerd 初始化
ctx, cancel := context.WithCancel(ctx) waitForContainerDShutdown, err := cli.initContainerd(ctx) defer cancel()initContainerd的策略:
- 检测系统的
/run/containerd/containerd.sock是否存在
- 存在 →直接复用,不另起
- 不存在 →
supervisor.Start把 containerd 作为子进程拉起
返回的waitForContainerDShutdown是个关闭函数,defer在 daemon 退出时调用,给 containerd 10 秒优雅退出。
阶段 5:HTTP Server 框架 + 信号 Trap
这一步创建了几个关键的协调原语:
httpServer := &http.Server{ReadHeaderTimeout: 5 * time.Minute} // 防 Slowloris trap.Trap(cli.stop) // SIGINT/SIGTERM → cli.stop() go func() { <-cli.apiShutdown // 等 cli.stop() 触发 httpServer.Shutdown(apiShutdownCtx) close(apiShutdownDone) }()cli.stop()的实现:
func (cli *daemonCLI) stop() { cli.stopOnce.Do(func() { close(cli.apiShutdown) }) }幂等(stopOnce保护)—— 即使被多次调用也只 close 一次。这个机制贯穿整个关闭流程,第 5 章会详细讲。
注意:这一步只创建http.Server骨架,Handler 在阶段 12 才填。中间这一段时间(阶段 6-11)服务器还不会响应请求。
阶段 6:可观测性(OpenTelemetry)
preNotifyReady() // sd_notify: 还在启动中 setOTLPProtoDefault() // OTLP 协议默认改 http/protobuf otel.SetTextMapPropagator(...) // W3C TraceContext + Baggage tp, otelShutdown := otelutil.NewTracerProvider(...) otel.SetTracerProvider(tp) log.G(ctx).Logger.AddHook(tracing.NewLogrusHook()) // 日志 ↔ trace 关联 opencensus.InstallTraceBridge() // hcsshim 用的是 OpenCensus,桥接过来为什么有opencensus.InstallTraceBridge?因为 Windows 的 hcsshim 库还用着老的 OpenCensus API,而 daemon 主线用 OpenTelemetry,需要桥接才能让两边的 trace 串起来。
阶段 7:插件与设备(CDI / GPU)
pluginStore := plugin.NewStore() if cdiEnabled(cli.Config) { cdiCache = daemon.RegisterCDIDriver(cli.Config.CDISpecDirs...) } daemon.RegisterGPUDeviceDrivers(cdiCache)CDI(Container Device Interface)必须在daemon.NewDaemon之前注册——否则还原依赖 CDI 设备的容器会失败(比如带 GPU 的容器)。GPU 驱动 hooks 也是同理。
阶段 8:API 中间件
authz, err := initMiddlewares(ctx, &apiServer, cli.Config, pluginStore) cli.authzMiddleware = authz注册三个中间件:
- Experimental:实验特性网关
- Version:在
/version返回的版本信息注入
- Authorization:鉴权插件链(可热重载)
authz句柄保存到cli是为了SIGHUP热重载配置时能更新插件列表。
阶段 9:核心 ——daemon.NewDaemon
d, err := daemon.NewDaemon(ctx, cli.Config, pluginStore, cli.authzMiddleware) d.StoreHosts(hosts) validateAuthzPlugins(...) cli.d = d整个文件最重的一行。NewDaemon内部会:
- 加载镜像层存储(layerDB 或 containerd snapshotter)
- 还原所有现存容器状态(从
/var/lib/docker/containers/读元数据)
- 初始化网络控制器(bridge / overlay / macvlan / ipvlan ...)
- 初始化卷驱动(local / NFS / 卷插件)
- 加载已启用的插件
- 启动 healthcheck / events / stream 等后台 goroutine
validateAuthzPlugins必须在 NewDaemon之后做,因为这时插件才被还原到pluginStore。
阶段 10:metrics + Swarm 集群
startMetricsServer(cfg.MetricsAddress) // Prometheus /metrics 端点 c, err := createAndStartCluster(d, cfg) // Swarm 集群(Raft) d.RestartSwarmContainers() // 重启依赖 Swarm endpoint 的自启动容器createAndStartCluster启动 Swarm 的 Raft、manager、worker 角色。即使节点不在 Swarm 模式下,cluster 对象也会被创建(处于 inactive 状态)。
阶段 11:BuildKit 初始化
b, shutdownBuildKit, err := initBuildkit(ctx, d, cdiCache)initBuildkit做四件事:
session.NewManager()—— 镜像构建会话管理(docker build上下文传输用)
dockerfile.NewBuildManager—— 经典 Dockerfile 解析器
buildkit.New(...)—— 集成 BuildKit(更强的构建引擎,可并行、缓存友好)
buildbackend.NewBackend—— 把上面两个统一封装
返回的shutdownBuildKit在函数尾部 defer 调用,确保 daemon 关闭时 BuildKit 也优雅退出。
阶段 12:组装 HTTP 路由 + gRPC + Handler
var p http.Protocols p.SetHTTP1(true); p.SetHTTP2(true); p.SetUnencryptedHTTP2(true) routers := buildRouters(routerOptions{daemon: d, cluster: c, builder: b, ...}) gs := newGRPCServer(ctx) b.backend.RegisterGRPC(gs) httpServer.Handler = newHTTPHandler(ctx, gs, apiServer.CreateMux(ctx, routers...)) go d.ProcessClusterNotifications(ctx, c.GetWatchStream()) cli.setupConfigReloadTrap() // SIGHUP → reloadConfigbuildRouters注册了完整的 REST API 表:
Router | 路径前缀 | 对应功能 |
|
| 容器生命周期 |
|
| 镜像管理 |
|
| 系统信息 |
|
| 卷管理 |
|
| 镜像构建 |
|
| Swarm 集群管理 |
|
| 网络管理 |
|
| 插件管理 |
|
| registry 交互 |
|
| 容器检查点 |
|
| debug 端点(pprof) |
每个 router 对应一个daemon/server/router/<name>包。
setupConfigReloadTrap让用户能通过SIGHUP信号热重载daemon.json的部分配置项(不会重启 daemon)。
阶段 13:实际对外服务 + 等待关闭
apiStartWG.Add(len(lss)) for _, ls := range lss { apiWG.Go(func() { log.G(ctx).Infof("API listen on %s", ls.Addr()) apiStartWG.Done() httpServer.Serve(ls) // 阻塞 }) } apiStartWG.Wait() // 等所有 listener 就绪 notifyReady() // sd_notify READY=1(systemd) apiWG.Wait() // ★★★ 主阻塞点 ★★★apiWG.Wait()是 daemon 的"主阻塞点"——dockerd 进程正常运行期间就停在这里。直到所有httpServer.Serve调用返回(即httpServer被关闭)才继续往下走。
notifyReady()的意义:告诉 systemd "我准备好了",systemd 才会认为服务启动成功。
第 5 章 信号处理与优雅关闭
优雅关闭是个独立的话题,值得单独讲一章。整个机制的核心是cli.stop()+cli.apiShutdownchannel。
关闭触发路径
用户按 Ctrl+C 或 systemctl stop docker │ ▼ 内核发送 SIGINT / SIGTERM │ ▼ trap.Trap 注册的处理器被调用 → cli.stop() │ ▼ cli.stopOnce.Do(close(cli.apiShutdown)) ← 幂等 │ ▼ 阶段 5 起的后台 goroutine 收到 <-cli.apiShutdown 信号 │ ▼ httpServer.Shutdown(ctx) ← 优雅关闭:处理完手上的请求再退 │ ▼ 所有 httpServer.Serve(ls) 返回 http.ErrServerClosed │ ▼ apiWG.Wait() 解除阻塞 │ ▼ 进入关闭流程(c.Cleanup → shutdownDaemon → shutdownBuildKit → cancel → otelShutdown) │ ▼ return nil → main.go 退出关闭顺序为什么是这样
步骤 | 为什么这个顺序 |
先关 HTTP Server | 拒绝新请求,避免关闭过程中又产生新工作 |
再关 Swarm cluster | cluster 会触发容器调度,要在 API 关闭后做 |
再关 daemon ( | 停止容器、清理网络、卸载卷 |
再关 BuildKit | BuildKit 依赖 daemon 的镜像服务,必须在 daemon 之后 |
最后 cancel ctx + otelShutdown | 取消所有后台 goroutine,flush trace 数据 |
shutdownDaemon自身带超时:
func shutdownDaemon(ctx context.Context, d *daemon.Daemon) { timeout := d.ShutdownTimeout() ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) go func() { defer cancel(); d.Shutdown(ctx) }() <-ctx.Done() if errors.Is(ctx.Err(), context.DeadlineExceeded) { log.G(ctx).Error("Force shutdown daemon") // 超时强制结束 } }防止某个容器拒绝退出导致整个 daemon 卡死。
defer 链
cli.start注册了多个 defer,按 LIFO 顺序执行:
defer otelShutdown(...) ← 最后执行 defer cancel() defer shutdownBuildKit() (shutdownDaemon 显式调用,不是 defer) defer pidfile.Remove(...) defer waitForContainerDShutdown(10s) defer httpServer.Close()/Shutdown()设计上很巧妙:即使 daemon 在阶段 9 失败退出,前面阶段注册的清理 defer 也会按相反顺序触发,不会泄露资源。
第 6 章 跨平台差异
dockerd同时支持 Linux / macOS / Windows,但实现细节有差异。
6.1 文件级差异
功能 | Linux/macOS | Windows |
入口资源嵌入 | 仅 |
|
|
|
|
|
|
|
平台特定选项 |
|
|
6.2 Windows 的服务模式
docker_windows.go的runDaemon多了initService步骤:
stop, runAsService, err := initService(ctx, cli) if stop { return nil } // 注册/注销服务后立即退出 if runAsService { cli.Config.Pidfile = "" } // SCM 托管时不写 PID err = cli.start(ctx)支持三种用法:
dockerd --register-service→ 注册 Windows 服务后立刻退出
dockerd --unregister-service→ 注销服务后立刻退出
- 由 SCM 启动的服务模式 → 正常跑 daemon,但日志走事件日志
6.3 监听器差异
协议 | Linux | Windows |
默认监听 |
|
|
TCP | 都支持 | 都支持 |
Unix socket | ✅ | ❌ |
Named pipe | ❌ | ✅ |