moby-client客户端

模块定位

client 包是 Docker Engine API 的 官方 Go SDK。其定位有三层:

维度 说明
角色 Docker 守护进程(dockerd)REST API 的客户端封装
使用者 1) Docker CLI(docker 命令);2) 第三方 Go 应用(Compose、Kind、Buildx、Testcontainers 等)
核心抽象 把"HTTP + JSON"封装成"方法 + 结构体",让调用者像调用本地函数一样调用远程 API

README.md 第一句话写得很直白:

The docker command uses this package to communicate with the daemon. It can also be used by your own Go applications to do anything the command-line interface does; running containers, pulling or pushing images, etc.

也就是说,docker CLI 命令行的所有功能,最终都通过这个包下沉到 HTTP

与其它模块的边界

go 复制代码
┌─────────────────────────┐
│  docker CLI (cli/)      │  ← 命令行入口、参数解析、输出格式化
└──────────┬──────────────┘
           │  调用 client.APIClient 方法
           ▼
┌─────────────────────────┐
│  client/  (本模块)       │  ← HTTP 客户端 SDK
│  - client.go (总入口)   │
│  - container_*.go       │
│  - image_*.go           │
│  - network_*.go         │
│  - ...                  │
└──────────┬──────────────┘
           │  HTTP over unix socket / npipe / tcp
           ▼
┌─────────────────────────┐
│  dockerd (daemon/)      │  ← Docker 守护进程
│  - /var/run/docker.sock │
│  - //./pipe/docker_engine│
└─────────────────────────┘

注意 client/不依赖 daemon/,反过来 daemon/ 也不依赖 client/。两者通过 HTTP/REST API 解耦------这是 Docker 架构上最关键的边界之一,也是 SDK 能够独立版本化、单独发布到 pkg.go.dev 的基础。


整体架构

arduino 复制代码
                  ┌────────────────────────────────────────────┐
                  │           client.New(opts...)              │
                  └────────────────────┬───────────────────────┘
                                       │ 1. 解析 DefaultDockerHost
                                       │ 2. defaultHTTPClient() 配置 transport
                                       │ 3. 按 opts 顺序修改 clientConfig
                                       │ 4. 决定 manual/env API 版本
                                       │ 5. 用 otelhttp 包装 transport
                                       │ 6. 若有 ResponseHook 再包一层
                                       ▼
                  ┌────────────────────────────────────────────┐
                  │              *Client                       │
                  │  ┌──────────────────────────────────────┐  │
                  │  │ clientConfig (内嵌)                  │  │
                  │  │  - host / proto / addr / basePath   │  │
                  │  │  - scheme (http/https)              │  │
                  │  │  - version (API 版本)               │  │
                  │  │  - *http.Client                     │  │
                  │  │  - customHTTPHeaders                │  │
                  │  │  - responseHooks / traceOpts        │  │
                  │  └──────────────────────────────────────┘  │
                  │  - negotiated atomic.Bool                  │
                  │  - negotiateLock sync.Mutex                │
                  │  - baseTransport *http.Transport           │
                  └────────────────────┬───────────────────────┘
                                       │
        ┌──────────────────────────────┼───────────────────────────────┐
        │                              │                               │
        ▼                              ▼                               ▼
┌────────────────┐         ┌────────────────────┐         ┌──────────────────┐
│ 资源方法层     │         │ HTTP 工具层         │         │ 流式/Hijack 层   │
│ ContainerList  │──►sendRequest  buildRequest │         │ postHijacked     │
│ ImagePull      │   get/post/put/delete       │         │ DialHijack       │
│ NetworkCreate  │   doRequest                 │         │ setupHijackConn  │
│ ...            │   checkResponseErr          │         │ dialer()         │
└────────────────┘ └───────────────────────────┘         └──────────────────┘
                            │
                            ▼
                  ┌──────────────────────────┐
                  │ http.Client.Do (stdlib)  │
                  │ transport =              │
                  │   responseHookTransport  │
                  │     └ otelhttp          │
                  │         └ *http.Transport│
                  └──────────────────────────┘
                            │
                            ▼
                  unix:///var/run/docker.sock
                  npipe:////./pipe/docker_engine
                  tcp://host:2376 (TLS)
                  ssh://user@host (CLI 侧扩展)

三条主线:

  1. 配置线 ------New()Opt 应用到 clientConfig,确定 host/scheme/version/transport。
  1. 请求线 ------所有资源方法(ContainerList 等)统一走 sendRequest → buildRequest → doRequest → checkResponseErr 四段流水线。
  1. 流式线 ------attachexeclogs -f 这类长连接通过 postHijacked / DialHijack 升级 HTTP 为裸 TCP 流。

核心常量与数据模型

DummyHost = "api.moby.localhost"

ini 复制代码
const DummyHost = "api.moby.localhost"

这个常量是整个客户端一个 看起来很怪、实则极其重要 的设计。注释里贴了 4 个 RFC 和 4 个 GitHub issue 才解释清楚:

问题背景:

  • Docker daemon 默认通过 unix:///var/run/docker.sock(Linux)或 npipe:////./pipe/docker_engine(Windows)访问。
  • 这些 scheme 没有 host 概念 ,但 Go 标准库的 http.Client 强制要求:
    • req.URL.Scheme 必须是 httphttps(参见 Go 源码 net/http/transport.go:558-569);
    • 同时要求 req.URL.Host 非空。
  • 否则会出现"missing or undefined authority"之类的错误。

为什么不用空字符串?

RFC 7230 §5.4 实际上规定:authority 缺失时,客户端应当发送空 Host 头。但 Go stdlib 不允许这么做。

解决方案 :

用一个 永不应当被 DNS 解析 的占位 hostname。api.moby.localhost 利用了 RFC 2606 §2 和 RFC 6761 §6.3 中保留的 .localhost TLD------任何正常的解析器都不会去查这个域名。

request.gobuildRequest 中可以见到:

ini 复制代码
if cli.proto == "unix" || cli.proto == "npipe" {
    req.Host = DummyHost   // 覆盖 Host 头,满足 stdlib 校验
}

