Skip to content
Go back

Colima运行linux容器(五):宿主机-VM 桥接 — 让 VM 层"消失"

Colima运行linux容器(五):宿主机-VM 桥接 — 让 VM 层”消失”

基于 Colima 源码深度分析

一、桥接:非 Linux 容器化的核心难题

前四篇解决了”怎么在 macOS 上跑一个 Linux 内核”以及”怎么在 VM 内启动容器运行时”的问题。但这只完成了一半——如果用户每次都要先 SSH 进 VM 才能操作容器,体验就不如在原生 Linux 上。

桥接的目标是让 VM 层对用户透明。用户在 macOS 终端里敲命令,就像容器直接跑在本机一样。

这需要打通四个通道:

macOS 宿主机                           Linux VM
┌──────────────────┐                 ┌──────────────────┐
│                  │                 │                  │
│  客户端工具      │   ① 控制通道    │  运行时守护进程   │
│  (docker/nerdctl │ ◄════════════► │  (dockerd/       │
│   /incus/kubectl)│                 │   containerd/    │
│                  │                 │   incusd/k3s)    │
│                  │                 │                  │
│  localhost:8080  │   ② 端口通道    │  容器服务 :80    │
│                  │ ◄════════════► │                  │
│                  │                 │                  │
│  ~/project/      │   ③ 文件通道    │  /Users/.../     │
│                  │ ◄════════════► │                  │
│                  │                 │                  │
│  宿主机服务      │   ④ 反向通道    │  容器内访问       │
│  :3000 :5432     │ ◄════════════► │  宿主机服务       │
└──────────────────┘                 └──────────────────┘

每个通道对应一类桥接机制:

通道问题机制
① 控制宿主机客户端怎么连接 VM 内的守护进程?Unix Socket 转发
② 端口宿主机怎么访问 VM 内容器监听的端口?TCP/UDP 端口转发
③ 文件VM 内的容器怎么读写宿主机的项目文件?文件系统挂载
④ 反向VM 内的容器怎么访问宿主机上的服务?DNS + 网关路由

下面逐一分析。

二、控制通道 — Unix Socket 转发

2.1 问题

所有容器运行时都通过 Unix socket 提供 API:

运行时VM 内的 socket 路径客户端工具
Docker/var/run/docker.sockdocker CLI
containerd/var/run/containerd/containerd.socknerdctl
BuildKit/var/run/buildkit/buildkitd.socknerdctl build
Incus/var/lib/incus/unix.socketincus CLI

这些 socket 都在 VM 内部,macOS 上的客户端工具无法直接访问。

2.2 Lima 的 Socket 转发

Colima 在生成 Lima YAML 配置时,根据选择的运行时注入 socket 转发规则:

// environment/vm/lima/yaml.go — Socket 转发规则(简化后的逻辑)
// 实际代码内联在 newConf() 函数中,这里提取为独立函数便于理解

switch conf.Runtime {
case "docker":
    // Docker socket + 底层 containerd socket
    l.PortForwards = append(l.PortForwards,
        limaconfig.PortForward{
            GuestSocket: "/var/run/docker.sock",
            HostSocket:  "~/.colima/<profile>/docker.sock",
        },
        limaconfig.PortForward{
            GuestSocket: "/var/run/containerd/containerd.sock",
            HostSocket:  "~/.colima/<profile>/containerd.sock",
        },
    )

case "containerd":
    // containerd socket + BuildKit socket
    l.PortForwards = append(l.PortForwards,
        limaconfig.PortForward{
            GuestSocket: "/var/run/containerd/containerd.sock",
            HostSocket:  "~/.colima/<profile>/containerd.sock",
        },
        limaconfig.PortForward{
            GuestSocket: "/var/run/buildkit/buildkitd.sock",
            HostSocket:  "~/.colima/<profile>/buildkitd.sock",
        },
    )

case "incus":
    // Incus socket
    l.PortForwards = append(l.PortForwards,
        limaconfig.PortForward{
            GuestSocket: "/var/lib/incus/unix.socket",
            HostSocket:  "~/.colima/<profile>/incus.sock",
        },
    )
}

Lima 收到这个配置后,在宿主机上创建对应的 socket 文件,并在底层建立转发通道。

2.3 转发的底层机制

转发方式由 portForwarder 配置决定(Colima 通过 LIMA_SSH_PORT_FORWARDER 环境变量传递给 Lima):

模式机制特点
ssh(默认)SSH 的 -L 本地转发稳定,有 SSH 协议开销
grpcLima guest agent 的 gRPC 协议延迟更低
none不转发需要 VM 有可达 IP,直接访问

