systemd-nspawn 入门指南:开小鸡&容器化的另一种选择

前言

市面上常见的容器化方案,像 Docker、Podman 或者 LXC/LXD,都是被广泛使用的技术。然而起步更早的 systemd-nspawn 却鲜有人关注。其实经过多年的发展,systemd-nspawn 也和其他方案一样使用了趋同的 Port Mapping、Mount Bind、Environment 等配置选项。

但是 systemd-nspawn 和其他方案相比,并不是一个「一站式」的解决方案,一方面它做的事情更加少,几乎只有「负责创建容器」这个功能,而网络、文件系统等功能比较依赖用户自己配置,另一方面也缺乏标准化的镜像打包和镜像仓库,几乎只能依赖用户自己创建 rootfs。因此对比起来,systemd-nspawn 更加「难用」,当然从另一个角度来说也更加「灵活」。

至于为什么在容器生态发展到今天,我依然要使用 systemd-nspawn,其实对我这个强迫症来说有个难以忍受的点:

「不喜欢没有边界感、乱动 nftables 的容器方案。」

对比一下前面的容器方案:

Docker Podman LXC/LXD systemd-nspawn
默认路由方式 (debian 12) iptables nftables nftables nftables
是否支持 nftables 不完全 不干涉 nftables
是否支持完全关闭 nftables 不完全 不完全

对于 Docker,即使在 config.josn 中关闭了 nftables,docker daemon 仍然会自动添加一些默认规则。

对于 LXC,iptables 规则由 lxc-net 管理。通过 systemd edit lxc-net 可以修改其启动顺序(来防止 nftables 配置刷新导致其规则被覆盖),或直接 mask 该 service 来手动管理所有网络配置。然而,LXC 强制依赖了 dnsmasq —— 而我不想使用它(而是自己定义 DHCP 服务)。

当然,并不是说乱动 nftables 的方案不好。它能提供一个简单的开箱即用的体验。不过,对于需要进行更加细致的路由设计和过滤的我来说,手搓 nftables 的方案更加符合个人的胃口。

此外不得不提的是,systemd-nspawn 依然有一个很大的缺点,即要求容器内的 Linux 必须使用 systemd 作为 init。

安装

Debian 12(下面均以 Debian 12 为例)上需要安装 systemd-container 包:

apt install systemd-container

创建镜像

可以使用 debootstrap 来创建一个 Debian 系统的 rootfs:

debootstrap --include=systemd,udev bookworm debian-12 https://deb.debian.org/debian

如果你没有安装 debootstrap,可以使用 apt install debootstrap 来安装。下面是每个参数的解释:

  • --include=systemd,udev:指定要包含的包。systemd-nspawn 需要容器镜像中包含 systemd 和 udev 来正常工作。
  • debian-12:输出的 rootfs 目录。
  • https://deb.debian.org/debian:指定 Debian 的镜像源。如果在国内,可以使用国内的镜像源来加速下载。

此外,也可以使用 --exclude 参数来排除一些不必要的包。我个人使用的命令为:

debootstrap --exclude=nano \
--include=systemd,dbus,jq,git,git-lfs,zip,7zip,unzip,bash-completion,vim,sudo,curl,wget,tree,tmux,file,zstd,dnsutils,iftop,iotop,btop,whois,rclone,rsync,gnupg,sqlite3,locales-all,uuid-runtime \
bookworm debian-12 https://deb.debian.org/debian

注意:虽然个 --include--exclude 参数可以预先安装一些常用的包,但是会显著增加 rootfs 的体积。具体的说,从最小可启动的镜像大小(128MB 左右)增加到 350MB 左右。

压缩和 OverlayFS

对于存储空间不敏感的情况,可以直接跳过此节,将 debian-12 文件夹移动到 /var/lib/machines/ 下。需要注意的是,虚拟机名字不可以超过 64 个字符,也不能包含特殊字符(如 /: 等)。

首先,将刚刚创建的镜像压缩为 squashfs。如果你没有安装 squashfs-tools,可以使用 apt install squashfs-tools 来安装。

mksquashfs debian-12 debian-12.squashfs -comp zstd

可以使用 -comp 参数来指定压缩算法(gzip、lz4、xz 等),-Xcompression-level 参数来指定压缩级别(1-9)。使用 zstd 时,默认的压缩级别时 15 并已经启用多线程,对于大多数用户来说不需要修改。

完成 squashfs 的创建后,就不再需要 debian-12 文件夹了,可以将其删除。

