Docker 基础体系 · 第 20/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker Engine API 与自动化:SDK、事件、权限和幂等操作

Docker CLI 并不是 Docker Engine 的唯一入口。它本质上是一个客户端:解析命令行参数,调用 Docker Engine API,再把 API 响应转换为人类可读的输出。SDK、Docker Compose、CI 控制器和自定义运维程序也可以走同一套 API。

因此,自动化 Docker 的关键不只是“会调用接口”,而是要同时理解:

  • Engine API 的传输方式、版本和资源模型;
  • SDK 如何映射 API,并处理连接、流和异常;
  • 事件流如何反映状态变化,以及为什么不能把它当作可靠队列;
  • API 权限为什么接近宿主机 root 权限;
  • 如何在超时、重试、并发和进程崩溃下保持幂等;
  • BuildKit、Compose 和 Linux 容器边界分别在哪里。

本文以现代 Docker Engine、BuildKit 和 Compose 规范为基础,示例针对 Linux 容器。Docker Desktop、Windows 容器和 Kubernetes 控制面有额外的实现差异,不能直接套用本文的权限和路径结论。


一、先建立模型:客户端、Engine API 和 daemon

Docker Engine 通常由三部分组成:

  1. 客户端docker CLI、Python/Go SDK、自定义程序或 Compose;
  2. Engine API:由 dockerd 提供的 HTTP API;
  3. daemon:负责镜像、容器、网络、卷、构建和事件等资源的实际管理。

数据流可以抽象为:

flowchart LR
    C[CLI / SDK / Compose / CI Controller]
    T[HTTP over Unix socket or TLS TCP]
    D[dockerd]
    S[Containerd / BuildKit / iptables / runc]
    R[Images Containers Networks Volumes]

    C --> T --> D
    D --> S
    D --> R
    D --> E[Event stream]
    E --> C

Engine API 是 HTTP API,但“HTTP”不意味着它默认监听公网 TCP 端口。在 Linux 上,默认连接通常是 Unix socket:

/var/run/docker.sock

也可能使用:

  • rootless Docker 的用户级 socket,例如 /run/user/<uid>/docker.sock
  • 通过 TLS 保护的 TCP endpoint;
  • Docker Desktop 提供的特殊本地通信方式。

查看 CLI 当前连接目标:

docker context show
docker context inspect "$(docker context show)"

查看服务端信息:

docker version
docker info

docker version 通常同时显示客户端和服务端的 API 版本。不要把 Docker CLI 版本、Engine 版本和 Engine API 版本当成同一个概念。


二、Engine API 的版本、路径和响应

2.1 API 版本不是装饰信息

Engine API 的 URL 通常包含版本前缀,例如:

GET /v1.45/containers/json

版本决定了:

  • 哪些 endpoint 存在;
  • 请求字段和响应字段是否可用;
  • 某些状态码和行为是否兼容;
  • 客户端与 daemon 能否协同工作。

服务端的版本信息可通过:

curl --unix-socket /var/run/docker.sock \
  http://localhost/version

典型响应包含:

{
  "Version": "27.x",
  "ApiVersion": "1.xx",
  "MinAPIVersion": "1.xx",
  "GitCommit": "...",
  "GoVersion": "..."
}

实际字段会随 Engine 版本变化。自动化程序不应假设某个 API 版本永远可用,而应使用 SDK 的版本协商能力,或者在启动时读取服务端版本并校验最低要求。

Python SDK 的常见初始化方式是:

import docker

client = docker.from_env(version="auto")

version="auto" 表示让 SDK 根据 daemon 协商 API 版本。也可以通过 DOCKER_API_VERSION 固定版本,但固定版本会降低跨版本兼容性,适合经过验证的受控环境,不适合无条件写入生产脚本。

2.2 一个 API 请求的完整形态

列出所有容器:

curl --unix-socket /var/run/docker.sock \
  'http://localhost/v1.45/containers/json?all=1'

这里有三个容易混淆的点:

  1. --unix-socket 指定传输通道;
  2. URL 中的 localhost 只是为了满足 HTTP URL 格式,并不会建立 TCP 连接;
  3. all=1 表示包含已停止容器,不传或传 0 时通常只返回运行中的容器。

