iot_cloud/docs/container_deploy_request.md
2026-05-30 15:25:40 +08:00

671 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# /container/deploy 容器创建请求参数说明
本文档说明 `iot` HTTP 接口 `POST /container/deploy` 当前支持的 JSON 参数、校验规则,以及服务端把 JSON 解码成 Erlang map 后如何转换成下发给 `efka` 的容器部署命令。
对应代码:
- HTTP 入口:[src/transport/http/container_handler.erl](/usr/local/code/cloudkit/iot/src/transport/http/container_handler.erl:63)
- HTTP JSON 解析:[src/transport/http/http_protocol.erl](/usr/local/code/cloudkit/iot/src/transport/http/http_protocol.erl:68)
- 参数校验与内部 map 构造:[src/docker/docker_container_builder.erl](/usr/local/code/cloudkit/iot/src/docker/docker_container_builder.erl:25)
- 主机命令下发:[src/host/iot_host.erl](/usr/local/code/cloudkit/iot/src/host/iot_host.erl:99)
## 1. 接口
```http
POST /container/deploy
Content-Type: application/json
```
HTTP body 必须是 JSON object。`http_protocol` 会使用 `json:decode/1` 解析请求体:
- JSON object -> Erlang map
- JSON array -> Erlang list
- JSON string -> Erlang binary
- JSON integer -> Erlang integer
- JSON float -> Erlang float
- JSON boolean -> Erlang `true | false`
`/container/deploy` handler 只接受顶层 map且必须匹配
```erlang
#{<<"uuid">> := UUID, <<"task_id">> := TaskId, <<"config">> := Config}
```
其中:
- `UUID` 必须是 binary也就是 JSON string。
- `TaskId` 必须是 integer。
- `Config` 必须是 map也就是 JSON object。
## 2. 完整 JSON 格式
下面示例包含当前支持的所有字段。实际请求可以只传必填字段和需要的可选字段。
```json
{
"uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm",
"task_id": 1001,
"config": {
"image": "docker.io/library/nginx:latest",
"container_name": "my_nginx",
"command": ["nginx", "-g", "daemon off;"],
"restart": "always",
"entrypoint": ["/docker-entrypoint.sh"],
"envs": ["ENV=prod", "TZ=Asia/Shanghai"],
"expose": ["80", "443/tcp", "53/udp"],
"volumes": ["/host/data:/data", "/host/log:/var/log:ro"],
"networks": ["bridge"],
"network_mode": "bridge",
"labels": {
"app": "nginx",
"env": "prod"
},
"user": "www-data",
"working_dir": "/app",
"hostname": "myhost",
"privileged": false,
"cap_add": ["NET_ADMIN"],
"cap_drop": ["MKNOD"],
"devices": ["/dev/ttyUSB0:/dev/ttyUSB0:rwm"],
"mem_limit": "512m",
"mem_reservation": "256m",
"cpu_shares": 512,
"cpus": 1.5,
"ulimits": {
"nofile": "1024:2048"
},
"sysctls": {
"net.ipv4.ip_forward": "1"
},
"tmpfs": ["/tmp", "/run:rw,size=64m"],
"extra_hosts": ["host.docker.internal:host-gateway"],
"healthcheck": {
"test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
"interval": "30s",
"timeout": "10s",
"retries": 3
}
}
}
```
## 3. 顶层参数
| 字段 | JSON 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `uuid` | string | 是 | 目标 efka 所属主机 UUID。服务端用它查找 `iot_host` 进程,不会下发到 efka。 |
| `task_id` | non-negative integer | 是 | 部署任务 ID。会进入内部命令的 `task_id` 字段,用于关联部署结果和部署日志流。`iot` 内部使用 `{uuid, task_id}` 作为任务唯一标识。 |
| `config` | object | 是 | 容器创建配置。会被校验并转换成内部部署参数 map。 |
顶层没有 `timeout` 字段。当前 HTTP handler 等待 efka command response 的超时时间固定为 10 秒。
部署过程的实时反馈通过 SSE 读取:
```http
GET /event_stream?uuid=<host_uuid>&task_id=<task_id>
```
`/container/deploy` 收到合法请求后会先在 `iot` 内部创建或复用 `{uuid, task_id}` 对应的任务进程,然后再向 `efka` 下发部署命令。SSE handler 订阅同一个任务进程;该进程会缓存最近的部署事件,支持多个页面同时订阅,并在收到 efka 的 close 事件后关闭 SSE。
## 4. config 参数
### 必填字段
| 字段 | JSON 类型 | 内部类型 | 说明 |
| --- | --- | --- | --- |
| `image` | string | binary | 镜像名。没有 tag 时 efka 部署逻辑会补 `:latest`。 |
| `container_name` | string | binary | 容器名称。 |
| `command` | string[] | binary list | 容器启动命令,对应 Docker create config 的 `cmd`。 |
| `restart` | string | binary | 重启策略,例如 `no``always``unless-stopped``on-failure``on-failure:3`。 |
### 可选字段
| 字段 | JSON 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `entrypoint` | string[] | `[]` | Docker create config 的 `entrypoint`。 |
| `envs` | string[] | `[]` | 环境变量列表,例如 `["A=1"]`,对应 Docker create config 的 `env`。 |
| `expose` | string[] | `[]` | 容器暴露端口,只表示容器端口,不绑定宿主机端口。 |
| `ports` | string[] | `[]` | 宿主机到容器的端口映射,格式为 `host_port:container_port`,例如 `["8080:80", "443:443"]`。 |
| `volumes` | string[] | `[]` | volume bind 列表,格式见后文。 |
| `networks` | string[] | `[]` | Docker network 名称列表,用于 networking config。 |
| `network_mode` | string | `""` | Docker host config 的 `network_mode`。 |
| `labels` | object string:string | `{}` | 容器 labels。key/value 都必须是 string。 |
| `user` | string | `""` | 容器运行用户。 |
| `working_dir` | string | `""` | 容器工作目录。 |
| `hostname` | string | `""` | 容器 hostname。 |
| `privileged` | boolean | `false` | 是否 privileged。 |
| `cap_add` | string[] | `[]` | 追加 Linux capability。 |
| `cap_drop` | string[] | `[]` | 删除 Linux capability。 |
| `devices` | string[] | `[]` | 设备映射列表,格式见后文。 |
| `mem_limit` | string | `0` | 内存上限,解析成字节数。 |
| `mem_reservation` | string | `0` | 内存软限制,解析成字节数。 |
| `cpu_shares` | non-negative integer | `0` | Docker CPU shares。 |
| `cpus` | non-negative number | `0` | CPU 数量,转换成 nano cpus。 |
| `ulimits` | object string:string | `[]` | ulimit 配置,格式见后文。 |
| `sysctls` | object string:string | `{}` | sysctl 配置。key/value 都必须是 string。 |
| `tmpfs` | string[] | `{}` | tmpfs mount 配置,格式见后文。 |
| `extra_hosts` | string[] | `[]` | 额外 hosts例如 `["host.docker.internal:host-gateway"]`。 |
| `healthcheck` | object | `undefined` | 健康检查配置,格式见后文。 |
### 不支持字段
| 字段 | 当前行为 | 说明 |
| --- | --- | --- |
| `container_dir` | 明确拒绝 | 容器目录由 efka 按系统默认规则管理HTTP 调用方不能指定 efka 主机上的部署目录。 |
| `env_file` | 忽略 | 当前校验不会识别该字段,后续构造 Docker create options 时也不会使用。 |
| 其他未知字段 | 忽略 | 除 `container_dir` 外,未知字段不会报错,也不会进入内部部署参数。 |
## 5. 校验规则
校验分为两层。
第一层在 `container_handler`
- `uuid` 必须是 binary。
- `task_id` 必须是非负 integer。
- `config` 必须是 map。
第二层在 `docker_container_builder:deploy_request/2`
- 先拒绝不支持的 `container_dir` 字段。
- 检查必填字段是否存在。
- 检查已知字段类型。
- 检查 `healthcheck` 内部字段类型,避免无效嵌套字段进入构造阶段。
- 构造内部部署 map。
- 对需要解析的字段做格式解析例如端口、volume、size、duration、ulimit。
类型错误会返回类似:
```text
required parameter: <<"command">>, type must be: list of string
optional parameter: <<"labels">>, type must be: map of string:string
```
缺少必填字段会返回类似:
```text
miss requied parameter: <<"image">>
```
注意:错误文本中的 `requied` 是当前代码里的原始拼写。
## 6. 内部转换结果
`config` 校验通过后,`docker_container_builder:deploy_request(TaskId, Config)` 生成:
```erlang
#{
<<"action">> => <<"deploy">>,
<<"task_id">> => TaskId,
<<"params">> => #{
<<"container_name">> => ContainerName,
<<"create">> => #{
<<"config">> => ContainerConfig,
<<"host_config">> => HostConfig,
<<"networking_config">> => NetworkingConfig
}
}
}
```
该 map 会通过 efka/iot 长连接协议下发:
```erlang
{<<"command">>, Ref, {<<"container">>, #{
<<"action">> => <<"deploy">>,
<<"task_id">> => TaskId,
<<"params">> => Params
}}}
```
efka 返回:
```erlang
{<<"command_response">>, Ref, {<<"container">>, Reply}}
```
其中 `Ref``crypto:strong_rand_bytes(16)` 生成的 16 字节 binary。网络帧只使用 safe term协议 label、command map key 和 action 使用 binary`efka` 收到后直接按 binary key/action 处理。
HTTP handler 最多等待 10 秒。超时返回 HTTP 504其他参数或执行错误通常返回 HTTP 400找不到 host 返回业务错误 code 404。
## 7. Docker create config 映射
`create.config``build_docker_container_config/1` 生成:
| 内部字段 | 来源 JSON 字段 | 转换规则 |
| --- | --- | --- |
| `image` | `image` | 原值。 |
| `cmd` | `command` | 原值。 |
| `entrypoint` | `entrypoint` | 默认 `[]`。 |
| `env` | `envs` | 默认 `[]`。 |
| `labels` | `labels` | 默认 `{}`。 |
| `volumes` | `volumes` | 只保留 container path 列表。 |
| `user` | `user` | 默认 `""`。 |
| `working_dir` | `working_dir` | 默认 `""`。 |
| `hostname` | `hostname` | 默认 `""`。 |
| `exposed_ports` | `expose` + `ports` | 转成 `#{container_port, protocol}` map 列表;`ports` 中的容器端口会自动补进 `exposed_ports`。 |
| `healthcheck` | `healthcheck` | 未传时为 `undefined`。 |
## 8. Docker host config 映射
`create.host_config``build_docker_host_config/1` 生成:
| 内部字段 | 来源 JSON 字段 | 转换规则 |
| --- | --- | --- |
| `binds` | `volumes` | 转成 Docker bind 字符串列表。 |
| `network_mode` | `network_mode` | 默认 `""`。 |
| `restart_policy` | `restart` | 转成 `#{name, maximum_retry_count}`。 |
| `privileged` | `privileged` | 默认 `false`。 |
| `cap_add` | `cap_add` | 默认 `[]`。 |
| `cap_drop` | `cap_drop` | 默认 `[]`。 |
| `devices` | `devices` | 转成设备映射 map 列表。 |
| `memory` | `mem_limit` | 解析成字节数,未传为 `0`。 |
| `memory_reservation` | `mem_reservation` | 解析成字节数,未传为 `0`。 |
| `nano_cpus` | `cpus` | `cpus * 1000000000`,未传为 `0`。 |
| `cpu_shares` | `cpu_shares` | 未传为 `0`。 |
| `port_bindings` | `ports` | 转成 `#{host_ip, host_port, container_port, protocol}` map 列表。 |
| `ulimits` | `ulimits` | 转成 ulimit map 列表。 |
| `tmpfs` | `tmpfs` | 转成 map。 |
| `sysctls` | `sysctls` | 默认 `{}`。 |
| `extra_hosts` | `extra_hosts` | 默认 `[]`。 |
## 9. Docker networking config 映射
`create.networking_config``build_docker_networking_config/1` 生成:
```erlang
#{<<"endpoints">> => [#{<<"name">> => Network} || Network <- Networks]}
```
来源字段:
```json
"networks": ["bridge", "mynet"]
```
转换结果:
```erlang
#{<<"endpoints">> => [
#{<<"name">> => <<"bridge">>},
#{<<"name">> => <<"mynet">>}
]}
```
## 10. 复杂字段转换规则
### restart
输入:
```json
"restart": "always"
```
转换:
```erlang
#{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0}
```
输入:
```json
"restart": "on-failure:3"
```
转换:
```erlang
#{<<"name">> => <<"on-failure">>, <<"maximum_retry_count">> => 3}
```
### expose
输入:
```json
["80", "443/tcp", "53/udp"]
```
转换:
```erlang
[
#{<<"container_port">> => 80, <<"protocol">> => <<"tcp">>},
#{<<"container_port">> => 443, <<"protocol">> => <<"tcp">>},
#{<<"container_port">> => 53, <<"protocol">> => <<"udp">>}
]
```
端口必须是无符号整数,且不能超过 `4294967295`
### ports
输入:
```json
["8080:80", "443:443", "8053:53/udp"]
```
转换为 `create.host_config.port_bindings`
```erlang
[
#{<<"host_ip">> => <<>>, <<"host_port">> => 8080, <<"container_port">> => 80, <<"protocol">> => <<"tcp">>},
#{<<"host_ip">> => <<>>, <<"host_port">> => 443, <<"container_port">> => 443, <<"protocol">> => <<"tcp">>},
#{<<"host_ip">> => <<>>, <<"host_port">> => 8053, <<"container_port">> => 53, <<"protocol">> => <<"udp">>}
]
```
规则:
- 端口映射之间使用冒号分隔:`host_port:container_port`
- 容器端口可以带协议:`host_port:container_port/protocol`
- 未指定协议时默认为 `tcp`
- host port 和 container port 都不能为空,且必须是 `0..65535` 范围内的无符号整数。
- `ports` 中出现的容器端口会自动补进 `create.config.exposed_ports`,因此不需要在 `expose` 里重复声明。
### volumes
输入:
```json
["/host/data:/data", "/host/log:/var/log:ro", "/host/cache:/cache:rw"]
```
转换为 `create.config.volumes`
```erlang
[<<"/data">>, <<"/var/log">>, <<"/cache">>]
```
转换为 `create.host_config.binds`
```erlang
[
<<"/host/data:/data">>,
<<"/host/log:/var/log:ro">>,
<<"/host/cache:/cache">>
]
```
规则:
- `host_path:container_path` -> 读写挂载。
- `host_path:container_path:ro` -> 只读挂载。
- `host_path:container_path:rw` -> 当前实现视为读写挂载,输出时不会保留 `:rw`
- host path 和 container path 都不能为空。
### devices
输入:
```json
["/dev/ttyUSB0:/dev/ttyUSB0", "/dev/snd:/dev/snd:rwm"]
```
转换:
```erlang
[
#{
<<"path_on_host">> => <<"/dev/ttyUSB0">>,
<<"path_in_container">> => <<"/dev/ttyUSB0">>,
<<"cgroup_permissions">> => <<"rwm">>
},
#{
<<"path_on_host">> => <<"/dev/snd">>,
<<"path_in_container">> => <<"/dev/snd">>,
<<"cgroup_permissions">> => <<"rwm">>
}
]
```
规则:
- `host_path:container_path` -> 权限默认为 `rwm`
- `host_path:container_path:permissions` -> 使用第三段作为权限。
- 任意路径或 permissions 为空时返回 `invalid device mapping`
### ulimits
输入:
```json
{
"nofile": "1024:2048",
"nproc": "4096"
}
```
转换:
```erlang
[
#{<<"name">> => <<"nofile">>, <<"soft">> => 1024, <<"hard">> => 2048},
#{<<"name">> => <<"nproc">>, <<"soft">> => 4096, <<"hard">> => 4096}
]
```
规则:
- `"soft:hard"` -> 分别设置 soft 和 hard。
- `"limit"` -> soft 和 hard 都等于 limit。
- 数值必须是无符号整数。
### tmpfs
输入:
```json
["/tmp", "/run:rw,size=64m"]
```
转换:
```erlang
#{
<<"/tmp">> => <<>>,
<<"/run">> => <<"rw,size=64m">>
}
```
规则:
- `"path"` -> options 为 `""`
- `"path:options"` -> options 为第二段。
- path 不能为空。
### healthcheck
输入:
```json
{
"test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
"interval": "30s",
"timeout": "10s",
"retries": 3
}
```
转换:
```erlang
#{
<<"test">> => [<<"CMD-SHELL">>, <<"curl -f http://localhost || exit 1">>],
<<"interval_ns">> => 30000000000,
<<"timeout_ns">> => 10000000000,
<<"retries">> => 3
}
```
字段规则:
| 字段 | JSON 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `test` | string[] | `[]` | Docker healthcheck test 参数列表。 |
| `interval` | string 或 non-negative integer | `"0s"` | string 会解析时间单位integer 直接视为纳秒。 |
| `timeout` | string 或 non-negative integer | `"0s"` | string 会解析时间单位integer 直接视为纳秒。 |
| `retries` | non-negative integer | `0` | Docker healthcheck 重试次数。 |
时间单位:
| 单位 | 含义 |
| --- | --- |
| `ns` | 纳秒 |
| `us` | 微秒 |
| `ms` | 毫秒 |
| `s` | 秒 |
| `m` | 分钟 |
| `h` | 小时 |
| 无单位 | 秒 |
示例:
- `"30s"` -> `30000000000`
- `"10ms"` -> `10000000`
- `"2m"` -> `120000000000`
- `"5"` -> `5000000000`
### mem_limit 和 mem_reservation
输入:
```json
"mem_limit": "512m",
"mem_reservation": "1g"
```
转换:
```erlang
<<"memory">> => 536870912,
<<"memory_reservation">> => 1073741824
```
支持单位:
| 单位 | 倍数 |
| --- | --- |
| `b` 或无单位 | 1 |
| `k`, `kb`, `ki`, `kib` | 1024 |
| `m`, `mb`, `mi`, `mib` | 1048576 |
| `g`, `gb`, `gi`, `gib` | 1073741824 |
| `t`, `tb`, `ti`, `tib` | 1099511627776 |
数值支持整数或小数,例如 `"1.5g"`
### cpus
输入:
```json
"cpus": 1.5
```
转换:
```erlang
<<"nano_cpus">> => 1500000000
```
规则:
- integer 或 float 都可以。
- 必须大于等于 0。
- 转换公式:`trunc(Cpus * 1000000000)`
## 11. 最小请求示例
```json
{
"uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm",
"task_id": 1001,
"config": {
"image": "docker.io/library/nginx:latest",
"container_name": "my_nginx",
"command": ["nginx", "-g", "daemon off;"],
"restart": "always"
}
}
```
对应下发给 `efka` 的协议命令示意:
```erlang
{<<"command">>, Ref, {<<"container">>, #{
<<"action">> => <<"deploy">>,
<<"task_id">> => 1001,
<<"params">> => #{
<<"container_name">> => <<"my_nginx">>,
<<"create">> => #{
<<"config">> => #{
<<"image">> => <<"docker.io/library/nginx:latest">>,
<<"cmd">> => [<<"nginx">>, <<"-g">>, <<"daemon off;">>],
<<"entrypoint">> => [],
<<"env">> => [],
<<"labels">> => #{},
<<"volumes">> => [],
<<"user">> => <<>>,
<<"working_dir">> => <<>>,
<<"hostname">> => <<>>,
<<"exposed_ports">> => [],
<<"healthcheck">> => undefined
},
<<"host_config">> => #{
<<"binds">> => [],
<<"network_mode">> => <<>>,
<<"restart_policy">> => #{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0},
<<"privileged">> => false,
<<"cap_add">> => [],
<<"cap_drop">> => [],
<<"devices">> => [],
<<"memory">> => 0,
<<"memory_reservation">> => 0,
<<"nano_cpus">> => 0,
<<"cpu_shares">> => 0,
<<"port_bindings">> => [],
<<"ulimits">> => [],
<<"tmpfs">> => #{},
<<"sysctls">> => #{},
<<"extra_hosts">> => []
},
<<"networking_config">> => #{<<"endpoints">> => []}
}
}
}}}
```
## 12. 响应
成功时 HTTP body 由 `iot_util:json_data/1` 包装:
```json
{
"result": "ok"
}
```
如果 efka 返回的是 JSON binaryhandler 会尝试解码后再放入 `result`
错误示例:
```json
{
"error": {
"code": 400,
"message": "invalid port binding"
}
}
```
常见状态:
| HTTP 状态 | 场景 |
| --- | --- |
| `200` | host not found 或构造阶段返回的业务错误也可能通过 200 包装业务错误。 |
| `400` | 参数校验失败、efka 返回普通错误。 |
| `504` | 等待 efka command response 超时。 |