把 Docker 搬上 WebSocket:六类资源四十个操作的远程管理实战

ServerManager 做的是远程管理节点——浏览器连 Panel,Panel 通过 WebSocket 连 Node,Node 在本地操作 Docker。听起来链路不长,但要把镜像、容器、网络、卷、配置、Compose 六类资源的四十多个操作都可靠地搬上 WebSocket,里面的弯弯绕并不少。

第一个问题:六类资源怎么统一路由?

Docker 的 API 表面上一堆方法,但抽象出来就六类——image、container、network、volume、config、compose。每个操作都是”资源.动作”的组合,比如 docker:image:pulldocker:container:start

消息到达 Node 的分发器后,按冒号拆分:

1
2
3
4
5
6
7
8
9
10
11
async def dispatch_docker(self, action: str, payload, raw_message: dict) -> bool:
if not await self.verify_if_required(action, payload, raw_message, label="Docker动作"):
return False
await self.docker_manager_action(action, payload)

async def docker_manager_action(self, action: str, data):
parts = action.split(":")
if len(parts) != 3 or parts[0] != "docker":
logger.warning(f"非法Docker动作: {action}")
return
await self.client.services.docker_manager.handle(parts[1], parts[2], data or {})

三段式 docker:resource:operation,第二段是资源类型,第三段是操作名。DockerManager.handle(resource, operation, payload) 收到后分发到 __image__container 等内部方法。为什么不用一个大的 if-else?因为六类资源的方法各有各的参数校验和返回格式,塞在一个方法里三四百行,拆开每个资源一个方法,代码结构清晰得多。

安全分层:读操作畅通,写操作要令牌

不是所有 Docker 操作都一样危险。docker:image:listdocker:container:kill 的安全级别显然不同。

dockerManager.py 里,安全检查分层了:

1
2
3
4
5
6
7
8
9
10
11
12
13
async def handle(self, resource: str, operation: str, payload: dict):
if not self.__safe_enabled("docker_manager", False):
return await self.__send_error(payload, resource, operation, "Docker管理未启用")

read_operations = {"list", "inspect", "history", "stats", "logs", "info", "df", "version", "events", "get", "validate"}
is_read = operation in read_operations or (
resource == "compose" and operation in {"list", "ps", "logs", "config"}
)

if not is_read:
required_flag = RESOURCE_FLAGS.get(resource)
if required_flag and not self.__safe_enabled(required_flag, False):
return await self.__send_error(payload, resource, operation, "该Docker写操作未启用")

总开关 docker_manager 控制能不能用 Docker 功能。如果打开了,读操作(list、inspect、logs 之类)不需要额外权限——让监控数据流出去问题不大。

但写操作要看更细粒度的权限:拉镜像要 docker_image_manage,搞容器要 docker_container_manage,Compose 要……好吧,目前没有单独的 docker_compose_manage,用的是 docker_manager 总开关。不过关键是所有安全检查都在业务逻辑之前,不会出现”先操作了再检查权限”的情况。

RESOURCE_FLAGS 这个映射表也是显式声明的:

1
2
3
4
5
6
7
RESOURCE_FLAGS = {
"image": "docker_image_manage",
"container": "docker_container_manage",
"network": "docker_network_manage",
"volume": "docker_volume_manage",
"config": "docker_config_manage",
}

跟前面那篇 Action Registry 的思路一脉相承——安全策略是数据,不是散落在代码里的字符串常量。

同步转异步:不能把事件循环堵住

Docker 的 Python SDK 是同步的——client.images.list()client.containers.get() 这些调用都会阻塞。但 Node 端跑的是 asyncio 事件循环,一个 docker.images.pull("python:3.12") 可能要跑几十秒。要是直接在协程里调,整个 WebSocket 的接收循环就卡住了。

解决办法是 asyncio.to_thread

1
2
result = await asyncio.to_thread(self.__execute, resource, operation, payload)
await self.__send_success(payload, resource, operation, result)