检查容器详情:

curl --unix-socket /var/run/docker.sock \
  'http://localhost/v1.45/containers/<container-id>/json'

容器列表适合发现资源,inspect 适合读取一个资源的权威当前状态。二者不能混为一谈:列表结果可能在返回后立即过时,而 inspect 也只能代表请求时刻的状态。

2.3 API 错误必须按 HTTP 状态和业务语义处理

一个失败请求通常包含 HTTP 状态码和 JSON 错误信息,例如:

{
  "message": "No such container: example"
}

常见分类如下:

状态 含义示例 自动化处理
400 参数或请求体无效 修复程序,不应盲目重试
401/403 认证或权限不足 检查 endpoint、TLS、socket 权限
404 资源不存在 可能是已被并发删除,也可能是名称错误
409 状态冲突,例如名称已占用 重新读取状态或调整流程
500 daemon 或底层运行时错误 可有限重试,但必须记录并复查状态
304 某些生命周期操作已经满足目标状态 以 API 版本文档和最终状态为准

网络超时尤其危险:超时只说明客户端没有得到响应,不说明 daemon 没有执行请求。对 POST /containers/createexec 或删除等操作,不能简单地“超时后重复发送”。


三、SDK:减少 HTTP 细节,但不替代生命周期设计

SDK 的价值在于:

  • 处理 endpoint 和 API 版本;
  • 将 JSON 转换为语言对象;
  • 提供流式日志、事件和构建接口;
  • 将部分错误映射为异常;
  • 管理连接和响应读取。

SDK 并不会自动保证幂等,也不会替程序解决并发竞态。

3.1 Python SDK 的基本生命周期

下面的程序展示一个完整但简单的容器生命周期:

import docker
from docker.errors import APIError, ImageNotFound

client = docker.from_env(version="auto")

try:
    image = "alpine:3.20"

    print("pulling image...")
    client.images.pull(image)

    container = client.containers.create(
        image=image,
        command=["sh", "-c", "echo hello; sleep 30"],
        name="engine-api-demo",
        labels={
            "example.owner": "wr-blog",
            "example.role": "demo",
        },
    )

    print("created:", container.id)

    container.start()
    print("started:", container.status)

    result = container.wait()
    print("wait result:", result)

    logs = container.logs(stdout=True, stderr=True).decode()
    print(logs, end="")

    container.remove()
    print("removed")

except ImageNotFound as exc:
    print("image not found:", exc)
except APIError as exc:
    print("Docker API error:", exc)
finally:
    client.close()

执行前提:

  • 当前用户能够访问 Docker endpoint;
  • 能够从镜像仓库拉取 alpine:3.20
  • 名称 engine-api-demo 没有被占用;
  • 宿主机允许运行 Linux 容器。

每一步的语义不同:

  1. images.pull 确保本地有镜像,但标签仍可能随着仓库更新;
  2. containers.create 只创建容器配置和可写层,不启动进程;
  3. start 才会创建并启动容器内的初始进程;
  4. wait 等待主进程退出;
  5. logs 读取日志,不等于读取应用全部日志;
  6. remove 删除容器元数据和可写层,但不会自动删除仍被其他资源引用的镜像或卷。

client.containers.run(...) 会把 create 和 start 组合起来,适合一次性任务,但不利于需要精细控制状态、重试和故障恢复的控制器。

3.2 SDK 对流式接口的特殊处理

日志、事件和构建输出不是普通的一次性 JSON 响应。它们通常是:

  • chunked HTTP response;
  • 长连接;
  • 多条 JSON 对象或带流头的输出;
  • 需要持续读取,直到断开或满足退出条件。

例如:

for line in container.logs(stream=True, follow=True):
    print(line.decode(errors="replace"), end="")

如果程序只读取一次响应头就关闭连接,daemon 可能仍在运行,但客户端会丢失后续日志。生产程序还必须处理:

  • socket 断开;
  • 容器提前退出;
  • 超时;
  • 输出过大导致内存增长;
  • 日志中非 UTF-8 字节。

