跳转到内容

工地日记

在 K8s/K3s 中为特定 Pod 分配独立物理 IP (Multus + Macvlan + VLAN)

在构建 HomeLab 或企业内网时,我们经常会将服务部署在 K8s/K3s 集群中。默认情况下,集群内的出站流量会经过 CNI(如 Flannel 或 Cilium)的 SNAT 转换,源 IP 会变成宿主机的 Node IP。

但在某些场景下(例如部署 qBittorrent 等 P2P 下载工具、需要被独立监控的服务,或者需要绕过网关透明代理的流量),我们需要让 Pod 拥有一个真实的局域网独立 IP,并且可能还需要将其划分到**特定的 VLAN(如 VLAN 10)**中。

本文将介绍如何使用 Multus CNI 配合 Macvlan,为特定的 Pod “插上一根直通物理局域网的网线”,并配置 OPNsense 网关实现流量直连。

本方案完美兼容硬核 HomeLab 常用的 Cilium CNI + kube-vip 环境。各组件分工明确,互不冲突:

  • Cilium (主 CNI & Service LB):负责集群内 eBPF 流量管理与 Service LB IP 分配,为 Pod 提供基础的 eth0 网卡。
  • kube-vip:负责控制平面 API Server 的 VIP 漂移与高可用。
  • Multus + Macvlan (旁路直通):作为一个旁路插件,专门为指定的 Pod 塞入第二块物理网卡 net1

⚠️ 核心避坑:IP 地址池必须严格隔离! 必须确保 Macvlan 使用的静态 IP(本文以 10.0.10.101 为例),与 Cilium LB IPAM 池、kube-vip 的地址、以及 OPNsense 的 DHCP 池完全错开,避免产生 IP 冲突。


Multus 作为一个“元 CNI”,允许 Pod 挂载多块网卡。如果你的集群尚未安装,可以通过官方提供的 DaemonSet 一键安装(Thick 模式兼容性最好):

Terminal window
kubectl apply -f https://raw.githubusercontent.com/k8snetworkplumbingwg/multus-cni/master/deployments/multus-daemonset-thick.yml
kubectl set resources daemonset kube-multus-ds -n kube-system -c kube-multus --limits=memory=512Mi --requests=memory=100Mi

等待所有 kube-system 命名空间下的 multus Pod 处于 Running 状态即可。

步骤二:创建网络附件定义 (NetworkAttachmentDefinition)

Section titled “步骤二:创建网络附件定义 (NetworkAttachmentDefinition)”

我们需要创建一个 CRD 资源,告诉 K8s 如何划分这个直通的局域网。

创建一个名为 macvlan-network.yaml 的文件。注意:该资源必须与目标 Pod 处于同一个 Namespace。

💡 关于 VLAN 10 的特殊说明:

  • 情况 A(推荐 PVE 用户): 如果你在 PVE 虚拟机的硬件设置中,为该网卡配置了 VLAN Tag: 10,那么 K3s 系统内部的物理网卡(如 eth0ens18)已经是解包后的 VLAN 10 网络了。下方配置中的 master 直接填 eth0 即可。
  • 情况 B(Trunk 模式): 如果 PVE 传递的是 Trunk 口,你需要在 K3s 宿主机系统层面(如 Netplan 或 NetworkManager)先创建一个 VLAN 子接口 eth0.10,然后下方的 master 必须填入 eth0.10
macvlan-network.yaml
apiVersion: "k8s.cni.cncf.io/v1"
kind: NetworkAttachmentDefinition
metadata:
name: macvlan-direct
namespace: default # 必须替换为你的应用所在的 namespace
spec:
# ⚠️ 核心提醒:下方 config 内的字符串会被解析为纯 JSON,绝对不能包含 '#' 注释。
# 所有的关键备注已经为你提取到此处,请根据这些提示修改下方 JSON 对应的值:
#
# 1. master: 根据上方 VLAN 说明,替换为 eth0 或 eth0.10
# 2. subnet: 10.0.10.0/24 (VLAN 10 的真实网段)
# 3. rangeStart: 10.0.10.101 (为 Pod 分配的静态独立 IP)
# 4. rangeEnd: 10.0.10.101 (与 rangeStart 保持一致)
# 5. gateway: 10.0.10.1 (VLAN 10 的网关)
# 6. routes: 必须添加 0.0.0.0/0 的默认路由,否则无法访问外网 (Tracker 会报错)
config: |
{
"cniVersion": "0.3.1",
"type": "macvlan",
"master": "eth0",
"mode": "bridge",
"ipam": {
"type": "host-local",
"subnet": "10.0.10.0/24",
"rangeStart": "10.0.10.101",
"rangeEnd": "10.0.10.101",
"gateway": "10.0.10.1",
"routes": [
{ "dst": "0.0.0.0/0", "gw": "10.0.10.1" }
]
}
}