一行把同步调用甩到线程池里,事件循环不阻塞,其他 WebSocket 消息照常接收和处理。to_thread 内部用的是 loop.run_in_executor(None, func),默认线程池大小够用了。

镜像自动拉取:拉还是不拉?

创建容器的时候有个细节——用户指定的镜像本地不存在怎么办?

1
2
3
4
5
6
7
8
9
10
11
12
def __ensure_container_image(self, client, image: str, payload: dict):
try:
client.images.get(image)
return None # 本地有,直接用
except (ImageNotFound, NotFound):
pass

if not self.__coerce_bool(payload.get("pull_missing"), True):
raise RuntimeError(f"节点本机缺少镜像 {image},请先拉取镜像或启用自动拉取")
if not self.__safe_enabled("docker_image_manage", False):
raise RuntimeError("节点本机缺少镜像,自动拉取需要启用 docker_image_manage")
return self.__pull_image_reference(client, image, payload)

三步走:先查本地有没有,没有的话看 pull_missing 参数(默认 True),再看有没有 docker_image_manage 权限。 Defaults toward 便利(自动拉),但安全策略随时可以拦住。

这里有个容易忽略的问题——pull_missing 默认是 True。如果你没关掉这个选项,创建容器的操作可能会隐式触发一个耗时很长的镜像拉取。在自动化场景下这是利好,但如果网络不好或者镜像仓库连不上,就会有一个请求卡在那很久。docker_manager 没有设全局超时来兜底拉镜像操作,这是个需要改进的点。

Compose 管理:在节点上用 docker compose CLI

Docker Compose 的操作没走 Python SDK——因为 SDK 对 Compose 的支持太弱了。取而代之的是直接调 docker compose 命令行:

1
2
3
4
5
6
7
8
9
10
11
12
13
def __compose_command(self, project_dir: Path, command: list[str],
json_lines: bool = False, check: bool = True):
if not shutil.which("docker"):
raise RuntimeError("当前系统未安装docker命令")
result = subprocess.run(
["docker", "compose", *command],
cwd=str(project_dir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=300,
)
# ...

配置文件存在 data/docker_compose/<project>/compose.yml 下,项目名就是目录名。这里有个路径安全检查:

1
2
3
4
5
6
7
8
def __compose_project_dir(self, project: str, create: bool = False) -> Path:
project = str(project or "").strip()
if not project.replace("-", "").replace("_", "").isalnum():
raise ValueError("Compose项目名只能包含字母、数字、下划线和短横线")
base = COMPOSE_BASE_DIR.resolve()
project_dir = (base / project).resolve()
if base != project_dir and base not in project_dir.parents:
raise ValueError("Compose项目路径非法")

项目名只允许字母数字下划线短横线,然后做路径穿越检查。同样是那条铁律——用户输入的路径不能逃出预设的根目录。

容器执行:docker exec 的远程版

docker:container:exec 是个值得单独说的操作。它允许通过 Panel 在节点上的容器里执行命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def __container_exec(self, payload: dict):
command = payload.get("command") or payload.get("cmd")
if isinstance(command, str):
command = command.strip()
if not command:
raise ValueError("容器执行命令不能为空")
result = self.__get_container(payload).exec_run(
command,
stdout=True, stderr=True,
tty=self.__coerce_bool(payload.get("tty"), False),
privileged=self.__coerce_bool(payload.get("privileged"), False),
user=payload.get("user") or "",
workdir=payload.get("workdir") or None,
environment=payload.get("environment") if isinstance(payload.get("environment"), dict) else None,
)

privileged=True 这个选项是故意暴露的——有些运维场景确实需要在特权容器里操作。但 docker:container:execSIGNED 级别的动作,签名验不过去连接直接断。安全边界不在参数白名单里,而在 HMAC 签名和应用层权限控制里。

Docker 配置管理:写 daemon.json 的正确姿势

docker:config:update 直接改 /etc/docker/daemon.json,听起来很危险。代码里做了几层保护:

1
2
3
4
5
6
7
8
9
10
11
12
13
def __config(self, operation: str, payload: dict):
if operation == "update":
if os.name == "nt":
raise RuntimeError("Windows节点暂不支持修改daemon.json")
data = self.__daemon_config_data(payload)
DAEMON_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
if DAEMON_CONFIG_PATH.exists():
backup = DAEMON_CONFIG_PATH.with_suffix(f".json.bak")
shutil.copy2(DAEMON_CONFIG_PATH, backup)
DAEMON_CONFIG_PATH.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8"
)