接下来需要创建 overlayfs。本文使用 /srv/containers 作为容器的存储目录,具体的说,是这样的文件结构:

/srv/containers/images/.squashfs  - 系统的 squashfs 镜像
/srv/containers/images/           - 系统 squashfs 镜像的只读挂载目录
/srv/containers/overlay//diff - 实际存储 systemd-nspawn 容器数据的目录
/srv/containers/overlay//work - OverlayFS 需要的缓存目录

其中,「实际存储 systemd-nspawn 容器数据的目录」是指容器内系统相比镜像文件的增量数据;这样就可以在容器之间共享基础的镜像,来节省存储空间。上述的目录皆可自定义,只需要在下文中将相关的目录替换为你自己的目录即可。

现在,mount squashfs 镜像到 /srv/containers/images/debian-12 目录:

mkdir -p /srv/containers/images/debian-12
mount -t squashfs -o loop debian-12.squashfs /srv/containers/images/debian-12

默认挂在的 squashfs 是只读的,这是符合预期的行为。

然后,创建 overlayfs:

mkdir /srv/containers/overlay/vm001/{diff,work}
mount -t overlay overlay -o metacopy=on,lowerdir=/srv/containers/images/debian-12,upperdir=/srv/containers/overlay/vm001/diff,workdir=/srv/containers/overlay/vm001/work /var/lib/machines/vm001

其中:

  • metacopy=on:开启元数据存储。由于 systemd-nspawn 会将容器内的 root 映射为宿主机上的普通用户(来提高安全性),而 overlayfs 默认会将权限不同的文件存储为两份数据。开启此选项后,overlayfs 只会在实际文件变化时,才会将数据存储为两份;如果只是权限文件的 uid/gid 变化,那么只会存储一份。
  • lowerdir:squashfs 的挂载目录,也就是基础镜像挂载出来的目录。
  • upperdir:overlayfs 的增量数据目录。
  • workdir:overlayfs 的工作目录。
  • /var/lib/machines/vm001:实际的容器根目录,必须是 /var/lib/machines/ 的形式,才能被 systemd-nspawn 识别。

如果你需要固化上述操作,你需要编辑 /etc/fstab 文件,添加以下内容:

/srv/containers/images/debian-12 /srv/containers/images/debian-12 squashfs loop 0 0
/srv/containers/overlay/vm001 /var/lib/machines/vm001 overlay metacopy=on,lowerdir=/srv/containers/images/debian-12,upperdir=/srv/containers/overlay/vm001/diff,workdir=/srv/containers/overlay/vm001/work 0 0

如果你需要添加多个容器,也需要多次在 /etc/fstab 中添加对应的行。

配置密码

systemd-nspawn 容器默认是没有 root 密码的,因此需要手动设置。使用下面的命令进入容器:

systemd-nspawn -D /var/lib/machines/ -U

进入容器后,使用 passwd 命令设置 root 密码:

passwd

配置网络

网络的配置有很多选项,包括桥接、veth、MACVLAN 等等。

如果你的 VPS 提供商没有开启 MAC 过滤且 TOS 允许,可以使用 MACVLAN 方案。对于一般需求,请使用桥接方案。

当然,我个人是有一些特殊需求的,由于我的 VPS 有 IX Port,而受限于相关规定:

  • 必须使用指定的 MAC 地址
  • 禁止在 IX 网卡上做桥接
  • BGP 会话必须使用指定的 IP 地址发起

因此,我的需求需要使用 IPVLAN 模式。下面我会介绍常用的几种配置方式。

使用 MACVLAN 模式配置

使用下面的命令启动 systemd-nspawn 容器:

systemd-nspawn -D /var/lib/machines/ -U --private-network --network-macvlan=eth0

其中 eth0 是 MACVLAN 父接口的名称。

使用 IPVLAN 模式配置

使用下面的命令启动 systemd-nspawn 容器:

systemd-nspawn -D /var/lib/machines/ -U --private-network --network-ipvlan=eth0

其中 eth0 是 MACVLAN 父接口的名称。容器内的 IP 需要和宿主机在同一个子网内,且不能和同局域网的其他 IP 冲突。

使用桥接模式配置

桥接模式和 VMWare/VirtualBox 的 Host-Only 模式类似,允许容器和宿主机在同一个网络中通信。

  • 如果不配置 NAT 规则,那么容器只能和宿主机通信,无法访问外网。
  • 如果配置 NAT 规则,那么和 VMWare/VirtualBox 的 NAT 模式以及 Docker 的 bridge 模式类似,容器通过宿主机的外网 IP 访问外网。