应用此配置:kubectl apply -f macvlan-network.yaml

步骤三:修改应用的 Deployment 配置

Section titled “步骤三:修改应用的 Deployment 配置”

在应用的 template.metadata.annotations 中声明使用刚刚创建的网络。同时,为了避免 DNS 流量黑洞,我们需要让该 Pod 绕过集群内部的 CoreDNS,直接使用公网 DNS。

以部署 qBittorrent 为例:

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: qbittorrent
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: qbittorrent
template:
metadata:
labels:
app: qbittorrent
annotations:
# 绑定前面创建的 Macvlan 网络
k8s.v1.cni.cncf.io/networks: macvlan-direct
spec:
# 跳过 K8s CoreDNS,直接使用公网 DNS 解析 Tracker
dnsPolicy: "None"
dnsConfig:
nameservers:
- 223.5.5.5
- 114.114.114.114
# 必须固定节点,防止漂移导致物理网卡名称不匹配
nodeSelector:
kubernetes.io/hostname: k3s-worker-02
containers:
- name: qbittorrent
image: lscr.io/linuxserver/qbittorrent:latest
# ... 其他配置保持不变

重新部署后,进入 Pod 内部执行 ip a,你会发现它拥有了两张网卡:eth0 (集群内部 IP) 和 net1 (直连 VLAN 10 的 10.0.10.101)。

关键一步: 必须在应用的内部设置(如 qBittorrent 的高级设置 -> 网络接口)中,强制将监听网络接口绑定为 net1,确保流量不走 Cilium 的默认路由,而是真正从 Macvlan 发出。

步骤四:在 OPNsense 中设置 NAT 豁免 (Do not NAT)

Section titled “步骤四:在 OPNsense 中设置 NAT 豁免 (Do not NAT)”

当 qB 绑定 net1 向外发送数据时,流量会到达 OPNsense 网关。由于 OPNsense 默认会对出站流量进行 NAT 伪装,我们需要对这个特定的 IP 进行“豁免”,让最外层的设备(如 OpenClash)能看到它的真实源 IP。

  1. 进入设置菜单: 登录 OPNsense,导航至 防火墙 (Firewall) -> NAT -> 出站 (Outbound)
  2. 修改模式: 将出站 NAT 模式修改为 混合出站 NAT 规则生成 (Hybrid outbound NAT rule generation),点击保存。
  3. 添加豁免规则: 点击右上角的 + 号添加一条新规则。
  4. 关键配置项:
    • 不进行 NAT (Do not NAT): 必须勾选页面最上方这个复选框。
    • 接口 (Interface): 选择流量离开 OPNsense 去往上层网络(如光猫或旁路由)的接口,通常是 WAN
    • TCP/IP 版本: IPv4
    • 协议 (Protocol): any
    • 源地址 (Source address): 下拉选择 单主机或网络 (Single host or Network),并输入分配给 Pod 的独立 IP 10.0.10.101,掩码选择 /32
    • 转换/目标 (Translation / target): 保持默认(勾选了不进行 NAT 后,此项会失效)。
  5. 调整规则优先级: 保存规则后,回到列表页。将这条新创建的豁免规则拖动到所有规则的最顶部(确保它在常规的子网 NAT 规则之前生效),然后点击 应用更改 (Apply Changes)

步骤五:配置透明代理直连 (OpenClash)

Section titled “步骤五:配置透明代理直连 (OpenClash)”

