在家里把 Kubernetes 集群立起来之后,接下来的问题通常都一样。拿它来做什么呢?
我决定做一个学习平台。学员打开课程、点下开始实验,属于这个人的专属容器立刻启动,浏览器里的终端连上真正的 Linux shell,每一步点击评分按钮后,服务器会检查 Pod 内的实际状态并告知是否通过。实验结束或超过时限,容器就消失。
做完回头看,难的部分不是功能,是隔离。
问题在于必须把 root 交出去
既然是实操,学员在容器里就必须是 root。要动手试 useradd、试 chmod、把文件系统翻个遍,才学得会。这一点没得商量。
可是这个 root 一旦跑到容器外面就麻烦了。这个集群位于我家的网络中,同一个网络里还有路由器、NAS、镜像仓库以及其他节点。
最初做出来时,实验 Pod 的 spec 是这样的。
spec:
containers:
- name: lab
image: registry.internal/labhub/lab-linux:v1
command: ["sleep", "infinity"]
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: "1", memory: 1Gi }
没有 securityContext。也没有网络策略。我实测了一下到底能不能打通。
# 在实验 Pod 内部
cat < /dev/null > /dev/tcp/192.168.219.1/80 # 路由器管理页面
cat < /dev/null > /dev/tcp/10.96.0.1/443 # Kubernetes API 服务器
两个都通了。而且因为没有关掉 automountServiceAccountToken,默认服务账号的令牌就挂载在 Pod 里。也就是说,学员只要有这个念头,就能调用集群 API。
第 1 层 — 按实验分别开放网络
Cilium 判断策略靠的是身份而不是 IP。所以 toEndpoints 只开 kube-dns 一个,就会与集群内其他所有 Pod 断开;toEntities 一个都不开,就会与节点和 API 服务器断开。toCIDRSet 只作用于集群外的目的地,所以在那里把私网段挖掉就行。
一开始我给所有实验都开了互联网 80/443。但仔细一想,Linux、Git、数据库这些实验需要的东西全都在镜像里,一个字节的互联网都用不上。没有理由敞开的门,关上更好。
于是我把配置分成三档。
| 配置档 | 开放范围 | 使用的实验 |
|---|---|---|
| 默认 (无标签) | 仅 DNS | Linux、Git、FDE、DB、Kubernetes |
net=internet | + 公网互联网 80/443 | 需要 apt、pip 的实验 |
net=registry | + 仅镜像仓库域名 | 容器实验 |
默认档最窄。Pod 上没有标签,就自动只开 DNS。
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: lab-egress-base
spec:
endpointSelector:
matchLabels:
app: lab-session
egress:
# 仅名称解析。挂上 L7 DNS 解析器后,谁查了哪个域名都会留在 Hubble 里。
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports: [{ port: "53", protocol: ANY }]
rules:
dns: [{ matchPattern: "*" }]
在开放互联网的配置档里,把私网段全部挖掉。
- toCIDRSet:
- cidr: 0.0.0.0/0
except:
- 10.0.0.0/8 # 私网 A + Pod CIDR + Service CIDR
- 172.16.0.0/12 # 私网 B
- 192.168.0.0/16 # 家庭网络、路由器、NAS
- 169.254.0.0/16 # 链路本地 + 云元数据
- 100.64.0.0/10 # 运营商 CGNAT
toPorts:
- ports:
- { port: "80", protocol: TCP }
- { port: "443", protocol: TCP }
把端口收紧到 80/443 也是刻意为之。apt、pip 和 git clone 能用,但出站的 SSH 暴力破解、SMTP 垃圾邮件和端口扫描都用不了。实验环境要是成了攻击别人的跳板,那就麻烦了。
入站流量被全面阻断。浏览器终端走的是经过 API 服务器和 kubelet 的 exec 路径,不使用 Pod 网络,所以什么都不需要开。唯一的例外是网页预览。学员要用浏览器查看自己在实验 Pod 内启动的应用,后端就得连到那个端口,因此只放行来自后端 Pod 的指定端口。多亏这条规则,学员之间互相攻击对方 Pod 的路也一并被堵死了。
应用之后我又敲了一遍。
路由器 192.168.219.1:80 → 阻断 (超时)
NAS 192.168.219.109:5001 → 阻断
镜像仓库 :80 → 阻断
Kubernetes API 10.96.0.1:443 → 阻断
隔壁的实验 Pod → 阻断
DNS → 放行
关键在于阻断表现为超时而不是拒绝 (Connection refused)。这意味着数据包被静默丢弃,而这正是防火墙正常工作时应有的样子。
第 2 层 — 按实验分别授予内核权限
就算把网络堵死,容器里的 root 依然是 root。Linux 能力集在这里登场。
一开始我给所有实验都发同样的九个。Linux 实验因为 useradd 需要 SETUID,并不构成 Kubernetes 实验也拿到同样权限的理由。于是我把默认值改成「什么都没有」,再按实验实际要求的量往上加,分成若干配置档。
CAP_PROFILES = {
# 默认值。一个 capability 都没有。
# Kubernetes、DB、FDE、Git 实验用这个就完全够了 — 创建自己拥有的
# 文件、启动进程,都不需要特别的权限。
"none": [],
# 需要操作他人所有文件的实验
"files": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID"],
# 需要创建和切换用户的实验
"users": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID",
"SETUID", "SETGID", "KILL"],
# 需要跑 rootless 容器的实验
"container": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "KILL",
"SETUID", "SETGID", "SETPCAP", "SYS_CHROOT"],
}
有几项任何配置档都不放。
NET_RAW— 它是抓包、ARP 欺骗和端口扫描的钥匙。不发它,正好与第 1 层的网络策略配成一对。但ping还是得留着,这靠net.ipv4.ping_group_range这个 sysctl 解决。它会开放 ICMP 数据报套接字,所以不用 raw socket 也能ping。而且这个 sysctl 在 kubelet 默认允许的安全清单里,直接写进 Pod spec 就行。SYS_ADMIN— 实际上就等于 root 本身。挂载和命名空间操作都跟着它一起来。SYS_PTRACE— 能读取其他进程的内存。SYS_MODULE— 宿主机内核是共享资源。
然后把 allowPrivilegeEscalation 关掉。它一旦开着,上面无论把 capability 削得多干净,一个 setuid 二进制就能全部拿回来。这项设置会加上 no_new_privs,从根上让 setuid 失效。
有一个副作用:sudo 不能用了。不过在容器里本来就已经是 root,实验不受影响,而且像 visudo -c 这样的语法检查照常可用,所以考证类实验也没问题。
第 3 层 — 不给凭证
serviceAccountName: lab-nobody # 一条 RoleBinding 都没有的账号
automountServiceAccountToken: false # 令牌本身就不挂载
enableServiceLinks: false # 不把其他服务的地址当环境变量泄出去
最后一行是最容易被忘掉的。Kubernetes 默认会把同一命名空间下所有服务的地址注入成环境变量。对实验 Pod 来说毫无用处,等于白白把内部地形告诉了别人。
那 Kubernetes 的实验怎么办
我在这里卡住了。要教 Kubernetes 就得给学员一个集群,但 kind 是把节点当容器启动,k3s 得跑 kubelet,两者都需要特权。一旦给出特权,前面堆起来的三层就全都失去意义。从特权容器里逃逸并不难,而逃出去就是家庭网络。
答案是 kwok。
kwok 把 etcd、API 服务器、控制器管理器和调度器 当作普通进程 启动。然后由 kwok 控制器模拟假节点,把 Pod 一路推到 Running。因为没有真正的容器运行时,一丝特权都不需要。
kwokctl create cluster --name lab --runtime binary --wait 180s
--runtime binary 是关键。kwokctl 的默认做法是用 docker 或 podman 把各组件当容器启动,而这个 Pod 里没有容器运行时。binary 运行时则直接把二进制当作进程执行。
我在剥离了全部 capability 的 Pod 里实测跑了一遍。
节点 3 个 Ready
CRD 注册 39 个
启动耗时 11.4 秒
这个环境里能做什么、不能做什么,必须老实地区分清楚。
能做的 — 所有资源的 CRUD、Deployment 扩缩容、滚动更新与回退、调度 (节点选择器、亲和性、污点、drain)、RBAC 验证、ResourceQuota 与 NetworkPolicy 对象、kubectl explain、kustomize、helm、etcd 快照的保存与恢复、应用第三方 CRD。
不能做的 — 对工作负载 Pod 执行 kubectl exec、真实的日志输出、端口转发、真正的 CNI 流量、卷里的数据。没有真正的容器,这是理所当然的。
巧的是,CKA 和 CKAD 考试笔试所考的范围,大部分都落在前一类里。后一类我用理论和测验来代替实操。
踩过的坑
我把就绪探针指向了业务 API
部署停住了。新 Pod 一个接一个起来却始终不变成 Ready,滚动更新超时,流水线失败。日志里一条错误都没有。
原因出在探针。
readinessProbe:
httpGet: { path: /api/courses, port: 8000 } # 业务 API
重构的时候我把 /api/courses 改成了 /api/paths。探针收到 404,Pod 永远无法变成 Ready。而应用本身明明运行得好好的。
教训很简单。探针只能指向专用的端点。
startupProbe: # 首次启动给足余量
httpGet: { path: /healthz, port: 8000 }
periodSeconds: 5
failureThreshold: 36 # 最多 3 分钟
readinessProbe:
httpGet: { path: /healthz, port: 8000 }
livenessProbe:
httpGet: { path: /healthz, port: 8000 }
failureThreshold: 5
/healthz 只检查进程,/readyz 则一直查到数据库连接。存活探针不加数据库检查,是因为数据库只是抖了一下就重启应用,只会让情况更糟。
这次事故还算有个好的一面:生产环境保住了旧版本。等于流水线尽到了本分。
移动标签与 imagePullPolicy
实验镜像重新烤了一遍却没生效。重建了好几次,Pod 里的文件还是原样。构建日志里明明打着「Pushed」。
Kubernetes 默认的 imagePullPolicy 只有在标签是 latest 时才是 Always,其余情况都是 IfNotPresent。我用的是 v2 这种移动标签,所以节点一直复用它拉过一次的镜像,永远如此。而且不会留下任何错误。
image: registry.internal/labhub/lab-k8s:v2
imagePullPolicy: Always # 用移动标签时必加
正统做法是使用不可变标签。只是课程内容还在不断增加、镜像要频繁重烤,考虑到镜像仓库就在同一个网络里,我选了 Always。
kwok 无视了缓存
实验 Pod 除了 DNS 哪儿都去不了。可 kwokctl 启动时会从互联网下载各组件的二进制。缓存是空的,集群创建就直接失败。
于是我在构建镜像时预先下载好,结果没用。
{"level":"INFO","msg":"Download","uri":"https://github.com/etcd-io/etcd/.../etcd-v3.5.11-linux-amd64.tar.gz"}
{"level":"ERROR","msg":"Failed to setup config","err":"... i/o timeout"}
kwok 用的是它自己定下的默认版本 (我预先下的是另一个版本),而且缓存路径原封不动地镜像了 URL 的结构。
/root/.kwok/cache/https/dl.k8s.io/release/v1.30.4/bin/linux/amd64/kube-apiserver
无论是版本还是路径,只要错一处就找不到缓存,于是重新下载。与其去猜 URL,我改成在构建过程中创建一次集群再删掉。
RUN kwokctl create cluster --name warmup --runtime binary --wait 180s && \
kwokctl delete cluster --name warmup && \
rm -rf /root/.kwok/clusters
由 kwok 自己按自己的方式下载它需要的东西、放进它自己的路径,就不存在对不上的余地。集群状态 (PKI 和 etcd 数据) 删掉,只留下缓存。启动耗时从 1 分 52 秒降到了 11.4 秒。
Pod 已死的会话永远占着名额
我把每位学员同时能开的实验限制为一个,因为窗口一关、人一忘,Pod 就白白浪费了。
但数据库说「运行中」,并不代表 Pod 还活着。节点重启了、被 OOM 杀掉了,或者有人手动删掉了,Pod 就没了,只剩记录。那这位学员就永远开不了新实验。
我改成在复用会话之前先确认 Pod 是否存活,若已死则先把记录关闭再重新创建。回收器也多加了一个方向。
- 过期 — 时间用尽的会话,删除 Pod 并关闭记录
- 孤儿 — 数据库里没有却还活着的 Pod,直接删掉
- 幽灵 — 数据库说在运行、Pod 却不存在的会话,把记录关闭
第三个是这次新加的。不过要排除创建不足 1 分钟的,因为刚创建的 Pod 有可能还没在 API 里显现出来。
让资源不外泄的三重机制
实验 Pod 是昂贵的资源。哪怕学员直接关掉窗口,也不该有资源残留。
- 每个会话都钉死一个到期时刻。创建时就确定,剩余时间显示在界面上。剩 5 分钟时弹出提醒,可以延长,但总时长最多为原来的 3 倍。
- 一人一会话。要开新实验,得先把现有的结束掉。界面会告诉你开着的是哪个实验,并让你直接跳过去或把它关掉。
- 回收器每 60 秒跑一次。按上面三个方向清理,运维界面则持续展示数据库与实际 Pod 是否一致。
命名空间上挂了 ResourceQuota 和 LimitRange。Pod 数量以及 CPU、内存、临时存储的总量都有上限,还加了 count/services: "0",让实验 Pod 无法创建 Service 或 PVC。临时存储的上限尤其重要,没有它就可能把磁盘塞满、把节点搞死。
用测试把安全回归钉住
这套隔离散落在好几个文件里:Pod spec、网络策略、命名空间标签。其中任何一处退回原样,隔离就被打穿,而打穿之后服务照样跑得好好的。所以没有测试就没人会发现。
我做了一套每次推送都会跑的检查。
✅ 默认配置档为空 (0 个权限)
✅ 任何配置档都没有授予 NET_RAW
✅ 任何配置档都没有授予 SYS_ADMIN
✅ 阻止了权限提升
✅ 没有挂载 SA 令牌
✅ 互联网配置档排除了 192.168.0.0/16
✅ 阻断了入站流量
✅ 入站例外仅限于后端
✅ PSA 没有被降级为 privileged
✅ 源码中没有硬编码 API 密钥
...
通过 — 38 个项目全部正常
有一点收获。最早那版检查是在源码里搜 NET_RAW 这个字符串,结果因为注释里写着「不授予 NET_RAW」而误报。现在改成用 Python 的 AST 解析真正的配置档字典、直接看值。检查器必须精确瞄准它想检查的对象 — 道理再明白不过,但没亲身踩过就是看不见。
部署走 git
一开始是 CI 用 kubectl set image 直接往集群里推。这样一来,想知道现在跑的是什么就只能去问集群,回滚取决于有没有人记得旧标签,谁手动改过的和部署上去的也分不清楚。
现在仓库里的 gitops/ 才是事实来源。
gitops/
base/ 各环境共用 — 既无镜像标签也无命名空间
dev/ namespace、会话 TTL、LoadBalancer 暴露
prod/ namespace、replicas 2、SSO 密钥、公开 URL
镜像标签就是 overlay 里 images.newTag 这一行。CI 只改这一行并提交,ArgoCD 会自行同步。回滚就是回退这一行的一次提交。用 kubectl 动手改,selfHeal 会把它还原,所以不会留下漂移。
这里有个坑。我在基础清单里只把 Service 定义成 ClusterIP,结果 ArgoCD 把开发环境的 LoadBalancer 改成了 ClusterIP,连接就断了。往 GitOps 迁移时,必须先把集群里已有的东西准确地誊写过去。没誊写的东西会消失 — 这样想更安全。
现在的状态
我测了一下实验 Pod 启动所需的时间。
Linux 实验 会话创建 15 秒 → 立即可用
Kubernetes 会话创建 6 秒 → 环境准备 15 秒 (kwok 集群启动)
Kubernetes 实验在 Pod 变成 Running 之后,还需要额外的时间等集群起来。这件事不在界面上说明,学员就会在这段时间里点评分,然后以为是自己做错了。所以准备脚本在结束时会留下一个标记文件,后端读取它来告知是否仍在准备中,界面则在准备好之前把评分按钮锁住。
局限与遗留问题
有些事得老实写下来。
容器实验用 rootless podman 跑真正的容器,但存储驱动是 vfs,所以慢。overlayfs 需要挂载权限,fuse-overlayfs 需要设备文件,这两样我都给不了。实验用的小镜像感受不到太大差距,但要设计处理大镜像的实验就很难。
虚拟化实验用软件模拟跑 QEMU。没有 /dev/kvm,就没得选。小的 Alpine 虚拟机能起来,但很慢。KVM 为什么快,这部分用理论来讲,实操则围绕磁盘镜像操作和快照来设计。
tcpdump 不能用,因为不授予 NET_RAW。要教抓包就得另找办法,目前还没有好答案,所以用理论和测验来覆盖。网络诊断实验是围绕 ss、dig、getent 和 /proc/net 设计的。
小结
用家里的服务器搭实操环境时,最大的约束不是性能,而是隔离。而恰恰是这个约束让设计变得更好。因为不能给特权,我找到了 kwok,结果得到一个 11 秒就能起集群的环境。因为不能开互联网,我把需要的一切都烤进了镜像,结果它在离线状态下也能完整运行。
如果你习惯于靠放宽权限来解决问题,不妨试着往反方向走一次。