TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

封闭网 k0s 多节点实测 1:如何证明拦截了互联网,以及单节点

状态:实测(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,必须从外部获取所需文件并带进去(导入)。到这里为止大家都比较清楚,但实际操作时会留下更棘手的问题: 在安装过程中,真的没有从外部接收任何东西吗? 如果在可以访问外部的实验室中进行练习,即使有人偷偷从互联网下载了一个漏掉的镜像,也无法察觉。这种漏洞直到进入现场的那天才暴露出来。

在双节点中当控制器挂掉时工作节点会发生什么,请参阅 第 2 篇;关于 3 节点 HA 及其上的 Rook-Ceph・CloudNativePG,请参阅 第 3 篇。

2. 如何搭建封闭网

由于没有专用的实验用封闭网设备,我们通过虚拟机内的 nftables 阻止了外部连接。没有使用包裹虚拟机的外部集群网络策略来阻止的原因有两个:该策略没有“无互联网”之类的模式,且必须让学习者亲自阅读并修改封锁表才能达到实操效果。但它的局限性也很明显。由于虚拟机内的 root 可以删除此表, 封锁只是学习和测试装置,而不是安全边界。 测试期间没有人删除它,我们重新测量了每个阶段封锁表是否完好无损以及是否仍然处于阻断状态。

반입 서버 하나와 봉인된 노드들 시험 환경의 구성 그대로, 주소는 예시 인터넷 반입 단계에서만 씀 반입 서버 받은 뒤 스스로도 봉인한다 받기 공식 릴리스 해시 대조 sha256sums 묶음·미러 OCI·레지스트리 봉인 nftables scp · k0sctl(SSH) · 미러에서 당기기 사내망(eth1), 노드끼리만 노드 1 첫 부팅부터 봉인 노드 2 첫 부팅부터 봉인 노드 3 첫 부팅부터 봉인 남긴 것(accept) 루프백 · 이미 맺은 연결 사내망 NIC · 사내망 대역 자기 주소(fib daddr type local) 세션망 VXLAN(UDP 4789) DNS · DHCP k0s 파드·서비스 대역 나머지 새 연결(reject) github.com · quay.io · docker.io → curl 000, rc=7, 0.014초 반입하지 않은 이미지 → ErrImagePull, 연결 거절 이름 풀이는 된다 (DNS 를 열어 둔 까닭은 본문)
图:一个导入服务器和被封锁的节点:导入服务器在完成接收、哈希校验、打包和镜像同步后自行封锁,节点从首次启动起即被封锁。

配置如下。

封锁脚本的核心如下。只保留需要的内容,其余的新连接一律 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

部分内容记录了原因。

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 가 죽는다 노드 1대 설치에서 실측한 경로, 노드 주소는 예시 kube-router(호스트 네트워크) API 를 Service 주소로 부른다 172.21.0.1:443 kubernetes Service kube-proxy 가 DNAT 192.0.2.3:6443 이 노드 자신의 API OUTPUT 필터 훅의 로그 OUT=enp1s0 DST=192.0.2.3 DPT=6443 oifname "eth1" 만 허용 → reject kube-router 가 1분마다 재시작 모든 파드 no IP ranges fib daddr type local 추가 → accept 목적지가 자기 주소면 통과 kube-system 4개 Ready
图:当 kube-router 通过 Service 地址调用 API 时,经 DNAT 后的目标是节点自身的地址,但在过滤钩子的日志中却记录为通过默认路由 NIC 发送的数据包。
  1. k0s 的默认 CNI kube-router 在主机网络中运行,并以 kubernetes Service 地址(172.21.0.1:443)调用 API。
  2. kube-proxy 将该地址 DNAT 转换为节点自身的 API 地址(例如 192.0.2.3:6443)。
  3. 然而,记录在 OUTPUT 过滤钩子中的日志却是 OUT=enp1s0 SRC=<NAT 주소> DST=192.0.2.3 DPT=6443 。目标地址已更改为内部网地址,但发出的 NIC 却显示为默认路由 NIC。因此,它没有命中 oifname "eth1" 而被 reject 了。
  4. 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

我们在配置中直接确定了三件事。

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 很方便,但这是一个无法回溯的选择。

5. 证明属于封闭网络

仅凭安装成功这一点,并不能证明这就是封闭网络。这次我们进行了三项测量。

  1. 外部已被阻断。 如果在所有节点和导入服务器上使用 github.com·quay.io·registry-1.docker.io 对 HTTPS 发起请求,HTTP 状态码为 000,curl 退出代码为 7 (连接拒绝),耗时 0.014 秒。每次评判实操步骤时,我们都会重新测量其是否仍被阻断。
  2. 未导入的资源绝对无法使用。 如果使用未导入的 docker.io/library/redis:8.2-alpine 创建 Pod,将会产生 dial tcp <docker.io 주소>:443: connect: connection refused 错误。 ErrImagePull 而在同一时间,使用已导入镜像启动的 Pod 全部都是 Running 状态。单节点场次的封锁计数器显示,拒绝向外部 发起的尝试共有 262 次。
  3. 导入之后,没有任何内容尝试向外部发送请求。 仅凭 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”,整个房子都会打不开!

登录后即可点赞

评论

还没有评论。

登录后即可发表评论