moby-dockerd-启动流程详解

📅 2026/7/2 1:46:17
moby-dockerd-启动流程详解
┌────────────────────────────────────────────────────────────────────────┐ │ 用户在 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启动逻辑的代码组织可以想成三层洋葱从外向内逐层装配层文件位置角色行数级别进程层cmd/dockerd/main.go进程入口、信号屏蔽、reexec 判定~40 行CLI 装配层daemon/command/docker.gocobra 命令树、flag 注册、Runner 接口~150 行服务层daemon/command/daemon.go真正的 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.gofunc 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]程序名来标记。每次 forkexec 自己时设置一个特殊名字子进程启动时调用reexec.Init()它会用argv[0]去查注册表命中已注册的子命令 → 执行它返回true→main直接return不走 daemon 启动流程。没命中用户直接敲dockerd→ 返回false继续后面。这就是为什么 main 的第一行就是它必须最早判断否则后面创建文件、绑定端口等动作都不对。[2] 顶层 contextcontext.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内部才初始化。代码索引函数/符号文件main()cmd/dockerd/main.goWindows 资源嵌入cmd/dockerd/main_windows.goreexec.Init()实现vendor/github.com/moby/sys/reexec/第 3 章 CLI 装配层docker.go位置daemon/command/docker.go这一层的产物是一个 cobra 命令对象。它做完三件事就把控制权交还给 mainNewDaemonRunner() ──▶ 设置日志格式 ──▶ 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 字段都镜像成了 flaginstallConfigFlags用户两种风格都能用。--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传下去代码索引函数文件NewDaemonRunnerdaemon/command/docker.gonewDaemonCommanddaemon/command/docker.gonewDaemonCLIdaemon/command/daemon.goloadDaemonCliConfigdaemon/command/daemon.godaemonRunner.Rundaemon/command/docker.goconfigureGRPCLogdaemon/command/grpclog.goinstallFlagsflag 注册daemon/command/options.go第 4 章 真正的启动cli.start 的 13 个阶段位置daemon/command/daemon.go中的(*daemonCLI).start()这是整个启动流程的重头戏~300 行的方法。按执行顺序划分为 13 个阶段。每一阶段的做什么 / 为什么这一步在这里如下。流程速查表阶段做什么关键产物 / 副作用1启动前置检查 环境/日志配置内核/cgroup 自检日志格式设置2文件系统准备/var/lib/docker、/var/run/docker、PID 文件3建立 API 监听器每个-H一个net.Listener4containerd 初始化复用系统 containerd 或自起一个5HTTP Server 框架 信号 Trap*http.Server、cli.stop协调机制6可观测性OTeltracer provider、systemd notify7插件与设备CDI/GPUCDI driver、GPU hooks8API 中间件experimental / version / authz9核心daemon.NewDaemon容器/镜像/网络/卷状态机全部还原10metrics Swarm 集群/metrics端点、Swarm Raft11BuildKit 初始化builder backend12组装 HTTP 路由 HandlerREST 路由 gRPC httpServer.Handler13实际对外服务 等待关闭阻塞在apiWG.Wait()阶段 1启动前置检查 环境/日志配置daemon.CheckSystem() // 内核 / cgroup / OS 版本检查 configureProxyEnv(...) // 把 daemon.json 里的代理设置写回环境变量 configureDaemonLogs(...) // 设置日志格式 (text/json) 和级别这一阶段还做几个轻量但致命的检查--debug模式开启内置 debug 服务器pprofRootlessKit 自检如果检测到 RootlessKit 但配置没开 rootless直接报错Linux 上非 root 又不在 rootless 模式 → 友好错误重置 umask避免从父进程继承到奇怪的掩码为什么把日志配置放在这么早后面所有步骤都依赖日志能正常输出。阶段 2文件系统准备daemon.CreateDaemonRoot(cli.Config) // /var/lib/docker设 ACLWindows 尤为重要 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 sockettcp://0.0.0.0:2375→ TCP如果没启用 TLS会输出大量安全告警 强制 sleep 15s 防呆npipe:////./pipe/docker_engineWindows→ Named pipeTCP 没 TLS 时为什么会强制 sleep因为这是一个严重的安全风险——任何能访问该端口的人都能拿到 root 权限。Moby 用这种方式强迫用户注意到这个问题。阶段 4containerd 初始化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 秒优雅退出。阶段 5HTTP 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可观测性OpenTelemetrypreNotifyReady() // 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 / GPUpluginStore : plugin.NewStore() if cdiEnabled(cli.Config) { cdiCache daemon.RegisterCDIDriver(cli.Config.CDISpecDirs...) } daemon.RegisterGPUDeviceDrivers(cdiCache)CDIContainer Device Interface必须在daemon.NewDaemon之前注册——否则还原依赖 CDI 设备的容器会失败比如带 GPU 的容器。GPU 驱动 hooks 也是同理。阶段 8API 中间件authz, err : initMiddlewares(ctx, apiServer, cli.Config, pluginStore) cli.authzMiddleware authz注册三个中间件Experimental实验特性网关Version在/version返回的版本信息注入Authorization鉴权插件链可热重载authz句柄保存到cli是为了SIGHUP热重载配置时能更新插件列表。阶段 9核心 ——daemon.NewDaemond, 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 等后台 goroutinevalidateAuthzPlugins必须在 NewDaemon之后做因为这时插件才被还原到pluginStore。阶段 10metrics 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 状态。阶段 11BuildKit 初始化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 Handlervar 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路径前缀对应功能container/containers容器生命周期image/images镜像管理system/system,/info,/version系统信息volume/volumes卷管理build/build镜像构建swarm/swarmSwarm 集群管理network/networks网络管理plugin/plugins插件管理distribution/distributionregistry 交互checkpoint/containers/{id}/checkpoints容器检查点debug/debugdebug 端点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 READY1systemd apiWG.Wait() // ★★★ 主阻塞点 ★★★apiWG.Wait()是 daemon 的主阻塞点——dockerd 进程正常运行期间就停在这里。直到所有httpServer.Serve调用返回即httpServer被关闭才继续往下走。notifyReady()的意义告诉 systemd 我准备好了systemd 才会认为服务启动成功。第 5 章 信号处理与优雅关闭优雅关闭是个独立的话题值得单独讲一章。整个机制的核心是cli.stop()cli.apiShutdownchannel。关闭触发路径用户按 CtrlC 或 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 clustercluster 会触发容器调度要在 API 关闭后做再关 daemon (shutdownDaemon)停止容器、清理网络、卸载卷再关 BuildKitBuildKit 依赖 daemon 的镜像服务必须在 daemon 之后最后 cancel ctx otelShutdown取消所有后台 goroutineflush 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/macOSWindows入口资源嵌入仅main.gomain.gomain_windows.go嵌入图标等资源runDaemondocker_unix.godocker_windows.go多一层 SCM 服务处理initLoggingdocker_unix.go输出到 stderrdocker_windows.go输出到 stdout ETW hook平台特定选项daemon_unix.gosetDefaultUmask、cgroup 等daemon_windows.go6.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 监听器差异协议LinuxWindows默认监听unix:///var/run/docker.socknpipe:////./pipe/docker_engineTCP都支持都支持Unix socket✅❌Named pipe❌✅