# 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` | `ContainerSpec.labels` | 转成 key/value 列表 | | `sysctls` | `map` | `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` - `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` - `sysctls: map` - `tmpfs: string[]` - `extra_hosts: string[]` - `healthcheck: map` ## 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"` 这类字符串解析进去。这样语义才完整。