520 lines
11 KiB
Markdown
520 lines
11 KiB
Markdown
# Container Deploy 请求格式说明
|
||
|
||
本文档说明 HTTP 接口 `/container/deploy` 接收的 JSON 格式,以及它在服务端如何被转换为 `message.proto` 中的 `ContainerRequest.Deploy` / `ContainerDeployParams` / `ContainerSpec`。
|
||
|
||
当前实现对应代码:
|
||
|
||
- HTTP 入口校验:[src/transport/http/container_handler.erl](/usr/local/code/cloudkit/iot/src/transport/http/container_handler.erl:63)
|
||
- 请求构造器:[src/host/iot_container_request_builder.erl](/usr/local/code/cloudkit/iot/src/host/iot_container_request_builder.erl:25)
|
||
- protobuf 定义:[proto/message.proto](/usr/local/code/cloudkit/iot/proto/message.proto:37)
|
||
|
||
## 1. HTTP 请求格式
|
||
|
||
接口:
|
||
|
||
- `POST /container/deploy`
|
||
|
||
请求体顶层结构:
|
||
|
||
```json
|
||
{
|
||
"uuid": "host-uuid",
|
||
"task_id": 1001,
|
||
"config": {
|
||
"image": "docker.io/library/nginx:latest",
|
||
"container_name": "my_nginx",
|
||
"container_dir": "/data/apps/my_nginx",
|
||
"command": ["nginx", "-g", "daemon off;"],
|
||
"entrypoint": ["/docker-entrypoint.sh"],
|
||
"envs": ["ENV1=val1", "ENV2=val2"],
|
||
"expose": ["80", "443/tcp"],
|
||
"volumes": ["/host/data:/data", "/host/log:/var/log:ro"],
|
||
"networks": ["bridge"],
|
||
"network_mode": "bridge",
|
||
"labels": {
|
||
"role": "web",
|
||
"env": "prod"
|
||
},
|
||
"restart": "always",
|
||
"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
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 2. 顶层字段与 protobuf 的关系
|
||
|
||
HTTP 顶层字段和 protobuf 请求体的关系如下:
|
||
|
||
| HTTP 字段 | 类型 | 目标 protobuf 字段 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| `uuid` | `string` | 不进入 protobuf body | 仅用于在服务端定位 `iot_host` 进程 |
|
||
| `task_id` | `integer` | `ContainerRequest.Deploy.task_id` | 必填,用于异步消息集合标识 |
|
||
| `config` | `object` | `ContainerRequest.Deploy.params` | 必填,转换成 `ContainerDeployParams` |
|
||
|
||
最终构造出来的请求体结构是:
|
||
|
||
```text
|
||
ContainerRequest {
|
||
action = {
|
||
deploy,
|
||
ContainerRequest.Deploy {
|
||
task_id = TaskId,
|
||
params = ContainerDeployParams {...}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 3. `config` 到 `ContainerDeployParams` 的映射
|
||
|
||
`config` 会被转换成:
|
||
|
||
```text
|
||
ContainerDeployParams {
|
||
container_name,
|
||
container_dir,
|
||
spec = ContainerSpec {...}
|
||
}
|
||
```
|
||
|
||
字段映射如下:
|
||
|
||
| `config` 字段 | 类型 | protobuf 字段 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| `container_name` | `string` | `ContainerDeployParams.container_name` | 必填 |
|
||
| `container_dir` | `string` | `ContainerDeployParams.container_dir` | 可选,默认 `""` |
|
||
| 其余部署字段 | 多种 | `ContainerDeployParams.spec` | 转成 `ContainerSpec` |
|
||
|
||
## 4. `config` 到 `ContainerSpec` 的详细映射
|
||
|
||
### 4.1 直接映射字段
|
||
|
||
以下字段基本按原值写入:
|
||
|
||
| `config` 字段 | 类型 | protobuf 字段 | 默认值 |
|
||
| --- | --- | --- | --- |
|
||
| `image` | `string` | `ContainerSpec.image` | 必填 |
|
||
| `command` | `string[]` | `ContainerSpec.command` | 必填 |
|
||
| `entrypoint` | `string[]` | `ContainerSpec.entrypoint` | `[]` |
|
||
| `envs` | `string[]` | `ContainerSpec.env` | `[]` |
|
||
| `networks` | `string[]` | `ContainerSpec.networks` | `[]` |
|
||
| `network_mode` | `string` | `ContainerSpec.network_mode` | `""` |
|
||
| `user` | `string` | `ContainerSpec.user` | `""` |
|
||
| `working_dir` | `string` | `ContainerSpec.working_dir` | `""` |
|
||
| `hostname` | `string` | `ContainerSpec.hostname` | `""` |
|
||
| `privileged` | `boolean` | `ContainerSpec.privileged` | `false` |
|
||
| `cap_add` | `string[]` | `ContainerSpec.cap_add` | `[]` |
|
||
| `cap_drop` | `string[]` | `ContainerSpec.cap_drop` | `[]` |
|
||
| `extra_hosts` | `string[]` | `ContainerSpec.extra_hosts` | `[]` |
|
||
|
||
### 4.2 map 转换字段
|
||
|
||
| `config` 字段 | 类型 | protobuf 字段 | 转换方式 |
|
||
| --- | --- | --- | --- |
|
||
| `labels` | `map<string,string>` | `ContainerSpec.labels` | 转成 key/value 列表 |
|
||
| `sysctls` | `map<string,string>` | `ContainerSpec.sysctls` | 转成 key/value 列表 |
|
||
|
||
### 4.3 结构化转换字段
|
||
|
||
#### `volumes`
|
||
|
||
输入类型:
|
||
|
||
```json
|
||
["/host/data:/data", "/host/log:/var/log:ro"]
|
||
```
|
||
|
||
目标:
|
||
|
||
```text
|
||
ContainerSpec.volumes = [VolumeBind...]
|
||
```
|
||
|
||
转换规则:
|
||
|
||
- `host_path:container_path`
|
||
- `read_only = false`
|
||
- `host_path:container_path:ro`
|
||
- `read_only = true`
|
||
- `host_path:container_path:rw`
|
||
- 当前实现也会被接受,但 `read_only = false`
|
||
|
||
生成结构:
|
||
|
||
```text
|
||
VolumeBind {
|
||
host_path,
|
||
container_path,
|
||
read_only
|
||
}
|
||
```
|
||
|
||
#### `expose`
|
||
|
||
输入类型:
|
||
|
||
```json
|
||
["80", "443/tcp", "53/udp"]
|
||
```
|
||
|
||
目标:
|
||
|
||
```text
|
||
ContainerSpec.expose = [PortExpose...]
|
||
```
|
||
|
||
转换规则:
|
||
|
||
- `"80"` -> `container_port = 80`, `protocol = "tcp"`
|
||
- `"443/tcp"` -> `container_port = 443`, `protocol = "tcp"`
|
||
- `"53/udp"` -> `container_port = 53`, `protocol = "udp"`
|
||
|
||
生成结构:
|
||
|
||
```text
|
||
PortExpose {
|
||
container_port,
|
||
protocol
|
||
}
|
||
```
|
||
|
||
#### `devices`
|
||
|
||
输入类型:
|
||
|
||
```json
|
||
["/dev/ttyUSB0:/dev/ttyUSB0", "/dev/snd:/dev/snd:rwm"]
|
||
```
|
||
|
||
目标:
|
||
|
||
```text
|
||
ContainerSpec.devices = [DeviceMapping...]
|
||
```
|
||
|
||
转换规则:
|
||
|
||
- `host_path:container_path`
|
||
- `cgroup_permissions = "rwm"`
|
||
- `host_path:container_path:permissions`
|
||
- 使用第三段作为 `cgroup_permissions`
|
||
|
||
#### `ulimits`
|
||
|
||
输入类型:
|
||
|
||
```json
|
||
{
|
||
"nofile": "1024:2048",
|
||
"nproc": "4096"
|
||
}
|
||
```
|
||
|
||
目标:
|
||
|
||
```text
|
||
ContainerSpec.ulimits = [Ulimit...]
|
||
```
|
||
|
||
转换规则:
|
||
|
||
- `"1024:2048"` -> `soft = 1024`, `hard = 2048`
|
||
- `"4096"` -> `soft = 4096`, `hard = 4096`
|
||
|
||
#### `tmpfs`
|
||
|
||
输入类型:
|
||
|
||
```json
|
||
["/tmp", "/run:rw,size=64m"]
|
||
```
|
||
|
||
目标:
|
||
|
||
```text
|
||
ContainerSpec.tmpfs = [TmpfsMount...]
|
||
```
|
||
|
||
转换规则:
|
||
|
||
- `"path"` -> `options = ""`
|
||
- `"path:options"` -> `options` 为第二段
|
||
|
||
#### `healthcheck`
|
||
|
||
输入类型:
|
||
|
||
```json
|
||
{
|
||
"test": ["CMD-SHELL", "curl -f http://localhost || exit 1"],
|
||
"interval": "30s",
|
||
"timeout": "10s",
|
||
"retries": 3
|
||
}
|
||
```
|
||
|
||
目标:
|
||
|
||
```text
|
||
ContainerSpec.healthcheck = Healthcheck {
|
||
test,
|
||
interval_ns,
|
||
timeout_ns,
|
||
retries
|
||
}
|
||
```
|
||
|
||
转换规则:
|
||
|
||
- `test` 直接写入 `Healthcheck.test`
|
||
- `interval` / `timeout` 会被解析成纳秒
|
||
- `retries` 直接写入
|
||
|
||
支持的时间单位:
|
||
|
||
- `ns`
|
||
- `us`
|
||
- `ms`
|
||
- `s`
|
||
- `m`
|
||
- `h`
|
||
|
||
示例:
|
||
|
||
- `"30s"` -> `30000000000`
|
||
- `"10ms"` -> `10000000`
|
||
- `"2m"` -> `120000000000`
|
||
|
||
### 4.4 资源限制字段
|
||
|
||
#### `restart`
|
||
|
||
输入类型:
|
||
|
||
```json
|
||
"always"
|
||
```
|
||
|
||
或:
|
||
|
||
```json
|
||
"on-failure:3"
|
||
```
|
||
|
||
目标:
|
||
|
||
```text
|
||
RestartPolicy {
|
||
name,
|
||
maximum_retry_count
|
||
}
|
||
```
|
||
|
||
转换规则:
|
||
|
||
- `"always"` -> `name = "always"`, `maximum_retry_count = 0`
|
||
- `"on-failure:3"` -> `name = "on-failure"`, `maximum_retry_count = 3`
|
||
|
||
#### `mem_limit` / `mem_reservation`
|
||
|
||
目标字段:
|
||
|
||
- `ResourceLimits.memory_bytes`
|
||
- `ResourceLimits.memory_reservation_bytes`
|
||
|
||
支持单位:
|
||
|
||
- `b`
|
||
- `k`, `kb`, `ki`, `kib`
|
||
- `m`, `mb`, `mi`, `mib`
|
||
- `g`, `gb`, `gi`, `gib`
|
||
- `t`, `tb`, `ti`, `tib`
|
||
|
||
示例:
|
||
|
||
- `"512m"` -> `536870912`
|
||
- `"1g"` -> `1073741824`
|
||
|
||
#### `cpus`
|
||
|
||
目标字段:
|
||
|
||
- `ResourceLimits.nano_cpus`
|
||
|
||
转换规则:
|
||
|
||
- 直接乘以 `1_000_000_000`
|
||
|
||
示例:
|
||
|
||
- `1` -> `1000000000`
|
||
- `1.5` -> `1500000000`
|
||
|
||
#### `cpu_shares`
|
||
|
||
目标字段:
|
||
|
||
- `ResourceLimits.cpu_shares`
|
||
|
||
直接按整数写入。
|
||
|
||
#### `resources` 对象生成规则
|
||
|
||
只有在以下字段至少存在一个时,才会生成 `ContainerSpec.resources`:
|
||
|
||
- `mem_limit`
|
||
- `mem_reservation`
|
||
- `cpus`
|
||
- `cpu_shares`
|
||
|
||
如果这些字段都不存在,则 `resources = undefined`。
|
||
|
||
## 5. 参数校验规则
|
||
|
||
在 HTTP 层,`config` 会先经过基础类型校验。
|
||
|
||
必填字段:
|
||
|
||
- `image: string`
|
||
- `container_name: string`
|
||
- `command: string[]`
|
||
- `restart: string`
|
||
|
||
可选字段:
|
||
|
||
- `privileged: boolean`
|
||
- `entrypoint: string[]`
|
||
- `envs: string[]`
|
||
- `ports: string[]`
|
||
- `expose: string[]`
|
||
- `volumes: string[]`
|
||
- `networks: string[]`
|
||
- `labels: map<string,string>`
|
||
- `user: string`
|
||
- `working_dir: string`
|
||
- `hostname: string`
|
||
- `container_dir: string`
|
||
- `network_mode: string`
|
||
- `cap_add: string[]`
|
||
- `cap_drop: string[]`
|
||
- `devices: string[]`
|
||
- `mem_limit: string`
|
||
- `mem_reservation: string`
|
||
- `cpu_shares: integer`
|
||
- `cpus: number`
|
||
- `ulimits: map<string,string>`
|
||
- `sysctls: map<string,string>`
|
||
- `tmpfs: string[]`
|
||
- `extra_hosts: string[]`
|
||
- `healthcheck: map<string,any>`
|
||
|
||
## 6. 当前限制
|
||
|
||
### `ports`
|
||
|
||
虽然 HTTP 校验允许 `ports` 字段出现,但当前 builder 仍然会拒绝它:
|
||
|
||
```text
|
||
unsupported container config keys: ports
|
||
```
|
||
|
||
原因是当前 `message.proto` 里只有:
|
||
|
||
- `ContainerSpec.expose`
|
||
|
||
它表达的是容器端口暴露,不包含主机端口绑定信息;而 `ports` 一般是类似 `8080:80` 的 host/container 绑定语义,两者并不等价。
|
||
|
||
### `env_file`
|
||
|
||
当前实现不再接收 `env_file`。如果传入,HTTP 层就不会通过类型校验,因为它不在 `validate_config/1` 的允许字段列表中。
|
||
|
||
## 7. 推荐请求示例
|
||
|
||
```json
|
||
{
|
||
"uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm",
|
||
"task_id": 1001,
|
||
"config": {
|
||
"image": "docker.io/library/nginx:latest",
|
||
"container_name": "my_nginx",
|
||
"container_dir": "/data/apps/my_nginx",
|
||
"command": ["nginx", "-g", "daemon off;"],
|
||
"entrypoint": ["/docker-entrypoint.sh"],
|
||
"envs": ["ENV=prod", "TZ=Asia/Shanghai"],
|
||
"expose": ["80", "443/tcp"],
|
||
"volumes": ["/host/data:/data", "/host/log:/var/log:ro"],
|
||
"networks": ["bridge"],
|
||
"network_mode": "bridge",
|
||
"labels": {
|
||
"app": "nginx",
|
||
"env": "prod"
|
||
},
|
||
"restart": "always",
|
||
"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
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 8. 后续如果要支持 `ports`
|
||
|
||
建议先扩展 `message.proto`,增加明确表达 host/container 端口绑定的结构,例如:
|
||
|
||
```proto
|
||
message PortBinding {
|
||
uint32 host_port = 1;
|
||
uint32 container_port = 2;
|
||
string protocol = 3;
|
||
string host_ip = 4;
|
||
}
|
||
```
|
||
|
||
然后再在 `ContainerSpec` 中增加类似 `repeated PortBinding ports = ...;` 的字段,再由 builder 把 `"8080:80/tcp"` 这类字符串解析进去。这样语义才完整。
|