首先需要创建一个桥。对于 debian,首先需要安装 bridge-utils 包:

apt install bridge-utils

血的教训:如果忘记安装 bridge-utils,会导致重启 networking.service 时失败,直接把整个 VPS 的网络炸掉。

然后编辑 /etc/network/interfaces 文件,添加以下内容:

auto br0
iface br0 inet manual
    bridge_ports   eth0
    bridge_stp     off
    bridge_fd      0
    bridge_maxwait 0
    up   ip link set dev $IFACE up
    down ip link set dev $IFACE down

如果你需要在桥上添加宿主机侧的 IP 地址,可以在 iface br0 inet manual 行下添加:

    post-up   ip addr add 10.0.0.1/24 dev $IFACE

你需要把 10.0.0.1/24 替换为你想要的 IP 地址和子网掩码。多个 IP 地址可以添加多行。虽然你也可以用 address 10.0.0.1/24 来添加 IP 地址,但是经过实测存在两个问题:

  • address 只能添加一个 IP 地址。
  • 添加 IPv6 的时候会进行 DAD IP 冲突检测,而如果此时没有虚拟机在运行,会导致卡住 10 ~ 30 秒然后 DAD timeout。

因此,使用 post-up 的方式添加 IP 地址是更好的选择。

修改完 /etc/network/interfaces 文件后,重启 networking.service

systemctl restart networking.service

然后,可以使用下面的命令启动 systemd-nspawn 容器:

systemd-nspawn -D /var/lib/machines/ -U --private-network --network-bridge=br0

使用 veth 模式配置

Veth 是更加底层的模式,你可以认为是一根虚拟网线,一端连着宿主机,一端连着容器,中间没有任何交换机、桥和路由器。

使用 Veth 模式你要准备两个 IP 地址:

  • 一个宿主机的 IP 地址。需要注意的是,每创建一个新的容器,都会创建一个新的宿主机上的 veth 接口,因此需要为每个容器分配两个 IP 地址,一个在宿主机上,一个在容器内。下面的例子中使用 10.0.0.1/30
  • 一个容器内的 IP 地址。需要注意的是,容器内的 IP 地址必须和宿主机在同一个子网内。下面的例子中使用 10.0.0.2/30

你需要编辑 /etc/network/interfaces 文件,添加以下内容:

请打开两个 ssh 窗口。在第一个 ssh 窗口中,执行下面的命令:

systemd-nspawn -D /var/lib/machines/ -U --private-network

在第二个 ssh 窗口中,执行下面的命令:

ip link show

查找以 ve- 开头的网卡。

  • 如果你的容器名称小于 12 个字符,那么网卡名称为 ve-
  • 如果你的容器名称大于等于 12 个字符,那么网卡名称为 ve-

接下来的配置中将以 ve-vm001 为例,你需要将其替换为你实际的网卡名称。

编辑 /etc/network/interfaces 文件,添加以下内容:

allow-hotplug ve-vm001
iface ve-vm001 inet static
    address 10.0.0.1/30

然后,重启 networking.service

systemctl restart networking.service

需要注意的是,这里使用 allow-hotplug 而不是 auto,因为 veth 是 systemd 动态添加的,如果使用 auto 会造成系统重启时网络初始化失败从而断网,以及不会自动查找动态添加的 veth 接口。

在容器内配置 IP 地址

容器内配置 IP 地址的方式和正常的 Debian 一样,编辑 /etc/network/interfaces 文件。

对于 DHCP,添加以下内容:

auto host0
iface host0 inet dhcp

对于静态 IP,添加以下内容:

auto host0
iface host0 inet static
    address IP地址/CIDR
    gateway 网关地址
    dns-nameservers DNS服务器地址

对于静态 IP,你还需要修改 /etc/resolv.conf 文件,添加 DNS 服务器地址:

echo "nameserver 1.1.1.1" > /etc/resolv.conf

需要注意的是,必须使用 auto 而不是 allow-hotplug,因为 host* 接口不会触发 udev 事件。