四、容器事件:通知状态变化,不是可靠消息队列

4.1 事件包含什么

Engine API 提供容器、镜像、网络、卷等资源的事件流。CLI 示例:

docker events \
  --filter type=container \
  --filter event=start \
  --filter label=example.owner=wr-blog

Python SDK 示例:

import docker

client = docker.from_env(version="auto")

filters = {
    "type": "container",
    "event": ["start", "die", "destroy"],
    "label": ["example.owner=wr-blog"],
}

try:
    for event in client.events(decode=True, filters=filters):
        actor = event.get("Actor", {})
        attributes = actor.get("Attributes", {})

        print({
            "type": event.get("Type"),
            "action": event.get("Action", event.get("status")),
            "id": actor.get("ID"),
            "name": attributes.get("name"),
            "time": event.get("time"),
            "timeNano": event.get("timeNano"),
        })
finally:
    client.close()

事件中的字段名称和内容应以目标 Engine API 版本为准。常见字段包括:

  • Type:资源类型,例如 containerimage
  • Action 或兼容字段 status:动作,例如 startdiedestroy
  • Actor.ID:资源 ID;
  • Actor.Attributes:名称、标签等附加信息;
  • timetimeNano:事件时间。

4.2 事件不是状态本身

事件表示“发生过某个动作”,而 inspect 表示“当前状态是什么”。正确的数据流通常是:

sequenceDiagram
    participant D as dockerd
    participant W as 事件监听器
    participant I as 控制器
    participant C as 容器

    D->>W: start 事件
    W->>I: 通知资源可能变化
    I->>D: inspect 容器
    D-->>I: 当前状态
    I->>I: 与期望状态比较
    I->>D: 必要时执行修复动作

例如,收到 die 事件后,控制器不能直接假设容器一定需要重启。它至少应检查:

  • 容器是否已经被删除;
  • 退出码是否表示正常结束;
  • 是否存在人工停止操作;
  • 是否有更新中的新容器已经接管服务;
  • 当前标签、镜像摘要和配置是否仍符合期望状态。

事件监听器的最小处理逻辑应是:

接收事件
  -> 解析资源 ID 和标签
  -> 读取 inspect 当前状态
  -> 计算实际状态与期望状态的差异
  -> 执行幂等修复
  -> 记录结果和新的状态

4.3 事件流的故障边界

Engine 事件流通常适合作为实时触发器,但不应直接当作持久化队列。原因包括:

  1. 监听器断开期间可能错过事件;
  2. 网络重连时可能重复收到事件;
  3. 事件发生后,资源状态可能已经再次变化;
  4. 多个资源的操作存在并发,不能依靠事件到达顺序推断全部因果关系;
  5. 事件保留和查询能力受 Engine 实现及 API 版本限制,不等价于 Kafka、数据库日志或任务队列。

所以应采用“事件唤醒 + 当前状态读取”的设计:

  • 事件用于降低轮询延迟;
  • inspect、列表和标签索引用于恢复事实;
  • 控制器启动时执行一次全量同步;
  • 断线重连后执行补偿扫描;
  • 事件处理必须允许重复。

一个常见错误是:

收到 die -> start

这会把人工停止、发布切换、健康检查失败和真正的异常退出全部混为一谈。更安全的逻辑是:

收到 die
  -> 读取退出码和标签
  -> 判断该容器是否仍是期望实例
  -> 判断期望状态是否为 running
  -> 重新读取同一服务的全部实例
  -> 必要时创建或启动正确版本

五、权限:访问 Docker socket 接近宿主机 root

5.1 Unix socket 的实际含义

典型 Linux Docker socket:

ls -l /var/run/docker.sock

可能看到:

srw-rw---- 1 root docker ... /var/run/docker.sock

只有 root 或属于 docker 组的用户可以访问。加入 docker 组通常意味着可以直接调用 Engine API。由于 Engine API 能够创建特权容器、挂载宿主机路径、修改网络和访问敏感资源,这种权限在实际效果上接近宿主机 root,而不是普通的“管理 Docker”权限。

典型的危险请求包括:

docker run --rm -it \
  --privileged \
  -v /:/host \
  alpine sh

