一句话定位
daemon/server/router/<resource>/是 Docker daemon 对外暴露 HTTP API 的入口。
每一条docker ps、docker run、docker pull命令,最终都会变成一个 HTTP 请求,落到这里的某条路由上。
dockerd 的对外接口不是 gRPC、不是自定义协议,就是最朴素的 HTTP/JSON RESTful API。
所有路由都按「资源」拆成独立目录,每个目录里管自己的事。
全景图:一条 docker 命令的旅程
docker CLI dockerd(HTTP server) daemon 业务层 ──────────── ───────────────────────── ────────────── docker run nginx │ │ HTTP POST /v1.42/containers/create body=JSON ▼ ┌──────────────────┐ │ gorilla/mux 路由 │ ← 把 URL + Method 匹配到 handler └──────────────────┘ │ │ 匹配到 container.NewRouter 注册的 /containers/create ▼ ┌──────────────────┐ │ Route 三元组 │ (method=POST, path="/containers/create", handler=postContainersCreate) └──────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ container_routes.go │ 解析 JSON → 校验 → 调 backend │ postContainersCreate(...) │ └──────────────────────────────────┘ │ │ backend.ContainerCreate(ctx, config) ▼ ┌──────────────────────────────────┐ │ daemon.create.go │ 真正的容器创建逻辑 │ ContainerCreate → containerCreate│ 生成容器 ID、写元数据 └──────────────────────────────────┘ │ ▼ 返回 {Id, Warnings} → handler 序列化为 JSON → HTTP 响应记住这张图,整篇文档都在解释它。
2. 目录结构:一资源一目录
打开daemon/server/router/,你会看到一组按资源命名的子目录:
目录 | HTTP 前缀 | 负责什么 | 对应 docker 命令 |
|
| 容器生命周期、exec、文件拷贝 |
|
|
| 镜像 list/inspect/pull/push/tag/delete |
|
|
| 网络创建、连接、断开 |
|
|
| 数据卷 CRUD |
|
|
| 镜像构建 |
|
|
| 系统/守护进程信息 |
|
|
| Swarm 集群 |
|
|
| buildkit 长连接会话(已废弃,向后兼容) | buildkit 内部 |
|
| 插件管理 |
|
|
| 镜像仓库元信息(manifest、blob) |
|
|
| 容器检查点(实验性) |
|
| gRPC 路由 | 已废弃的 gRPC builder 接口 | (无对外命令) |
|
| pprof 调试端点 | 调试用 |
约定:每个<resource>/目录里就是三件套——
container/ ├── <resource>.go # 路由表:声明所有 (method, path, handler) 三元组 ├── backend.go # Backend 接口:定义 router 需要 daemon 提供哪些方法 ├── <resource>_routes.go # handler 实现:解析 HTTP 请求 → 调 backend → 写响应 └── *_test.go # 测试个别资源(如system/)把路由表直接写在<resource>.go里没拆initRoutes,但本质一样。
3. 三件套详解
3.1 路由表:<resource>.go
每个资源都有一个<resource>Router结构体,持有:
backend Backend:业务接口(不直接持有 daemon,做解耦)
routes []router.Route:路由列表
以container/container.go:46为例:
type containerRouter struct { backend Backend routes []router.Route } func NewRouter(b Backend) router.Router { r := &containerRouter{backend: b} r.initRoutes() return r } func (c *containerRouter) initRoutes() { c.routes = []router.Route{ router.NewGetRoute ("/containers/json", c.getContainersJSON), router.NewPostRoute("/containers/create", c.postContainersCreate), router.NewPostRoute("/containers/{name:.*}/start", c.postContainersStart), router.NewPostRoute("/containers/prune", c.postContainersPrune, router.WithMinimumAPIVersion("1.25")), // 1.25 之后才有 router.NewDeleteRoute("/containers/{name:.*}", c.deleteContainers), // ... } }几个要点:
router.NewXxxRoute(method, path, handler)是工厂方法,XXX = Get/Post/Put/Delete/Head/Options。
- 路径用 gorilla/mux 语法:
{name:.*}是路径参数,.*是允许的字符正则。匹配后vars["name"]可以取出值。
router.WithMinimumAPIVersion("1.25")是路由级装饰器:API 版本不够的客户端访问会被拒绝(见第 4 节)。
- 路由表就是一张静态查找表,没注册的 URL 直接 404。注册顺序在某些场景下有意义(见第 8 节 FAQ)。
3.2 Backend 接口:backend.go
router不直接调用*daemon.Daemon,而是依赖一个 Go interface。
这样做的好处:router 可以脱离真实 daemon 单测,将来换实现也不影响 HTTP 层。
container/backend.go:78把这个 interface 拆得很细:
type Backend interface { commitBackend // CreateImageFromContainer(...) execBackend // ContainerExecCreate/Start/Inspect(...) copyBackend // ContainerArchivePath/Export/ExtractToDir(...) stateBackend // ContainerCreate/Start/Stop/Kill/Pause/... monitorBackend // ContainerInspect/Logs/Stats/Top/Containers(...) attachBackend // ContainerAttach(...) systemBackend // ContainerPrune(...) sysInfoProvider // RawSysInfo() }每个子接口对应一类功能。*daemon.Daemon实现了全部方法,所以传给NewRouter(opts.daemon)没问题。
新手关键认知:「router 是薄壳,daemon 是厚核心」。HTTP handler 普遍只有 10~30 行,复杂度全在 daemon 业务层。
3.3 handler:<resource>_routes.go
handler 统一签名(来自httputils.APIFunc):
func (c *containerRouter) postContainersStart( ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string, // gorilla/mux 提取的路径参数 ) error统一签名的好处:上层可以套统一的中间件(鉴权、版本检查、错误转换、日志、tracing)。
handler 通用模式(4 步):
func (c *containerRouter) postContainersStart(ctx, w, r, vars) error { // 1. 解析请求(路径参数 / form / JSON body) name := vars["name"] // 2. 可选校验(参数合法性) // ... // 3. 调 backend(业务全在 daemon 里) if err := c.backend.ContainerStart(ctx, name, "", ""); err != nil { return err // ← 注意:直接 return error,不写 HTTP status code } // 4. 写响应 w.WriteHeader(http.StatusNoContent) // 204 return nil }关键设计:handler 只return error,不写 status code。
error 由上层中间件根据类型自动转成 HTTP 状态码:
errdefs.NotFound→ 404
errdefs.InvalidParameter→ 400
errdefs.Conflict→ 409
- 其他 → 500
这样 handler 专注业务,错误处理统一。详见daemon/server/httpstatus/。
4. 路由核心:local.go拆解
daemon/server/router/local.go是路由系统的最小积木。全文不到 120 行,必须看懂。
4.1 Route 接口与 localRoute 实现
router.go定义接口:
type Route interface { Handler() httputils.APIFunc // 处理函数 Method() string // HTTP 方法 Path() string // URL 模式 }local.go给出唯一实现:
type localRoute struct { method string path string handler httputils.APIFunc }(method, path, handler)三元组——所有 HTTP 框架里最经典的模型。
4.2 NewRoute:工厂 + 装饰器
func NewRoute(method, path string, handler httputils.APIFunc, opts ...RouteWrapper) Route { var r Route = localRoute{method, path, handler} for _, o := range opts { r = o(r) // ← 逐层包裹 } return r }opts ...RouteWrapper是**装饰器模式(middleware)**的精髓:
传WithMinimumAPIVersion("1.25"),就给 handler 套一层「检查版本」的壳;
将来还能套「实验性开关」「权限校验」等。
NewGetRoute/NewPostRoute/...都是对NewRoute的薄封装,只固定了 HTTP method。
4.3 WithMinimumAPIVersion:版本协商核心
Docker API 有版本概念(v1.41、v1.42、……)。客户端访问/v1.42/containers/...,server 怎么知道?
func WithMinimumAPIVersion(minAPIVersion string) RouteWrapper { return func(route Route) Route { return localRoute{ method: route.Method(), path: route.Path(), handler: func(ctx, w, r, vars) error { v := httputils.VersionFromContext(ctx) if v != "" && versions.LessThan(v, minAPIVersion) { return versionError(...) // → 400 InvalidParameter } return route.Handler()(ctx, w, r, vars) }, } } }为什么版本不够返回 400 而不是 404?
源码注释里讲得很清楚(也是新手最容易踩的坑):
业务逻辑广泛用 404 表示「对象不存在」(比如GET /volumes/foo找不到就 404)。
如果「端点不存在」也返回 404,客户端无法区分两种情况。
统一用 400(InvalidParameter),客户端能正确分支处理。
4.4 localRoute 没有实现的:ExperimentalRoute
注意local.go里没有「实验性路由」逻辑——它在experimental.go里通过类型断言实现:
// daemon/server/router/experimental.go(同目录) type ExperimentalRoute interface { Route Enable() }只有 daemon 开启了实验特性,才会调用Enable()激活这些路由。具体启用点在daemon/command/daemon.go:1012附近的if opts.daemon.HasExperimental()分支。
5. 路由注册全流程:从启动到生效
router 自己只是数据结构,谁来把它们装进真正的 HTTP server?看daemon/command/daemon.go:989:
func initRouters(opts daemonOptions) []router.Router { routers := []router.Router{ // 注意顺序:checkpoint 必须在 container 之前,否则 DELETE 会被 container 抢匹配 checkpointrouter.NewRouter(opts.daemon), container.NewRouter(opts.daemon), image.NewRouter(opts.daemon, opts.daemon.RegistryService()), systemrouter.NewRouter(opts.daemon, opts.cluster, opts.builder.buildkit, opts.daemon.Features), volume.NewRouter(opts.daemon.VolumesService(), opts.cluster), build.NewRouter(opts.builder.backend, opts.daemon), sessionrouter.NewRouter(opts.builder.sessionManager), swarmrouter.NewRouter(opts.cluster), pluginrouter.NewRouter(opts.daemon.PluginManager()), distributionrouter.NewRouter(opts.daemon.ImageBackend()), network.NewRouter(opts.daemon, opts.cluster), debugrouter.NewRouter(), } if opts.builder.backend != nil { routers = append(routers, grpcrouter.NewRouter(opts.builder.backend)) } if opts.daemon.HasExperimental() { // 启用所有 ExperimentalRoute } return routers }之后这些 router 被丢给apiserver.Server(daemon/server/server.go),server 把每条 route 注册到底层gorilla/mux,再加版本前缀/v{version}/,再叠上中间件链:
请求进来 → CORS 中间件 → Debug 中间件 → 实验性中间件 → 版本协商中间件(注入版本号到 ctx) → 鉴权中间件(authorization plugin) → gorilla/mux 路由匹配 → RouteWrapper 链(如 WithMinimumAPIVersion) → handler6. 实战:跟着docker run nginx走一遍
这是把所有知识点串起来的最佳练习。
Step 1:CLI 端
docker run nginx→ 实际发两个HTTP 请求:
POST /v1.42/containers/createbody=容器配置 → 拿到容器 ID
POST /v1.42/containers/{id}/start→ 启动
Step 2:路由匹配
两个 URL 都在container/container.go:67的路由表里:
/containers/create→c.postContainersCreate
/containers/{name:.*}/start→c.postContainersStart
Step 3:handler 解析(container/container_routes.go)
postContainersCreate大致流程:
httputils.ReadJSON(r, &body)解析 JSON
- 调
runconfig.DecodeContainerConfig把 JSON 转成container.Config+hostconfig.HostConfig
- 校验:不能同时
detach=true和stdin_open=true
- 调
c.backend.ContainerCreate(ctx, config)→ 走到daemon.create.go::ContainerCreate
- 返回
{"Id": "...", "Warnings": [...]}
postContainersStart流程:
vars["name"]取容器名/ID
- 调
c.backend.ContainerStart(ctx, name, checkpoint, checkpointDir)
- 成功返回 204
Step 4:daemon 业务层
daemon.create.go/daemon.start.go才是真正干活的地方——读镜像、写容器元数据、建 namespace、调 containerd 等。这一层已经和 HTTP 无关。
Step 5:响应回写
handler 把结果 JSON 序列化 →w.Write(...)→ docker CLI 收到 → 打印容器 ID 给用户。
7. 各资源路由速查表
下面是各资源的核心端点一览,方便你拿到一条 docker 命令时,立刻定位代码。
container(container/container.go)
端点 | handler | docker 命令 |
GET |
|
|
GET |
|
|
GET |
|
|
GET |
|
|
GET |
|
|
GET |
|
|
GET |
|
|
GET |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
POST |
|
|
PUT |
|
|
DELETE |
|
|
image(image/image.go)
端点 | docker 命令 |
GET |
|
GET |
|
GET |
|
GET |
|
GET |
|
POST |
|
POST |
|
POST |
|
POST |
|
POST |
|
DELETE |
|
network(network/network.go)
端点 | docker 命令 |
GET |
|
GET |
|
POST |
|
POST |
|
POST |
|
POST |
|
DELETE |
|
volume(volume/volume.go)
端点 | docker 命令 |
GET |
|
GET |
|
POST |
|
POST |
|
PUT |
|
DELETE |
|
system(system/system.go)
端点 | docker 命令 |
GET | 健康检查(CLI 启动时探测) |
GET |
|
GET |
|
GET |
|
GET |
|
POST |
|
OPTIONS | CORS 预检 |
build(build/build.go)
端点 | docker 命令 |
POST |
|
POST |
|
POST | 取消进行中的 build |
8. FAQ 与进阶话题
Q1:为什么 checkpoint router 必须在 container router 之前注册?
daemon/command/daemon.go:990注释明说:因为DELETE /containers/{id}/checkpoints/{checkpoint}和DELETE /containers/{name:.*}路径有重叠。gorilla/mux 按注册顺序匹配,先注册的优先。
如果 container 在前,所有/containers/...的 DELETE 都会被deleteContainers抢走。
Q2:handler 报错怎么变成 HTTP 状态码?
看daemon/server/httpstatus/:它根据 error 实现的接口(NotFound()、InvalidParameter()、Conflict()等)
映射到 404/400/409。错误类型定义在errdefs包。
Q3:URL 前缀的/v1.42/是谁加的?
不是各资源 router 自己加,是 server 层用 subrouter 自动拼上/v{version}/。
版本号由VersionMiddleware解析客户端请求头/URL 后注入 ctx,handler 通过httputils.VersionFromContext(ctx)取。
Q4:router 之间会复用 backend 吗?
会。比如container.NewRouter(opts.daemon)、image.NewRouter(opts.daemon, ...)、network.NewRouter(opts.daemon, ...)传的都是同一个*daemon.Daemon,
只是各自从 Backend 接口里挑自己需要的方法。
Q5:handler 里的 streaming(logs、attach、events)怎么实现?
通过 hijack HTTP 连接 / chunked transfer / websocket。比如:
getContainersLogs把 backend 返回的<-chan *LogMessage逐条写到 ResponseWriter
wsContainersAttach用golang.org/x/net/websocket升级连接
postContainersAttach直接 hijack TCP 连接
这部分属于进阶话题,新手先理解同步 JSON 端点即可。
Q6:为什么有些资源有两个 backend(Backend+ClusterBackend)?
如volume、network、system、swarm:单机功能走Backend,
Swarm 集群相关功能走ClusterBackend。在非 Swarm 节点上ClusterBackend是 nil,
handler 内部判 nil 决定走单机还是集群分支。