# IOT 容器命令到 Docker JSON 的转换说明 本文档描述 `efka` 收到 `iot` 下发的容器管理 command 后,接受的 Erlang map 格式,以及 deploy 命令中 `create` 参数如何转换成 Docker Engine API 接收的 JSON。 对应代码: - command 接收入口:[src/transport/efka_client.erl](/usr/local/code/cloudkit/efka/src/transport/efka_client.erl:177) - 部署任务管理:[src/docker/docker_deploy_manager.erl](/usr/local/code/cloudkit/efka/src/docker/docker_deploy_manager.erl:36) - 部署执行:[src/docker/docker_deployer.erl](/usr/local/code/cloudkit/efka/src/docker/docker_deployer.erl:39) - Docker JSON 构造:[src/docker/docker_container_builder.erl](/usr/local/code/cloudkit/efka/src/docker/docker_container_builder.erl:14) - Docker API 调用:[src/docker/docker_commands.erl](/usr/local/code/cloudkit/efka/src/docker/docker_commands.erl:36) ## 1. 协议入口 `iot` 通过 TLS 长连接向 `efka` 下发容器命令: ```erlang {<<"command">>, Ref, {<<"container">>, CommandMap}} ``` `efka` 执行后回复: ```erlang {<<"command_response">>, Ref, {<<"container">>, Reply}} ``` `Reply` 取值: ```erlang <<"ok">> {<<"ok">>, Result} {<<"error">>, Reason} ``` `Ref` 是 `crypto:strong_rand_bytes(16)` 生成的 16 字节 binary。网络帧只使用 `binary_to_term(PacketBin, [safe])` 可解码的 safe term;协议 label、业务 label、map key 和 action 都使用 binary。 只有 `efka_client` 处于 `activated` 状态时,容器命令才会正常执行;处于非 activated 状态时会返回错误。 ## 2. 容器命令 map `efka_client` 收到网络协议里的 binary-key `CommandMap` 后,直接按 binary key 和 binary action 做函数参数匹配,不再做整包 atom-key 转换。 - command 顶层、`target`、deploy `params`、`create` 中间结构都使用 binary key。 - `<<"action">>` 的取值使用 binary:`<<"list">>`、`<<"deploy">>`、`<<"start">>`、`<<"stop">>`、`<<"kill">>`、`<<"remove">>`、`<<"config">>`。 - `undefined` 作为 Erlang 已有 atom 可以直接出现在 safe term 中;协议不再使用额外的哨兵 binary 表示缺省值。 下面各小节描述的是 `efka` 直接接收和处理的 map 格式。 ### list ```erlang #{<<"action">> => <<"list">>} ``` 执行: ```erlang docker_commands:get_containers() ``` Docker API: ```http GET /containers/json?all=true ``` ### deploy ```erlang #{ <<"action">> => <<"deploy">>, <<"task_id">> => TaskId, <<"params">> => Params } ``` 执行: ```erlang docker_deploy_manager:deploy(TaskId, Params) ``` `deploy/2` 会启动独立部署进程,HTTP command response 只表示部署任务是否成功启动。实际部署过程和结果通过 `task_event` 消息流上报给 `iot`。 ### start ```erlang #{ <<"action">> => <<"start">>, <<"target">> => Target } ``` 执行: ```erlang docker_commands:start_container(ContainerNameOrId) ``` Docker API: ```http POST /containers/{name_or_id}/start ``` ### stop ```erlang #{ <<"action">> => <<"stop">>, <<"target">> => Target, <<"timeout_seconds">> => TimeoutSeconds } ``` 执行: ```erlang docker_commands:stop_container(ContainerNameOrId, TimeoutSeconds) ``` Docker API: ```http POST /containers/{name_or_id}/stop?t={TimeoutSeconds} ``` ### kill ```erlang #{ <<"action">> => <<"kill">>, <<"target">> => Target, <<"signal">> => Signal } ``` 执行: ```erlang docker_commands:kill_container(ContainerNameOrId, Signal) ``` ### remove ```erlang #{ <<"action">> => <<"remove">>, <<"target">> => Target, <<"force">> => Force, <<"remove_volumes">> => RemoveVolumes } ``` 执行: ```erlang docker_commands:remove_container(ContainerNameOrId, Force, RemoveVolumes) ``` ### config ```erlang #{ <<"action">> => <<"config">>, <<"target">> => Target, <<"config">> => Config } ``` 执行: ```erlang docker_helper:update_container_config(ContainerNameOrId, iolist_to_binary(Config)) ``` 该命令不会调用 Docker API,只会更新 `efka` 主机上对应容器目录里的 `service.conf`。 ## 3. Target 解析规则 `target` 是 map: ```erlang #{ <<"name">> => ContainerName, <<"id">> => ContainerId } ``` 解析规则: - 优先使用 `name`。 - `name` 为空时使用 `id`。 - `name` 和 `id` 都为空时会触发匹配错误。 ## 4. deploy params 格式 `deploy` 的 `params` 必须包含: ```erlang #{ <<"container_name">> => ContainerName, <<"create">> => Create } ``` 字段说明: | 字段 | 类型 | 说明 | | --- | --- | --- | | `container_name` | binary | 容器名称。用于容器创建 URL 的 `name` 参数,也会注入环境变量 `CONTAINER_NAME`。 | | `create` | map | Docker create options 的中间结构,由 `iot` 侧构造,`efka` 侧补丁后转为 Docker JSON。 | `docker_deploy_manager` 会根据 `container_name` 按系统默认规则确保容器目录存在。目录固定为: ```text efka.root_dir/container_name/ ``` 即使旧客户端在 `params` 中携带 `container_dir`,当前 deploy 流程也会忽略它,避免 HTTP 调用方控制 efka 主机上的写入目录。 ## 5. create 中间结构 `create` 的结构: ```erlang #{ <<"config">> => ContainerConfig, <<"host_config">> => HostConfig, <<"networking_config">> => NetworkingConfig } ``` 这三个 map 会被 `docker_container_builder:build_options/3` 转成 Docker Engine API JSON。 ## 6. efka 自动补丁规则 在转 Docker JSON 前,`efka` 会先执行补丁: ### 环境变量 向 `create.config.env` 前置注入: ```erlang <<"CONTAINER_NAME=", ContainerName/binary>> ``` 如果原 env 列表已经包含完全相同的值,则不会重复添加。 ### 配置文件 volume 向 `create.config.volumes` 前置注入容器内配置路径: ```erlang <<"/usr/local/etc/service.conf">> ``` ### 配置文件 bind 向 `create.host_config.binds` 前置注入宿主机配置文件映射: ```erlang <> ``` 其中 `ConfigFile` 是当前容器目录下的: ```text service.conf ``` 如果列表里已存在完全相同的 bind,则不会重复添加。 ## 7. Docker JSON 顶层结构 `docker_commands:create_container/2` 最终调用: ```http POST /containers/create?name={container_name} Content-Type: application/json ``` 请求 body 来自: ```erlang Options = docker_container_builder:build_options(ContainerName, ContainerDir, Create), Body = iolist_to_binary(json:encode(Options)) ``` 最终 JSON 顶层字段: ```json { "Image": "...", "Cmd": [], "Entrypoint": [], "Env": [], "Labels": {}, "Volumes": {}, "User": "", "WorkingDir": "", "Hostname": "", "ExposedPorts": {}, "NetworkingConfig": {}, "Healthcheck": {}, "HostConfig": {} } ``` 空列表或空 map 字段有的会保留,有的会被省略到 `{}`,具体见下面映射规则。 ## 8. create.config 到 Docker JSON | 中间字段 | Docker JSON 字段 | 转换规则 | | --- | --- | --- | | `image` | `Image` | 转成 binary;缺省为 `""`。部署流程拉镜像时会对 image 补 `:latest`,但 create JSON 使用传入值。 | | `cmd` | `Cmd` | 列表元素逐个转 binary。 | | `entrypoint` | `Entrypoint` | 列表元素逐个转 binary。 | | `env` | `Env` | 列表元素逐个转 binary,并自动注入 `CONTAINER_NAME=...`。 | | `labels` | `Labels` | key/value 都转 binary。 | | `volumes` | `Volumes` | 转成 Docker 要求的 object,key 是容器内路径,value 是 `{}`。 | | `user` | `User` | 转成 binary;缺省为 `""`。 | | `working_dir` | `WorkingDir` | 转成 binary;缺省为 `""`。 | | `hostname` | `Hostname` | 转成 binary;缺省为 `""`。 | | `exposed_ports` | `ExposedPorts` | 转成 Docker 端口 object。 | | `healthcheck` | `Healthcheck` | 转成 Docker Healthcheck object;未传时为 `{}`。 | ### Volumes 输入: ```erlang [<<"/data">>, <<"/usr/local/etc/service.conf">>] ``` 输出 JSON: ```json { "Volumes": { "/data": {}, "/usr/local/etc/service.conf": {} } } ``` ### ExposedPorts 输入: ```erlang [ #{<<"container_port">> => 80, <<"protocol">> => <<"tcp">>}, #{<<"container_port">> => 53, <<"protocol">> => <<"udp">>} ] ``` 输出 JSON: ```json { "ExposedPorts": { "80/tcp": {}, "53/udp": {} } } ``` 规则: - `protocol` 为空或 `<<"tcp">>` 时输出 `tcp`。 - 其他 protocol 原样输出。 ### Healthcheck 该字段由 iot 校验后下发。`test` 必须是 binary list,`interval_ns`、`timeout_ns` 和 `retries` 必须是非负整数;efka 只按收到的中间 map 转成 Docker JSON 字段。 输入: ```erlang #{ <<"test">> => [<<"CMD-SHELL">>, <<"curl -f http://localhost || exit 1">>], <<"interval_ns">> => 30000000000, <<"timeout_ns">> => 10000000000, <<"retries">> => 3 } ``` 输出 JSON: ```json { "Healthcheck": { "Test": ["CMD-SHELL", "curl -f http://localhost || exit 1"], "Interval": 30000000000, "Timeout": 10000000000, "Retries": 3 } } ``` ## 9. create.host_config 到 Docker JSON `host_config` 会被合并到 Docker JSON 的 `HostConfig` 字段。 | 中间字段 | Docker JSON 字段 | 转换规则 | | --- | --- | --- | | `binds` | `HostConfig.Binds` | 列表元素逐个转 binary,并自动注入 `service.conf` bind。空列表时省略。 | | `network_mode` | `HostConfig.NetworkMode` | 空 binary 时省略。 | | `restart_policy` | `HostConfig.RestartPolicy` | 转成 `Name` 和可选 `MaximumRetryCount`。 | | `privileged` | `HostConfig.Privileged` | 只有 true 时输出。false 时省略。 | | `cap_add` / `cap_drop` | `HostConfig.CapAdd` / `HostConfig.CapDrop` | 两者都为空时省略;只要一个非空,两个字段都会输出。 | | `devices` | `HostConfig.Devices` | 转成 Docker device object 列表。 | | `memory` | `HostConfig.Memory` | 0 时省略。 | | `memory_reservation` | `HostConfig.MemoryReservation` | 0 时省略。 | | `nano_cpus` | `HostConfig.NanoCpus` | 0 时省略。 | | `cpu_shares` | `HostConfig.CpuShares` | 0 时省略。 | | `port_bindings` | `HostConfig.PortBindings` | 转成 Docker 端口绑定 object;空列表时省略。 | | `ulimits` | `HostConfig.Ulimits` | 空列表时省略。 | | `tmpfs` | `HostConfig.Tmpfs` | 空 map 时省略。 | | `sysctls` | `HostConfig.Sysctls` | 空 map 时省略。 | | `extra_hosts` | `HostConfig.ExtraHosts` | 空列表时省略。 | ### Binds 输入: ```erlang [ <<"/host/data:/data">>, <<"/host/log:/var/log:ro">> ] ``` efka 自动补丁后输出: ```json { "HostConfig": { "Binds": [ "/path/to/container/service.conf:/usr/local/etc/service.conf", "/host/data:/data", "/host/log:/var/log:ro" ] } } ``` ### RestartPolicy 输入: ```erlang #{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0} ``` 输出: ```json { "HostConfig": { "RestartPolicy": { "Name": "always" } } } ``` 输入: ```erlang #{<<"name">> => <<"on-failure">>, <<"maximum_retry_count">> => 3} ``` 输出: ```json { "HostConfig": { "RestartPolicy": { "Name": "on-failure", "MaximumRetryCount": 3 } } } ``` ### Devices 输入: ```erlang [ #{ <<"path_on_host">> => <<"/dev/ttyUSB0">>, <<"path_in_container">> => <<"/dev/ttyUSB0">>, <<"cgroup_permissions">> => <<"rwm">> } ] ``` 输出: ```json { "HostConfig": { "Devices": [ { "PathOnHost": "/dev/ttyUSB0", "PathInContainer": "/dev/ttyUSB0", "CgroupPermissions": "rwm" } ] } } ``` `cgroup_permissions` 为空时默认输出 `rwm`。 ### PortBindings 输入: ```erlang [ #{<<"host_ip">> => <<>>, <<"host_port">> => 8080, <<"container_port">> => 80, <<"protocol">> => <<"tcp">>}, #{<<"host_ip">> => <<>>, <<"host_port">> => 443, <<"container_port">> => 443, <<"protocol">> => <<"tcp">>} ] ``` 输出: ```json { "HostConfig": { "PortBindings": { "80/tcp": [ {"HostIp": "", "HostPort": "8080"} ], "443/tcp": [ {"HostIp": "", "HostPort": "443"} ] } } } ``` 规则: - key 使用容器端口和协议组成:`container_port/protocol`。 - `protocol` 为空或 `tcp` 时输出 `tcp`。 - `HostPort` 按 Docker API 要求输出为字符串。 ### 资源限制 输入: ```erlang #{ <<"memory">> => 536870912, <<"memory_reservation">> => 268435456, <<"nano_cpus">> => 1500000000, <<"cpu_shares">> => 512 } ``` 输出: ```json { "HostConfig": { "Memory": 536870912, "MemoryReservation": 268435456, "NanoCpus": 1500000000, "CpuShares": 512 } } ``` 值为 0 的资源字段会被省略。 ### Ulimits 输入: ```erlang [ #{<<"name">> => <<"nofile">>, <<"soft">> => 1024, <<"hard">> => 2048} ] ``` 输出: ```json { "HostConfig": { "Ulimits": [ { "Name": "nofile", "Soft": 1024, "Hard": 2048 } ] } } ``` ### Tmpfs 输入: ```erlang #{ <<"/tmp">> => <<>>, <<"/run">> => <<"rw,size=64m">> } ``` 输出: ```json { "HostConfig": { "Tmpfs": { "/tmp": "", "/run": "rw,size=64m" } } } ``` ### Sysctls 输入: ```erlang #{<<"net.ipv4.ip_forward">> => <<"1">>} ``` 输出: ```json { "HostConfig": { "Sysctls": { "net.ipv4.ip_forward": "1" } } } ``` ### ExtraHosts 输入: ```erlang [<<"host.docker.internal:host-gateway">>] ``` 输出: ```json { "HostConfig": { "ExtraHosts": ["host.docker.internal:host-gateway"] } } ``` ## 10. create.networking_config 到 Docker JSON 输入: ```erlang #{ <<"endpoints">> => [ #{<<"name">> => <<"bridge">>}, #{<<"name">> => <<"mynet">>} ] } ``` 输出: ```json { "NetworkingConfig": { "EndpointsConfig": { "bridge": {}, "mynet": {} } } } ``` 未传或 endpoints 为空时输出: ```json { "NetworkingConfig": {} } ``` ## 11. 完整转换示例 收到的 deploy command: ```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;">>], <<"env">> => [<<"ENV=prod">>], <<"volumes">> => [<<"/data">>], <<"exposed_ports">> => [#{<<"container_port">> => 80, <<"protocol">> => <<"tcp">>}] }, <<"host_config">> => #{ <<"binds">> => [<<"/host/data:/data">>], <<"restart_policy">> => #{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0}, <<"memory">> => 536870912, <<"port_bindings">> => [ #{ <<"host_ip">> => <<>>, <<"host_port">> => 8080, <<"container_port">> => 80, <<"protocol">> => <<"tcp">> } ] }, <<"networking_config">> => #{ <<"endpoints">> => [#{<<"name">> => <<"bridge">>}] } } } }}} ``` 生成的 Docker JSON 示意: ```json { "Image": "docker.io/library/nginx:latest", "Cmd": ["nginx", "-g", "daemon off;"], "Entrypoint": [], "Env": ["CONTAINER_NAME=my_nginx", "ENV=prod"], "Labels": {}, "Volumes": { "/usr/local/etc/service.conf": {}, "/data": {} }, "User": "", "WorkingDir": "", "Hostname": "", "ExposedPorts": { "80/tcp": {} }, "NetworkingConfig": { "EndpointsConfig": { "bridge": {} } }, "Healthcheck": {}, "HostConfig": { "Binds": [ "/efka/root/my_nginx/service.conf:/usr/local/etc/service.conf", "/host/data:/data" ], "RestartPolicy": { "Name": "always" }, "Memory": 536870912, "PortBindings": { "80/tcp": [ { "HostIp": "", "HostPort": "8080" } ] } } } ``` ## 12. 部署流程补充 deploy action 的执行流程: 1. 根据 `container_name` 和 `efka.root_dir` 确保默认容器目录存在。 2. 启动独立部署进程。 3. 部署进程上报任务事件流。 4. 规范化镜像名:如果镜像最后一段没有 tag,则补 `:latest`。 5. 调用 Docker API 拉取镜像。 6. 构造 Docker create JSON。 7. 调用 `POST /containers/create?name={container_name}`。 8. 创建空 `service.conf` 文件。 9. 写入部署摘要日志并关闭任务事件流。 注意:当前 `ensure_container_absent/2` 只上报“开始创建容器”,不会删除已有同名容器;如果 Docker 返回名称冲突,部署会失败并上报“本地容器已经存在”。