moby-dockerd-启动流程详解

Go 复制代码
┌────────────────────────────────────────────────────────────────────────┐
│ 用户在 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.go | cobra 命令树、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.go

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] 去查注册表:

  • 命中已注册的子命令 → 执行它,返回 truemain 直接 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 后面用作日志和错误输出。

56 装配 + 执行
Go 复制代码
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.go |
| Windows 资源嵌入 | cmd/dockerd/main_windows.go |
| reexec.Init() 实现 | vendor/github.com/moby/sys/reexec/ |


第 3 章 CLI 装配层:docker.go

位置daemon/command/docker.go

这一层的产物是一个 cobra 命令对象。它做完三件事就把控制权交还给 main:

Go 复制代码
NewDaemonRunner() ──▶ 设置日志格式
                ──▶ initLogging(把 logger 接到 stderr/stdout)
                ──▶ newDaemonCommand()  ← cobra 命令树 + flag 注册

返回的 Runner 是个包装了 *cobra.CommanddaemonRunner 结构。

3.1 newDaemonCommand 做了什么

Go 复制代码
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 的"配置三层合并":

Go 复制代码
默认值 (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.3 daemonRunner.Run ------ 执行入口

Go 复制代码
func (d daemonRunner) Run(ctx context.Context) error {
    configureGRPCLog(ctx)              // 抑制 grpc 的噪声日志
    return d.ExecuteContext(ctx)       // cobra 接管
}

ExecuteContext 是 cobra 的方法,它会:

  1. 解析 os.Args
  1. 触发对应命令的 RunE
  1. ctx 传下去

代码索引

|-------------------------|-----------------------------|
| 函数 | 文件 |
| NewDaemonRunner | daemon/command/docker.go |
| newDaemonCommand | daemon/command/docker.go |
| newDaemonCLI | daemon/command/daemon.go |
| loadDaemonCliConfig | daemon/command/daemon.go |
| daemonRunner.Run | daemon/command/docker.go |
| configureGRPCLog | daemon/command/grpclog.go |
| installFlags(flag 注册) | 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.Listener |
| 4 | containerd 初始化 | 复用系统 containerd 或自起一个 |
| 5 | HTTP Server 框架 + 信号 Trap | *http.Servercli.stop 协调机制 |
| 6 | 可观测性(OTel) | tracer provider、systemd notify |
| 7 | 插件与设备(CDI/GPU) | CDI driver、GPU hooks |
| 8 | API 中间件 | experimental / version / authz |
| 9 | 核心:daemon.NewDaemon | 容器/镜像/网络/卷状态机全部还原 |
| 10 | metrics + Swarm 集群 | /metrics 端点、Swarm Raft |
| 11 | BuildKit 初始化 | builder backend |
| 12 | 组装 HTTP 路由 + Handler | REST 路由 + gRPC + httpServer.Handler |
| 13 | 实际对外服务 + 等待关闭 | 阻塞在 apiWG.Wait() |

阶段 1:启动前置检查 + 环境/日志配置

Go 复制代码
daemon.CheckSystem()        // 内核 / cgroup / OS 版本检查
configureProxyEnv(...)      // 把 daemon.json 里的代理设置写回环境变量
configureDaemonLogs(...)    // 设置日志格式 (text/json) 和级别

这一阶段还做几个"轻量但致命"的检查:

  • --debug 模式开启内置 debug 服务器(pprof)
  • RootlessKit 自检(如果检测到 RootlessKit 但配置没开 rootless,直接报错)
  • Linux 上:非 root 又不在 rootless 模式 → 友好错误
  • 重置 umask,避免从父进程继承到奇怪的掩码

为什么把日志配置放在这么早? 后面所有步骤都依赖日志能正常输出。

阶段 2:文件系统准备

Go 复制代码
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 监听器

Go 复制代码
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 初始化

Go 复制代码
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

这一步创建了几个关键的协调原语:

Go 复制代码
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() 的实现:

Go 复制代码
func (cli *daemonCLI) stop() {
    cli.stopOnce.Do(func() { close(cli.apiShutdown) })
}

幂等(stopOnce 保护)------ 即使被多次调用也只 close 一次。这个机制贯穿整个关闭流程,第 5 章会详细讲

注意:这一步只创建 http.Server 骨架,Handler 在阶段 12 才填。中间这一段时间(阶段 6-11)服务器还不会响应请求。

阶段 6:可观测性(OpenTelemetry)

Go 复制代码
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)