这是一种"务实"的兼容性补丁------一个长期 issue(golang/go#61076、moby/moby#45935)的妥协结果。

MaxAPIVersion = "1.54" / MinAPIVersion = "1.40"

arduino 复制代码
const MaxAPIVersion = "1.54"   // 客户端能讲的最大 API 版本
const MinAPIVersion = "1.40"   // 协商时不会再降的下限
  • 这两个常量定义了客户端的 API 兼容窗口
  • MaxAPIVersion 是 SDK 当前编译进去的 api 模块版本。注释特别提醒:它可能低于 api 库的版本(api 模块本身可能定义了更多新类型,但客户端方法暂未使用)。
  • MinAPIVersion 是协商的下限。低于此版本的 daemon 会被拒绝(negotiateAPIVersion 中返回 ErrInvalidArgument)。

Client 结构体

arduino 复制代码
type Client struct {
    clientConfig                  // 内嵌配置(见 4.4)

    negotiated  atomic.Bool       // 是否已完成版本协商
    negotiateLock sync.Mutex      // 单飞(SingleFlight)协商过程
    baseTransport *http.Transport // 原始 transport(被 otelhttp/hook 包裹前的)
}

var _ APIClient = &Client{}       // 编译期接口断言

三个非内嵌字段的设计意图:

字段 设计意图
negotiated atomic.Bool 标记协商是否完成。atomic.Bool 而非 bool + Mutex,因为读多写少且不需要与其他字段原子
negotiateLock sync.Mutex 协商时持锁,避免并发首请求触发多次 Ping
baseTransport *http.Transport client.Transport 会被 otelhttp、ResponseHook 层层包裹,但 Close() 时需要原始 transport 来调用 CloseIdleConnections()。所以单独保存一份

clientConfig ------ 一组"可被 Opt 修改的字段"

go 复制代码
type clientConfig struct {
    scheme           string              // http / https
    host             string              // 形如 "unix:///var/run/docker.sock"
    proto            string              // unix / npipe / tcp
    addr             string              // socket 路径或 host:port
    basePath         string              // tcp://a/b 中的 /b 部分
    client           *http.Client        // 实际发请求的 HTTP 客户端
    version          string              // 当前 API 版本(如 "1.54")
    userAgent        *string             // nil=默认;""=删除;非空=自定义
    customHTTPHeaders map[string]string  // 自定义请求头
    manualAPIVersion string              // WithAPIVersion 设置
    envAPIVersion    string              // DOCKER_API_VERSION 设置(优先级更高)
    responseHooks    []ResponseHook      // 响应钩子
    traceOpts        []otelhttp.Option   // OpenTelemetry 选项
}

把配置抽出来作为独立结构体(而非直接挂在 Client 上)有两个好处:

  1. Opt 函数签名统一 :type Opt func(*clientConfig) error,所有选项操作同一个结构体,语义清晰。
  1. 构造期间的"可失败"语义集中 :Opt 可以返回 error,New() 中遇到 error 立即返回,而 Client 实例化完成后几乎所有方法都不再失败。

ResponseHook

go 复制代码
type ResponseHook func(*http.Response)

调用约束写在注释里:Hooks 不得读取或关闭 resp.Body。这是为监控、追踪、调试这类"只看不改"的需求设计的------例如统计请求耗时、记录状态码分布。


入口函数 New() ------ 装配流水线

go 复制代码
func New(ops ...Opt) (*Client, error)

New() 是整个 SDK 的唯一推荐入口(NewClientWithOpts 已废弃,仅作兼容别名,带 //go:fix inline 提示 IDE 自动替换)。其装配过程分为 7 个阶段:

阶段 1:解析默认 host

css 复制代码
hostURL, err := ParseHostURL(DefaultDockerHost)

DefaultDockerHost 是平台相关的常量:

  • Linux/macOS: unix:///var/run/docker.sock(client_unix.go)
  • Windows: npipe:////./pipe/docker_engine(client_windows.go)

阶段 2:构造默认 HTTP 客户端

go 复制代码
func defaultHTTPClient(hostURL *url.URL) (*http.Client, error) {
    transport := &http.Transport{}
    transport.MaxIdleConns = 6
    transport.IdleConnTimeout = 30 * time.Second
    err := sockets.ConfigureTransport(transport, hostURL.Scheme, hostURL.Host)
    if err != nil {
        return nil, err
    }
    return &http.Client{
        Transport:     transport,
        CheckRedirect: CheckRedirect,
    }, nil
}

两个看似平凡但非常重要的设置:

  • MaxIdleConns = 6------防止长时间运行的进程泄漏空闲连接(参考 moby/moby#45539)。这个数字对应 daemon 端通常允许的并发请求数。
  • IdleConnTimeout = 30 * time.Second------半分钟不活动就释放连接,避免 daemon 重启后客户端还握着僵尸连接。
  • CheckRedirect = CheckRedirect------客户端的 重定向策略(见下文)。

阶段 3:初始化 Client 与默认 clientConfig

go 复制代码
c := &Client{
    clientConfig: clientConfig{
        host:    DefaultDockerHost,
        version: MaxAPIVersion,
        client:  client,
        proto:   hostURL.Scheme,
        addr:    hostURL.Host,
        traceOpts: []otelhttp.Option{
            otelhttp.WithSpanNameFormatter(func(_ string, req *http.Request) string {
                return req.Method + " " + req.URL.Path
            }),
        },
    },
}

注意默认的 trace span 命名规则是 "METHOD PATH"(如 "GET /v1.54/containers/json")------这对 Jaeger / Tempo 这类追踪系统的可视化非常友好。

阶段 4:按顺序应用所有 Opt

go 复制代码
for _, op := range ops {
    if op == nil {
        continue
    }
    if err := op(cfg); err != nil {
        return nil, err
    }
}

nil opt 被跳过(允许调用方用 cond && WithXxx(...) 这种惯用法)。任何 opt 返回错误立即终止构造。

阶段 5:决定 API 版本(优先级:env > manual)

arduino 复制代码
if cfg.envAPIVersion != "" {
    c.setAPIVersion(cfg.envAPIVersion)
} else if cfg.manualAPIVersion != "" {
    c.setAPIVersion(cfg.manualAPIVersion)
}

setAPIVersion 同时把 negotiated 置为 true------手动设置版本等价于"显式禁用协商"

阶段 6:探测 scheme 与保留 baseTransport

ini 复制代码
if tr, ok := c.client.Transport.(*http.Transport); ok {
    c.baseTransport = tr
}

if c.scheme == "" {
    if c.tlsConfig() != nil {
        c.scheme = "https"
    } else {
        c.scheme = "http"
    }
}

注释里 stevvooe 留下了一段"反思":这种"host 当 URL 用"的设计混淆了协议层和传输层,理想做法应当是只接受 *http.Client。但为了向后兼容,只能继续维护现状。

阶段 7:层层包裹 transport

ini 复制代码
c.client.Transport = otelhttp.NewTransport(c.client.Transport, c.traceOpts...)

if len(cfg.responseHooks) > 0 {
    c.client.Transport = &responseHookTransport{
        base:  c.client.Transport,
        hooks: slices.Clone(cfg.responseHooks),
    }
}

最终 transport 链(从外到内):

scss 复制代码
responseHookTransport  (可选,若有 ResponseHook)
  └ otelhttp.Transport (始终存在,负责追踪)
      └ *http.Transport (来自 defaultHTTPClient 或 WithHTTPClient 注入)

responseHookTransport.RoundTrip 在响应返回后调用所有 hook------非常简洁的实现,只有 23 行代码:

go 复制代码
func (t *responseHookTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    resp, err := t.base.RoundTrip(req)
    if err != nil {
        return resp, err
    }
    for _, h := range t.hooks {
        h(resp)
    }
    return resp, nil
}

配置体系:clientConfigOpt

函数式选项模式

go 复制代码
type Opt func(*clientConfig) error

Go 社区经典的 Functional Options 模式(Dave Cheney 2014 年推广)。优点:

  • 可选参数、默认值、可失败初始化一气呵成;
  • API 演进时新增选项不破坏既有调用;
  • 编译期类型检查。

选项清单(按类别)

连接 / 主机

Opt 作用
FromEnv 组合下述三个 *FromEnv,即 WithTLSClientConfigFromEnv + WithHostFromEnv + WithAPIVersionFromEnv
WithHost(host) 设置 daemon 地址,会重新配置 transport
WithHostFromEnv() DOCKER_HOST 读取
WithScheme(scheme) 直接覆盖 scheme(罕见)
WithHTTPClient(c) 注入自定义 *http.Client(会克隆 transport)
WithDialContext(fn) 自定义拨号函数

TLS

Opt 作用
WithTLSClientConfig(caFile, certFile, keyFile) 显式提供 CA、客户端证书、私钥;强制 TLS 1.2+
WithTLSClientConfigFromEnv() DOCKER_CERT_PATH 读 ca.pem / cert.pem / key.pem,DOCKER_TLS_VERIFY 决定是否校验

版本

Opt 作用
WithAPIVersion("1.50") 锁定 API 版本,禁用协商
WithAPIVersionFromEnv() DOCKER_API_VERSION 读取,优先级最高
WithAPIVersionNegotiation() 已废弃,空实现------因为协商现在默认开启

HTTP 行为

Opt 作用
WithTimeout(d) 设置请求超时
WithUserAgent(ua) 自定义 UA;空串=删除头;nil=用默认
WithHTTPHeaders(map) 自定义头;重复 canonical key 会报错
WithResponseHook(h) 追加响应钩子(可多次调用)

Trace

Opt 作用
WithTraceProvider(p) 设置 OTel TracerProvider
WithTraceOptions(...) 追加 OTel span 选项

已废弃但保留的别名

scss 复制代码
// Deprecated: use [New]
//go:fix inline
func NewClientWithOpts(ops ...Opt) (*Client, error) { return New(ops...) }

// Deprecated: use [WithAPIVersion]
//go:fix inline
func WithVersion(version string) Opt { return WithAPIVersion(version) }

// Deprecated: use [WithAPIVersionFromEnv]
//go:fix inline
func WithVersionFromEnv() Opt { return WithAPIVersionFromEnv() }

//go:fix inlinegolang.org/x/tools/gopatches 工具识别的指令------Docker 团队后续可以通过自动 patch 工具迁移所有调用方。这是一种 温和的 API 演进策略:先标 deprecated 保留两到三个版本,再通过 patch 工具批量迁移。

WithHTTPHeaders 的细节

go 复制代码
func WithHTTPHeaders(headers map[string]string) Opt {
    return func(c *clientConfig) error {
        c.customHTTPHeaders = make(map[string]string)
        for k, v := range headers {
            k = http.CanonicalHeaderKey(k)
            _, ok := c.customHTTPHeaders[k]
            if ok {
                return cerrdefs.ErrInvalidArgument.WithMessage(...)
            }
            c.customHTTPHeaders[k] = v
        }
        return nil
    }
}

两个细节值得注意:

  • 覆盖式 :每次调用都会清空 customHTTPHeaders,而不是追加。
  • canonical 化 :所有 key 用 http.CanonicalHeaderKey 标准化,避免 "User-Agent""user-agent" 这种坑。
  • 重复检测 :同 canonical key 出现两次直接返回 ErrInvalidArgument

API 版本协商机制

这是 client 包最巧妙也最难理解的部分。Docker Engine API 有 几十个版本(从 1.0 到 1.54+),每个 daemon 编译进去的最大版本可能不同。客户端怎么和不同版本的 daemon 对话?

三种模式

模式 触发方式 行为
自动协商(默认) 啥都不设置 第一次请求时 Ping daemon,把客户端版本降到 min(client.Max, server.Max)
手动锁定 WithAPIVersion("1.45") 永远用 1.45,不 Ping
环境锁定 DOCKER_API_VERSION=1.45 同上,优先级比手动高

协商状态机

go 复制代码
// 每个请求路径都会触发:
func (cli *Client) getAPIPath(ctx context.Context, p string, query url.Values) string {
    _ = cli.checkVersion(ctx)   // 懒协商
    if cli.version != "" {
        apiPath = path.Join(cli.basePath, "/v"+strings.TrimPrefix(cli.version, "v"), p)
    } else {
        apiPath = path.Join(cli.basePath, p)
    }
    return (&url.URL{Path: apiPath, RawQuery: query.Encode()}).String()
}

func (cli *Client) checkVersion(ctx context.Context) error {
    if cli.negotiated.Load() {
        return nil    // 已协商过,直接返回
    }
    _, err := cli.Ping(ctx, PingOptions{NegotiateAPIVersion: true})
    return err
}

设计要点:

  • 懒触发 :不在 New() 里就 Ping(避免构造失败),而是在第一次真正发请求时触发。
  • atomic.Bool 短路:已协商过的客户端每次请求只多一次原子读,几乎零开销。
  • 强制路径版本化 :即使 daemon 不带版本协商(老版本),客户端也会用 MaxAPIVersion,即"乐观地认为 daemon 是最新的"。

协商的"单飞"(SingleFlight)

Ping 内部对协商做了 double-checked locking:

go 复制代码
func (cli *Client) Ping(ctx context.Context, options PingOptions) (PingResult, error) {
    if !options.NegotiateAPIVersion {
        return cli.ping(ctx)
    }
    if cli.negotiated.Load() && !options.ForceNegotiate {
        return cli.ping(ctx)  // 第一道:无锁快路径
    }

    cli.negotiateLock.Lock()
    defer cli.negotiateLock.Unlock()

    ping, err := cli.ping(ctx)
    if err != nil {
        return ping, err
    }

    if cli.negotiated.Load() && !options.ForceNegotiate {
        return ping, nil  // 第二道:持锁复查(防竞态)
    }

    if ping.APIVersion == "" {
        cli.setAPIVersion(MaxAPIVersion)
        return ping, nil
    }
    return ping, cli.negotiateAPIVersion(ping.APIVersion)
}

为什么需要 double-checked locking?

考虑场景:客户端刚构造完,10 个 goroutine 同时发请求。每个都会进到 checkVersion → Ping。如果没有锁,会触发 10 次 Ping,虽然有 atomic.Bool 兜底,但协商逻辑会重复跑(每次都会写 version 字段)。

加锁后:

  • 第 1 个 goroutine 持锁,执行 Ping + 协商,设置 negotiated=true
  • 第 2~10 个 goroutine 阻塞在锁上。
  • 第 1 个释放锁后,其余 goroutine 拿到锁,第一件事就是重新读 negotiated,发现已是 true,立即返回 ------ 这就是第二道检查的意义。

协商的"妥协"逻辑

go 复制代码
func (cli *Client) negotiateAPIVersion(pingVersion string) error {
    pingVersion, err = parseAPIVersion(pingVersion)
    if err != nil {
        return err
    }

    if versions.LessThan(pingVersion, MinAPIVersion) {
        return cerrdefs.ErrInvalidArgument.WithMessage(...)  // 太老,拒绝
    }

    negotiatedVersion := cli.version
    if negotiatedVersion == "" {
        negotiatedVersion = MaxAPIVersion
    }

    if versions.LessThan(pingVersion, negotiatedVersion) {
        negotiatedVersion = pingVersion  // daemon 老,降到 daemon 的版本
    }
    // 注意:如果 daemon 比 client 还新,client 不会"升级"------保持自己的 MaxAPIVersion

    cli.setAPIVersion(negotiatedVersion)
    return nil
}

单向降级:协商只会降不会升。这是因为客户端代码写死了对 API 响应结构的理解,无法使用新版本的特性。

Ping 的 HEAD→GET 回退

go 复制代码
func (cli *Client) ping(ctx context.Context) (PingResult, error) {
    req, err := cli.buildRequest(ctx, http.MethodHead, path.Join(cli.basePath, "/_ping"), nil, nil)
    ...
    resp, err := cli.doRequest(req)
    if err == nil && resp.StatusCode == http.StatusOK {
        return newPingResult(resp), nil  // HEAD 成功,直接返回
    }
    ensureReaderClosed(resp)

    // HEAD 失败或非 200,回退到 GET
    req2, _ := cli.buildRequest(ctx, http.MethodGet, path.Join(cli.basePath, "/_ping"), nil, nil)
    ...
}

为什么 HEAD 优先?

  • HEAD 没有 body,网络开销极小。
  • 但 HEAD 不会有错误详情,所以失败时回退到 GET 来获得 daemon 返回的错误信息。

另一个细节:Ping 不走版本化路径 (用 /_ping 而不是 /v1.54/_ping)。因为 Ping 本身就是用来发现版本的,在版本未确定前打 /v1.54/_ping 会陷入循环依赖。

Ping 携带的元数据

go 复制代码
type PingResult struct {
    APIVersion     string
    OSType         string
    Experimental   bool
    BuilderVersion build.BuilderVersion
    SwarmStatus    *SwarmStatus
}

这些全部从 HTTP response header 提取,而非 body:

字段
Api-Version APIVersion
Ostype OSType
Docker-Experimental Experimental
Builder-Version BuilderVersion
Swarm SwarmStatus(格式 <state>/<role>,如 active/manager)

这种"用 header 带元数据"的设计让 Ping 的语义变得很丰富:一次请求就能拿到 daemon 的健康、版本、构建器、Swarm 状态等关键信息。


接口契约:APIClient 的接口分离

顶层接口

go 复制代码
type APIClient interface {
    stableAPIClient
    CheckpointAPIClient  // 实验性,独立出去
}

var _ APIClient = &Client{}  // 编译期断言

stableAPIClient 的"按资源拆分"

scss 复制代码
type stableAPIClient interface {
    ConfigAPIClient
    ContainerAPIClient
    DistributionAPIClient
    RegistrySearchClient
    ExecAPIClient
    ImageBuildAPIClient
    ImageAPIClient
    NetworkAPIClient
    PluginAPIClient
    SystemAPIClient
    VolumeAPIClient
    SwarmManagementAPIClient   // 复合,见下
    ClientVersion() string
    DaemonHost() string
    ServerVersion(ctx, options) (ServerVersionResult, error)
    HijackDialer               // 流式连接
    Dialer() func(context.Context) (net.Conn, error)
    Close() error
}

type SwarmManagementAPIClient interface {
    SwarmAPIClient
    NodeAPIClient
    ServiceAPIClient
    TaskAPIClient
    SecretAPIClient
    ConfigAPIClient
}

这是经典的 接口分离原则(Interface Segregation Principle, ISP) :使用者只依赖自己用得到的接口,不会被无关方法的变更影响。

例如 Compose 主要用 ServiceAPIClient + ConfigAPIClient,buildx 主要用 ImageBuildAPIClient + ImageAPIClient,各自不互相耦合。

命名约定:XxxOptions + XxxResult

scss 复制代码
type ContainerAPIClient interface {
    ContainerCreate(ctx, options ContainerCreateOptions) (ContainerCreateResult, error)
    ContainerInspect(ctx, container string, options ContainerInspectOptions) (ContainerInspectResult, error)
    ContainerList(ctx, options ContainerListOptions) (ContainerListResult, error)
    ContainerUpdate(ctx, container string, updateConfig ContainerUpdateOptions) (ContainerUpdateResult, error)
    ContainerRemove(ctx, container string, options ContainerRemoveOptions) (ContainerRemoveResult, error)
    ...
}

观察 ContainerAPIClient:

每个方法的输入输出都是 包装类型(Options/Result),而非裸结构体。

为什么不直接返回 []container.Summary ?

因为这种"包装"为未来扩展留下了空间。今天 ContainerListResult 只有 Items 字段:

go 复制代码
type ContainerListResult struct {
    Items []container.Summary
}

但如果明天 daemon 加了一个 X-Total-Count header,客户端只需要扩展 ContainerListResult:

go 复制代码
type ContainerListResult struct {
    Items      []container.Summary
    TotalCount int  // 未来加的字段
}

而签名 func (cli *Client) ContainerList(...) (ContainerListResult, error)不变 ------这是 Go API 设计中"为未来留余地"的标准手法。代价是调用方今天要多写一行 result.Items

实验性隔离

go 复制代码
type CheckpointAPIClient interface { ... }

APIClient 把实验性的 Checkpoint API 单独抽出来。使用者可以这样写,避免无意中依赖实验特性:

java 复制代码
var cli stableAPIClient = client.New(...)  // 故意只接受 stable 接口

流式方法的"无 error 返回"

scss 复制代码
ContainerWait(ctx, container string, options ContainerWaitOptions) ContainerWaitResult
Events(ctx, options EventsListOptions) EventsResult

这两个方法的签名 没有 error 返回值 ------它们返回的是一个 channel-like 结构,错误通过结构内的 channel 异步推送。这是为长轮询/事件流设计的:

go 复制代码
type ContainerWaitResult struct {
    StatusC <-chan container.WaitResponse
    ErrC    <-chan error
}

HTTP 请求流水线

所有资源方法最终都走 sendRequest,这是请求的核心管线。

入口与分发

scss 复制代码
// request.go
func (cli *Client) head(ctx, path, query, headers) (*http.Response, error)
func (cli *Client) get(ctx, path, query, headers) (*http.Response, error)
func (cli *Client) post(ctx, path, query, body, headers) (*http.Response, error)
func (cli *Client) postRaw(ctx, path, query, body io.Reader, headers) (*http.Response, error)
func (cli *Client) put(ctx, path, query, body, headers) (*http.Response, error)
func (cli *Client) putRaw(ctx, path, query, body io.Reader, headers) (*http.Response, error)
func (cli *Client) delete(ctx, path, query, headers) (*http.Response, error)

post/put 会调用 prepareJSONRequest 把 body 序列化为 JSON 并设置 Content-Type: application/json;postRaw/putRaw 跳过这一步(用于二进制流,如镜像导入)。

四段管线

go 复制代码
func (cli *Client) sendRequest(ctx, method, path, query, body, headers) (*http.Response, error) {
    req, err := cli.buildRequest(ctx, method, cli.getAPIPath(ctx, path, query), body, headers)
    if err != nil {
        return nil, err
    }
    resp, err := cli.doRequest(req)
    if err != nil {
        return resp, err  // 连接错误
    }
    return resp, checkResponseErr(resp)  // HTTP 状态码错误
}

阶段 1:buildRequest ------ 构造 *http.Request

go 复制代码
func (cli *Client) buildRequest(ctx, method, path string, body io.Reader, headers http.Header) (*http.Request, error) {
    req, err := http.NewRequestWithContext(ctx, method, path, body)
    if err != nil {
        return nil, err
    }
    req = cli.addHeaders(req, headers)
    req.URL.Scheme = cli.scheme
    req.URL.Host = cli.addr

    if cli.proto == "unix" || cli.proto == "npipe" {
        req.Host = DummyHost   // 关键:用占位 host 满足 stdlib
    }
    return req, nil
}

阶段 2:addHeaders ------ 注入自定义头

go 复制代码
func (cli *Client) addHeaders(req, headers) *http.Request {
    // 1. 先打 cli.customHTTPHeaders(用户通过 WithHTTPHeaders 设置的)
    for k, v := range cli.customHTTPHeaders {
        req.Header.Set(k, v)
    }
    // 2. 再打每次请求的 headers(会覆盖上面的)
    for k, v := range headers {
        req.Header[http.CanonicalHeaderKey(k)] = v
    }
    // 3. 处理 User-Agent(三态:nil=默认,空=删除,非空=自定义)
    if cli.userAgent == nil {
        if req.Header.Get("User-Agent") == "" {
            req.Header.Set("User-Agent", defaultUserAgent())
        }
    } else if *cli.userAgent == "" {
        req.Header.Del("User-Agent")
    } else {
        req.Header.Set("User-Agent", *cli.userAgent)
    }
    return req
}

注释明确说明:"customHTTPHeaders 先打"------这样每次请求的 headers 可以覆盖 cli 级别的 ,但用户 不能通过 headers 参数覆盖内置头。这是一个有意为之的"防御"设计。

阶段 3:doRequest ------ 真正发请求 + 错误装饰

go 复制代码
func (cli *Client) doRequest(req *http.Request) (*http.Response, error) {
    resp, err := cli.client.Do(req)  // #nosec G704 -- API client 故意发送用户提供的请求
    if err == nil {
        return resp, nil
    }
    // === 以下全是对各类错误的"贴心装饰" ===
    ...
}

doRequest 的错误处理是整个 SDK 最有人情味的代码,针对十几种失败场景给出对应的提示。详见第 10 节。

阶段 4:checkResponseErr ------ 把 HTTP 状态码翻译成 Go error

go 复制代码
func checkResponseErr(serverResp *http.Response) (retErr error) {
    if serverResp == nil {
        return nil
    }
    if serverResp.StatusCode >= 200 && serverResp.StatusCode < 400 {
        return nil  // 2xx/3xx 不算错
    }
    defer func() {
        retErr = httpErrorFromStatusCode(retErr, serverResp.StatusCode)
    }()

    // 读取最多 1 MiB body
    bodyMax := 1 * 1024 * 1024
    bodyR := &io.LimitedReader{R: serverResp.Body, N: int64(bodyMax)}
    body, err := io.ReadAll(bodyR)
    ...

    // 优先解析 application/json 中的 ErrorResponse.Message
    if serverResp.Header.Get("Content-Type") == "application/json" {
        var errorResponse common.ErrorResponse
        if err := json.Unmarshal(body, &errorResponse); err != nil { ... }
        if errorResponse.Message == "" { ... }
        daemonErr = errors.New(strings.TrimSpace(errorResponse.Message))
    } else {
        // 非 JSON(可能是 HTML 错误页或纯文本),原样返回
        daemonErr = errors.New(strings.TrimSpace(string(body)))
    }
    return fmt.Errorf("Error response from daemon: %w", daemonErr)
}

为什么限制 1 MiB?

防止恶意/异常 daemon 通过超大 error body 把客户端 OOM。这是 防御性编程 的标准做法。

为什么不使用 DisallowUnknownFields ?

注释里有解释:API schema 是开放的,未来可能加新字段,strict 解析会拒绝合法响应。

httpErrorFromStatusCode ------ errdef 类型映射

go 复制代码
func httpErrorFromStatusCode(err error, statusCode int) error {
    if err == nil {
        return nil
    }
    base := errhttp.ToNative(statusCode)  // containerd/errdefs 的标准映射
    if base != nil {
        return &httpError{err: err, errdef: base}
    }

    switch {
    case statusCode >= 200 && statusCode < 400:
        return err
    case statusCode >= 400 && statusCode < 500:
        return &httpError{err: err, errdef: cerrdefs.ErrInvalidArgument}
    case statusCode >= 500 && statusCode < 600:
        return &httpError{err: err, errdef: cerrdefs.ErrInternal}
    default:
        return &httpError{err: err, errdef: cerrdefs.ErrUnknown}
    }
}

这样调用方就可以用 Go 标准的 errors.Is(err, cerrdefs.ErrNotFound) 来判断错误类型------把 HTTP 错误码翻译成了 containerd errdefs 的"语义错误" 。这是 SDK 易用性的关键一环。

ensureReaderClosed ------ 连接复用的小技巧

ini 复制代码
func ensureReaderClosed(response *http.Response) {
    if response == nil || response.Body == nil { return }
    if response.ContentLength == 0 || (response.Request != nil && response.Request.Method == http.MethodHead) {
        _ = response.Body.Close()
        return
    }
    // 关键:读取最多 512 字节再 close,让 transport 复用连接
    _, _ = io.CopyN(io.Discard, response.Body, 512)
    _ = response.Body.Close()
}

Go 的 http.Transport 只有在 body 被读完 时才会把连接归还连接池。如果调用方忘了读 body,连接就会泄漏。这里读 512 字节是为了在"性能"和"防止 daemon 把超大错误塞给客户端"之间平衡------超过 512 字节的剩余 body 会被丢弃,连接也不会复用,但避免了 OOM。


错误处理与"贴心提示"

doRequest 中针对错误的装饰逻辑非常详尽,这一节专门拆解。整个错误处理体系的目标是:当用户跑 docker ps 看到 "Cannot connect to the Docker daemon" 时,能立即知道哪里出了问题

errConnectionFailed 包装类型

go 复制代码
type errConnectionFailed struct{ error }

func IsErrConnectionFailed(err error) bool {
    return errors.As(err, &errConnectionFailed{})
}

func connectionFailed(host string) error {
    var err error
    if host == "" {
        err = errors.New("Cannot connect to the Docker daemon. Is the docker daemon running on this host?")
    } else {
        err = fmt.Errorf("Cannot connect to the Docker daemon at %s. Is the docker daemon running?", host)
    }
    return errConnectionFailed{error: err}
}

所有"连不上"的错误都包装为 errConnectionFailed,调用方可以用 IsErrConnectionFailed 区分"连接问题" vs "API 业务错误"。

十多种具体场景

kotlin 复制代码
// 场景 1:明文连 TLS daemon
if cli.scheme != "https" && strings.Contains(err.Error(), "malformed HTTP response") {
    return nil, errConnectionFailed{fmt.Errorf(
        "%w.\n* Are you trying to connect to a TLS-enabled daemon without TLS?", err)}
}

// 场景 2:TLS 握手失败(daemon 启了 --tlsverify 但客户端没证书)
const (
    alertBadCertificate   = "bad certificate"   // TLS 1.2
    alertHandshakeFailure = "handshake failure" // TLS 1.3
)
if cli.scheme == "https" && (strings.Contains(err.Error(), alertHandshakeFailure) ||
    strings.Contains(err.Error(), alertBadCertificate)) {
    return nil, errConnectionFailed{fmt.Errorf(
        "the server probably has client authentication (--tlsverify) enabled; ...", err)}
}

// 场景 3:context.Canceled / DeadlineExceeded 透传,不装饰
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
    return nil, err  // 让调用方用 errors.Is 比对
}

// 场景 4:权限问题(socket 不可读)
if errors.Is(err, os.ErrPermission) {
    return nil, errConnectionFailed{fmt.Errorf(
        "permission denied while trying to connect to the docker API at %v", cli.host)}
}

// 场景 5:socket 文件不存在
if errors.Is(err, os.ErrNotExist) {
    err = errors.Unwrap(err)  // 去掉外层 "Get http://..." 包装
    return nil, errConnectionFailed{fmt.Errorf(
        "failed to connect to the docker API at %v; check if the path is correct and if the daemon is running: %w", cli.host, err)}
}

// 场景 6:DNS 解析失败
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
    return nil, errConnectionFailed{fmt.Errorf(
        "failed to connect to the docker API at %v: %w", cli.host, dnsErr)}
}

// 场景 7:网络超时
var nErr net.Error
if errors.As(err, &nErr) {
    if nErr.Timeout() {
        return nil, connectionFailed(cli.host)
    }
    if strings.Contains(nErr.Error(), "connection refused") || strings.Contains(nErr.Error(), "dial unix") {
        return nil, connectionFailed(cli.host)
    }
}

// 场景 8:Windows npipe 默认路径找不到
if strings.Contains(err.Error(), `open //./pipe/docker_engine`) {
    if f, elevatedErr := os.Open(`\\.\PHYSICALDRIVE0`); elevatedErr != nil {
        // 没权限打开 PHYSICALDRIVE0 → 不是管理员
        err = fmt.Errorf("in the default daemon configuration on Windows, the docker client must be run with elevated privileges to connect: %w", err)
    } else {
        _ = f.Close()
        err = fmt.Errorf("this error may indicate that the docker daemon is not running: %w", err)
    }
}

// 场景 9:其它兜底
return nil, errConnectionFailed{fmt.Errorf("error during connect: %w", err)}

值得称道的细节:

  1. 多语言友好 :Windows 错误信息可能是本地化的(注释里举例法语:"Le fichier spécifié est introuvable."),所以代码只匹配固定的管道路径 open //./pipe/docker_engine,不匹配本地化文本。
  1. 管理员检测的小技巧 :尝试打开 \.\PHYSICALDRIVE0 来判断是否以管理员身份运行------非管理员会失败,管理员能打开但随后立即关闭。这是个非常聪明的"权限嗅探"。
  1. 错误链保留 :几乎都用 %w 包装,调用方可以用 errors.Is/As 解包到原始错误。
  1. context 错误不装饰 :context.Canceled 是用户主动取消,不该被混淆成"连接失败"。

objectNotFoundError

go 复制代码
type objectNotFoundError struct {
    object string
    id     string
}

func (e objectNotFoundError) NotFound() {}
func (e objectNotFoundError) Error() string {
    return fmt.Sprintf("Error: No such %s: %s", e.object, e.id)
}

资源方法(如 ContainerInspect)在收到 404 时会构造这个错误,带 NotFound() 方法让 errors.As(&cerrdefs.ErrNotFound) 能识别。


流式连接:Hijack 与 Dialer

docker attachdocker exec -idocker logs -f 等命令需要 长连接的双向流 ------HTTP 请求/响应模型不够用。SDK 通过 HTTP Upgrade 把连接"劫持"为裸 TCP/unix 流。

三种入口

入口 用途
cli.postHijacked(ctx, path, query, body, headers) 内部使用,用于 attachexec
cli.DialHijack(ctx, url, proto, meta) 公开 API,自定义 URL 拨号
cli.Dialer() 返回一个 func(ctx) (net.Conn, error),用于 docker dial-stdio 代理

setupHijackConn ------ Hijack 的核心

go 复制代码
func setupHijackConn(dialer func(context.Context) (net.Conn, error), req *http.Request, proto string) (_ net.Conn, _ string, retErr error) {
    ctx := req.Context()
    req.Header.Set("Connection", "Upgrade")
    req.Header.Set("Upgrade", proto)   // 比如 "tcp"

    conn, err := dialer(ctx)
    if err != nil {
        return nil, "", fmt.Errorf("cannot connect to the Docker daemon. ...")
    }
    defer func() {
        if retErr != nil {
            _ = conn.Close()
        }
    }()

    // 1. TCP 连接开启 KeepAlive,防止长时间静默被 NAT 切断
    if tcpConn, ok := conn.(*net.TCPConn); ok {
        _ = tcpConn.SetKeepAlive(true)
        _ = tcpConn.SetKeepAlivePeriod(30 * time.Second)
    }

    // 2. 用 bufio 包装,因为 HTTP 响应头可能"少读"了部分数据
    hc := &hijackedConn{conn, bufio.NewReader(conn)}

    // 3. 发送请求 + 读响应,用 otelhttp 包装以保持 trace 一致性
    resp, err := otelhttp.NewTransport(hc).RoundTrip(req)
    if err != nil { return nil, "", err }

    // 4. 检查 Upgrade 是否成功
    if resp.StatusCode != http.StatusSwitchingProtocols {
        _ = resp.Body.Close()
        return nil, "", fmt.Errorf("unable to upgrade to %s, received %d", proto, resp.StatusCode)
    }

    // 5. 处理 bufio 中"多读"的数据
    if hc.r.Buffered() > 0 {
        if _, ok := hc.Conn.(CloseWriter); ok {
            conn = &hijackedConnCloseWriter{hc}
        } else {
            conn = hc
        }
    } else {
        hc.r.Reset(nil)  // 没有 buffered 数据,Reset 后让连接直接透传
    }

    return conn, resp.Header.Get("Content-Type"), nil
}

关键设计点:

  • TCP KeepAlive :Hijack 后的连接可能是 docker exec bash 这种长时间无数据的会话。注释解释:NAT/防火墙可能会因为长时间静默切断连接,开启 KeepAlive(30s)可以"保活"。
  • hijackedConn 双重包装 :bufio.Reader 会"预读"超过 HTTP 响应头的数据,这部分缓冲数据需要在 hijack 后还能被读出来;所以 hijackedConn.Read 实际读的是 bufio.Reader,从而不丢数据。
  • CloseWriter 探测 :有些底层连接(如 unix socket)支持半关闭写,SDK 通过类型断言探测并在支持时返回 hijackedConnCloseWriter,让上层可以"关闭写但保留读"------docker attach 用这个机制实现 Ctrl-D 退出。
  • otelhttp 包装:即使是 hijacked 的连接,也走 trace,保证可观测性一致。

HijackedResponse ------ 对外暴露的劫持结果

go 复制代码
type HijackedResponse struct {
    mediaType string
    Conn      net.Conn
    Reader    *bufio.Reader
}

func (h *HijackedResponse) Close()       { h.Conn.Close() }
func (h *HijackedResponse) MediaType() (string, bool) { ... }
func (h *HijackedResponse) CloseWrite() error { ... }

调用方拿到这个对象后,可以直接 Conn.Write/Read,绕过 HTTP。这就是 docker attach 实现"键盘输入直接进容器 stdin"的底层机制。

dialer() ------ 三协议拨号

go 复制代码
func (cli *Client) dialer() func(context.Context) (net.Conn, error) {
    return func(ctx context.Context) (net.Conn, error) {
        if dialFn := cli.dialerFromTransport(); dialFn != nil {
            return dialFn(ctx, cli.proto, cli.addr)  // 用 transport 的 DialContext
        }
        switch cli.proto {
        case "unix":
            return net.Dial(cli.proto, cli.addr)
        case "npipe":
            ctx, cancel := context.WithTimeout(ctx, 32*time.Second)
            defer cancel()
            return dialPipeContext(ctx, cli.addr)  // 调用 go-winio
        default:
            if tlsConfig := cli.tlsConfig(); tlsConfig != nil {
                return tls.Dial(cli.proto, cli.addr, tlsConfig)
            }
            return net.Dial(cli.proto, cli.addr)
        }
    }
}

注意 TLS 的特殊处理 :dialerFromTransport 在检测到 TLS 配置时会返回 nil(注释承认这是个"历史遗留"问题),然后由 dialer()tls.Dial 分支。这是 hijack 部分代码的一个 已知改进点

dialerFromTransport 的"启用条件"

go 复制代码
func (cli *Client) dialerFromTransport() func(context.Context, string, string) (net.Conn, error) {
    if cli.baseTransport == nil || cli.baseTransport.DialContext == nil {
        return nil
    }
    if cli.baseTransport.TLSClientConfig != nil {
        return nil  // TLS 情况下不能用 transport 的 DialContext
    }
    return cli.baseTransport.DialContext
}

这意味着只有 未配置 TLS 且 transport 自带 DialContext 时,才会复用 transport 的拨号器。这避免了"普通请求用 TLS 但 hijack 不用"的不一致。


重定向策略与 CheckRedirect

go 复制代码
var ErrRedirect = errors.New("unexpected redirect in response")

func CheckRedirect(_ *http.Request, via []*http.Request) error {
    if via[0].Method == http.MethodGet {
        return http.ErrUseLastResponse  // GET: 不跟随,返回原响应
    }
    return ErrRedirect  // 非 GET: 直接报错
}

这段代码看似简单,注释却讲了一个 Go 升级引发的"事故":

Go 1.8 之前:

  • HTTP 301/307/308 不自动跟随。
  • POST /containers//start(注意双斜杠)被 daemon 重定向到 POST /containers/start,客户端不跟随,但也不会报错

Go 1.8 之后:

  • 标准库开始自动跟随 301/307/308,并把 POST 转成 GET(对 301 来说)。
  • 结果是 GET /containers/start → 404 → 用户看到 "Error response from daemon: page not found",而原本应当正常 start。

解决方案 :CheckRedirect 拒绝任何非 GET 的重定向。GET 允许"不跟随"(返回最后响应),其它方法直接报错,这样用户能立即发现 URL 写错了。

这是一个 罕见的"语言/标准库升级导致生产 bug" 的真实案例,值得在文档中保留这段历史。


User-Agent 的"模块版本内省"

go 复制代码
var defaultUserAgent = sync.OnceValue(userAgent)  // OnceValue:惰性计算且并发安全

func userAgent() string {
    const defaultVersion = "v0.0.0+unknown"
    const moduleName = "github.com/moby/moby/client"

    version := defaultVersion
    if v := mod.Version(moduleName); v != "" {
        version = v
    }
    return "moby-client/" + version + " " + runtime.GOOS + "/" + runtime.GOARCH
}

mod.Version 来自 client/internal/mod/mod.go,其本质是利用 Go 的 runtime/debug.ReadBuildInfo 提取嵌入在二进制中的模块版本信息。

为什么不用 git rev-parse HEAD?

  • 编译后的二进制是脱离 git 仓库运行的。
  • Go module 通过 -buildvcs 或 build setting 把 vcs 信息嵌入二进制,但这是 构建时 的事情。
  • debug.ReadBuildInfo() 是 Go 标准的"在运行时获取自己依赖信息"的接口。

伪版本(pseudo-version)规范化

mod.go 中有大量逻辑处理 Go 的伪版本格式:

scss 复制代码
vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456   ← 预发布基线
vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456   ← 正常基线(Z+1)
vX.0.0-yyyymmddhhmmss-abcdef123456         ← 无基线

规范化后变成 vX.Y.Z+abcdef123456(更易读),并自动剥离 +incompatible+dirty 后缀。

这是一个非常细心的设计:SDK 想在 User-Agent 中报告"真实有效"的版本,而不是 Go module 系统的内部编码。


环境变量与平台差异

四个核心环境变量(envvars.go)

变量 用途
DOCKER_HOST 覆盖 DefaultDockerHost(连接地址)
DOCKER_API_VERSION 锁定 API 版本,禁用协商(调试用)
DOCKER_CERT_PATH TLS 证书目录,内含 ca.pem/cert.pem/key.pem
DOCKER_TLS_VERIFY 非空则启用服务端证书校验

注释里有大量 安全告警 ------比如 EnvOverrideCertPath 上面整整 30 行,反复强调"暴露 API 等价于 root 权限"。这是 Docker 团队把安全意识刻进 API 文档的做法。

FromEnv 的组合方式

go 复制代码
func FromEnv(c *clientConfig) error {
    ops := []Opt{
        WithTLSClientConfigFromEnv(),
        WithHostFromEnv(),
        WithAPIVersionFromEnv(),
    }
    for _, op := range ops {
        if err := op(c); err != nil {
            return err
        }
    }
    return nil
}

注意 顺序 :先 TLS,再 Host,最后 API Version。这样:

  • TLS 先设置 transport 的 TLSClientConfig;
  • Host 再调用 sockets.ConfigureTransport,此时能正确处理 TLS 配置;
  • API Version 最后,仅修改 version 字段。

如果反过来,Host 设置 transport 时 TLS 还没装,会导致 transport 不带 TLS------这是顺序敏感性的典型案例。

平台差异文件

go 复制代码
client_unix.go    // build !windows
client_windows.go // build windows

差异点只有两处:

Unix Windows
DefaultDockerHost "unix:///var/run/docker.sock" "npipe:////./pipe/docker_engine"
dialPipeContext 返回 syscall.EAFNOSUPPORT(不支持) 调用 go-winioDialPipeContext

dialPipeContext 在 Unix 上故意返回错误------因为 npipe 是 Windows 概念,Unix 不该被调用到。这是一种 防御性设计:即便代码路径走错,用户也能立即得到明确错误。


调用链总览:以 ContainerList 为例

让我们跟随一次 docker ps 的完整路径,贯穿所有层。

调用方代码

css 复制代码
apiClient, err := client.New(client.FromEnv)
result, err := apiClient.ContainerList(ctx, client.ContainerListOptions{All: true})
for _, ctr := range result.Items {
    fmt.Println(ctr.ID, ctr.Status)
}

完整调用链

css 复制代码
apiClient.ContainerList(ctx, options)
│
│  container_list.go:40
├─► 构建 query: all=1&limit=...&size=...&filter=...
│
│  container_list.go:57
├─► cli.get(ctx, "/containers/json", query, nil)
│      │
│      │  request.go:26
│      ├─► cli.sendRequest(ctx, "GET", "/containers_json", query, nil, nil)
│           │
│           │  request.go:108
│           ├─► cli.getAPIPath(ctx, "/containers/json", query)
│           │      │
│           │      │  client.go:315
│           │      ├─► cli.checkVersion(ctx)  ← 触发懒协商
│           │      │      │
│           │      │      │  client.go:303
│           │      │      └─► if !negotiated: Ping(NegotiateAPIVersion=true)
│           │      │             │
│           │      │             │  ping.go:73 双检锁
│           │      │             ├─► HEAD /_ping → 成功返回
│           │      │             └─► GET  /_ping → fallback
│           │      │             └─► newPingResult(resp) 解析 headers
│           │      │             └─► negotiateAPIVersion(apiVersion)
│           │      │                    ├─► LessThan(MinAPIVersion)? → ErrInvalidArgument
│           │      │                    ├─► LessThan(serverMax, clientMax)? → 降级
│           │      │                    └─► setAPIVersion(...)
│           │      │
│           │      └─► path.Join(basePath, "/v1.54", "/containers/json")
│           │
│           │  request.go:90
│           ├─► cli.buildRequest(ctx, "GET", path, nil, nil)
│           │      ├─► http.NewRequestWithContext(...)
│           │      ├─► cli.addHeaders(req, nil)
│           │      │      ├─► 打 cli.customHTTPHeaders
│           │      │      └─► 设置 User-Agent(defaultUserAgent() 惰性计算)
│           │      ├─► req.URL.Scheme = "http"
│           │      ├─► req.URL.Host = "/var/run/docker.sock"
│           │      └─► req.Host = "api.moby.localhost"  ← DummyHost
│           │
│           │  request.go:113
│           ├─► cli.doRequest(req)
│           │      ├─► cli.client.Do(req)
│           │      │      │
│           │      │      │  transport 链(从外到内):
│           │      │      ├─► responseHookTransport.RoundTrip  (若有 hooks)
│           │      │      │      └─► 调用所有 ResponseHook
│           │      │      ├─► otelhttp.Transport.RoundTrip      (开 trace span)
│           │      │      └─► *http.Transport.RoundTrip         (实际网络)
│           │      │             └─► unix socket dial → dockerd
│           │      │
│           │      └─► err == nil ? 返回 : 走 doRequest 的错误装饰链
│           │
│           └─► checkResponseErr(resp)
│                  ├─► 2xx/3xx → nil
│                  └─► 4xx/5xx → 读 body(≤1MiB)→ 解析 ErrorResponse
│                                  → httpErrorFromStatusCode → &httpError{...}
│
│  container_list.go:58
├─► defer ensureReaderClosed(resp)  ← 读 512B 后关闭,允许连接复用
│
│  container_list.go:64
├─► json.NewDecoder(resp.Body).Decode(&containers)
│
└─► return ContainerListResult{Items: containers}, err

链路上几个值得记住的点

  1. 懒协商 :Ping 发生在第一次实际请求里,而不是 New() 中。
  1. 路径版本化 :每次请求路径都是 /v1.54/containers/json,让 daemon 知道用哪一版 API 响应。
  1. transport 层层包裹:从最外层的 hook,到 otelhttp,到 stdlib transport,每一层都有清晰职责。
  1. 错误双路 :连接错误(doRequest 内部装饰)和 API 错误(checkResponseErr 翻译)分开处理。
  1. 连接复用 :ensureReaderClosed 是性能保证的关键。

链路上几个值得记住的点

  1. 懒协商 :Ping 发生在第一次实际请求里,而不是 New() 中。
  1. 路径版本化 :每次请求路径都是 /v1.54/containers/json,让 daemon 知道用哪一版 API 响应。
  1. transport 层层包裹:从最外层的 hook,到 otelhttp,到 stdlib transport,每一层都有清晰职责。
  1. 错误双路 :连接错误(doRequest 内部装饰)和 API 错误(checkResponseErr 翻译)分开处理。
  1. 连接复用 :ensureReaderClosed 是性能保证的关键。

关键设计要点小结

把整篇文档的核心浓缩成一组"为什么",方便回顾:

设计 为什么
DummyHost 绕开 Go stdlib "URL.Host 不能为空"的限制,用 .localhost TLD 保证永不解析
MaxAPIVersion / MinAPIVersion 客户端编译进去的 API 版本窗口,既保护客户端不被新特性误导,也拒绝过老的 daemon
懒协商 + atomic.Bool 短路 兼顾"零成本请求路径"和"按需协商";double-checked locking 防并发首请求风暴
Opt 函数式选项 可选参数 + 可失败初始化 + API 演进友好;//go:fix inline 支持 IDE 自动迁移 deprecated
transport 层层包裹 otelhttp(可观测)+ responseHook(可扩展)+ stdlib(网络),职责单一
baseTransport 单独保留 因为最终 transport 被层层包裹,Close() 需要原始 transport 才能 CloseIdleConnections
接口分离(ISP) stableAPIClient 拆成 13 个资源接口,使用者只依赖需要的;XxxOptions/Result 包装类型为未来扩展留空间
CheckRedirect 拒绝非 GET 规避 Go 1.8 改变 301/307/308 跟随行为带来的 POST→GET 误转换 bug
路径版本化 (/v1.54/...)** daemon 根据 URL 中的版本号路由到对应 handler;Ping 用 /_ping 避免循环依赖
checkResponseErr 限 1MiB body 防止恶意/异常 daemon 通过超大 error body 把客户端 OOM
errConnectionFailed 包装 + 10 种场景装饰 让"连不上 daemon"的错误信息立刻指出可能原因(没启 TLS?权限不足?daemon 没跑?)
httpErrorFromStatusCode 翻译 把 HTTP 状态码翻译成 containerd errdefs 语义错误,调用方可用 errors.Is 比对
Hijack 保留 bufio.Reader buffer HTTP 响应头读取可能"多读"了流数据,hijack 后这部分数据不能丢
Hijack TCP KeepAlive 30s docker exec 这类长会话防 NAT 切断
Hijack TLS 不走 transport.DialContext 历史遗留,目前 TLS hijack 走 tls.Dial,理想做法是用 transport 拨号后包 tls.Conn(TODO)
FromEnv 顺序:TLS→Host→Version Host 修改 transport 时需要 TLS 配置已就绪;顺序反了会导致 TLS 失效
defaultUserAgent sync.OnceValue 惰性计算且并发安全;伪版本规范化让 UA 更可读
ensureReaderClosed 读 512B 后关闭 让 transport 复用连接(性能),又不让超大错误 body 累积(防 OOM)
WithHTTPHeaders 拒绝重复 canonical key 防止 "User-Agent""user-agent" 这种 canonical 化后冲突
Ping 用 headers 带元数据 一次请求拿到 API 版本、OS、Swarm 状态、Builder 版本------开销极小
deprecated alias 带 //go:fix inline API 演进时温和迁移:先标 deprecated,后续用 gopatches 工具批量替换

整体设计哲学

通读 client 包,可以总结出几条 Docker 团队的工程哲学:

  1. 向后兼容至上 :废弃函数保留至少一两个版本,带 //go:fix inline 提示自动迁移。
  1. 错误信息为用户服务 :doRequest 里几十行错误装饰,目的都是"让用户一看就知道下一步该做什么"。
  1. 可观测性内建:OpenTelemetry 默认开启,ResponseHook 提供扩展点,Hijack 也走 trace。
  1. 务实优于纯粹 :DummyHost、TLS hijack TODO、//go:fix inline 这些都体现了"先解决问题,留下注释,慢慢优化"的工程态度。
  1. 接口与实现分离 :APIClient 是契约,Client 是实现,第三方可以替换实现进行测试。
  1. 平台差异最小化 :client_unix.go / client_windows.go 只暴露两个差异点,其余完全共享。
相关推荐
杨运交2 小时前
[055][调度模块]Spring动态任务调度框架的设计与实现
java·后端·spring
卷福同学3 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
Csvn4 小时前
📊 SQL 入门 Day 11:CASE 表达式:SQL 里的 if-else 魔法
后端·sql
QQ_21696290964 小时前
Spring Boot 养老院管理系统:从入住、护理到费用结算的全流程实现(源码可领)
java·spring boot·后端
万少6 小时前
DeepSeek-V4-Flash 正式版上线了,但这 3 个坑我帮你提前踩了
前端·javascript·后端
明月_清风6 小时前
🚀 Palantir Foundry 本体论实战:当 Ontology 从"知识图谱"进化为"企业操作系统"
前端·后端
明月_清风6 小时前
从概念到代码:用 Ontology 构建你的第一个知识图谱
前端·后端
Python私教7 小时前
Django 6.1 邮件配置大改:旧项目如何平稳升级?
后端·python·django
Python私教7 小时前
Django 6.1 升级避坑:数据库版本不兼容怎么解决?
后端·python·django