先备份旧配置(.json.bak),再写入新配置。万一新配置写坏了,至少有个回退点。

reloadrestart_service 更是通过 subprocess.run 调用 systemctl reload dockersystemctl restart docker,这些是标准的服务管理命令,不是什么黑魔法。但它们也需要 docker_config_manage 权限开关开着才能执行。

返回值的 JSON 安全化

Docker SDK 返回的对象经常包含不能直接 JSON 序列化的类型(比如 datetimeImage 对象等)。所有返回到 WebSocket 的数据都要过一遍 JSON 安全化:

1
2
3
4
5
6
@staticmethod
def __json_safe(value: Any):
try:
return json.loads(json.dumps(value, default=str))
except Exception:
return str(value)

default=str 是个万能兜底——任何不能序列化的类型都转成字符串。虽然丢失了原始类型信息,但保证了 WebSocket 消息永远不会因为序列化报错而中断。比起精心设计每个类型的序列化器,这种简单粗暴的做法在运维场景下更靠谱——最怕的不是数据格式不完美,而是操作到一半因为序列化炸了。

错误处理的分层

Docker 操作可能出各种错——镜像不存在、容器名冲突、网络被占用、权限不够……DockerManager 的错误处理分了三层:

参数校验层——在调 Docker SDK 之前就拦住明显不合法的输入:

1
2
3
4
5
6
7
@staticmethod
def __required(payload: dict, *keys: str) -> Any:
for key in keys:
value = payload.get(key)
if value not in (None, ""):
return value
raise ValueError(f"缺少参数: {'/'.join(keys)}")

__required 支持多个候选 key——container/container_id/id 都能匹配,这在对齐 Panel 和 Node 的数据结构差异时很实用。

Docker SDK 异常层——ImageNotFoundAPIError 这些异常会在 handle 方法的外层 try-catch 里被统一捕获,然后通过 __send_error 发回 Panel。

未知异常兜底——任何 Exception 都会被捕获,error 消息发回 Panel,不会导致节点断连或者 WebSocket 消息循环崩溃。

四十条操作的完整画面

把六类资源的操作列出来,就是这么大一张表:

资源操作安全
imagelist, inspect, history, pull, remove, tag, prunepull/remove/tag/prune 需 docker_image_manage
containerlist, inspect, stats, logs, create, start, stop, restart, kill, pause, unpause, remove, rename, update, exec写操作需 docker_container_manage
networklist, inspect, create, remove, connect, disconnect, prune写操作需 docker_network_manage
volumelist, inspect, create, remove, prune写操作需 docker_volume_manage
configget, validate, update, reload, restart_service写操作需 docker_config_manage
composelist, save, ps, logs, config, up, down, start, stop, restart, pull, build, rm, delete写操作需 docker_manager

每一个操作都是 WebSocket action → 安全检查 → Docker SDK 调用 → JSON 安全化 → WebSocket response 的标准流程。看起来枯燥,但这种一致性的好处是出了问题能快速定位——任何 Docker 操作失败,查日志就能看到 action、resource、operation、error 四个字段,不用猜。

关于直播室和手术室的关系——WebSocket 是直播室,Docker SDK 是手术室。直播室里消息飞来飞去出了问题可以打断、可以重试,手术室里出了问题就是真出了问题。这中间的缓冲层——安全检查、参数校验、错误回传——每一层都是保护手术室不出事故的双保险。

欢迎关注我的其它发布渠道