从端口号到独立 IP——卡级容器调度的环境分配方案设计演进.

背景

最近业务上有容器化批量执行任务的诉求:用户想大批量并行执行一系列的任务,并且任务的环境诉求各不相同,从负载的角度看也不依赖完整的物理机,只需要一张或者几张卡就能够完成任务目标。

从使用角度看,用户想只描述任务和依赖的环境,然后通过平台就能批量拉起任务并执行——将用户任务和具体环境做解耦——这样不仅能够减少用户管理成本,也方便了业务后续进行自动化场景对接。

卡级容器化调度

分析下来,用户实际需要能够提供一个卡级容器化环境分配方案,在集群中动态挑选出符合条件的物理机,最后根据要求拉取基础镜像并部署好容器化的环境之后提供给用户使用。正常情况下,通行思路很简单粗暴:

  1. 定义用户的约束建模,包括硬件属性约束(NPU、CPU、storage、显存等),以及具体任务的 entrypoint;
  2. 解析用户约束建模,根据需求模型调度出符合条件的服务器;
  3. 根据读取到的镜像和配置 docker run,拉起容器后再进行后置处理流程,完成容器准备;
  4. 将准备好的容器提供给用户。

其中,1 是一个环境数字化的业务问题(模型怎么定,用户怎么用),2 的难度主要在于设计调度算法,这两块在之前设计物理机动态调度的时候已经完成了;而第 3 步的“容器以什么样的方式提供给用户”则是诉求中需要重点考虑到的问题。

正常的使用方式主要是一下两种:

  1. 通过容器提供:直接提供服务器的用户/密码 + 容器 ID,用户直接通过 docker exec 的方式来执行任务。这种方式主要适合任务需要感知到宿主机的场景,比如 letsencrypt 定时刷新证书;
  2. 通过端口提供:再容器内起 sshd,然后提供服务器的端口 + 容器内用户 / 密码,用户通过 IP + 端口的方式登入容器执行操作。

大道至简:映射端口提供服务

实际上用户的诉求很简单,就是需要有一个能自定义的容器环境来执行任务;出于隔离的考虑,首版方案采取**“通过端口提供容器”**的方式执行任务。具体逻辑如下:

  1. 解析用户约束建模,根据需求模型调度出符合条件的服务器;
  2. 启动镜像,并拉起容器内的 sshd 服务;
  3. 在先前划定好的端口段内 Random() 一个范围内无冲突的大端口,然后通过 -p <port>:22 的方式来把容器的 SSH 端口挂载到 host 上。
  4. 返回相关环境信息给用户,完成环境分配。

这套方案在正常运行中并没有特别大的问题,但是随着任务复杂性提高,端口号这种简单粗暴的方式渐渐无法满足用户的使用场景了。

用户开始给单个任务赋予更多职责,让以前的单体小任务逐渐变成了跨容器和多服务的大任务:一个任务容器中可能同时会起多个服务,并且还会给其他的任务提供服务(比如 MySQL、pip 源代理、dns 服务器)。这意味着更复杂的关联关系,还有更多的服务端口;考虑到物理机是动态调度出来的,平台还需要自动关联大量任务之间的 IP 和端口依赖关系。

此时事情开始变得复杂了起来,同时服务端口号映射也需要在任务和环境配置上做声明,对用户又是一块巨大的管理成本。作为平台自然需要用更优雅的方式来去解决这个问题,方案亟待优化。

返璞归真:给容器绑定独立 IP

先前方案与用户诉求之间主要的矛盾点,就在于一个任务可能要在宿主机暴露很多端口,同时用户又不想去为此去做端口管理,还想通过以前的方式拉起容器环境。

经过一段时间的讨论和技术验证,我们最终决定通过“给容器单独绑定一个小网 IP”的方式解决这个问题。这种方式从技术上看并不复杂,做技术验证主要还是因为平台自己 host 的集群,里面的 IP & 网络管理都需要和这个方案做适配,防止上线之后因为奇奇怪怪的 corner case 导致返工。

具体流程如下:

  1. 在服务器上创建一个 macvlan 容器网络:docker network create -d macvlan --subnet=11.38.64.0/19 --gateway=11.38.64.1 -o parent=enp189s0f0 mynet;(具体的子网掩码、网关、网卡等信息可以通过 ip route 之类的方式查询)
  2. 通过平台动态获取空闲的 IP,提供给容器绑定;
  3. 启动容器时,通过添加配置 --network=mynet --ip=11.38.64.100绑定容器的网络和 IP;
  4. 返回容器给用户;

至此大功告成,用户拿到了独立 IP 的容器,因此多个任务的端口号也没有了冲突,平台也少了动态端口产生的额外功能,只需要管理好集群的空闲 IP 网段即可。方案上线后,绝大多数任务可以平滑切换到新方案,几乎不需要额外做改造适配。

独立 IP 变成了一个问题

