ekfa/docs/container_command_docker_json.md
2026-05-11 22:42:00 +08:00

16 KiB
Raw Blame History

IOT 容器命令到 Docker JSON 的转换说明

本文档描述 efka 收到 iot 下发的容器管理 command 后,接受的 Erlang map 格式,以及 deploy 命令中 create 参数如何转换成 Docker Engine API 接收的 JSON。

对应代码:

1. 协议入口

iot 通过 TLS 长连接向 efka 下发容器命令:

{<<"command">>, Ref, {<<"container">>, CommandMap}}

efka 执行后回复:

{<<"command_response">>, Ref, {<<"container">>, Reply}}

Reply 取值:

<<"ok">>
{<<"ok">>, Result}
{<<"error">>, Reason}

Refcrypto: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 paramscreate 中间结构都使用 binary key。
  • <<"action">> 的取值使用 binary<<"list">><<"deploy">><<"start">><<"stop">><<"kill">><<"remove">><<"config">>
  • undefined 作为 Erlang 已有 atom 可以直接出现在 safe term 中;协议不再使用额外的哨兵 binary 表示缺省值。

下面各小节描述的是 efka 直接接收和处理的 map 格式。

list

#{<<"action">> => <<"list">>}

执行:

docker_commands:get_containers()

Docker API

GET /containers/json?all=true

deploy

#{
    <<"action">> => <<"deploy">>,
    <<"task_id">> => TaskId,
    <<"params">> => Params
}

执行:

docker_deploy_manager:deploy(TaskId, Params)

deploy/2 会启动独立部署进程HTTP command response 只表示部署任务是否成功启动。实际部署过程和结果通过 task_event 消息流上报给 iot

start

#{
    <<"action">> => <<"start">>,
    <<"target">> => Target
}

执行:

docker_commands:start_container(ContainerNameOrId)

Docker API

POST /containers/{name_or_id}/start

stop

#{
    <<"action">> => <<"stop">>,
    <<"target">> => Target,
    <<"timeout_seconds">> => TimeoutSeconds
}

执行:

docker_commands:stop_container(ContainerNameOrId, TimeoutSeconds)

Docker API

POST /containers/{name_or_id}/stop?t={TimeoutSeconds}

kill

#{
    <<"action">> => <<"kill">>,
    <<"target">> => Target,
    <<"signal">> => Signal
}

执行:

docker_commands:kill_container(ContainerNameOrId, Signal)

remove

#{
    <<"action">> => <<"remove">>,
    <<"target">> => Target,
    <<"force">> => Force,
    <<"remove_volumes">> => RemoveVolumes
}

执行:

docker_commands:remove_container(ContainerNameOrId, Force, RemoveVolumes)

config

#{
    <<"action">> => <<"config">>,
    <<"target">> => Target,
    <<"config">> => Config
}

执行:

docker_helper:update_container_config(ContainerNameOrId, iolist_to_binary(Config))

该命令不会调用 Docker API只会更新 efka 主机上对应容器目录里的 service.conf

3. Target 解析规则

target 是 map

#{
    <<"name">> => ContainerName,
    <<"id">> => ContainerId
}

解析规则:

  • 优先使用 name
  • name 为空时使用 id
  • nameid 都为空时会触发匹配错误。

4. deploy params 格式

deployparams 必须包含:

#{
    <<"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 按系统默认规则确保容器目录存在。目录固定为:

docker.root_dir/container_name/

即使旧客户端在 params 中携带 container_dir,当前 deploy 流程也会忽略它,避免 HTTP 调用方控制 efka 主机上的写入目录。

5. create 中间结构

create 的结构:

#{
    <<"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 前置注入:

<<"CONTAINER_NAME=", ContainerName/binary>>

如果原 env 列表已经包含完全相同的值,则不会重复添加。

配置文件 volume

create.config.volumes 前置注入容器内配置路径:

<<"/usr/local/etc/service.conf">>

配置文件 bind

create.host_config.binds 前置注入宿主机配置文件映射:

<<ConfigFile/binary, ":/usr/local/etc/service.conf">>

其中 ConfigFile 是当前容器目录下的:

service.conf

如果列表里已存在完全相同的 bind则不会重复添加。

7. Docker JSON 顶层结构

docker_commands:create_container/2 最终调用:

POST /containers/create?name={container_name}
Content-Type: application/json

请求 body 来自:

Options = docker_container_builder:build_options(ContainerName, ContainerDir, Create),
Body = iolist_to_binary(json:encode(Options))

最终 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 要求的 objectkey 是容器内路径value 是 {}
user User 转成 binary缺省为 ""
working_dir WorkingDir 转成 binary缺省为 ""
hostname Hostname 转成 binary缺省为 ""
exposed_ports ExposedPorts 转成 Docker 端口 object。
healthcheck Healthcheck 转成 Docker Healthcheck object未传时为 {}

Volumes

输入:

[<<"/data">>, <<"/usr/local/etc/service.conf">>]

输出 JSON

{
  "Volumes": {
    "/data": {},
    "/usr/local/etc/service.conf": {}
  }
}

ExposedPorts

输入:

