小Cの已经记不起来的博客

Kubernetes 分布式锁 配置避坑指南

起因:任务跑了两遍

前段时间把公司的一个定时任务调度器从单副本扩到了两个副本做高可用,结果没过几天,凌晨的任务在数据库里写了两遍。看日志才发现,两个 Pod 同时认为自己是“当前执行者”,说白了就是没有锁。本以为加个分布式锁五分钟的事儿,结果在 Kubernetes 里一路踩坑,forbidden、双主、锁丢失全碰上了。折腾完之后记录一下,没准儿你也能用上。

选型:能不自己造轮子就别造

大致三条路:自己撸 Redis 锁、直连 etcd、client-go 自带的 leaderelection(底层是 Lease 资源)。如果你的需求只是“同一时刻只有一个实例在干活”,直接用 leaderelection,别犹豫。kube-scheduler、kube-controller-manager 选主用的就是这套,成熟度不用怀疑。只有跨集群、或者业务拿不到 k8s API 权限的时候,才考虑 Redis。

坑一:RBAC 少配一个 create,锁永远拿不到

第一个坑来得最快,程序一启动就报 leases.coordination.k8s.io "xxx" is forbidden。查了半天,Lease 属于 coordination.k8s.io 这个组,Role 里我只配了 get 和 update,漏了 create——第一次选主的时候锁资源还不存在,总得有人去创建它吧,结果谁都没权限。别问我是怎么知道的。下面是实测可用的配置:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: task-leader
  namespace: scheduler
rules:
  - apiGroups: ["coordination.k8s.io"]
    resources: ["leases"]
    verbs: ["get", "create", "update"] # <--- create 一定要有,第一次选主靠它
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: task-leader
  namespace: scheduler
subjects:
  - kind: ServiceAccount
    name: task-sa
    namespace: scheduler
roleRef:
  kind: Role
  name: task-leader
  apiGroup: rbac.authorization.k8s.io

另外,如果你用的是 configmapsleases 这种兼容模式,ConfigMap 的 get/create/update 也得一起加上,少一个都白搭。

坑二:三个时间参数,抄错了就双主

LeaseDuration、RenewDeadline、RetryPeriod 这仨的关系必须记牢:RenewDeadline < LeaseDuration,RetryPeriod < RenewDeadline。我一开始图省事抄了个网上的配置,RenewDeadline 比 LeaseDuration 还大,续约还没成功锁就被判过期,另一个副本趁机上位,又双主了。API Server 抖一下或者限流,续约一样会失败,client-go 默认 QPS 才 5,集群里控制器一多很容易 429,该调就调大点。

leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
    Lock:          lock,
    LeaseDuration: 15 * time.Second,
    RenewDeadline: 10 * time.Second, // <--- 必须 < LeaseDuration
    RetryPeriod:   2 * time.Second,  // <--- 必须 < RenewDeadline
    Callbacks: leaderelection.LeaderCallbacks{
        OnStartedLeading: func(ctx context.Context) { run(ctx) },
        OnStoppedLeading: func() {
            log.Fatalf("lost leadership, exit") // <--- 必须退出,别硬撑着继续跑
        },
    },
})

OnStoppedLeading 里的 Fatal 也不是随手写的:丢了领导权还赖着不走的实例,本质上就是半个双主,宁可让 Pod 重启。还有 Identity 一定要用 Pod 的 hostname,别手滑写死成常量,两个 Pod 身份一样的话它们会都觉得自己是 leader,这锁就白加了。

坑三:锁的名字撞了,两个不相干的服务互斥

排查期还遇到过更离谱的:任务莫名其妙一直不执行,最后发现是隔壁组的服务和我们的锁同名同命名空间,两边互相卡。锁资源名就当成全局唯一来对待,带上服务名,放自己的 namespace 里。排查的时候直接看谁在续约:

kubectl get leases -A -w
NAMESPACE   NAME                  HOLDER       AGE
scheduler   order-task-lock       task-7f9d2   3d

HOLDER 那列就是 holderIdentity,加上 -w 能看到切换瞬间,比翻日志快多了。

坑四:用 Redis 的话,Pod 一重启锁就没了

如果最后还是选了 Redis,记住几点。加锁必须一条命令:SET order-task-lock <uuid> NX PX 30000,别拆成 SETNX 加 EXPIRE 两步,中间挂了就是死锁。释放也不能直接 DEL,得用 Lua 先比对再删,不然 A 超时、B 拿到锁,A 干完活顺手把 B 的锁删了。

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

然后是 k8s 特有的坑:Redis 部署成 Pod,一重启内存里的锁直接蒸发,所以 AOF 必须开,--appendonly yes。但就算 everysec,宕机那一秒也可能丢,持久化只是缩小概率,不是保险箱。真要稳,至少三实例做 Redlock,并且用反亲和打散到不同节点,三个副本挤一台机器,节点一挂锁服务直接瘫痪。

affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: redis-lock
        topologyKey: kubernetes.io/hostname

坑五:拿到锁也不代表绝对安全

GC 停顿、网络分区,都可能让锁已经易主而旧持有者还在写库。锁只能降低概率,兜底得靠 fencing token,或者干脆把任务做成幂等。我们的定时任务最后加了 job_id + date 的唯一索引,重复执行直接被数据库拒了,锁就算偶尔抽风也不至于出事故。

小结

总结一下:能用 leaderelection 就别自己造;RBAC 里 create 别漏;15/10/2 别乱抄,丢了 leadership 就退出;Identity 唯一;Redis 锁就是 SET NX PX 加 Lua 删加 AOF 加反亲和;最后永远给业务留幂等兜底。锁这东西平时感觉不到存在,一出问题就是双主这种大事故,配置时多花十分钟,比事后捞数据强多了。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放