无论哪种模式,对用户表现一致:macOS 上出现一个 socket 文件,连接它就等于连接 VM 内的守护进程。

2.4 客户端如何找到 socket

socket 文件创建出来后,还需要让客户端工具知道去连接它。不同运行时有不同的策略:

Docker — 通过 Docker Context

// environment/container/docker/context.go
func (d dockerRuntime) setupContext() error {
    host.Run("docker", "context", "create", profileID,
        "--description", profileDisplayName,
        "--docker", "host=unix://"+HostSocketFile())
    // HostSocketFile() → ~/.colima/<profile>/docker.sock
}

func (d dockerRuntime) useContext() error {
    if conf.AutoActivate() {
        host.Run("docker", "context", "use", profileID)
    }
}

Docker CLI 通过 context 机制找到正确的 socket。激活后所有 docker 命令自动走 Colima 的 socket。

Incus — 通过 Remote

// environment/container/incus/incus.go
host.Run("incus", "remote", "add", profileID, "unix://"+socketFile)

Incus 的 remote 机制类似 Docker context,指向转发的 socket。

Kubernetes — 通过 kubeconfig

// environment/container/kubernetes/kubeconfig.go
// K3s 的 API server 通过 TCP 端口转发(不是 socket)
// kubeconfig 中的 server 地址指向 VM 的 IP:port

2.5 多实例隔离

每个 Colima profile 有独立的 socket 文件和客户端配置:

Profile "default":
  ~/.colima/colima/docker.sock        → Docker context "colima"
  ~/.colima/colima/containerd.sock
  ~/.colima/colima/incus.sock         → Incus remote "colima"

Profile "dev":
  ~/.colima/colima-dev/docker.sock    → Docker context "colima-dev"
  ~/.colima/colima-dev/containerd.sock

用户通过切换 context/remote 来选择操作哪个实例。

2.6 完整链路示例

以 Docker 为例,一次 docker ps 的完整路径:

docker ps
  → Docker CLI 读取当前 context = "colima"
  → 连接到 unix:///Users/you/.colima/colima/docker.sock
  → Lima 将请求通过 SSH/gRPC 转发到 VM
  → VM 内的 /var/run/docker.sock
  → dockerd 处理请求,返回容器列表
  → 响应原路返回到 macOS 终端

以 nerdctl 为例(containerd 运行时):

nerdctl ps
  → nerdctl 读取 CONTAINERD_ADDRESS 环境变量
  → 连接到 unix:///Users/you/.colima/colima/containerd.sock
  → Lima 转发到 VM 内的 /var/run/containerd/containerd.sock
  → containerd 返回容器列表

三、端口通道 — TCP/UDP 端口转发

3.1 问题

VM 内的容器监听了网络端口(如 nginx 监听 80),macOS 上的浏览器怎么通过 localhost:80 访问它?

3.2 端口转发规则

Colima 配置了全端口范围的转发规则:

// environment/vm/lima/yaml.go — 端口转发规则(简化后的逻辑)
// 实际代码内联在 newConf() 函数中

l.PortForwards = append(l.PortForwards,
    // TCP:VM 所有接口 1-65535 → macOS 0.0.0.0
    limaconfig.PortForward{
        GuestIPMustBeZero: true,
        GuestIP:           net.ParseIP("0.0.0.0"),
        GuestPortRange:    [2]int{1, 65535},
        HostIP:            net.ParseIP("0.0.0.0"),
        Proto:             "tcp",
    },
    // UDP:同上
    limaconfig.PortForward{
        GuestIPMustBeZero: true,
        GuestIP:           net.ParseIP("0.0.0.0"),
        GuestPortRange:    [2]int{1, 65535},
        HostIP:            net.ParseIP("0.0.0.0"),
        Proto:             "udp",
    },
    // TCP:VM 127.0.0.1 → macOS 127.0.0.1
    limaconfig.PortForward{
        GuestIP:        net.ParseIP("127.0.0.1"),
        GuestPortRange: [2]int{1, 65535},
        HostIP:         net.ParseIP("127.0.0.1"),
        Proto:          "tcp",
    },
)

工作机制:Lima 监控 VM 内哪些端口被监听(通过扫描 /proc/net/tcp 等)。当检测到新端口时,自动在 macOS 上建立对应的映射。这意味着你不需要显式声明端口——任何容器启动后监听的端口都会自动暴露到 macOS。

3.3 端口转发方式

与 socket 转发共用同一套端口转发器配置:

3.4 特殊场景:Kubernetes 端口排除