Go 复制代码
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 中间件

Go 复制代码
authz, err := initMiddlewares(ctx, &apiServer, cli.Config, pluginStore)
cli.authzMiddleware = authz

注册三个中间件:

  • Experimental:实验特性网关
  • Version :在 /version 返回的版本信息注入
  • Authorization:鉴权插件链(可热重载)

authz 句柄保存到 cli 是为了 SIGHUP 热重载配置时能更新插件列表。

阶段 9:核心 ------ daemon.NewDaemon

Go 复制代码
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 集群

Go 复制代码
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 初始化

Go 复制代码
b, shutdownBuildKit, err := initBuildkit(ctx, d, cdiCache)

initBuildkit 做四件事:

  1. session.NewManager() ------ 镜像构建会话管理(docker build 上下文传输用)
  1. dockerfile.NewBuildManager ------ 经典 Dockerfile 解析器
  1. buildkit.New(...) ------ 集成 BuildKit(更强的构建引擎,可并行、缓存友好)
  1. buildbackend.NewBackend ------ 把上面两个统一封装

返回的 shutdownBuildKit 在函数尾部 defer 调用,确保 daemon 关闭时 BuildKit 也优雅退出。

阶段 12:组装 HTTP 路由 + gRPC + Handler

Go 复制代码
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 → reloadConfig

buildRouters 注册了完整的 REST API 表:

|----------------|--------------------------------|-----------------|
| Router | 路径前缀 | 对应功能 |
| container | /containers | 容器生命周期 |
| image | /images | 镜像管理 |
| system | /system, /info, /version | 系统信息 |
| volume | /volumes | 卷管理 |
| build | /build | 镜像构建 |
| swarm | /swarm | Swarm 集群管理 |
| network | /networks | 网络管理 |
| plugin | /plugins | 插件管理 |
| distribution | /distribution | registry 交互 |
| checkpoint | /containers/{id}/checkpoints | 容器检查点 |
| debug | /debug | debug 端点(pprof) |

每个 router 对应一个 daemon/server/router/<name> 包。

setupConfigReloadTrap 让用户能通过 SIGHUP 信号热重载 daemon.json 的部分配置项(不会重启 daemon)。

阶段 13:实际对外服务 + 等待关闭

Go 复制代码
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.apiShutdown channel。

关闭触发路径

Go 复制代码
用户按 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 (shutdownDaemon) | 停止容器、清理网络、卸载卷 |
| 再关 BuildKit | BuildKit 依赖 daemon 的镜像服务,必须在 daemon 之后 |
| 最后 cancel ctx + otelShutdown | 取消所有后台 goroutine,flush trace 数据 |

shutdownDaemon 自身带超时:

Go 复制代码
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 顺序执行:

Go 复制代码
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 |
| 入口资源嵌入 | 仅 main.go | main.go + main_windows.go(嵌入图标等资源) |
| runDaemon | docker_unix.go | docker_windows.go(多一层 SCM 服务处理) |
| initLogging | docker_unix.go(输出到 stderr) | docker_windows.go(输出到 stdout + ETW hook) |
| 平台特定选项 | daemon_unix.gosetDefaultUmask、cgroup 等) | daemon_windows.go |

6.2 Windows 的服务模式

docker_windows.gorunDaemon 多了 initService 步骤:

Go 复制代码
stop, runAsService, err := initService(ctx, cli)
if stop { return nil }   // 注册/注销服务后立即退出
if runAsService { cli.Config.Pidfile = "" }   // SCM 托管时不写 PID
err = cli.start(ctx)

支持三种用法:

  1. dockerd --register-service → 注册 Windows 服务后立刻退出
  1. dockerd --unregister-service → 注销服务后立刻退出
  1. 由 SCM 启动的服务模式 → 正常跑 daemon,但日志走事件日志

6.3 监听器差异

|-------------|-------------------------------|----------------------------------|
| 协议 | Linux | Windows |
| 默认监听 | unix:///var/run/docker.sock | npipe:////./pipe/docker_engine |
| TCP | 都支持 | 都支持 |
| Unix socket | ✅ | ❌ |
| Named pipe | ❌ | ✅ |