menu

技术中台CozyStack

  • date_range 20/01/2023 03:51
    点击量:
    info
    sort
    技术中台
    label
    技术中台
    数据中台
    业务中台
    智算中台

Cozystack Dashboard 503 战役复盘

一次持续 21 天的生产故障排查实录:从一个 Web UI 的 503,一路挖到开源库的 init 死锁,中途完整重建 Talos 集群,最终修复并登录成功。

核心价值:本文档既是技术资产,也是一个可直接用于 P8/P9 面试的真实战例。全程贯彻”反吹嘘、用证据说话”——每一个错误假设都被实验证伪,最终靠进程内部硬证据(/proc、goroutine 栈)定位真因。


0. 环境与背景

项目 内容
平台 单节点 Cozystack v1.4.2
底座 Talos Linux v1.12.6
虚拟化 Parallels Desktop(MacBook Pro 16” 2019,Intel i9,64GB)
K8s 版本 v1.34.3
工作目录 /Users/andrewyang/cozystack-cluster(talm GitOps 项目)
故障现象 Cozystack dashboard 持续返回 503,已 21 天
故障组件 认证网关 incloud-web-gatekeeper(镜像 token-proxy:v1.4.2),CrashLoopBackOff 62 次

故障关键特征(最棘手的地方):

  • token-proxy 容器 Exit code 2,liveness/readiness 探针 http-get http://:8000/ping 连接被拒;
  • 容器日志永远是空的——这是整场排查最大的障碍,没有任何错误信息可循;
  • 镜像是 distroless(无 shell、无包管理器),常规调试手段全部失效。

1. 排查全过程:逐层深入

阶段一:从 503 定位到崩溃组件

# 503 来自 dashboard,追踪到认证网关 pod
kubectl -n cozy-dashboard get pods
# incloud-web-gatekeeper  0/1  CrashLoopBackOff  62 (xxx ago)

# describe 看崩溃细节
kubectl -n cozy-dashboard describe pod incloud-web-gatekeeper-xxx
# Exit Code: 2
# Liveness probe failed: Get "http://:8000/ping": dial tcp connection refused
# Readiness probe failed: 同上

# 看日志 —— 永远是空的(关键障碍)
kubectl -n cozy-dashboard logs incloud-web-gatekeeper-xxx
# (无任何输出)

初步结论:token-proxy 进程启动后没有监听 8000 端口,导致探针失败 → 反复被 kill → CrashLoop。但为什么不监听、日志为什么空,未知。

阶段二:排除”配置错误”——手动跑不崩、deploy 崩

# 用相同镜像和参数手动起一个无探针的 pod
kubectl -n cozy-dashboard run tp-test --image=ghcr.io/cozystack/cozystack/token-proxy:v1.4.2 ...
# 状态显示 1/1 Running

# ⚠️ 陷阱:无探针的 pod 即使不监听端口也显示 Running!
# 这是一个 FALSE 信号,后面用 port-forward 才能验证真实状态

教训 #1:无探针的测试 pod 显示 1/1 Running 不代表进程正常工作。必须用 port-forward / ss 验证端口是否真的在监听。

阶段三:port-forward 证明进程不监听 8000

kubectl -n cozy-dashboard port-forward pod/tp-test 18000:8000 &
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18000/ping
# 8000 = 000   (connection refused)

铁证 #1:token-proxy 进程在,但 8000 端口没有监听

阶段四:连续证伪 8 个假设(关键的”排除法”过程)

这是整场排查最磨人、也最体现纪律的部分。每一个假设都基于合理推断,但全部被实验证伪:

# 假设 验证方法 结果
1 非标准 clusterDomain cozy.local 导致 DNS 解析失败 重建集群改成 cluster.local ❌ 仍崩溃
2 短域名 + ndots:5 导致 Go resolver 解析慢 测试 pod 验证 DNS ❌ 内外部解析都正常
3 token-proxy 卡在拉取 JWKS 用相同 SA curl JWKS 端点 ❌ 25ms 返回 200,短域名和 FQDN 都成功
4 CPU limit 200m 太小 去掉 CPU limit 测试 ❌ 仍不监听(8000=000
5 GOMAXPROCS 相关 GOMAXPROCS=2 测试 ❌ 仍 futex 死锁
6 K8s 注入的 service env 触发解析死锁 enableServiceLinks: false 测试 ❌ 仍 futex 死锁
7 RBAC / ServiceAccount 权限 检查 SA 与权限 ❌ 权限正常
8 cookieSecret 无效 验证 secret 16 bytes ❌ secret 有效

教训 #2:合理的外部推断 ≠ 真相。当日志为空时,从外部”推断”原因极易陷入连环误判。只有进程内部的直接证据才是结论性的

阶段五:突破口——ephemeral container 进入 distroless 进程命名空间

distroless 没有 shell,但 K8s 1.25+ 的 kubectl debug 可以注入一个临时容器,共享目标容器的进程和网络命名空间

# 注入 netshoot 调试容器,共享 token-proxy 的进程命名空间
kubectl -n cozy-dashboard debug tp-debug-live -it \
  --image=nicolaka/netshoot \
  --target=token-proxy \
  --profile=restricted \
  -- bash

进去后直接观察 token-proxy(PID 1)的内部状态:

ss -tlnp
# (空!没有任何 LISTEN 端口)—— 确认不监听 8000

ps aux
# PID 1  /token-proxy --upstream=... --http-address=0.0.0.0:8000 ...
# (进程活着,参数完整)

cat /proc/1/status | grep -i state
# State: S (sleeping)   —— 卡在睡眠等待

ls -la /proc/1/fd
# 0 -> /dev/null
# 1 -> pipe
# 2 -> pipe
# 3 -> /sys/fs/cgroup/cpu.max
# 5 -> anon_inode:[eventpoll]
# 6 -> anon_inode:[eventfd]
# ⚠️ 没有任何网络 socket!token-proxy 根本没发起过任何网络请求

cat /proc/1/wchan; echo
# futex_do_wait   —— 卡在用户态锁等待!

# 看每个线程卡在哪
for t in /proc/1/task/*; do echo "TID $(basename $t): $(cat $t/wchan 2>/dev/null)"; done
# TID 1:  futex_do_wait
# TID 7:  futex_do_wait
# TID 8:  futex_do_wait
# TID 10: futex_do_wait
# TID 18: futex_do_wait
# TID 9:  do_epoll_wait   (Go netpoller,正常)

铁证 #2

  • token-proxy 没有打开任何网络 socket(fd 列表里只有 stdin/out/err + cgroup 文件 + Go runtime 的 eventpoll/eventfd)→ 它根本没走到发起网络请求那一步,所以”JWKS 拉取失败”等假设全部不成立;
  • 进程卡在 futex_do_wait(用户态锁),5 个线程 futex + 1 个 netpoller → 典型的 Go 程序主 goroutine 死锁

阶段六:终极武器——SIGQUIT 拿 goroutine 栈

Go 程序收到 SIGQUIT 会把所有 goroutine 的堆栈打印到 stderr。在共享 pid namespace 的 netshoot 里发信号:

# 给 token-proxy (PID 1) 发 SIGQUIT
kubectl -n cozy-dashboard debug tp-nolinks -it --image=nicolaka/netshoot \
  --target=token-proxy --profile=restricted \
  -- sh -c 'kill -QUIT 1; sleep 2; echo done'

# 立刻取日志(goroutine 栈在这)
kubectl -n cozy-dashboard logs tp-nolinks 2>&1 | head -80

拿到了决定性的 goroutine 栈

SIGQUIT: quit

goroutine 1 gp=... m=nil [select, locked to thread]:
runtime.gopark(...)
runtime.selectgo(...)
github.com/lestrrat-go/httprc/v3.(*ResourceBase[...]).Ready(...)
    /go/pkg/mod/github.com/lestrrat-go/httprc/v3@v3.0.5/resource.go:100
github.com/lestrrat-go/httprc/v3.(*controller).Add(...)
    /go/pkg/mod/github.com/lestrrat-go/httprc/v3@v3.0.5/controller.go:156
github.com/lestrrat-go/jwx/v3/jwk.(*Cache).Register(...)
    /go/pkg/mod/github.com/lestrrat-go/jwx/v3@v3.1.0/jwk/cache.go:190
main.init.0()
    /src/main.go:98 +0x9c8          ← 死锁在 main 的 init 函数!
runtime.doInit1(...)
runtime.main()

铁证 #3——精确到代码行的根因

token-proxy 在 main.init.0()(包初始化阶段,main.go:98 调用 jwk.Cache.Register() 注册 JWKS 缓存,隐式触发 Ready() 阻塞等待,最终卡死在 httprc v3.0.5resource.go:100select

阶段七:搜到上游 issue,确认是已知 bug

搜索 lestrrat-go/httprc v3 Ready deadlock 找到 httprc issue #119

当注册的 JWKS URL 指向不可达的 host 时,worker goroutine 无限阻塞;连接被拒绝时无退避地立即重试,形成紧密死循环;Ready() 使用 context.Background()(无超时)永久阻塞

报告环境:httprc v3.0.5、jwx v3.0.13、Go 1.26.2 —— 与 token-proxy v1.4.2 使用的库版本(httprc v3.0.5、jwx v3.1.0)高度吻合。


2. 根因定论

Cozystack v1.4.2 的 token-proxy 镜像,编译时打包了有 bug 的 lestrrat-go/httprc v3.0.5 库。

token-proxy 在 main.go:98包初始化(init.0)阶段调用 jwk.Cache.Register() 注册 JWKS URL,隐式调用 Ready() 阻塞等待第一次 fetch 成功。httprc v3.0.5 存在已知 bug(issue #119):首次 fetch 失败时 worker 无退避无限重试、Ready()context.Background() 永久阻塞。

结果链:init 永不返回 → main() 永不执行 → 永不监听 8000 → 探针失败 → CrashLoopBackOff → dashboard 503。

为什么之前所有外部假设都不影响——因为死锁发生在 init.0(),即 Go 程序 main() 之前的包初始化阶段:

现象 原因
改命令行参数(如 --jwks-url)无效 init 在参数解析之前执行,用的是硬编码默认 URL
永不监听 8000 init 死锁,main() 从未运行
fd 里没有 socket 卡在 Ready() 的 select,还没真正发出请求
日志永远为空 init 死锁,main() 没机会执行任何日志代码
手动 curl JWKS 成功但进程卡 curl 是正常时机;token-proxy 是 init 早期时机 + 库 bug
5 线程 futex httprc 的 worker + 主 goroutine 全部卡住

关键性质:这是上游库 bug,编译进二进制,无法通过任何运行时配置(参数 / env / 资源 / 网络 / DNS)修复


3. 修复方案

3.1 验证修复版本

列出 token-proxy 可用镜像版本:

brew install crane
crane ls ghcr.io/cozystack/cozystack/token-proxy
# ... v1.4.2, v1.4.3, v1.4.4, v1.5.0, v1.5.1 ...

测试最新稳定版 v1.5.1 是否修复:

# 用 v1.5.1 起测试 pod(其他配置与真实 deploy 一致)
kubectl -n cozy-dashboard run tp-v151 --restart=Never \
  --image=ghcr.io/cozystack/cozystack/token-proxy:v1.5.1 ...

# port-forward 验证是否真的监听 8000
kubectl -n cozy-dashboard port-forward pod/tp-v151 18000:8000 &
curl -s -o /dev/null -w "v1.5.1 8000 = %{http_code}\n" http://localhost:18000/ping
# v1.5.1 8000 = 302   ✅ 返回 302(oauth2-proxy 重定向到登录页,正常行为!)

对比:v1.4.2 = 000(refused),v1.5.1 = 302(正常)→ v1.5.1 修复了 init 死锁

3.2 应用修复

export KUBECONFIG=~/cozystack-cluster/kubeconfig.new

# 1. 把 deploy 的 token-proxy 镜像从 v1.4.2 换成 v1.5.1
kubectl -n cozy-dashboard set image deploy/incloud-web-gatekeeper \
  token-proxy=ghcr.io/cozystack/cozystack/token-proxy:v1.5.1

# 2. 立刻 suspend Flux,防止它把镜像 reconcile 回 v1.4.2(否则又死锁)
kubectl -n cozy-dashboard patch helmrelease dashboard \
  --type=merge -p '{"spec":{"suspend":true}}'

# 3. 验证
kubectl -n cozy-dashboard get pods
# incloud-web-gatekeeper-cbbd8f574-xxx  1/1  Running  0  25s   ✅

结果:token-proxy 1/1 Running0 重启(不再 CrashLoop),带探针的真实 deploy 也通过 → 探针 /ping 成功 = 真的监听了 8000。

⚠️ 重要:dashboard deploy 由 Flux/Helm 管理。镜像 override 只在 HelmRelease suspend 期间有效。若未来升级 Cozystack,需要先 un-suspend


4. 附带战役:Talos 集群完整重建

排查途中(假设 #1,验证 clusterDomain)执行了一次完整的 Talos 集群重建。虽然重建没有修复 dashboard(根因是二进制 bug,不是 DNS),但成功把非标准 cozy.local 改成了标准 cluster.local,并提供了一次干净的集群环境 + Talos 重建实操。

# 1. 修改配置:clusterName cozy.local → cozystack,clusterDomain cozy.local → cluster.local
vim /Users/andrewyang/cozystack-cluster/values.yaml

# 2. 重新生成 PKI secrets
talosctl gen secrets -o secrets.yaml   # 旧的备份为 secrets.yaml.cozylocal.old

# 3. 渲染节点配置(⚠️ 必须显式指定 K8s 版本,否则默认 1.35+)
talm template --template templates/controlplane.yaml \
  --with-secrets secrets.yaml \
  --kubernetes-version 1.34.3 \
  --endpoints 10.211.55.200 --nodes 10.211.55.200 --full > nodes/node1.yaml

# 4. reset 节点(wipe)
talosctl reset --graceful=false --reboot --wipe-mode all \
  -n 10.211.55.200 -e 10.211.55.200
# 节点重启进入 maintenance 模式,回到物理 IP 10.211.55.29
# (VIP .200 只在 bootstrap 之后才浮动)

# 5. apply 配置(注意用物理 IP)
talm apply --insecure --file nodes/node1.yaml --with-secrets secrets.yaml \
  --kubernetes-version 1.34.3 --nodes 10.211.55.29 --endpoints 10.211.55.29

# 6. 生成新 talosconfig(旧的是 cozy.local CA,不匹配)
talosctl gen config cozystack https://10.211.55.200:6443 \
  --with-secrets secrets.yaml \
  --output-types talosconfig --output talosconfig.new --force

# 7. bootstrap
talm bootstrap --talosconfig talosconfig.new \
  --nodes 10.211.55.29 --endpoints 10.211.55.29

# 8. 获取 kubeconfig
talosctl --talosconfig talosconfig.new --nodes 10.211.55.29 \
  --endpoints 10.211.55.29 kubeconfig ./kubeconfig.new --force

重装 Cozystack

export KUBECONFIG=~/cozystack-cluster/kubeconfig.new

helm upgrade --install cozystack \
  oci://ghcr.io/cozystack/cozystack/cozy-installer \
  --version 1.4.2 --namespace cozy-system --create-namespace --take-ownership

kubectl apply -f ~/cozystack-cluster/cozystack-platform.yaml
# 等待 ~80 个 HelmRelease 全部 READY
# (Cilium / Kube-OVN / KubeVirt / LINSTOR / cert-manager / metallb / 各 operator)

5. 接通外部访问:MetalLB + Ingress

dashboard 修好后,还需要接通外部访问入口。

export KUBECONFIG=~/cozystack-cluster/kubeconfig.new

# 1. apply MetalLB 地址池(重建后需重新创建)
kubectl apply -f ~/cozystack-cluster/metallb-pool.yaml
# IPAddressPool: 10.211.55.210-10.211.55.230

# 2. 启用 tenant-root 的 ingress(关键!默认是 false)
kubectl patch -n tenant-root tenants.apps.cozystack.io root \
  --type=merge -p '{"spec":{"ingress":true}}'

# 3. 等 ingress-nginx 部署,确认拿到外部 IP
kubectl get svc -A -o wide | grep LoadBalancer
# root-ingress-controller  LoadBalancer  10.96.230.3  10.211.55.210  80:.../443:...  ✅

访问地址https://dashboard.10-211-55-210.nip.io(self-signed 证书,浏览器点”继续访问”)

# 端到端验证(命令行确认 503 消失)
curl -sk -o /dev/null -w "dashboard = %{http_code}\n" \
  https://dashboard.10-211-55-210.nip.io
# dashboard = 302   ✅(不再是 503)

6. 登录 Dashboard

dashboard 登录页要求 Kubernetes API Token。创建一个 cluster-admin token:

export KUBECONFIG=~/cozystack-cluster/kubeconfig.new

kubectl create sa dashboard-admin -n kube-system
kubectl create clusterrolebinding dashboard-admin \
  --clusterrole=cluster-admin \
  --serviceaccount=kube-system:dashboard-admin
kubectl create token dashboard-admin -n kube-system --duration=720h
# 输出一长串 eyJhbGci... 的 JWT,粘贴到登录页 "Paste token here" → Login

结果:成功登录,进入完整的 Cozystack Marketplace 界面(IaaS / PaaS / NaaS 全部应用可见)。21 天的 503,彻底终结。 🏆

注:登录后顶部 “No tenants found” 是正常初始状态——新集群还未创建业务租户,不影响 dashboard 本身。Marketplace 应用需部署到某个 tenant。


7. 关键教训与踩坑清单

排查方法论

  1. 日志为空时,外部推断极易连环误判——本次连续证伪了 8 个合理假设。只有进程内部的直接证据(/proc/wchan/proc/fd、goroutine 栈)才是结论性的
  2. distroless 调试三板斧
    • kubectl debug --target=xxx(ephemeral container 共享进程命名空间)
    • /proc/1/{fd,wchan,status,task/*/wchan}(进程内部状态)
    • kill -QUIT 1(Go 程序吐 goroutine 栈,死锁定位神器)
  3. init 阶段死锁的识别特征:进程活着但不监听端口、无 socket、日志全空、改任何运行时参数都无效——因为 init 在 main() 之前、在参数解析之前执行。