当启用 Kubernetes 且 VM 有可达 IP 时,Colima 排除 80 和 443 端口的转发:

if conf.Kubernetes.Enabled && reachableIPAddress {
    // 禁用 80/443 转发
    // 原因:K3s 内置的 Traefik ingress 会监听这些端口
    // 有可达 IP 时,直接通过 VM IP 访问更合理
    l.PortForwards = append(l.PortForwards,
        limaconfig.PortForward{GuestPort: 80, Ignore: true},
        limaconfig.PortForward{GuestPort: 443, Ignore: true},
    )
}

类似地,Incus 在有可达 IP 时会禁用所有端口转发——因为 Incus 容器有独立 IP,直接路由访问是更自然的方式。

四、文件通道 — 宿主机目录挂载

4.1 问题

开发者的代码在 macOS 上,容器需要读写这些文件(如 docker run -v ~/project:/appnerdctl run -v ~/data:/data)。两个文件系统分属不同操作系统内核,怎么共享?

4.2 挂载配置

// environment/vm/lima/yaml.go — 挂载点配置(简化)
for _, m := range conf.MountsOrDefault() {
    // MountsOrDefault(): 没有配置时默认挂载 home 目录
    l.Mounts = append(l.Mounts, limaconfig.Mount{
        Location:   m.Location,    // macOS 路径,如 /Users/you
        MountPoint: m.MountPoint,  // VM 内路径(默认与 Location 相同)
        Writable:   m.Writable,    // 是否可写
    })
}

Lima 负责在 VM 启动时将宿主机目录挂载到 VM 内相同路径。这样容器做 -v ~/project:/app 时,VM 内的 ~/project 已经是宿主机的文件。

4.3 三种挂载技术

挂载方式取决于虚拟化引擎(第二篇已介绍选择逻辑),但桥接层面需要理解它们的数据路径差异:

virtiofs(VZ 模式)

macOS 文件系统

Apple Virtualization.framework — VZVirtioFileSystemDevice
     ↓  VirtIO 设备通道(不走网络协议栈)
VM Linux Kernel — virtiofs 内核驱动

VM 内的挂载点(如 /Users/you)
     ↓  容器 bind mount
容器内(如 /app)

virtiofs 通过 VirtIO 设备实现零拷贝文件共享,性能最接近原生。Apple 的 Virtualization.framework 原生支持此协议。

9p(QEMU 模式)

macOS 文件系统

QEMU — 9p VirtIO 设备
     ↓  VirtIO 设备通道
VM Linux Kernel — 9P 文件系统驱动

VM 内的挂载点 → 容器 bind mount

9p 是 Plan 9 操作系统的文件系统协议。QEMU 将其映射为 VirtIO 设备暴露给 VM。性能不如 virtiofs,但兼容性好。

reverse-sshfs(通用回退)

macOS — SFTP 服务(SSH 内置)
     ↑  反向 SSH 连接
VM — sshfs 客户端(基于 FUSE)

VM 内的挂载点 → 容器 bind mount

reverse-sshfs 是纯网络方案——VM 通过 SSH 反向连接 macOS 读取文件。性能最低,但不依赖任何虚拟化特性,任何引擎都能用。

4.4 inotify 事件传播

macOS 使用 FSEvents,Linux 使用 inotify——两者不互通。使用 sshfs 或 9p 时,macOS 上修改文件不会触发 VM 内的 inotify 事件,导致依赖文件监听的工具(webpack —watch、nodemon、热重载等)失效。

Colima 的解决方案:

// 当 conf.MountINotify == true 时
// 启动一个专用守护进程:
//   macOS 端:监听 FSEvents
//   通过自定义协议将变更事件传递到 VM
//   VM 端:触发对应的 inotify 事件
startDaemon("inotify")

virtiofs 模式不需要这个守护进程——virtiofs 协议本身就传播文件变更事件。这也是 VZ + virtiofs 对开发体验更优的原因之一。

五、反向通道 — 容器访问宿主机

5.1 问题

开发中常见场景:容器内的应用需要连接宿主机上的数据库、API 服务或其他开发工具。但容器在 VM 内,VM 和 macOS 是不同的网络栈。容器内的 localhost 指的是容器自己,不是 macOS。

5.2 DNS 解决方案

Colima 配置了 DNS 映射,让 host.docker.internal 解析为宿主机的 IP:

// environment/vm/lima/yaml.go — Lima DNS 配置
l.HostResolver.Enabled = len(conf.Network.DNSResolvers) == 0  // 没有自定义 DNS 时启用
l.HostResolver.Hosts["host.docker.internal"] = "host.lima.internal"
// Lima 的 host resolver 会将 host.lima.internal 解析为宿主机网关 IP

