本地 Controller 调试 -- 在共享集群上切出自己的地盘

下午想验证一个简单的问题:后端 controller 改完代码,能不能快速看到效果?不是跑单元测试那种"逻辑对不对"的验证,而是真的创建一个 K8s 资源,看 controller 把它 reconcile 成什么样,然后打开 Portal 页面确认前端也能展示出来。

这个问题的答案不复杂,但回答的过程揭示了我们项目在本地开发体验上做的一整套设计。

起点:195 个测试文件够不够

项目的测试基础设施其实很扎实。195 个 _test.go 文件,Ginkgo + Gomega 的 BDD 风格测试覆盖了所有 controller,envtest 模拟 K8s API,make test 一键跑完不依赖任何外部服务。API 集成测试在进程内起一个 API server 加 envtest,一轮跑完大约 8 秒。

但单元测试证明的是逻辑正确性,不是功能正确性。你的 reconcile 函数返回了正确的 status condition,不代表 Portal 页面上真的能看到这个 Project。要验证后者,需要一个真实的 K8s 集群。

共享集群的困境

团队有一个 staging K8s 集群,上面跑着真实的 controller pod,管理着二十多个 tenant 的资源。如果每次本地调试都要把 staging 的 controller 停掉、换成自己的本地进程,那同事的环境全挂了。

解法是 namespace 隔离。

我们的 controller 支持两个互斥的环境变量:

  • WATCHED_NAMESPACES:只 reconcile 这些 namespace 里的资源
  • EXCLUDED_NAMESPACES:忽略这些 namespace 里的资源

staging 集群上的 controller pod 配了 EXCLUDED_NAMESPACES=local-alice,local-bob,local-carol,...,把每个开发者的专属 namespace 排除在外。本地 controller 配 WATCHED_NAMESPACES=local-alice,只管自己的那片地盘。两个 controller 的 leader election ID 也不同,lease 不冲突。

这样一来,5 个开发者可以同时各自跑本地 controller,互不干扰,staging 的 controller 继续照看其他所有 tenant。

实际操作:7 秒启动,全链路验证

启动本地 controller 的流程很直接:

source local-dev.env
export KUBECONFIG=~/.kube/my-project/staging.yaml
export WATCHED_NAMESPACES=local-alice
export LEADER_ELECTION_ID=my-controller-alice
go run -tags dev . ctrl

-tags dev 是关键。这个编译标签切换了 controller 的初始化路径:cmd/ctrl/init_dev.go 注入 stub 实现替代所有外部服务(ArgoCD、Azure DevOps、Cloudflare 等),但 K8s 操作是真实的 – 连的是 staging 集群的 etcd。

启动后,日志确认了隔离生效:

Namespace isolation: WATCHED mode  {"namespaces": ["local-alice"]}
DEV MODE: Using stub ArgoCD clients (no real cluster connection)
DEV MODE: Using stub external services (no real API connections)

然后创建一个 Project CR:

apiVersion: myplatform.example.io/v1
kind: Project
metadata:
  name: ctrl-test-project
  namespace: local-alice
spec:
  name: "Controller Test Project"
  tenantSubdomain: local-alice
  description: "E2E test: verify controller reconcile shows up in Portal UI"

controller 日志立刻响应,打印出完整的 reconcile 链条:创建子 namespace local-alice-ctrl-test-projec-db670f2c1a,创建 3 个 VariableSet(dev、staging、prod 各一个),同步 object store secret,最后状态变成 Ready。

打开 Portal 页面 https://local-alice.portal.c-xxxxx.example.cloud,登录 IdP,项目列表里赫然出现了 “Controller Test Project”。

kubectl apply 到 Portal 上看到结果,整个过程不到 10 秒。

为什么 Portal 能看到本地 controller 写的数据

这个问题初看有点反直觉:controller 是本地跑的,Portal 是 staging 的,数据怎么通的?

答案在于写路径和读路径共享同一个 K8s 数据源。