[
    #{<<"container_port">> => 80, <<"protocol">> => <<"tcp">>},
    #{<<"container_port">> => 53, <<"protocol">> => <<"udp">>}
]

输出 JSON

{
  "ExposedPorts": {
    "80/tcp": {},
    "53/udp": {}
  }
}

规则:

  • protocol 为空或 <<"tcp">> 时输出 tcp
  • 其他 protocol 原样输出。

Healthcheck

该字段由 iot 校验后下发。test 必须是 binary listinterval_nstimeout_nsretries 必须是非负整数efka 只按收到的中间 map 转成 Docker JSON 字段。

输入:

#{
    <<"test">> => [<<"CMD-SHELL">>, <<"curl -f http://localhost || exit 1">>],
    <<"interval_ns">> => 30000000000,
    <<"timeout_ns">> => 10000000000,
    <<"retries">> => 3
}

输出 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

输入:

[
    <<"/host/data:/data">>,
    <<"/host/log:/var/log:ro">>
]

efka 自动补丁后输出:

{
  "HostConfig": {
    "Binds": [
      "/path/to/container/service.conf:/usr/local/etc/service.conf",
      "/host/data:/data",
      "/host/log:/var/log:ro"
    ]
  }
}

RestartPolicy

输入:

#{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0}

输出:

{
  "HostConfig": {
    "RestartPolicy": {
      "Name": "always"
    }
  }
}

输入:

#{<<"name">> => <<"on-failure">>, <<"maximum_retry_count">> => 3}

输出:

{
  "HostConfig": {
    "RestartPolicy": {
      "Name": "on-failure",
      "MaximumRetryCount": 3
    }
  }
}

Devices

输入:

[
    #{
        <<"path_on_host">> => <<"/dev/ttyUSB0">>,
        <<"path_in_container">> => <<"/dev/ttyUSB0">>,
        <<"cgroup_permissions">> => <<"rwm">>
    }
]

输出:

{
  "HostConfig": {
    "Devices": [
      {
        "PathOnHost": "/dev/ttyUSB0",
        "PathInContainer": "/dev/ttyUSB0",
        "CgroupPermissions": "rwm"
      }
    ]
  }
}

cgroup_permissions 为空时默认输出 rwm

PortBindings

输入:

[
    #{<<"host_ip">> => <<>>, <<"host_port">> => 8080, <<"container_port">> => 80, <<"protocol">> => <<"tcp">>},
    #{<<"host_ip">> => <<>>, <<"host_port">> => 443, <<"container_port">> => 443, <<"protocol">> => <<"tcp">>}
]

输出:

{
  "HostConfig": {
    "PortBindings": {
      "80/tcp": [
        {"HostIp": "", "HostPort": "8080"}
      ],
      "443/tcp": [
        {"HostIp": "", "HostPort": "443"}
      ]
    }
  }
}

规则:

  • key 使用容器端口和协议组成:container_port/protocol
  • protocol 为空或 tcp 时输出 tcp
  • HostPort 按 Docker API 要求输出为字符串。

资源限制

输入:

#{
    <<"memory">> => 536870912,
    <<"memory_reservation">> => 268435456,
    <<"nano_cpus">> => 1500000000,
    <<"cpu_shares">> => 512
}

输出:

{
  "HostConfig": {
    "Memory": 536870912,
    "MemoryReservation": 268435456,
    "NanoCpus": 1500000000,
    "CpuShares": 512
  }
}

值为 0 的资源字段会被省略。

Ulimits

输入:

[
    #{<<"name">> => <<"nofile">>, <<"soft">> => 1024, <<"hard">> => 2048}
]

输出:

{
  "HostConfig": {
    "Ulimits": [
      {
        "Name": "nofile",
        "Soft": 1024,
        "Hard": 2048
      }
    ]
  }
}

Tmpfs

输入:

#{
    <<"/tmp">> => <<>>,
    <<"/run">> => <<"rw,size=64m">>
}

输出:

{
  "HostConfig": {
    "Tmpfs": {
      "/tmp": "",
      "/run": "rw,size=64m"
    }
  }
}

Sysctls

输入:

#{<<"net.ipv4.ip_forward">> => <<"1">>}

输出:

{
  "HostConfig": {
    "Sysctls": {
      "net.ipv4.ip_forward": "1"
    }
  }
}

ExtraHosts

输入:

[<<"host.docker.internal:host-gateway">>]

输出:

{
  "HostConfig": {
    "ExtraHosts": ["host.docker.internal:host-gateway"]
  }
}

10. create.networking_config 到 Docker JSON

输入:

#{
    <<"endpoints">> => [
        #{<<"name">> => <<"bridge">>},
        #{<<"name">> => <<"mynet">>}
    ]
}

输出:

{
  "NetworkingConfig": {
    "EndpointsConfig": {
      "bridge": {},
      "mynet": {}
    }
  }
}

未传或 endpoints 为空时输出:

{
  "NetworkingConfig": {}
}

11. 完整转换示例

收到的 deploy command

{<<"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 示意:

{
  "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_namedocker.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 返回名称冲突,部署会失败并上报“本地容器已经存在”。