这不是一个普通应用容器。它把宿主机根文件系统暴露给容器,并在特定条件下提供接近宿主机管理权限的能力。

因此,下面这种做法风险很高:

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  my-ci-image

这相当于把宿主机 Docker 控制面交给了容器内程序。容器内的“非 root 用户”并不能抵消 socket 本身的控制权。

5.2 TCP endpoint 必须有明确的认证和加密边界

将 daemon 监听在 TCP 上,例如:

tcp://0.0.0.0:2376

并不自动提供安全性。生产环境至少需要:

  • TLS 加密;
  • 双向证书认证;
  • 防火墙限制来源;
  • 最小化监听地址;
  • 证书轮换和吊销流程;
  • 审计谁访问了哪些 API;
  • 不把未认证 daemon 暴露到网络。

2375 这个约定端口常被用于无 TLS 的 Docker TCP 连接。将其暴露到可达网络是高风险配置,不应因为“只在内网”就视为安全。

SDK 可以通过环境变量选择 endpoint:

export DOCKER_HOST=tcp://docker.example.internal:2376
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH="$HOME/.docker/certs"

但环境变量只是客户端配置,不是服务器端访问控制。证书私钥本身必须受到保护。

5.3 Rootless Docker 的边界

Rootless Docker 让 daemon 和容器尽量在普通用户身份下运行,降低 daemon 被攻破后直接获得宿主机 root 的风险。但它不是“容器天然安全”,仍有边界:

  • 能力受用户命名空间、内核和网络配置限制;
  • 某些存储驱动、网络功能和设备访问可能不可用;
  • 容器内 root 通常映射为宿主机普通用户,而不是宿主机 root;
  • 访问用户 socket 的程序仍然拥有该用户范围内的 Docker 控制权;
  • 若普通用户本身拥有敏感文件或凭据,风险仍然存在。

验证当前 endpoint 和 daemon 信息:

docker context ls
docker info

不要仅通过容器内 id 输出判断整体安全性。应同时审计:

  • daemon 是否 rootless;
  • socket 所属用户和权限;
  • 是否启用 TLS;
  • 容器是否挂载 Docker socket;
  • 是否使用 --privileged、宿主机 PID/网络/文件系统;
  • 是否删除了不必要的 Linux capabilities;
  • 是否启用了 seccomp 和只读根文件系统。

这些设置与“Docker 容器安全:非 root、Capabilities、Seccomp、只读根和隔离”直接相关,但它们不能把 Docker socket 的控制权降级为普通应用权限。


六、幂等操作:在超时和重试下仍得到同一目标状态

6.1 幂等性的形式化定义

对操作 f f ,如果对相同输入 x x 有:

f(f(x))=f(x)f(f(x)) = f(x)

则称该操作在该状态模型下是幂等的。

在 Docker 自动化中,更实用的定义是“期望状态幂等”。设:

  • S S 是当前 Docker 状态;
  • D D 是期望状态;
  • R(S,D) R(S, D) 是一次 reconcile,即调和操作。

要求:

R(R(S,D),D)=R(S,D)R(R(S,D),D)=R(S,D)

直觉是:第一次执行可能创建或启动资源,第二次执行不应再次产生副作用,也不应破坏已经达到的目标。

注意,HTTP 方法本身不能决定业务操作是否幂等。POST 通常不是幂等的,但一个设计良好的“按标签查找并确保目标存在”的控制器可以实现幂等行为。

6.2 Docker 生命周期操作的实际分类

操作 是否天然适合重复调用 关键边界
inspect、列表 读取结果可能在返回后过时
按名称创建容器 同名通常返回冲突
start 已运行容器 版本和状态相关 不应只依赖状态码,应复查状态
stop 已停止容器 通常可按目标状态处理 超时后需 inspect
删除容器 不是严格意义上的重复无差别 已删除时可能返回 404
拉取标签 不是内容幂等 标签可能移动
按 digest 拉取 更接近内容幂等 仓库和平台仍需匹配
exec 写文件或改数据库 重复执行可能产生重复副作用
prune 不适合盲目重试 可能删除刚刚变成未使用的资源
事件监听 断线和重复是正常故障路径