本地 controller 通过 staging kubeconfig 连接到 staging K8s API,写入的 CR status 变更直接落到 staging 的 etcd 里。Portal 的读路径是:浏览器 -> staging API server(集群内的 pod)-> 同一个 etcd。两条路径起点不同,但终点是同一个数据库。

唯一的例外是 OData/CAP 服务,它跑在集群内部,本地 controller 无法访问,所以始终用 ODATA_MOCK=true mock 掉。这是"Lightweight 模式"唯一无法覆盖的部分。

Tenant 是怎么来的

测试的前提是 local-alice 这个 tenant 已经存在且状态 Ready。Tenant CR 住在 staging namespace(runtime namespace),但它的 reconcile 会自动创建一个同名的 local-alice namespace。这个 namespace 就是开发者的地盘 – 所有子资源(Project、Repository、Deployment 等)都放在这里。

Tenant CR 的设计很精简,核心就三个字段:appTid(平台应用 ID)、subaccountId(子账号 ID)、subdomain(子域名)。其中 subdomain 决定了两件事:namespace 名称和 Portal URL。

Portal URL 的生成规则藏在 api/v1/tenant_types.goGetApplicationUrl() 函数里:

https://{subdomain}.portal.{PORTAL_ROOT_DOMAIN}

staging 环境下 PORTAL_ROOT_DOMAINc-xxxxx.example.cloud,所以 local-alice 对应的 Portal 就是 https://local-alice.portal.c-xxxxx.example.cloud。知道这个规则,任何 tenant 的 Portal 地址都能直接拼出来。

Project 创建的连锁反应

一个 Project CR 的 kubectl apply 看似简单,背后触发了一串自动化:

  1. 创建子 namespace(命名模式:{tenant}-{project-truncated}-{hash}
  2. 创建 3 个 VariableSet,每个环境一个
  3. 从 tenant namespace 复制 project-limit ConfigMap
  4. 同步 object store secret 到各环境
  5. 更新 status 为 Ready,记录子 namespace 名称

这个链条在调试时很有用。如果 Project 卡在 InProgress 或 Error,按链条顺序逐个检查就能定位问题。

两种模式:Dev Stub 与 Full

今天跑的是 Lightweight 模式(-tags dev),外部服务全是 stub,只有 K8s 是真的。这已经覆盖了大部分日常开发场景:reconcile 逻辑、status 更新、跨 CR 关联、CRD 变更、finalizer 行为。

但如果要验证真实的 ArgoCD app 创建、Azure DevOps pipeline 触发、Cloudflare DNS 操作,就需要 Full 模式 – 去掉 -tags dev 编译标签,注入真实服务客户端,加载从集群提取的 staging 密钥。Full 模式的代价是独占 staging:需要关闭 ArgoCD auto-sync、缩容 staging controller pod,测完必须恢复。

两种模式的选择标准很简单:你的代码改动路径上有没有真实的外部 API 调用。如果只是 K8s 层面的逻辑,Lightweight 够了,7 秒一个编辑-重启-验证循环。如果涉及外部服务交互,才需要切到 Full 模式,承担额外的环境管理成本。

还差一步

CdnZone controller 的 Secret watch handler 目前是全局的,不受 WATCHED_NAMESPACES 过滤。启动时日志里刷了大量其他 namespace 的 Secret 事件,虽然不影响功能,但噪音很大。这是 namespace 隔离设计的已知短板 – watch handler 也需要加 namespace predicate。

此外 Portal 登录依赖 IdP,没有 headless 登录方式,这对自动化 E2E 测试流水线是个障碍。目前的办法是把凭据存到本地文件(.idp-credentials,已 gitignore),在需要时读取。

这些是已知的毛边,不影响核心流程,但值得在后续迭代中打磨。

一句话总结

在共享的 staging 集群上,通过 namespace 隔离切出专属地盘,本地 controller 获得了 7 秒级别的编辑-验证循环,同时 Portal UI 也能实时看到变更。既不用等 CI 构建和部署,也不影响同事的环境。这是"本地开发体验"和"真实环境验证"之间一个很实际的平衡点。

Built with Hugo
Theme Stack designed by Jimmy