// environment/vm/lima/dns.go — VM 内 dnsmasq 配置
// host.docker.internal → 192.168.5.2(网关 IP = macOS)
// host.lima.internal   → 192.168.5.2
// colima.internal      → VM 自身 IP

同时,Docker daemon 的启动参数也配合了这个机制:

ExecStart=/usr/bin/dockerd --host-gateway-ip=<gateway_ip>

这使得 docker run --add-host=host.docker.internal:host-gateway 也能正确解析。

效果:容器内的代码用 http://host.docker.internal:3000 就能访问 macOS 上跑的开发服务器。

5.3 宿主机地址复制

更进一步,Colima 支持将 macOS 的所有网络接口 IP 复制到 VM 的 loopback 上:

// environment/vm/lima/network.go:61-70
func (l *limaVM) replicateHostAddresses(conf config.Config) error {
    // 仅当:network.Address 未启用(VM 没有可达 IP)且 HostAddresses 启用 时才复制
    // 如果 VM 已有独立 IP,就没必要在 lo 上叠加宿主机地址
    if !conf.Network.Address && conf.Network.HostAddresses {
        for _, ip := range util.HostIPAddresses() {
            l.RunQuiet("sudo", "ip", "address", "add", ip.String()+"/24", "dev", "lo")
        }
    }
    return nil
}

这让容器内直接用 macOS 的实际 IP 地址也能访问宿主机服务,无需知道特殊的 host.docker.internal 域名。

5.4 代理转换

一个容易被忽略的细节——网络代理:

// environment/container/docker/proxy.go
// 用户可能在 Docker 配置中设置了代理:
//   "proxies": {"http-proxy": "http://localhost:7890"}
//
// 但容器内的 localhost 不是宿主机!
// Colima 自动将 localhost/127.0.0.1 替换为 host gateway IP
// 这样容器才能通过宿主机上的代理访问网络

六、VM 网络架构

6.1 默认拓扑(user-v2 网络)

┌─────────────────────────────────────────────────────────┐
│                      macOS                               │
│                                                         │
│  ┌─────────────────────────────────────────────────┐    │
│  │  Lima user-v2 网络(用户态网络栈)                │    │
│  │                                                 │    │
│  │  macOS ←─── SSH/gRPC ──→ VM:22 (sshd)          │    │
│  │  macOS:端口 ←── 端口转发 ──→ VM:端口             │    │
│  │  macOS:socket ←─ 转发 ──→ VM:*.sock             │    │
│  │                                                 │    │
│  │  VM 网络:                                       │    │
│  │  ┌─────────────────────────────────────┐       │    │
│  │  │  eth0: 192.168.5.x (DHCP)          │       │    │
│  │  │  网关: 192.168.5.2 → macOS 网络     │       │    │
│  │  │                                     │       │    │
│  │  │  docker0/cni0: 172.17.0.1/16       │       │    │
│  │  │  ├── container1: 172.17.0.2        │       │    │
│  │  │  ├── container2: 172.17.0.3        │       │    │
│  │  │  └── ...                           │       │    │
│  │  └─────────────────────────────────────┘       │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

默认模式下,VM 没有 macOS 可直接访问的 IP。所有通信都通过 Lima 的转发机制。

6.2 可达 IP 模式

当用户配置 network.address: true 时,VM 获得一个宿主机可直接路由的 IP:

// environment/vm/lima/yaml.go
if conf.Network.Address {
    if l.VMType == limaconfig.VZ && conf.Network.Mode != "bridged" {
        // VZ 模式:使用 VZ 原生 NAT
        l.Networks = append(l.Networks, limaconfig.Network{
            VZNAT:     true,
            Interface: "col0",
            Metric:    metric,
        })
    } else {
        // QEMU / VZ bridged 模式:使用 vmnet daemon
        l.Networks = append(l.Networks, limaconfig.Network{
            Socket:    vmnet.Info().Socket.File(),
            Interface: "col0",
            Metric:    metric,
        })
    }
}

有了可达 IP 后:

七、SSH — 一切桥接的基础通道

Lima 所有操作(命令执行、socket 转发、文件传输)的底层都建立在 SSH 连接之上:

// environment/vm/lima/lima.go

// 在 VM 内执行命令 — 通过 limactl shell(底层是 SSH)
func (l *limaVM) Run(args ...string) error {
    return l.host.Run(append([]string{"limactl", "shell", profileID}, args...)...)
}