例如:

docker pull alpine:latest

并不保证每次得到同样的镜像内容,因为 latest 是可变标签。若发布系统要求可复现,应使用摘要:

docker pull alpine@sha256:<verified-digest>

摘要必须来自可信发布流程,并且应在部署记录中保存。

6.3 “先查再建”仍然有竞态

下面的逻辑看起来幂等:

如果不存在 example 容器:
    创建 example
启动 example

但两个控制器并发运行时可能发生:

控制器 A: 查询,不存在
控制器 B: 查询,不存在
控制器 A: 创建成功
控制器 B: 创建,名称冲突

这不是异常到无法处理,而是说明“查询和创建”不是原子事务。正确处理方式是:

  1. 使用确定性的资源名称或标签;
  2. 创建时把冲突视为需要重新读取状态;
  3. 重新检查现有容器是否符合期望配置;
  4. 如果符合,则把冲突视为成功;
  5. 如果不符合,则按发布策略停止、替换或拒绝接管;
  6. 多进程控制器之间使用单实例、租约或外部锁,避免无意义竞态。

Docker Engine API 没有为所有写操作提供通用的幂等键。应用程序不能假设“给请求加一个 request ID”就会由 daemon 自动去重。


七、一个可运行的幂等 reconcile 示例

下面的示例确保一个带有固定标签的容器处于运行状态。它展示了比 run() 更清晰的控制流程:

import docker
from docker.errors import APIError, ImageNotFound, NotFound

IMAGE = "alpine:3.20"
NAME = "wr-blog-reconcile-demo"
LABEL_KEY = "wr.example.managed"
LABEL_VALUE = "reconcile-demo"

client = docker.from_env(version="auto")


def find_managed_container():
    containers = client.containers.list(
        all=True,
        filters={"label": f"{LABEL_KEY}={LABEL_VALUE}"},
    )

    # 这里要求该标签在本机唯一。
    if len(containers) > 1:
        raise RuntimeError("managed label is not unique")

    return containers[0] if containers else None


def desired_config():
    return {
        "image": IMAGE,
        "command": ["sh", "-c", "while true; do date; sleep 10; done"],
        "name": NAME,
        "labels": {
            LABEL_KEY: LABEL_VALUE,
        },
    }


def reconcile():
    client.images.pull(IMAGE)

    container = find_managed_container()

    if container is None:
        try:
            container = client.containers.create(**desired_config())
        except APIError as exc:
            # 可能是另一个控制器刚刚创建了同一资源。
            # 不能直接把所有 APIError 都当作成功。
            if getattr(exc, "status_code", None) != 409:
                raise
            container = find_managed_container()
            if container is None:
                raise
        print("created:", container.name)

    container.reload()
    status = container.attrs["State"]["Status"]

    if status != "running":
        container.start()
        container.reload()
        status = container.attrs["State"]["Status"]

    if status != "running":
        raise RuntimeError(f"container did not reach running state: {status}")

    print("desired state reached:", container.name, status)


try:
    reconcile()
finally:
    client.close()

7.1 运行前提

执行:

python -m pip install docker
python reconcile.py

程序要求:

  • Python 环境安装了 docker SDK;
  • 当前用户可以访问 Docker daemon;
  • daemon 能拉取 alpine:3.20
  • 标签 wr.example.managed=reconcile-demo 没有被其他程序错误复用。

重复执行程序时:

  • 第一次通常创建并启动容器;
  • 后续执行只读取并确认运行状态;
  • 容器已停止时会再次启动;
  • 如果创建阶段遇到名称冲突,程序会重新读取资源,而不是无条件重复创建。

7.2 这个示例仍未解决什么

它不是完整的生产控制器,仍有以下边界:

  1. pull(IMAGE) 使用可变标签,镜像内容不保证固定;
  2. 只比较运行状态,没有比较镜像摘要、命令、环境变量、挂载和网络;
  3. 两个进程仍可能在查找和创建之间竞争;
  4. start() 请求超时后,代码应通过 reload() 判断是否已经启动;
  5. 没有发布版本、灰度、回滚和健康检查;
  6. 它没有处理容器被替换后旧标签、孤儿资源和清理策略。