工具与命令踩坑

说明
无探针 pod 显示 1/1 Running 不代表进程正常!必须 port-forward/ss 验证端口监听
talm template/apply/bootstrap 没有 -n/-e 简写 必须用完整的 --nodes/--endpoints
talm/talosctl 默认 K8s 版本是 1.35+/1.36+ 必须显式 --kubernetes-version 1.34.3
Talos VIP(floatingIP)只在 bootstrap 后浮动 reset 后节点回到物理 IP,apply 要用物理 IP
重建集群后旧 talosconfig 不可用 旧的是旧 CA,必须用新 secrets 重新生成
Shell > 重定向到失败命令会清空目标文件 注意渲染产物文件
Cozystack tenant-root 默认 ingress: false 需手动 patch 启用才有 ingress controller
Flux 会 reconcile 回原镜像 手动 override 镜像后必须 suspend HelmRelease

PodSecurity 相关

  • cozy-dashboard namespace 是 baseline 策略,kubectl debug --profile=general(含 SYS_PTRACE)会被拒绝;
  • 改用 --profile=restricted(不加特权能力),共享进程/网络命名空间足够看 ss/ps//proc

8. 一句话面试版

排查一个持续 21 天的 Cozystack dashboard 503:从 503 定位到认证网关 CrashLoop,因容器日志为空且镜像为 distroless,常规手段失效。先后证伪了 DNS、JWKS、CPU limit 等 8 个假设后,用 kubectl debug 注入 ephemeral container 共享进程命名空间,通过 /proc/1/wchan 发现进程卡在 futex、/proc/1/fd 确认无网络 socket,再用 SIGQUIT 拿到 Go goroutine 栈,精确定位到 main.init.0 调用 jwk.Cache.Register 死锁在 httprc v3.0.5 库(对应上游 issue #119)。确认是编译进二进制的上游库 bug 后,验证 token-proxy v1.5.1 已修复,替换镜像并 suspend Flux 防回滚。期间还完整重建了 Talos 集群把非标准 clusterDomain 改回标准值。


附录:完整诊断链路图

dashboard 503
    │
    ▼
incloud-web-gatekeeper CrashLoopBackOff (62 restarts)
    │  describe → Exit 2 + liveness probe failed
    │  logs → 空(distroless + init 死锁,无输出)
    ▼
手动跑 1/1 Running(假信号)vs deploy 崩
    │  port-forward → 8000 = 000(不监听)
    ▼
证伪 8 个假设:cozy.local / ndots / JWKS / CPU limit /
              GOMAXPROCS / service env / RBAC / cookie
    │  (每个都用独立实验 pod 验证)
    ▼
kubectl debug 注入 ephemeral container(共享 pid namespace)
    │  ss -tlnp → 空(确认不监听)
    │  ps aux → PID 1 token-proxy 活着
    │  /proc/1/fd → 无 socket(没发起网络请求)
    │  /proc/1/wchan → futex_do_wait(锁死)
    │  /proc/1/task/*/wchan → 5 线程 futex + 1 epoll
    ▼
kill -QUIT 1 → goroutine 栈
    │  main.init.0 (main.go:98)
    │    → jwk.Cache.Register (jwx v3.1.0)
    │      → httprc.ResourceBase.Ready (httprc v3.0.5 resource.go:100)
    │        → select 永久阻塞
    ▼
搜到 httprc issue #119(已知 bug,版本吻合)
    │  根因确定:init 阶段 JWKS 缓存注册死锁
    ▼
crane ls → 发现 v1.4.3/v1.4.4/v1.5.0/v1.5.1
    │  测 v1.5.1 → port-forward 8000 = 302(修复确认)
    ▼
set image v1.5.1 + suspend Flux
    │  token-proxy 1/1 Running 0 restarts
    ▼
apply MetalLB pool + 启用 tenant ingress
    │  root-ingress-controller → EXTERNAL-IP 10.211.55.210
    ▼
create admin token → 登录
    ▼
✅ Dashboard 完整界面(503 终结)

评论:


技术文章推送

手机、电脑实用软件分享

微信搜索公众号: AndrewYG的算法世界
wechat 微信公众号:AndrewYG的算法世界

热门文章