// 交互式 SSH 终端
func (l *limaVM) SSH(workingDir string, args ...string) error {
    return l.host.RunInteractive(
        append([]string{"limactl", "shell", "--workdir", workingDir, profileID},
            args...)...)
}

// 执行命令并获取输出
func (l *limaVM) RunOutput(args ...string) (string, error) {
    return l.host.RunOutput(
        append([]string{"limactl", "shell", profileID}, args...)...)
}

SSH 连接在 Lima 创建 VM 时自动配置:

  1. Lima 生成 ED25519 密钥对
  2. 公钥通过 cloud-init 注入到 VM 的 ~/.ssh/authorized_keys
  3. Lima 选择一个本地随机端口作为 SSH 端口
  4. 后续所有 limactl shell 命令通过此端口连接 VM

Colima 还将 SSH 配置暴露给用户,方便直接 SSH:

# 自动生成 ~/.colima/ssh_config
# 用户可以:
ssh -F ~/.colima/ssh_config colima
# 或在 ~/.ssh/config 中 Include 这个文件

八、桥接全景图

┌───────────────────────── macOS 宿主机 ─────────────────────────┐
│                                                                │
│  客户端工具                        桥接到                       │
│  ─────────                        ──────                       │
│  docker CLI ──→ Docker Context ──→ docker.sock (local)         │
│  nerdctl    ──→ CONTAINERD_ADDR ──→ containerd.sock (local)    │
│  incus      ──→ Incus Remote   ──→ incus.sock (local)          │
│  kubectl    ──→ kubeconfig     ──→ VM:6443 (TCP)               │
│  curl       ──→ localhost:8080 ──→ 端口转发                     │
│  编辑器     ──→ ~/project/     ──→ virtiofs/9p/sshfs            │
│                                                                │
│  ┌────────────────── Lima 桥接层 ─────────────────────────┐    │
│  │                                                        │    │
│  │  控制通道:Socket 转发                                  │    │
│  │    *.sock (macOS) ←─ SSH/gRPC ─→ *.sock (VM)          │    │
│  │                                                        │    │
│  │  端口通道:自动端口转发                                  │    │
│  │    localhost:* ←─ SSH/gRPC ─→ VM:* (自动检测新端口)    │    │
│  │                                                        │    │
│  │  文件通道:目录挂载                                      │    │
│  │    ~/  ←─ virtiofs/9p/sshfs ─→ /Users/you/ (VM)       │    │
│  │                                                        │    │
│  │  反向通道:容器→宿主机                                   │    │
│  │    host.docker.internal ──→ macOS gateway IP           │    │
│  │                                                        │    │
│  └────────────────────────────────────────────────────────┘    │
│                                                                │
├──────────────────────── 虚拟化边界 ────────────────────────────┤
│                                                                │
│  ┌────────────────── Linux VM ────────────────────────────┐    │
│  │  sshd(接受 Lima 连接,一切桥接的基础)                  │    │
│  │  容器运行时(dockerd / containerd / incusd / k3s)      │    │
│  │  容器网络(docker0/cni0 bridge)                        │    │
│  │  数据存储(/var/lib/{docker,containerd,...} 在数据磁盘) │    │
│  └────────────────────────────────────────────────────────┘    │
└────────────────────────────────────────────────────────────────┘

九、总结:在非 Linux 上跑 Linux 容器的完整架构

回顾整个五篇系列,核心架构可以归纳为:

问题:macOS/Windows 没有 Linux 内核特性(namespaces/cgroups)

解决:虚拟化一个 Linux 内核

引擎:QEMU (通用/跨架构) / VZ (Apple 原生) / Krunkit (microVM)

管理:Lima 管理 VM 生命周期,Colima 编排全流程

运行时:VM 内启动 Docker/containerd/Incus(标准 Linux 运行时)

桥接四通道:
  ① 控制通道 — Socket 转发(SSH/gRPC 隧道)
  ② 端口通道 — TCP/UDP 自动转发(1-65535)
  ③ 文件通道 — 目录挂载(virtiofs/9p/sshfs)
  ④ 反向通道 — DNS + 网关路由(host.docker.internal)

结果:用户在 macOS 上无感使用 Linux 容器

一句话总结:在非 Linux 上运行 Linux 容器 = 虚拟化引擎(跑 Linux 内核)+ 容器运行时(标准 Linux 容器管理)+ 四通道桥接(让 VM 层透明)。

Colima 的价值在于:把这三层的复杂性封装成一条 colima start 命令。


Share this post on:

Previous Post
Colima运行linux容器(四):VM 内的容器运行时
Next Post
Colima运行linux容器(六):macOS 容器方案横评