真正的发布控制器通常把期望状态编码为版本化配置,例如:

服务:payment-api
版本:2025-03-01
镜像:registry.example/payment-api@sha256:...
配置摘要:...
期望副本:3
期望状态:running

控制器比较的是“实际配置摘要和期望配置摘要”,而不是仅比较容器是否存在。


八、超时、重试与“未知结果”

自动化最难处理的情况不是明确的 400,而是:

客户端发送请求
daemon 可能已经执行
网络连接在响应返回前断开
客户端只看到 timeout

此时结果属于 unknown outcome,不能把它当作失败或成功。

8.1 按操作处理未知结果

创建容器

超时后不要直接再次创建。应:

  1. 根据确定性名称或标签查询;
  2. 如果找到资源,检查配置是否匹配;
  3. 匹配则认为创建已完成;
  4. 不匹配则根据策略删除、替换或报警;
  5. 找不到时才考虑再次创建。

启动容器

超时后:

container.reload()
status = container.attrs["State"]["Status"]

如果已经是 running,说明目标状态已达到;如果仍是 createdexited,再决定是否重试。

删除容器

超时后先查询。若资源不存在,可以把“最终不存在”视为删除目标已经达到;若仍存在,再根据是否允许强制删除决定后续动作。

exec

exec 往往不可安全重试。例如:

docker exec app sh -c 'echo 1 >> /data/count'

超时后无法仅凭客户端结果判断是否已经追加一次。若业务需要重试,应让容器内操作本身带有幂等键,例如写入:

/data/jobs/<operation-id>.done

执行前检查该标记,执行成功后原子创建标记。更可靠的做法是把副作用交给支持幂等请求的业务 API 或数据库事务。

8.2 重试条件

经验上,重试逻辑至少应区分:

  • 参数错误和权限错误:通常不重试;
  • 资源不存在:根据目标状态判断是否算成功;
  • 名称冲突:重新读取并验证;
  • 临时网络错误、连接重置和部分 5xx:有限次数、指数退避;
  • 长时间操作:使用合理超时,并在重试前读取状态;
  • daemon 重启:等待 endpoint 恢复后执行补偿扫描。

不能只写:

for _ in range(10):
    try:
        create()
        break
    except Exception:
        time.sleep(1)

这段代码会把权限错误、参数错误、名称冲突和未知执行结果都当成同一种错误,可能产生重复资源或持续制造日志噪声。


九、事件驱动控制器的可靠结构

一个较完整的 Linux 主机级 Docker 控制器通常有四个部分:

  1. 初始同步:启动时读取所有带管理标签的资源;
  2. 事件监听:接收事件,缩短发现变化的延迟;
  3. 状态读取器:通过 inspect 获取当前事实;
  4. 调和执行器:把资源推进到期望状态。
flowchart TD
    A[控制器启动] --> B[全量扫描]
    B --> C[建立当前状态]
    C --> D[执行 reconcile]
    E[Engine events] --> F[事件去重/解析]
    F --> G[加入待处理资源集合]
    G --> H[读取 inspect]
    H --> I[计算差异]
    I --> J[创建/启动/停止/替换]
    J --> K[记录结果]
    K --> G
    L[连接断开] --> M[指数退避重连]
    M --> B

关键设计不是“收到事件马上调用某个命令”,而是让每次 reconcile 都具有以下性质:

  • 可以重复执行;
  • 可以从当前状态重新开始;
  • 不依赖上一次进程内存中的临时变量;
  • 能处理资源已被人工修改或删除;
  • 能记录目标版本、实际版本和失败原因。

事件去重可以使用资源 ID 加事件时间构造短期键,但去重不是可靠性的根本。即使重复处理同一事件,也应因为 reconcile 幂等而不会产生错误结果。

对于单机 Docker,控制器可以把状态存储在本地数据库或文件中;对于多个控制器实例,应使用外部一致性机制,例如数据库唯一约束、租约或单活部署。Docker daemon 自身不是一个供任意业务使用的通用分布式锁服务。


