状态:实测(2026-10-31)。 这是在 TT Lab 实验虚拟机(KubeVirt 上的 Ubuntu 24.04)中搭建 k0s v1.37.1+k0s.0 测得的值。单节点方案是使用修改后的正确配置从头到尾运行一次的测量值,而封锁和导入在 1、2、3 节点方案中均以相同方式重复。节点地址已更改为示例网段(192.0.2.0/24)。编写规则:以实测数据为基础,未能测量的内容标记为“未测”,原因未经确认的解释标记为“推测”。
目录
1. 从结论开始
公共机构、金融、工厂等场所的服务器通常无法连接互联网。要在这些地方安装 Kubernetes,必须从外部获取所需文件并带进去(导入)。到这里为止大家都比较清楚,但实际操作时会留下更棘手的问题: 在安装过程中,真的没有从外部接收任何东西吗? 如果在可以访问外部的实验室中进行练习,即使有人偷偷从互联网下载了一个漏掉的镜像,也无法察觉。这种漏洞直到进入现场的那天才暴露出来。
- 封闭网比起搭建,证明它更难。 本次测试通过三重验证:外部 HTTPS 在 0.014 秒内被拒绝(curl rc=7),未导入的镜像会报出
ErrImagePull,并且在封锁规则前插入日志规则后,确认导入服务器在使用 k0sctl 进行安装和备份期间,向外部发起的 TCP 连接为 0 次。 - k0s 需要导入的东西很少。 只需一个二进制文件(271,493,593 字节)和一个包含 13 个系统镜像的压缩包(417,884,160 字节)即可建立节点。导入服务器接收文件、校验哈希并制作应用镜像包共耗时 68~72 秒。
- 单节点从安装到节点 Ready 耗时 144 秒,4 个系统 Pod 达到 Ready 状态又额外花了 49 秒。不过,
--single建立的节点以后无法加入新节点。 - 我们在封锁脚本中踩了两个坑。 nftables 链名称
fwd是保留字,导致整个规则表被拒绝;而写成“只允许通过内部网 NIC 发送”的规则则杀死了 k0s 的 CNI。这两种情况的修复方法和原因都写在第 2 节中。
在双节点中当控制器挂掉时工作节点会发生什么,请参阅 第 2 篇;关于 3 节点 HA 及其上的 Rook-Ceph・CloudNativePG,请参阅 第 3 篇。
2. 如何搭建封闭网
由于没有专用的实验用封闭网设备,我们通过虚拟机内的 nftables 阻止了外部连接。没有使用包裹虚拟机的外部集群网络策略来阻止的原因有两个:该策略没有“无互联网”之类的模式,且必须让学习者亲自阅读并修改封锁表才能达到实操效果。但它的局限性也很明显。由于虚拟机内的 root 可以删除此表, 封锁只是学习和测试装置,而不是安全边界。 测试期间没有人删除它,我们重新测量了每个阶段封锁表是否完好无损以及是否仍然处于阻断状态。
配置如下。
- 仅导入服务器(bastion)一台机器使用互联网。 它从官方发布渠道获取文件,与发布版中附带的
sha256sums.txt的值进行比对(如果不一致则删除并重新获取,连续三次不一致则停止),在创建镜像包后, 它也会自我封锁。 因为只有在导入结束后导入服务器也无法访问外部,才能证明“安装期间没有偷偷下载任何东西”。 - 节点从首次启动起就被封锁。 操作系统镜像中没有额外安装任何软件包。所需文件由导入服务器通过内部网传输(通过 scp 和 k0sctl 的 SSH)。学习者在节点上使用
sha256sum -c进行再次校验。导入服务器的校验用于确认“来自互联网的是否为官方文件”,而节点的校验用于确认“在传输过程中是否被篡改”。 - 每个节点都有两个 NIC。 一个是拥有默认路由的 NIC(
enp1s0,通往外部的通路),另一个是节点间专用的内部网 NIC(eth1)。这种配置导致了第 2 节中的第二个陷阱以及第 2 篇中的加入失败。
封锁脚本的核心如下。只保留需要的内容,其余的新连接一律 reject 。
DNS=$(awk '/^nameserver/{print $2}' /run/systemd/resolve/resolv.conf | paste -sd, -)
VXLAN_PEERS=... # 시험 환경의 세션망 상대(바깥 클러스터의 주소라 생략)
nft -f - <<NFT
table inet airgap {
chain airgap_out { # 이름에 fwd 를 쓰지 않는다(2.1)
type filter hook output priority 0; policy accept;
oifname "lo" accept
ct state established,related accept
oifname "eth1" accept # 사내망 NIC
fib daddr type local accept # 목적지가 이 노드 자신의 주소(2.2)
ip daddr 192.0.2.0/24 accept # 사내망 대역
ip daddr $VXLAN_PEERS udp dport 4789 accept # 시험 환경의 세션망을 실어 나르는 VXLAN
ip daddr { $DNS } meta l4proto { tcp, udp } th dport 53 accept
udp dport 67 accept # DHCP
ip daddr { 172.20.0.0/16, 172.21.0.0/16 } accept # k0s 파드·서비스 대역
counter reject
}
chain airgap_fwd {
type filter hook forward priority 0; policy accept;
ct state established,related accept
oifname "eth1" accept
ip daddr { 172.20.0.0/16, 172.21.0.0/16, 192.0.2.0/24 } accept
counter reject
}
}
NFT
部分内容记录了原因。
- 使用的是 reject 而不是 drop。 drop 会让发送方一直等待到超时。而 reject 会立即返回拒绝,因此被阻止的尝试在 0.014 秒内就会显现为失败,并且哪里以及什么被阻止会立即记录在日志和计数器中。现场的防火墙通常是 drop,所以也需要记住,同样的尝试在现场可能会挂起数十秒。
- DNS 保持开放。 测试环境的内部网是通过 VXLAN 将虚拟机连接起来的网络,它每 30 秒会通过 DNS 重新查找邻居的外部地址。如果阻止 DNS,内部网本身就会崩溃。因此,这个网络是一个“名称可以解析但连接被拒绝”的网络(实测:
getent hosts github.com返回地址,而curl返回 rc=7)。由于实际现场通常有内部 DNS,我们认为这种差异不会改变测试结果。 - 封锁通过 systemd 单元(
Type=oneshot,RemainAfterExit=yes)在每次启动时重新应用。 重启后的整体行为未进行测量(未测)。
2.1 陷阱:链名称 fwd 是保留字
最初编写的封锁表中,链名称被命名为 out·fwd 。 nft -f 报了如下错误(Ubuntu 24.04, nftables 1.0.9)。
Error: syntax error, unexpected fwd, expecting string or last
随后接连出现了诸如 unexpected type, unexpected policy 之类的错误。由于一个最初的错误导致解析器迷失了方向,如果只看输出的下半部分,
type filter hook forward 行看起来像是写错了,很难找到原因。 必须查看最顶部的错误。
原因是 fwd 是 nftables 的语句(statement)关键字。 nft 手册的 FWD STATEMENT 节将其解释为
fwd to device 是将数据包发送到其他接口的语句(仅用于 netdev 系列的 ingress 和 egress 钩子),同一手册的词汇规则规定:“与关键字冲突的标识符必须用双引号括起来”。我们没有使用引号,而是将名称改为了 airgap_out·airgap_fwd 。用引号括起来的
"fwd" 是否在此版本中被接受并未进行测试(未测)。
造成严重破坏的原因还有另一个。封锁是在虚拟机的首次启动配置(cloud-init, set -e)中运行的,当这里失败时,整个配置过程停滞,“虚拟机准备就绪”的标志永远没有被打印出来。测试工具在等待 1200 秒后才得知失败。现在我们在应用前会使用 nft -c -f(仅检查不应用)来首先检查语法。
2.2 陷阱:“只允许通过内部网 NIC 发送”会杀死 k0s 的 CNI
最初的封锁表中只保留了 oifname "eth1" accept 。我们认为只要允许通过内部网 NIC 发送、拒绝其余流量即可。然而,安装完 1 个节点后,kube-system 的 Pod 始终无法达到 Ready 状态。排查后发现存在这样一条链式反应:
- k0s 的默认 CNI kube-router 在主机网络中运行,并以
kubernetesService 地址(172.21.0.1:443)调用 API。 - kube-proxy 将该地址 DNAT 转换为节点自身的 API 地址(例如 192.0.2.3:6443)。
- 然而,记录在 OUTPUT 过滤钩子中的日志却是
OUT=enp1s0 SRC=<NAT 주소> DST=192.0.2.3 DPT=6443。目标地址已更改为内部网地址,但发出的 NIC 却显示为默认路由 NIC。因此,它没有命中oifname "eth1"而被 reject 了。 - kube-router 留下
failed to synchronize cache: 1m0s timeout并每分钟重启一次,由于无法写入 CNI 配置文件,所有 Pod 都卡在了no IP ranges specified。
关于为什么在第 3 点中 NIC 会表现为那样,我们未能验证到内核的路由重新计算顺序这一层。我们认为,最初为 Service 地址确定的路由(默认路由)在过滤钩子执行时仍然保留着(推测)。修复方法是明确的。 fib daddr type local accept,即添加 “如果目标地址是此节点自身的地址,则无论 NIC 是什么都予以放行”的规则后,kube-router 成功连接,4 个系统 Pod 全部达到了 Ready。为了应对访问其他节点 API 的情况,我们还同时保留了
ip daddr 192.0.2.0/24 accept 。
得到的教训是: 封闭网规则不应写成“从哪个 NIC 发送”,而应写成“去往哪里”。 在 Kubernetes 节点上,由于 DNAT 的存在,数据包的外观在每个钩子处都会发生变化。
3. 需要带什么进去
版本固定为测试当日(2026-10-31)的最新稳定版。
| 文件 | 版本 | 大小(字节) | 备注 |
|---|---|---|---|
k0s-v1.37.1+k0s.0-amd64 |
v1.37.1+k0s.0 | 271,493,593 | 单个二进制文件中包含控制器、工作节点和 containerd |
k0s-airgap-bundle-v1.37.1+k0s.0-linux-amd64.tar |
〃 | 417,884,160 | 13个系统镜像的 OCI 包 |
k0sctl-linux-amd64 |
v0.33.1 | 19,796,128 | 用于 2、3 节点 |
apps-bundle.tar |
nginx 1.30.6-alpine, busybox 1.36.1-1 | 29,202,432 | 应用镜像包(由导入服务器制作) |
airgap 压缩包中的 13 个与官方列表(airgap-images-linux-amd64.txt)相同。分别是:pause、konnectivity agent、kube-proxy、coredns、metrics-server、pushgateway、CNI 插件、kube-router、三个 Calico、用于 NLLB 的 envoy 以及 traefik,它们全部在 quay.io/k0sproject/ 下方。
k0s 的离线环境安装流程如下。根据 官方文档,将 OCI 压缩包文件放在工作节点的
/var/lib/k0s/images/ 中后,k0s 在启动工作节点时会将其放入 containerd 中。并且在集群配置中指定 spec.images.default_pull_policy: Never 后, k0s 管理的组件的 imagePullPolicy 就会变为 Never ,甚至不会尝试从互联网拉取镜像(实际测量:coredns 的策略为 Never)。需要注意的是,此设置仅适用于 k0s 启动的系统 Pod。直接部署的应用会遵循自身的策略,因此需要将应用镜像一起打包放入压缩包中(本篇中的 apps-bundle.tar),或者像第 3 篇那样配置内部镜像仓库。应用压缩包是在导入服务器上使用 k0s airgap bundle-artifacts 制作的,耗时 7 秒。由于该命令每次制作时生成的结果文件哈希值都不同,因此只有应用压缩包不使用官方哈希值,而是记录本次生成的值并在节点上进行比对。
导入服务器端的时间如下(1、2 号机场景,实际测量)。
| 阶段 | 时间 |
|---|---|
| 在导入服务器上安装工具 (apt) | 43~49秒 |
| 下载二进制文件、压缩包、k0sctl 并核对哈希 | 13~15秒 |
| 制作应用压缩包 | 7秒 |
| 合计(直至封装完成) | 68~72秒 |
4. 单节点: k0s install controller --single
4.1 什么时候使用
这是将控制平面和工作节点部署在同一个节点上的配置。适用于边缘设备、演示环境、CI 的临时集群等不需要具备容错能力、希望轻量化部署的场景。只需传输一个二进制文件和一个包,再执行两行命令即可搞定。
scp k0s-v1.37.1+k0s.0-amd64 node01:/usr/local/bin/k0s
scp k0s-airgap-bundle-*.tar apps-bundle.tar node01:/var/lib/k0s/images/
ssh node01 'cat > /etc/k0s/k0s.yaml' <<'YAML'
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
metadata: {name: k0s}
spec:
api: {address: 192.0.2.3, sans: [192.0.2.3, node01]}
network: {provider: kuberouter, podCIDR: 172.20.0.0/16, serviceCIDR: 172.21.0.0/16}
images: {default_pull_policy: Never}
telemetry: {enabled: false}
YAML
ssh node01 'k0s install controller --single -c /etc/k0s/k0s.yaml --kubelet-extra-args=--node-ip=192.0.2.3 && k0s start'
ssh node01 k0s kubeconfig admin > ~/.kube/config
我们在配置中直接确定了三件事。
api.address和--node-ip被固定为内网地址。 如果不进行设置,k0s 会使用节点上找到的第一个非回环地址,但在拥有两个 NIC 的这个节点上,会捕获默认路由 NIC 的地址。虽然在单节点时看不出来,但在第二篇中随着节点增加,马上就以加入失败的形式暴露出来了。- 更改了服务网段。 k0s 的服务网段默认值(
serviceCIDR, 配置文档)是以 10 开头的 /12 网段,而本次测试的虚拟机所获取的 DNS 服务器地址正好在其中。如果发生重叠,节点会将发往自身 DNS 服务器的数据包误认为是 Kubernetes Service。在安装之前,务必对照配置文档中的默认值,确认现场的内网 DNS 及镜像仓库地址是否落在了默认服务网段或默认 Pod 网段(podCIDR)内。 telemetry.enabled: false。在封闭网络中无论如何都会被拒绝,这样做的目的是为了防止被拒绝的尝试扰乱日志和计数器。
4.2 实际测量
| 项目 | 值 |
|---|---|
| 开始安装 → 节点就绪 (node Ready) | 144秒 |
| node Ready → kube-system 파드 4개(coredns·kube-proxy·kube-router·metrics-server) Ready | +49秒 |
coredns 的 imagePullPolicy |
Never(default_pull_policy 生效) |
| 节点镜像 | 15个(系统 13 + 应用 2),全部来自归档包 |
| 节点(4 vCPU·4GiB)内存使用量 | 1.0/3.9GiB |
/var/lib/k0s |
1.7GiB |
4.3 --single 的限制
--single 很方便,但这是一个无法回溯的选择。
- 安装文档中写着
--single「关闭多节点所需的功能」。因此, 这样搭建的集群 以后无法添加节点。 如果哪怕有一点扩容的计划,最好一开始就分离控制节点和工作节点,或者controller --enable-worker以 开头的方案(在本次测试中,--enable-worker单节点配置未作评估)。 - CPLB和 NLLB 文档中都写着不能与
--single一起使用。 第 3 篇中的高可用设备无法加入此配置。 - 理所当然地,如果节点宕机,API 和工作负载也会同时停止。我们没有进行故障测试。
5. 证明属于封闭网络
仅凭安装成功这一点,并不能证明这就是封闭网络。这次我们进行了三项测量。
- 外部已被阻断。 如果在所有节点和导入服务器上使用
github.com·quay.io·registry-1.docker.io对 HTTPS 发起请求,HTTP 状态码为000,curl 退出代码为 7 (连接拒绝),耗时 0.014 秒。每次评判实操步骤时,我们都会重新测量其是否仍被阻断。 - 未导入的资源绝对无法使用。 如果使用未导入的
docker.io/library/redis:8.2-alpine创建 Pod,将会产生dial tcp <docker.io 주소>:443: connect: connection refused错误。ErrImagePull而在同一时间,使用已导入镜像启动的 Pod 全部都是 Running 状态。单节点场次的封锁计数器显示,拒绝向外部 发起的尝试共有 262 次。 - 导入之后,没有任何内容尝试向外部发送请求。 仅凭 1 和 2,只能确认“处于被阻断状态”。为了证明“不需要访问外部”,必须观察是否有尝试外发的内容。我们在封锁规则前插入日志规则后发现,节点唯一尝试发往外部的内容,只有步骤 2 中刻意触发的拉取 redis 操作(docker.io 的 8 个地址,TCP 443)。在导入服务器上使用 k0sctl 进行安装和备份期间,发往外部的 TCP 连接数为 0,被阻断的仅有时间同步(chrony)的 UDP 123 端口。
第3点之所以重要,是因为现场的防火墙通常采取 drop 策略。在实验室里因 reject 而直接失败并跳过的外部尝试(遥测、检查更新),在现场可能会因为等待超时而导致安装停滞。提前展示外部尝试次数为0,就能消除这种担忧。
此外,k0sctl v0.33.1 中没有 --disable-telemetry·--disable-upgrade-check 标志。如果参考旧文章加上它,apply 会在 flag provided but not defined 0秒内结束。在没有该标志的情况下,外部连接尝试次数也是0次。
6. 总结
| 检查项 | 原因(本文的实际测量) |
|---|---|
| 导入服务器在导入后也会进行封锁 | 这样才能证明在安装过程中没有偷偷接收任何东西 |
| 哈希值在导入服务器和节点上进行两次检查 | 一个是确认来源,另一个是检查传输过程中的损坏 |
| 封锁规则应用于目标地址 | 如果仅将其用作 NIC,经过 DNAT 的 API 调用会被阻止,从而导致 CNI 崩溃 |
在放入 nft 之前, nft -c -f |
fwd 整个表因一个规则而被拒绝,启动配方随之停止 |
| 服务与 Pod 网段同内网地址比对 | 默认服务网段覆盖了 VM 的 DNS 地址 |
| 外部尝试通过日志统计 | “被阻断”与“不需要”是不同的证明 |
如果计划扩容, --single 应避免使用 |
无法附加节点,也无法使用 CPLB·NLLB |
本文的实际测量可以在 TT Lab 的实训课程“闭网 k0s:1、2、3 个节点及其之上的 Ceph·PostgreSQL”(网站路径 /course/k0s-airgap-multinode)的第一个模块“1 个节点”中,使用相同的版本和相同的引入清单进行复现。在实际虚拟机中进行引入服务器封存和 --single 安装,评分器会在每个步骤检查结果。
TT 总结。 锁了门并不算完。必须连有没有想偷偷溜出门的朋友的足迹(日志)一起统计,才能说“确实没有人出去”。而且如果把门牌挂成“fwd”,整个房子都会打不开!