模块定位
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 侧扩展)
三条主线:
- 配置线 ------
New()把Opt应用到clientConfig,确定 host/scheme/version/transport。
- 请求线 ------所有资源方法(
ContainerList等)统一走sendRequest → buildRequest → doRequest → checkResponseErr四段流水线。
- 流式线 ------
attach、exec、logs -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必须是http或https(参见 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.go 的 buildRequest 中可以见到:
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 上)有两个好处:
Opt函数签名统一 :type Opt func(*clientConfig) error,所有选项操作同一个结构体,语义清晰。
- 构造期间的"可失败"语义集中 :
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
}
配置体系:clientConfig 与 Opt
函数式选项模式
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 inline 是 golang.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)}
值得称道的细节:
- 多语言友好 :Windows 错误信息可能是本地化的(注释里举例法语:"Le fichier spécifié est introuvable."),所以代码只匹配固定的管道路径
open //./pipe/docker_engine,不匹配本地化文本。
- 管理员检测的小技巧 :尝试打开
\.\PHYSICALDRIVE0来判断是否以管理员身份运行------非管理员会失败,管理员能打开但随后立即关闭。这是个非常聪明的"权限嗅探"。
- 错误链保留 :几乎都用
%w包装,调用方可以用errors.Is/As解包到原始错误。
- 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 attach、docker exec -i、docker logs -f 等命令需要 长连接的双向流 ------HTTP 请求/响应模型不够用。SDK 通过 HTTP Upgrade 把连接"劫持"为裸 TCP/unix 流。
三种入口
| 入口 | 用途 |
|---|---|
cli.postHijacked(ctx, path, query, body, headers) |
内部使用,用于 attach、exec |
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-winio 的 DialPipeContext |
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
链路上几个值得记住的点
- 懒协商 :
Ping发生在第一次实际请求里,而不是New()中。
- 路径版本化 :每次请求路径都是
/v1.54/containers/json,让 daemon 知道用哪一版 API 响应。
- transport 层层包裹:从最外层的 hook,到 otelhttp,到 stdlib transport,每一层都有清晰职责。
- 错误双路 :连接错误(
doRequest内部装饰)和 API 错误(checkResponseErr翻译)分开处理。
- 连接复用 :
ensureReaderClosed是性能保证的关键。
链路上几个值得记住的点
- 懒协商 :
Ping发生在第一次实际请求里,而不是New()中。
- 路径版本化 :每次请求路径都是
/v1.54/containers/json,让 daemon 知道用哪一版 API 响应。
- transport 层层包裹:从最外层的 hook,到 otelhttp,到 stdlib transport,每一层都有清晰职责。
- 错误双路 :连接错误(
doRequest内部装饰)和 API 错误(checkResponseErr翻译)分开处理。
- 连接复用 :
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 团队的工程哲学:
- 向后兼容至上 :废弃函数保留至少一两个版本,带
//go:fix inline提示自动迁移。
- 错误信息为用户服务 :
doRequest里几十行错误装饰,目的都是"让用户一看就知道下一步该做什么"。
- 可观测性内建:OpenTelemetry 默认开启,ResponseHook 提供扩展点,Hijack 也走 trace。
- 务实优于纯粹 :
DummyHost、TLS hijack TODO、//go:fix inline这些都体现了"先解决问题,留下注释,慢慢优化"的工程态度。
- 接口与实现分离 :
APIClient是契约,Client是实现,第三方可以替换实现进行测试。
- 平台差异最小化 :
client_unix.go/client_windows.go只暴露两个差异点,其余完全共享。