十、BuildKit:构建也是 API 生命周期的一部分

现代 Docker Engine 通常使用 BuildKit 进行镜像构建,但“使用 BuildKit”不等于客户端直接调用一个与容器生命周期完全相同的接口。

Python SDK 构建示例:

import docker

client = docker.from_env(version="auto")

try:
    image, build_logs = client.images.build(
        path=".",
        tag="example/app:ci",
        pull=True,
        rm=True,
    )

    print("image id:", image.id)

    for item in build_logs:
        if "stream" in item:
            print(item["stream"], end="")
        elif "error" in item:
            raise RuntimeError(item["error"])
finally:
    client.close()

执行前提是当前目录存在有效的 Dockerfile。构建过程可能输出多条日志事件,而不是一个简单的最终 JSON。

构建自动化需要区分:

  • 构建上下文:发送给 daemon 的文件集合;
  • Dockerfile:定义构建步骤;
  • 缓存:可能使构建复用以前的层;
  • 标签:指向镜像内容的可变名称;
  • 摘要:对镜像内容的内容寻址标识;
  • 输出:镜像是否加载到本地 Engine、推送到仓库或输出到其他后端。

对于可复现发布,通常应:

  1. 固定基础镜像 digest;
  2. 控制构建上下文,不把凭据和无关文件打包;
  3. 使用 BuildKit secret 机制,而不是把秘密写入 Dockerfile 的 ARG 或普通层;
  4. 构建后记录最终镜像 digest;
  5. 部署时使用 digest,而不是只使用 tag;
  6. 将构建失败、推送失败和部署失败分开记录。

构建操作本身不一定具有业务幂等性。相同源码和 Dockerfile 在基础镜像标签、网络依赖、构建参数或时间输入变化时,可能得到不同内容。BuildKit 的缓存改善效率,但不自动保证完全可复现。


十一、Compose 与 Engine API 的关系

Compose 是声明多容器应用的规范和客户端工具。Compose 文件中的:

  • services;
  • networks;
  • volumes;
  • dependency;
  • profiles;
  • project name;

会被 Compose 客户端转换为一组 Engine API 调用。Compose 不是 Engine daemon 内置的通用资源控制器,也不能把 Compose 文件直接当作 Engine API 请求体。

例如:

docker compose up -d
docker compose ps
docker compose events

Compose 通常会使用项目名和服务相关标签标识资源。排查时可查看:

docker inspect <container-id>

并检查标签,例如项目、服务和配置哈希等字段。具体标签名称属于 Compose 实现细节,自动化程序不应只依赖未在目标版本中确认的内部标签;如果自己的控制器需要稳定识别资源,应增加自有标签。

Compose 的“重复执行”通常表现为把实际资源调和到 Compose 文件描述的状态,但以下行为仍需明确:

  • up 是否重建容器;
  • 镜像标签是否发生变化;
  • 绑定挂载内容是否由宿主机管理;
  • 卷数据是否需要保留;
  • 网络和项目名是否稳定;
  • 是否允许删除未声明的旧服务。

生产发布不能仅因为 docker compose up -d 可重复执行,就认为更新、回滚和数据迁移已经具备幂等性。数据库迁移、消息重复消费和外部 API 调用仍需独立设计。


十二、容器安全配置对自动化的影响

自动化程序经常需要创建容器,因此安全参数不能只由人工 CLI 操作时考虑。

一个偏安全的示例:

docker run -d --name app \
  --user 10001:10001 \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop=ALL \
  --security-opt no-new-privileges:true \
  --security-opt seccomp=default \
  app-image@sha256:<digest>

各项含义不同:

  • --user:容器内进程不以 root 身份运行;
  • --read-only:容器根文件系统只读;
  • --tmpfs /tmp:为必须写入临时文件的路径提供内存文件系统;
  • --cap-drop=ALL:删除 Linux capabilities,再按需增加;
  • no-new-privileges:防止进程通过某些机制获得更高权限;
  • 默认 seccomp:限制部分危险系统调用;
  • 镜像 digest:避免部署时标签漂移。