然而随着任务规模增长,出现了新的问题场景:任务需要通过 ssh 连接到宿主机,并且使用到物理机上的某些文件资源和操作,这在实际场景中出现了大量容器 SSH 宿主机失败的情况。

查了一段时间的资料,我发现“容器无法连接到 host 宿主机”实际上是 macvlan 的一个特性:

  • 使用 macvlan 时,您无法 ping 或与默认命名空间 IP 地址通信。例如,如果您创建一个容器并尝试 ping Docker 主机的 eth0 接口,则无法成功。该流量由内核模块本身明确过滤,以提供额外的提供商隔离和安全性。
  • 您可以将 macvlan 子接口添加到 Docker 主机,以允许 Docker 主机和容器之间的流量。需要在此子接口上设置 IP 地址,并将其从父地址中移除。

确认了这是个特性之后,就要开始着手解决这个场景了。经过又一段时间的资料查询,我找到了解决方法:既然容器通过 macvlan 无法连接 host IP,那么就给 host 绑定一个新的 IP,让容器和 host 通过新 IP 进行通信就可以了。

步骤如下:

  1. 在 host 上创建一个 macvlan,并绑定一个新的 IP:
ip link add mynet-shim link enp189s0f0 type macvlan mode bridge
ip addr add 11.38.64.117/32 dev mynet-shim
ip link set mynet-shim up
  1. 这个时候,通过 ifconfig 可以看到已经添加了一个新的 macvlan 网卡:
mynet-shim: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 11.38.64.117  netmask 255.255.255.255  broadcast 0.0.0.0
        inet6 fe80::588a:1dff:febb:35f4  prefixlen 64  scopeid 0x20<link>
        ether 5a:8a:1d:bb:35:f4  txqueuelen 1000  (Ethernet)
        RX packets 9  bytes 598 (598.0 B)
        RX errors 0  dropped 3  overruns 0  frame 0
        TX packets 5  bytes 426 (426.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
  1. 然后添加路由,让容器 IP(以 11.38.64.100 为例)默认路由到这个新建的 macvlan,如果规划了一个网段作为容器 IP 分配的话,可以使用对应的网段:
ip route add 11.38.64.100/32 dev mynet-shim

至此就可以正常让容器和 host 的通过新的 IP 进行通信了。至此技术上已经打通,剩下的工作就是需要通过配置的方式,让任务内的逻辑去使用新的 IP 来 SSH host。

虽然这种方式略微有点奇怪(需要调度容器前在 host 上做很多前置环境配置),但实际上是我能够找到比较快速且合适的方法了。

事后:Podman 和 docker 的网络比较

在这段时间解决容器网络问题时,我发现很多人在吐槽 Docker 的网络架构时,也在推荐大家使用 Podman 来代替 Docker 管理容器。Podman 作为后期之秀有着非常多和 Docker 不同的设计——比如 rootless 管理容器,无 daemon 等,podman 也通过插件方式代替了 Docker 繁杂且会影响 host 网络环境的网络组件;

其优劣先按下不表,这里记录一下二者之间在网络方面的对比:

功能 Docker Podman
网络接口标准 自研 libnetwork CNI 标准
默认网络实现 docker0
桥接网络
CNI bridge 插件 + slirp4netns
(rootless)
Rootless 网络 需额外配置(如 rootlesskit
原生支持,通过 slirp4netns
rootlesskit
端口映射 通过 dockerd
转发(特权操作)
通过 CNI portmap
插件(rootless 支持)
DNS 服务 内置 DNS 服务器(127.0.0.11) 使用主机 DNS 或 CNI dnsname
插件
多网络模式 支持 bridge
host
overlay
支持所有 CNI 插件(如 macvlan
ipvlan
网络策略 通过 iptables
或第三方插件管理
通过 CNI firewall
插件或主机防火墙

性能

  • Docker:中心化架构可能导致网络请求经过多层转发,存在一定延迟。
  • Podman:直接调用 CNI 插件,减少中间层,理论上网络延迟更低(尤其在 rootless 模式下)。

安全

  • Docker:网络配置需 root 权限,存在提权风险;容器与宿主机共享 iptables,可能引发规则冲突。
  • Podman:Rootless 模式下网络隔离更彻底,降低安全风险;CNI 插件支持细粒度网络策略(如 Calico),增强网络安全。

REF

  1. https://blog.oddbit.com/post/2018-03-12-using-docker-macvlan-networks/
  2. https://sreeninet.wordpress.com/2016/05/29/docker-macvlan-and-ipvlan-network-plugins/
  3. https://sreeninet.wordpress.com/2016/05/29/macvlan-and-ipvlan/
  4. https://stackoverflow.com/questions/63203538/docker-macvlan-no-route-to-host-container?rq=3
  5. https://stackoverflow.com/questions/42083546/docker-macvlan-network-unable-to-access-internet?rq=3
  6. https://docs.docker.com/engine/network/drivers/macvlan/