完成 NAT 豁免后,外部网络(包括旁路代理)就能直接识别到 10.0.10.101 这个源 IP 了。

进入你的代理软件(如 OpenClash),在 访问控制 (Access Control) -> 不走代理的局域网设备 (Not Proxy LAN IPs) 中,将 10.0.10.101 加入列表。至此,你的 P2P 下载流量将完美绕过集群的网络虚拟化层和全局科学代理。

步骤六:访问方式变更与联动服务更新 (*arr)

Section titled “步骤六:访问方式变更与联动服务更新 (*arr)”

当你为 Pod 加上了默认物理路由并绑定了网卡后,它已经彻底变成了一台插在局域网上的“实体机”。这会带来两个必须处理的变更:

  1. 集群 Ingress / 域名访问失效: 由于流量现在是从集群的 eth0 进,却从直连的 net1 出,这会导致严重的“异步路由”(Asymmetric Routing),使得原有的 Nginx/Traefik Ingress 域名访问或 NodePort 访问不断转圈超时。
    • 解决方案: 放弃域名和 NodePort 转发,以后请直接在浏览器输入分配的独立 IP 与端口(如 http://10.0.10.101:8080)来访问后台 WebUI。
  2. 更新 Radarr / Sonarr 下载客户端: 如果你使用了 Radarr、Sonarr 或 Prowlarr 等自动化追剧工具,由于 qBittorrent 的访问入口已经改变,你必须前往这些工具的 设置 -> 下载客户端 (Download Clients) 中,将 qBittorrent 的主机地址更新为新的独立 IP(10.0.10.101)。如果不更新,这些联动组件将无法把种子推送到下载器中。

如果在配置过程中遇到 Pod 无法获取独立 IP 的情况,通常是由以下三个核心问题导致的:

如果你的 K3s 节点是运行在 Proxmox VE (PVE) 等虚拟化平台上的虚拟机,请务必注意以下两点限制:

  • 混杂模式与 MAC 地址过滤: 进入 PVE 的虚拟机设置,找到 硬件 (Hardware) -> 网络设备 (Network Device),双击打开编辑,关闭“防火墙 (Firewall)”选项。如果开启该选项,PVE 会拦截 Macvlan 自动生成的非原生 MAC 地址,导致 Pod 无法联网。
  • VLAN 隔离问题: 如果你在 Macvlan 层面使用的是子接口(如 eth0.10),请确保 PVE 分配给该虚拟机的网桥(Bridge)开启了 VLAN 感知 (VLAN aware),否则宿主机将会直接丢弃带有 VLAN Tag 的数据包。

2. Cilium 独占模式导致 Multus 失效

Section titled “2. Cilium 独占模式导致 Multus 失效”

如果你发现配置完全正确,但 Pod 依然只分配了 Cilium 的默认内部 IP,请登录宿主机检查 /etc/cni/net.d/ 目录。如果你看到 00-multus.conf 被系统自动重命名为了 00-multus.conf.cilium_bak,这说明 Cilium 开启了“独占模式”,强行禁用了 Multus。

解决办法: 关闭 Cilium 的独占模式并重启相关组件抢回控制权:

Terminal window
# 1. 修改 ConfigMap,关闭独占模式
kubectl patch configmap cilium-config -n kube-system --type merge -p '{"data":{"cni-exclusive":"false"}}'
# 2. 重启 Cilium 代理以应用新配置
kubectl rollout restart daemonset cilium -n kube-system
# 3. 等待 Cilium 启动后,重启 Multus 使其重新生成 00-multus.conf
kubectl rollout restart daemonset kube-multus-ds -n kube-system

3. K3s 节点缺失官方 CNI 基础插件

Section titled “3. K3s 节点缺失官方 CNI 基础插件”

K3s 是一个极致精简的轻量级发行版,特别是配合 Cilium 使用时,宿主机的 /opt/cni/bin 目录下可能并没有预装 macvlan 等基础官方插件。此时 Multus 虽能正常工作,但在为 Pod 创建网络沙盒时会抛出如下报错: failed to find plugin "macvlan" in path [/opt/cni/bin]

解决办法: SSH 登录到运行该 Pod 的 K3s 宿主机节点,手动下载并解压官方的标准 CNI 插件包:

Terminal window
# 确保目录存在
sudo mkdir -p /opt/cni/bin
# 下载官方 CNI 插件包 (此处以稳定的 v1.4.1 为例)
wget https://github.com/containernetworking/plugins/releases/download/v1.4.1/cni-plugins-linux-amd64-v1.4.1.tgz
# 将压缩包解压到 /opt/cni/bin 目录下
sudo tar -C /opt/cni/bin -xzf cni-plugins-linux-amd64-v1.4.1.tgz
# 重建失败的 Pod 即可正常挂载

K8s 存储踩坑记:强拆 Longhorn '僵尸 Pod' 钉子户

在折腾 K3s + Longhorn 搭建我的赛博影音堡垒时,我遇到了一个极其让人血压升高的玄学 Bug。

整个流水线突然停工,应用 Pod 疯狂报错无法启动,而罪魁祸首,居然是一个在系统里“死活退不出来”的僵尸进程。

一切起因于我修改了媒体应用(Radarr/Sonarr)的配置文件并重新部署。新 Pod 迟迟无法启动,查看事件日志,赫然飘着那句经典的存储死锁报错:

Multi-Attach error for volume ... Volume is already exclusively attached to one node and can't be attached to another

意思是:存储卷正被旧节点死死锁着,新 Pod 拿不到权限。

顺藤摸瓜,我发现底层负责管理挂载的 longhorn-csi-plugin Pod 一直卡在 Terminating(正在终止)状态,时长超过了 20 分钟!查看它的详细日志,看到了这句致命的超时宣告:

error killing pod: failed to "KillContainer" for "longhorn-liveness-probe" ... rpc error: code = DeadlineExceeded desc = context deadline exceeded

🧠 赛博验尸报告:为什么它“杀不死”?

Section titled “🧠 赛博验尸报告:为什么它“杀不死”?”

表面上看是 Kubernetes 出了 Bug,但深入探究,这其实是 Linux 内核底层的自我保护机制导致的。

  1. I/O 阻塞:由于之前尝试跨网段挂载 NFS 导致了网络超时,底层的存储进程卡在了“等待磁盘响应”的状态。
  2. 内核级锁死 (D-state):在 Linux 中,当进程卡在底层 I/O 等待时,内核会将其标记为 不可中断睡眠 (Uninterruptible Sleep, D状态)。在这个状态下,进程会无视任何外部信号(包括 kill -9)。
  3. Kubelet 懵逼:Kubernetes 的包工头 Kubelet 给容器引擎下达了“拔管”指令,但底层容器因为 D 状态根本不鸟它。Kubelet 苦等 2 分钟后无奈宣布超时(DeadlineExceeded),于是这个 Pod 就成了一个永远卡在 Terminating 的“钉子户”,并且牢牢霸占着 Longhorn 的存储锁。

对付这种级别的钉子户,常规的清理已经没用了。必须采取暴力强拆手段。

第一板斧:K8s 逻辑强抹除(本次生效的绝招)

Section titled “第一板斧:K8s 逻辑强抹除(本次生效的绝招)”

既然正常的优雅退出走不通,那就直接在 K8s 的记录本上把它强行划掉,不给任何宽限时间。

执行以下命令强杀卡死的 Pod:

Terminal window
kubectl delete pod <卡住的Pod名称> -n longhorn-system --force --grace-period=0

效果:命令敲下的瞬间,Pod 从列表中消失,Longhorn 控制平面终于意识到锁可以释放,随后新 Pod 瞬间挂载存储卷成功,业务恢复!

第二板斧:重启底层容器引擎(备用方案)

Section titled “第二板斧:重启底层容器引擎(备用方案)”

如果在执行完第一步后,kubectl get pods 里确实没它了,但应用依然报 Multi-Attach error,说明底层 Linux 进程依然在霸占资源。此时需要重启 K3s 的 agent 服务来重置容器状态:

Terminal window
# 登录出问题的 K3s 工作节点执行
sudo systemctl restart k3s-agent
# 如果是主节点则执行 restart k3s

第三板斧:物理重启(终极毁灭)

Section titled “第三板斧:物理重启(终极毁灭)”

如果前两招都打完,存储依然处于死锁状态,说明 Linux 内核的 I/O 队列已经彻底崩溃。别犹豫,直接 sudo reboot 重启该节点所在的物理机或虚拟机,这是清理 D 状态进程最彻底的方案。

这次踩坑不仅让我深入理解了 Kubernetes 和底层操作系统的联动关系,也总结出了一条铁律:

在 HomeLab 环境中,对于独占存储(ReadWriteOnce)的单副本有状态应用(如 qBittorrent、数据库等),在 deployment.yaml 中务必将更新策略设置为重建!

spec:
replicas: 1
strategy:
type: Recreate # 强制先杀旧,再启新,绝不抢锁

遇到 Terminating 不要慌,找准病根,果断拔管!

🚨 赛博排障实录:CNPG 密码“脑裂”与内核级破门指南

⚠️ 事故现场:完美的图纸,打不开的大门

Section titled “⚠️ 事故现场:完美的图纸,打不开的大门”

在部署 pgAdmin 接入 CloudNativePG (CNPG) 时,即便你严格遵守了 GitOps 的每一行配置,提取了最新的 K8s Secret 密码,依然可能遇到那个令人抓狂的报错:

pgAdmin 4 - Connection Error
FATAL: password authentication failed for user "postgres"

这种“钥匙(Secret)打不开自家大门(Database)”的现象,在 Operator 模式下被称为 “状态撕裂” (State Desync),俗称 “脑裂”


🧠 深度复盘:为什么会被“天坑”算计?

Section titled “🧠 深度复盘:为什么会被“天坑”算计?”

这本质上是 Kubernetes 的声明式状态数据库的物理持久化状态 发生了脱节。

CNPG 管家只在集群 第一次初始化 (Bootstrap) 的那一瞬间,会生成随机密码并同时写入 K8s Secret 和数据库底层引擎。

2. K8s 与物理存储 (Longhorn) 的脱节

Section titled “2. K8s 与物理存储 (Longhorn) 的脱节”

如果你曾删除过 CNPG 实例但保留了 Longhorn 数据卷,当你再次拉起集群时:

  • K8s 视角:这是一个新任务,它会生成一个全新的 Secret(新钥匙)。
  • Postgres 视角:挂载旧硬盘后,它依然守着第一次建库时的旧密码(老锁芯)。
  • 结果:外面显示的密码是 A,但门里认的密码是 B。

🔨 赛博暴力破门方案:潜入内核强行修正

Section titled “🔨 赛博暴力破门方案:潜入内核强行修正”

既然外面的钥匙失效了,作为赛博堡垒的造物主,我们不再纠结于 Secret,直接利用 Socket 直连 潜入数据库心脏,从内部强制重置密码。

利用 kubectl exec 绕过网络认证,以本地超级用户身份进入数据库 Pod:

Terminal window
# 进入主节点终端并直连 psql
kubectl exec -it -n database homelab-db-cluster-1 -- psql

进入 postgres=# 提示符后,执行 SQL 语句进行“物理修正”:

-- 强行将 postgres 用户的锁芯更换为你预设的密码
ALTER USER postgres WITH PASSWORD '你的新密码';
-- 退出内核
\q

👑 总结:首席承包商的终极底牌

Section titled “👑 总结:首席承包商的终极底牌”

这次排障告诉我们一个真理:在云原生时代,GitOps 是流程,但 kubectl 是真理。

不管外面的声明式配置如何演变,只要你能以系统造物主的身份进行“内核级干预”,你就永远拥有整座赛博堡垒的最终解释权。

  • 重建集群时:若要彻底重置密码,必须连同 Longhorn 的 PVC 一起清理。
  • 排障优先级:当应用层认证失败时,优先检查物理存储是否包含陈旧状态。

🗺️ 脑裂排障逻辑图 (Troubleshooting Flow)

Section titled “🗺️ 脑裂排障逻辑图 (Troubleshooting Flow)”

K8s 焦土政策:网关与证书系统的终极卸载指南

这是一份针对 Kubernetes 集群的**“彻彻底底、连根拔起”**清理方案。

当你的网关和证书系统因为各种改动搅得一团糟,且面临诸如 ArgoCD 陷入死锁、命名空间卡在 Terminating 无法删除等极其顽固的底层状态错误时,**“焦土政策(物理核平)”**绝对是最有效、最干净的排障手段。

严格按照以下顺序执行。这套连招能保证把系统里的残留基因拔得干干净净,绝不影响下一次重装。


☢️ 第一阶段:斩断源头 (切断 ArgoCD 重建链)

Section titled “☢️ 第一阶段:斩断源头 (切断 ArgoCD 重建链)”

在使用 GitOps (如 ArgoCD) 时,决不能直接去删底层资源,否则 ArgoCD 会发现“建筑没了”,并立刻把它们重新拉起来。我们必须先切断图纸的下发。

Terminal window
kubectl delete application cert-manager traefik -n argocd

2. 遭遇死锁?强行剥夺 ArgoCD 的遗愿清单

Section titled “2. 遭遇死锁?强行剥夺 ArgoCD 的遗愿清单”

如果上面的命令卡住转圈(报错 metadata.finalizers: Forbidden),说明 ArgoCD 本身陷入了死结。立刻打开新终端,执行强制“拔管”:

Terminal window
kubectl patch application cert-manager -n argocd -p '{"metadata": {"finalizers": null}}' --type merge
kubectl patch application traefik -n argocd -p '{"metadata": {"finalizers": null}}' --type merge

执行后,ArgoCD 面板上的应用会瞬间消失,系统不再自动重建。


💣 第二阶段:深度扫荡 (清理 Webhooks 与 全局 CRDs)

Section titled “💣 第二阶段:深度扫荡 (清理 Webhooks 与 全局 CRDs)”

这是 99% 的人重装失败的根源! 命名空间好删,但全局注册的蓝图规范(CRD)和拦截器(Webhook)如果不清空,下次安装必定会报出各种版本冲突。

Cert-manager 会向 K8s API Server 注入拦截器,必须优先干掉,否则它会拦截你接下来的所有安装请求:

Terminal window
kubectl delete validatingwebhookconfiguration cert-manager-webhook --ignore-not-found
kubectl delete mutatingwebhookconfiguration cert-manager-webhook --ignore-not-found
Terminal window
# 删光 Traefik 和 Cert-manager 的残留基因
kubectl get crd -o name | grep -E 'traefik|cert-manager' | xargs kubectl delete

3. 扫荡残留的“全局权限” (RBAC)

Section titled “3. 扫荡残留的“全局权限” (RBAC)”

这一步经常被忽视,导致重装时报 Ownership/Adoption 错误:

Terminal window
# 清理集群级别的角色与绑定
kubectl get clusterrole,clusterrolebinding -o name | grep -E 'cert-manager|traefik' | xargs kubectl delete
# 清理隐藏在 kube-system 核心区的领导者选举权限
kubectl get role,rolebinding -o name -n kube-system | grep cert-manager | xargs -I {} kubectl delete {} -n kube-system

🧨 第三阶段:核平街区 (强制清理 Namespace 内部残留)

Section titled “🧨 第三阶段:核平街区 (强制清理 Namespace 内部残留)”

如果你发现 Namespace 删不掉,或者里面总剩下一堆 Role, Service, ServiceAccount,那是它们身上带着 Finalizers(保护锁)

在删除 Namespace 之前,先手动清除这些顽固住户的遗愿清单:

Terminal window
# 定义目标命名空间
TARGET_NS=("cert-manager" "traefik")
for ns in "${TARGET_NS[@]}"; do
echo "正在强制碎锁 $ns 中的局部资源..."
# 批量清除 Role, RoleBinding, ServiceAccount, Service 的保护锁
kubectl get rolebinding,role,serviceaccount,service -n $ns -o name | xargs -I {} kubectl patch {} -n $ns -p '{"metadata":{"finalizers":null}}' --type merge
done

现在执行删除指令,由于内部资源已碎锁,它们会随街区一并消失:

Terminal window
kubectl delete namespace cert-manager traefik --force --grace-period=0

💀 第四阶段:物理超度 (清理僵尸命名空间)

Section titled “💀 第四阶段:物理超度 (清理僵尸命名空间)”

如果执行完第三步,使用 kubectl get ns 发现它们依然死死卡在 Terminating 状态,说明遭遇了**“僵尸命名空间”**。动用最后的 API 接口直连手段:

Terminal window
kubectl proxy &

2. 发射核弹指令,清空命名空间的 Finalizers

Section titled “2. 发射核弹指令,清空命名空间的 Finalizers”

直接调用 API 接口,强行抹除 Namespace 最后的执念:

Terminal window
# 针对 cert-manager
curl -H "Content-Type: application/json" -X PUT --data-binary "{\"kind\":\"Namespace\",\"apiVersion\":\"v1\",\"metadata\":{\"name\":\"cert-manager\"},\"spec\":{\"finalizers\":[]}}" http://localhost:8001/api/v1/namespaces/cert-manager/finalize
# 针对 traefik
curl -H "Content-Type: application/json" -X PUT --data-binary "{\"kind\":\"Namespace\",\"apiVersion\":\"v1\",\"metadata\":{\"name\":\"traefik\"},\"spec\":{\"finalizers\":[]}}" http://localhost:8001/api/v1/namespaces/traefik/finalize
Terminal window
# 确认僵尸已经彻底消失
kubectl get ns
# 杀掉后台开启的 kubectl proxy
kill %1

💡 架构师总结 走完这完整的四阶段,你的集群就真正回到了**“像素级纯净”**的状态。所有的 Role、ServiceAccount 和全局权限都已化为灰烬。现在,你可以放心地去推送你配置了 OpenDNS 5353 端口 的新图纸,享受一次零报错的全新丝滑安装体验!

K8s 证书避坑指南:如何安全地测试并申请 Let's Encrypt 泛域名证书

在 Kubernetes 集群中,使用 cert-manager 配合 Let’s Encrypt 自动签发免费的 HTTPS 泛域名证书,是目前的业界标配。

但是,无数新手在第一次配置时都会踩进一个**“死亡陷阱”**:Let’s Encrypt 对正式接口有极其严格的速率限制。如果你因为 DNS 配置填错、Token 权限不对等原因导致申请失败超过 5 次,你的域名将被拉黑封锁 1 个小时;如果反复重试,甚至会被封锁 7 天!

为了避免“坠机”,最标准、最专业的做法是:先用测试服 (Staging) 跑通流程,再切换到正式服 (Production)。

今天这篇文章,就带你完整走一遍这个“演习到转正”的标准全流程。


🚦 第一阶段:使用 Staging (测试服) 进行实战演习

Section titled “🚦 第一阶段:使用 Staging (测试服) 进行实战演习”

Let’s Encrypt 提供了一个专门的 Staging 测试环境,它的请求限制极其宽松,你可以随便报错、随便重试,绝对不会封锁你的域名。

我们的第一步,就是写一份指向测试服的图纸。

创建一个名为 cluster-issuer-and-cert.yaml 的文件。请注意看代码中的注释,有 3 个地方是测试服独有的

# 1. 声明一个测试服的“发证机关” (ClusterIssuer)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging # 🧪 标志 1:命名为 staging
spec:
acme:
# 🧪 标志 2:必须使用 Staging 的 API 地址!
server: [https://acme-staging-v02.api.letsencrypt.org/directory](https://acme-staging-v02.api.letsencrypt.org/directory)
email: your-email@example.com # ⚠️ 替换为你的真实邮箱,用于接收过期通知
privateKeySecretRef:
# 🧪 标志 3:测试服专用的账号私钥名称,千万别和正式服重名
name: letsencrypt-staging-account-key
solvers:
- dns01:
cloudflare: # 这里以 Cloudflare DNS 验证为例
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
---
# 2. 提交一份“证书申请单” (Certificate)
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-website-tls
namespace: default # ⚠️ 替换为你网关 (如 Traefik/Nginx) 所在的命名空间
spec:
secretName: my-website-tls # 最终生成的证书 Secret 名称
issuerRef:
# 🧪 标志 4:指定上面那个测试服发证机关来处理这张单子
name: letsencrypt-staging
kind: ClusterIssuer
commonName: "*.example.com" # ⚠️ 替换为你的域名
dnsNames:
- "example.com"
- "*.example.com"

将上面的配置应用到集群中:

Terminal window
kubectl apply -f cluster-issuer-and-cert.yaml

接下来,我们不需要去浏览器里看,直接在终端里盯着它的状态:

Terminal window
# 监控证书状态 (将 default 换成你自己的 namespace)
kubectl get certificate my-website-tls -n default -w
  • 成功标志:当你看到 READY 这一列从 False 变成了 True,恭喜你,演习大获全胜!这证明你的 DNS Token 和底层网络链路已经 100% 跑通。

  • 终极查验 (彻底放心):如果你想彻底确认这张申请下来的证书到底是谁发的,建议使用以下兼容性最强的“临时文件法”进行查验:

    Terminal window
    # 1. 提取 Secret 中的证书内容,解码后存入临时文件
    kubectl get secret my-website-tls -n default -o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/cert_verify.crt
    # 2. 调用 openssl 物理查验该文件并过滤颁发者
    openssl x509 -in /tmp/cert_verify.crt -text -noout | grep "Issuer"
    # 3. 查完即焚,删除临时文件
    rm /tmp/cert_verify.crt

    预计输出结果:

    • 演习环境 (Staging):输出包含 Issuer: ..., CN=(STAGING) Artificial Apricot R3

      这证明 Let’s Encrypt 已经认可了你的域名所有权,并把测试用的“假证书”发给你了。

    • 正式环境 (Production):输出包含 Issuer: ..., CN=R3 (没有 STAGING 字样)。

      这说明你的正式绿锁已经下发。

🚀 第二阶段:销毁假证,正式转正 (Production)

Section titled “🚀 第二阶段:销毁假证,正式转正 (Production)”

既然流程已经跑通,我们就可以放心大胆地去正式服领真绿锁了。

回到你刚才的 cluster-issuer-and-cert.yaml 文件,把那 4 个测试服的标志全部改掉:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod # ✅ 1. 改为 prod
spec:
acme:
# ✅ 2. 换成 Let's Encrypt 的正式服 API
server: [https://acme-v02.api.letsencrypt.org/directory](https://acme-v02.api.letsencrypt.org/directory)
email: your-email@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key # ✅ 3. 换个正式的工作证名称
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-website-tls
namespace: default
spec:
secretName: my-website-tls
issuerRef:
name: letsencrypt-prod # ✅ 4. 向正式服机关提交申请
kind: ClusterIssuer
commonName: "*.example.com"
dnsNames:
- "example.com"
- "*.example.com"

再次应用配置:

Terminal window
kubectl apply -f cluster-issuer-and-cert.yaml

2. 【核心必做】物理销毁旧证书!

Section titled “2. 【核心必做】物理销毁旧证书!”

这是无数人翻车的一步! 虽然你修改了申请单,但 Kubernetes 发现你的系统里已经存在一个叫 my-website-tls 的 Secret(刚才测试服发的那个),并且还没过期,它很可能不会去触发重新签发

你必须手动把那个假证书“撕毁”,倒逼 cert-manager 重新去正式服申请:

Terminal window
# 删除测试服生成的假证书 Secret
kubectl delete secret my-website-tls -n default

删掉假证书后,cert-manager 会立刻反应过来:“哎呀,证书没了,而且现在的发证机关变成了 Prod,我得赶紧去申请一张新的!”

再次查看状态:

Terminal window
kubectl get certificate my-website-tls -n default -w

由于之前的网络链路已经验证过完全没问题,这次的申请会极快通过。当状态再次变为 READY: True 时,刷新你的浏览器页面(建议使用无痕模式清除缓存)。

那把坚不可摧、受全球所有设备信任的 HTTPS 绿色小锁,就已经完美挂在你的域名上了!


💡 总结: 无论是个人折腾还是企业级部署,先用 Staging 测试,再切 Prod 签发,最后 delete secret 强制重签,这套“三步走”战略能帮你避开 99% 的证书签发雷区。