Push-based GitOps 管理 k3s 小集群
上一篇通过 Komodo + Renovate 构建 Docker Compose GitOps 流水线写的是 Docker Compose 那套:主机上常驻一个 Komodo,收到 push webhook 就拉取仓库、重新部署,Renovate 盯着上游镜像提升级 PR。它的核心是有一个常驻服务,替你把 Git 里的状态搬到机器上。
后来我把一部分服务挪到了一个四节点的 k3s 小集群:Oracle 的一台 arm64 当 control-plane、两台 amd64 当 agent,外加一台腾讯云新加坡节点,四台机器跨两家云,节点间用 Tailscale 组网。到了 Kubernetes 上,顺着惯性最容易想到的是 Argo CD 或 Flux,把 pull 式 GitOps 原样搬过来。但这次我选了相反的方向:仓库仍是唯一事实来源,但集群从不读它,所有改动都从控制机 push 进去。
这篇不是要论证 push 比 pull 好;对一个跑几十个服务、多人协作的环境,结论多半相反。这里记录的是:四个节点、一个人维护的小集群,把 controller 这一层拿掉之后,整套 GitOps 流水线怎么补齐。
push 和 pull 的分界
两种模型的差别只在一个问题上:谁主动发起那次同步。
| pull(Argo CD / Flux) | push(这套做法) | |
|---|---|---|
| 谁在 reconcile | 集群里的 controller,持续运行 | 控制机上的一条命令,手动触发 |
| 集群是否读 Git | 是,controller 直连仓库 | 否,集群完全不知道 GitHub 的存在 |
| 凭据放在哪 | 集群里要有 repo 的读权限、可能还有解密用的私钥 | 全在控制机:kubeconfig、SSH key、age 私钥 |
| drift 怎么发现 | controller 自动对账并纠偏 | 自己跑 make diff 查,不会自动纠偏 |
| 改坏了的拦截点 | controller / admission webhook | push 之前的 make lint |
pull 模型的好处是实打实的:自动对账意味着就算不小心直接 kubectl edit 改了线上,controller 也会把它纠回来;新机器接管也简单,controller 一跑就收敛。代价是集群里多了一个常驻组件,而且这个组件需要能读到仓库、有时还要能解密 secret,集群本身成了一个需要被授权、被信任的 Git 客户端。
对四个节点来说,我更在意另一面:
- 集群里少一个常驻 controller,就少一处要升级、要排障、要授权的东西。k3s 本身已经够小,不必再叠一层 GitOps 平台。
- 凭据全留在控制机。集群里没有任何能回连 GitHub 的东西,仓库可以放心公开(这个仓库就是公开的),泄露面只剩控制机一台。
- 集群挂了不影响恢复能力。解密密钥不在集群里,集群没了和还能不能恢复是两件独立的事。
这套取舍能成立,前提是把 pull 模型免费附带的两样东西自己补上:自动对账换成手动的 drift 检查,集群侧的拦截换成 push 前的本地校验。下面几节基本都在讲这两件事怎么补。
仓库是线上状态的镜像,靠 diff 验证
push 模型最容易退化的地方,是仓库和集群慢慢对不上:仓库里躺着几个早就删掉的 app,或者线上被临时改过、却没有回写进 Git。pull 模型靠 controller 强制两者一致;push 模型没有这层强制,只能靠两条约定:
apps/下一个文件夹对应一个真实部署着的 app,不放任何打算以后上的东西。服务上线就加文件夹(新服务从templates/service-with-tls拷一份改名开始,Namespace、Deployment、Service、Ingress 四件套齐全),下线就删。仓库始终是线上的镜像,而不是愿望清单。- 让这条约定可以被检验,也就是
make diff:预览出如果现在把每个 apply 都跑一遍会改动什么,但什么都不应用。
helm diff → 本地 chart pin + values.yaml vs 集群里存着的 releasekubectl diff → 仓库里的 manifest vs 线上对象ssh arm cat …/traefik-config.yaml | diff … → 仓库里的 traefik 配置 vs ARM 上那份最后一行有点特殊:traefik 的配置不是用 kubectl 管的(原因见下一节),所以 diff 也得 ssh 到 ARM 节点把那份文件抓回来比对。输出里某一段是空的,就说明那部分没有 drift。这条命令是这套 push 模型里对账的全部:它不纠偏,只告诉我哪里偏了,纠不纠、怎么纠由我决定。
文件放在哪个目录,决定它怎么到达集群
pull 模型里怎么应用这件事基本是统一的,controller 包办。push 模型里它是显式的,这个仓库用目录来编码:一个文件该用什么机制 apply,看它在哪个目录就知道。
| 路径 | 机制 | 怎么应用 |
|---|---|---|
apps/<name>/(kustomize app,如 wallos) |
Kustomize | kubectl apply -k apps/<name>/ |
apps/multica/values.yaml、apps/supabase/values.yaml |
Helm | helm upgrade --install <release> <chart> -f values.yaml |
platform/cert-manager/、platform/headlamp/ |
Helm + 附带的 plain manifest | helm upgrade … 再 kubectl apply -f … |
platform/traefik/traefik-helmchartconfig.yaml |
k3s auto-deploy | scp 到 ARM 的 /var/lib/rancher/k3s/server/manifests/,不是 kubectl |
node-config/<node>.config.yaml |
k3s 节点配置 | 渲染后经 ssh 写进 /etc/rancher/k3s/config.yaml,再重启 k3s,不是 kubectl |
平台层就三样:k3s 自带的 Traefik 当公网入口,cert-manager 走 Cloudflare DNS-01 签 Let’s Encrypt 证书,Headlamp 是网页 dashboard;app 层目前跑着 wallos、multica、supabase 三个。前三种路径是常规的 kubectl / helm,后两种需要特别注意,它们看着像普通 manifest,却不能用 kubectl apply:
- traefik 那份是
HelmChartConfig,定制的是 k3s 自带的 Traefik。k3s 会监视 server 节点上的 auto-deploy 目录(/var/lib/rancher/k3s/server/manifests/)并自动 reconcile 里面的文件,所以它的应用动作是把文件 scp 到那个目录,k3s 自己接手;持久状态以节点上那份文件为准,仓库里的副本是留档和重建依据。 node-config/下是 k3s 节点的config.yaml,配的是 k3s 进程本身(比如node-external-ip,跨云组网时每个节点都要把自己的公网 IP 显式告诉 k3s)。这层比任何 Kubernetes 对象都低,只能写文件加重启进程。
这些命令不用记,都收进了 Makefile,每个 target 都是底层那条 kubectl / helm / scp / ssh 命令的薄包装:
make app APP=wallos # kubectl apply -k apps/wallos/make multica # helm upgrade --install plus its cert and backup CronJobmake traefik # scp the ARM-pinned config to the server nodemake node-config NODE=sg # render sg's k3s config, push it, restart k3s-agentmake platform # apply all platform components in the right order顺序也编码在里面:make platform 先建 Cloudflare 的 token Secret,再装 cert-manager(ClusterIssuer 要等 CRD 就位),最后 Headlamp 和 traefik,重建集群时不用回忆先后。把怎么 apply 显式写进目录和 Makefile,某种程度上是在补 controller 的课:pull 模型里这套知识沉淀在 controller 的实现里,push 模型里它得沉淀在仓库结构和 make target 里,否则就只存在我脑子里,过半年就忘。
一切 pin 到 ARM 节点
集群里几乎所有东西都带着 nodeSelector: kubernetes.io/arch: arm64,固定在那台 ARM 节点上。这不是随手写的,是几个事实叠加出来的约束:
- ARM 节点是 control-plane(server),其余三台是 agent;
- 默认存储用的是 k3s 自带的
local-path,它是节点本地的:PV 建在哪台机器的磁盘上,数据就只存在那台机器上; - 于是任何带 PVC 的 stateful pod 都必须回到它数据所在的节点,一旦被调度到别的节点,要么挂载不上原来的 PV,要么挂上一个空的新目录。
所以这行 nodeSelector 不能去掉:它保证 wallos 的 SQLite、multica 的 pgvector、supabase 的 Postgres 重启后都回到 ARM,找回自己的盘。traefik 也一并固定在 ARM,因为公网入口要稳定指向 control-plane 这一台。代价是 ARM 节点成了单点,对一个家用规模的集群,我接受这笔交换:数据和入口都在一台已知的机器上,换掉一整套分布式存储的复杂度。新加坡那台 agent 额外打了 location=singapore 的 taint,默认不接收 workload,只跑明确容忍它的。
密钥只以密文进 Git
push 模型在 secret 这件事上反而比 pull 干净,因为它把一个本来很别扭的问题直接绕开了。
pull 模型下,集群要自己从 Git 拿配置,加密的 secret 也得能在集群里解开:要么上 Sealed Secrets,集群里跑一个持有私钥的 controller;要么给 Argo 配解密插件。无论哪种,解密能力都得放进集群。
push 模型不需要:apply 从控制机发起,解密也在控制机做。这个仓库用的是 SOPS + age:
- 所有 secret 值,外加不算机密但也不想公开的
NODE_IP_*节点公网 IP,都存在secrets.enc.env里。这个文件提交进 Git:变量名和注释明文可读,每个值是一段ENC[...]密文。age 的 recipient 写在.sops.yaml,对应的私钥只在控制机和密码管理器里有备份。 scripts/apply-secrets.sh在内存里sops -d解密(密钥不在就直接失败,不会默默跳过),再用kubectl create … --dry-run=client | apply把 Secret 对象建出来,幂等,明文不写入磁盘。脚本里一个 app 一段,没填值的 app 自动跳过,所以拉起平台不依赖任何 app 密钥。- manifest 本身不读环境变量,pod 通过
secretKeyRef引用这些建好的 Secret。
这套流程贴合 push 模型的原因是它纯客户端:集群里没有任何解密组件,集群挂了也不会把解密密钥一起带走。灾备路径因此短到一句话:这个仓库,加上那把 age 私钥。
明文绝不进库这条规则,可以交给机器来守:make lint 里有一步专门扫描 secrets.enc.env,逐行确认每个值要么是 ENC[...] 要么为空,发现明文就让构建失败。节点公网 IP 也顺带被保护了:仓库公开,但 IP 只以密文出现,apply 时才由 make node-config 渲染进节点配置模板,直接流式写到节点上,本地不留渲染后的副本。
CI 只做校验,永远碰不到集群
pull 模型里有 controller 和 admission webhook 在集群边上把关,写坏的 manifest 进不了线上;push 模型把这道关挪到了 push 之前。这个仓库的 CI(GitHub Actions)每次 push 只跑一条 make lint,包含四步:
lint-manifests:把apps/和templates/下每个 kustomization build 出来,连同所有 plain manifest 一起交给kubeconform -strict校验;lint-helm:用各自 pin 的版本和values.yaml把四个 Helm charthelm template渲染出来,同样过kubeconform -strict;lint-scripts:shellcheck扫描scripts/;lint-secrets:上一节的明文扫描。
-strict 会拒绝未知字段,校验又是对着集群真实的 Kubernetes 版本(1.36.1,跟集群一起升级)做的,所以一个 YAML 拼写错误、或者 values 和 chart 对不上,在 CI 里就会失败,而不是拖到 apply 时才发现。
对 push 模型来说还有一个要紧的设计:CI 在结构上就够不到集群。GitHub 那边没有 kubeconfig、没有 SSH key、没有 age 私钥,哪怕 CI 流程被改坏或被人钻了空子,它能做的最坏的事也只是校验不通过,动不到线上。pull 模型把信任放在集群里那个能读 Git 的 controller 上;push 模型把信任收在控制机,CI 只是一个无害的 linter。对一个公开仓库,CI 天生无权限这一点让人放心。
把版本钉死
push 模型没有 controller 持续把集群拉回声明的状态,所以以为没动过的东西必须真的没动过,否则 drift 会悄悄累积。具体要求是:任何一次 re-run 都不能静默升级任何东西。
四个 Helm release 的 chart 版本全部 pin 在 Makefile 的变量里(cert-manager v1.20.2、headlamp 0.42.0、multica 0.3.19、supabase 0.5.6),kustomize app 的镜像 tag pin 在各自的 deployment.yaml,不用 :latest。于是 make platform 跑十遍结果都一样,不会因为上游发了新版就把集群顺手带上去。
升级是一个显式动作:make bump APP=<name> 列出可选的新版本、或把指定版本写进对应的 pin,改动就是一行 diff,过一遍 CI,再由我决定哪天 apply。升级和手改一行配置走同一条路:先改 Git、再 push 到集群。Renovate 那套改 tag 也要过 PR 的思路还在,只是提 PR 的机器人换成了我自己。
stateful app 都带一个夜间备份
local-path 是节点本地存储,没有副本,ARM 节点的磁盘坏了数据就没了。所以每个 stateful app 都配了一个夜间备份的 CronJob(apps/<name>/backup.yaml),把状态 dump 出来上传到 Cloudflare R2,保留 30 天,三个 app 的时间错开在 03 / 03 / 03(Asia/Shanghai)。
以 multica 为例,它同时有数据库和上传文件两种状态,Job 分两段、共用一个 emptyDir:
- init 容器用
postgres:17-alpine的pg_dump客户端,走集群网络连到multica-postgres:5432把库 dump 下来、gzip 压缩。这里特地开了set -o pipefail,否则pg_dump失败、gzip却退出 0,整个 Job 会假装成功; - main 容器把 uploads PVC 以只读方式挂进来打成 tar(同节点的 RWO 多挂载在 local-path 上是允许的),再用 rclone 把两个文件 copy 到
r2:<bucket>/backups/multica/,顺带删掉 30 天前的旧备份。
R2 的凭据走前面那套 secret 流程:BACKUP_R2_* 在 secrets.enc.env 里,经 apply-secrets.sh 变成每个 app namespace 下的 backup-r2 Secret,它的几个 key 经 envFrom 注入后直接拼成一个免配置文件的 rclone remote;非机密的 TYPE/PROVIDER 写在 CronJob 的 manifest 里。想立刻验证一次就 make backup-now APP=multica,它会从 CronJob 现建一个 Job,等运行结束把日志打出来。备份是这套模型里少数由集群自己定时执行的活,但它只向 R2 写数据,不读 Git,不破坏集群从不回连 GitHub 这条原则。
这套做法的边界
把上面几块连起来看:GitOps 平时由 controller 负责的几件事,在这里拆开交给了仓库结构(怎么 apply)、make diff(对账)、make lint 加 CI(拦截)、SOPS(解密)和 Makefile 里的 version pin(防漂移)。controller 那层的复杂度没有消失,只是摊薄成了一组约定和一个 Makefile。
它适用的前提很具体:节点数个位数、基本一个人维护、能接受 drift 靠手动 make diff 发现而不是自动纠偏。一旦节点和服务多起来、需要多人协作、要审计每一次实际下发,常驻 controller 的价值就会超过它的成本,该上 Argo / Flux 就上。对现在这个四节点的小集群,我还乐意自己当那个 controller。
完整的代码在 synthpop123/k3s-manifests,每个子目录都有自己的 README,写清了各自的 apply 步骤和 bootstrap 顺序。