下面是关于容器内 IP 地址配置的注意事项:

  • 如果使用 MACVLAN 模式,且你的 LAN 内有 DHCP 服务器,可以直接使用 DHCP 来获取 IP 地址。
  • 如果使用 MACVLAN 模式,且 LAN 内无 DHCP 服务器,或 DHCP 服务期有 MAC 白名单(典型的场景是 VPS 上把 IPv6 分配给容器),那么需要手动配置静态 IP 地址。该地址必须是你的 LAN 内的地址,或者是 VPS 提供商分配给你的 IPv6 地址段内的地址。
  • 如果使用 IPVLAN 模式,只能使用静态 IP 地址,因为 IPVLAN 模式会和宿主机使用相同的 MAC 地址。
  • 如果使用桥接模式,且你将宿主机的主网卡桥接到 br0,且 你的 LAN 内有 DHCP 服务器,可以直接使用 DHCP 来获取 IP 地址。
  • 如果使用桥接模式,虽然没有桥接到主网卡,但是你在任意一个容器或者宿主机中运行了 DHCP 服务,那么可以直接使用 DHCP 来获取 IP 地址。
  • 桥接模式的其他情况,需要手动配置静态 IP 地址。该地址必须和你宿主机上的 br0 接口在同一个子网内。
  • 如果使用 veth 模式,必须手动配置静态 IP 地址。该地址必须和宿主机上的 ve- 接口在同一个子网内。

在容器中运行 Docker,以及固化配置

需要确保 /etc/systemd/nspawn 文件夹存在。

mkdir -p /etc/systemd/nspawn

然后,创建 /etc/systemd/nspawn/.nspawn 文件。需要注意的是,etc/systemd/nspawn/.nspawn 的容器名必须和 /var/lib/machines/ 中的目录名一致。

在该文件内添加以下内容:

[Network]
Private=yes
# 如果使用 MACVLAN 模式,需要添加下面这行。将 eth0 替换为 MACVLAN 父接口的名称。
MACVLAN=eth0
# 如果使用 IPVLAN 模式,需要添加下面这行。将 eth0 替换为 IPVLAN 父接口的名称。
IPVLAN=eth0
# 如果使用桥接模式,需要添加下面这行。将 br0 替换为宿主机上桥接口的名称。
Bridge=br0
# 如果使用 veth 模式,需要添加下面这行。将 ve-vm001 替换为容器内的 veth 接口名称。不支持自定义 veth 接口名称。
VirtualEthernet=yes

如果需要在容器中运行 Docker,可以在 /etc/systemd/nspawn/.nspawn 文件中添加以下内容:

[Exec]
SystemCallFilter=@keyring keyctl

此外还可以在 [Exec] 部分添加其他的配置选项,比如:

LimitCPU=100%
LimitRSS=1G
LimitNPROC=1000

来限制 CPU、内存和进程数。

容器启停相关命令以及设置开机自启

Systemd,启动!

systemctl 类似,可以像管理服务一样来管理 systemd-nspawn 容器。

  • 查看运行中的容器:machinectl(需要注意的是,machinectl 只会显示正在运行的容器。停止的容器即使存在于 /var/lib/machines/ 目录下,也不会显示在 machinectl 中。)
  • 启动容器:machinectl start
  • 停止容器:machinectl stop
  • 查看容器状态:machinectl status
  • 容器开机自启:machinectl enable
  • 取消容器开机自启:machinectl disable

怎么样,是不是和 systemctl 管理服务一模一样?

怎么登录 systemd-nspawn 容器

最简单的办法是使用 machinectl login 命令。但是这里有个坑:

如果你用的是 SSH 登录,那么除非你停止这个容器,否则无法回到宿主机的 shell 中。

因此,推荐的方法是在容器里安装 ssh 服务。安装方式和正常的 Debian 系统一样,此处就不再赘述。

此外,在容器中也可以用 rebootpoweroff 命令来重启或停止容器。

容器 NAT 相关的配置

如果使用 nftabels 作为防火墙,配置容器 NAT 上网很简单。编辑 /etc/nftables.conf 文件,添加以下内容:

table inet nat {
    chain postrouting {
        type nat hook postrouting priority srcnat;
        policy accept;
        ip saddr 10.0.0.0/24 counter masquerade;
    }
}

其中,把 10.0.0.0/24 替换为你容器内的 IP 地址段。

然后,重启 nftables 服务:

systemctl restart nftables.service

你可能需要设置 nftables 服务开机自启:

systemctl enable --now nftables.service

如果你有其他干扰 nftables/iptables 的服务,那么你可能还要调整他们的开机启动顺序。

如果使用 MACVLAN/IPVLAN 模式,或者把外网接口接到了桥上,那么你是大概率不需要 NAT 的,因为 NAT 由 LAN 的路由器完成了。

点赞
  1. xy说道:

    沙发

  2. shc说道:

    赞一个!
    一直受不了docker在我的iptables里拉屎… 这个systemd-nspawn是个好东西

发表回复

电子邮件地址不会被公开。必填项已用 * 标注

×
订阅图标按钮