这些参数可能导致应用启动失败。例如应用尝试写入 /etc、依赖 CAP_NET_ADMIN、需要特定系统调用,或者以固定 UID 运行却没有访问挂载卷的权限。自动化程序应把这类失败作为配置兼容性问题诊断,而不是简单地改成 --privileged

验证容器配置:

docker inspect app
docker top app
docker exec app id
docker exec app sh -c 'test -w / && echo writable || echo read-only'

docker exec 本身要求容器仍在运行,并且会引入额外副作用和权限路径。诊断时应优先使用 inspect、日志和宿主机审计信息。


十三、常见误解与诊断路径

13.1 “有了 SDK,就不需要了解 API”

错误表现:

  • SDK 抛出 APIError,程序无法判断是权限、版本还是状态冲突;
  • SDK 自动组合多个动作,超时后不知道哪一步已经完成;
  • 代码依赖某个字段,但升级 Engine 后字段行为变化。

诊断顺序应是:

docker version
docker context show
echo "$DOCKER_HOST"
docker info

然后用最小 API 请求验证 endpoint:

curl --unix-socket /var/run/docker.sock \
  http://localhost/version

如果 curl 能工作而 SDK 失败,检查 SDK 版本协商、TLS 环境变量和 Python 包版本;如果 curl 也失败,优先检查 daemon、socket 和权限。

13.2 “事件收到就代表状态已经稳定”

错误表现:

  • 收到 start 后立即读取到 createdrestarting
  • 收到 die 后重启了本应停止的容器;
  • 重连后遗漏一段时间的变化。

修复方法是把事件当作触发器,重新读取状态,并在控制器启动和重连后执行全量扫描。

13.3 “容器内是非 root,所以挂 socket 也安全”

错误原因是权限主体发生了变化:程序虽然不是容器内 root,但它通过 socket 请求 daemon 执行操作。daemon 根据 socket、TLS 证书或远端认证判断权限,而不是根据容器内进程的 UID 判断。

13.4 “超时后重试一定更可靠”

如果请求已经执行,第二次请求可能造成:

  • 重复创建;
  • 重复 exec
  • 重复推送或业务写入;
  • 删除已更新资源;
  • 与人工操作产生竞态。

正确策略是区分“明确失败”和“未知结果”,对未知结果先读状态,再决定是否继续。

13.5 “镜像 tag 就是版本”

标签可以移动。生产交付应记录:

docker image inspect alpine:3.20 \
  --format '{{json .RepoDigests}}'

实际部署时使用经过校验的 digest,并在 CI、灰度、回滚记录中保存镜像摘要、配置摘要和变更时间。


十四、生产自动化的最小检查表

一套可维护的 Engine API 自动化至少应明确回答以下问题:

  1. 使用哪个 Docker context、Unix socket 或 TLS endpoint?
  2. 客户端如何协商或固定 API 版本?
  3. 每个资源如何用名称、ID、标签或外部数据库稳定识别?
  4. 期望状态是什么,实际状态从哪个 inspect 字段读取?
  5. 创建、启动、停止、删除和 exec 的重复执行语义是什么?
  6. 请求超时时如何判断 daemon 是否已经执行?
  7. 事件断线后如何补偿?
  8. 多个控制器并发时由谁负责锁、租约或冲突处理?
  9. 容器是否使用非 root、最小 capabilities、seccomp、只读根和受限挂载?
  10. 镜像是否按 digest 发布,构建是否使用受控上下文和安全 secret?
  11. 容器被人工删除、修改或替换后,控制器如何恢复?
  12. 日志、事件、API 错误和最终状态是否可审计?

Engine API 自动化的核心不是把 CLI 命令改写成 HTTP 请求,而是建立一个能在故障中恢复的状态控制循环:

读取当前状态
  -> 与期望状态比较
  -> 执行最小变更
  -> 在不确定时重新读取
  -> 接收事件作为触发
  -> 通过全量扫描补偿遗漏

SDK 让调用更方便,事件让变化更及时,权限模型决定控制面的风险,而幂等设计决定程序在重试、并发和崩溃后能否安全恢复。只有把这四者放在同一个生命周期和故障模型中,Docker Engine API 才能成为可靠的生产自动化基础。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。