753 lines
15 KiB
Markdown
753 lines
15 KiB
Markdown
# 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/binary, ":/usr/local/etc/service.conf">>
|
||
```
|
||
|
||
其中 `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 时省略。 |
|
||
| `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`。
|
||
|
||
### 资源限制
|
||
|
||
输入:
|
||
|
||
```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
|
||
},
|
||
<<"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
|
||
}
|
||
}
|
||
```
|
||
|
||
## 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 返回名称冲突,部署会失败并上报“本地容器已经存在”。
|