k8s 从头实现加强版 statefulSet - 下篇
这是为 mcyouyou dev team 实现 unicore 的系列笔记,其中关于云原生的内容基本上业务无关,通用性很强,提取出来作为笔记。
上篇实现原版 sts 的基本内容,下篇会实现一些高级实用特性。
原地变更
原理
为什么需要原地变更?
容器化的思想认为,容器镜像应当作为“不可变基础设施”,从而去解决环境依赖和发布环境的一致性问题。因此 k8s 也希望每次 pod 的更新都是通过删除、重建这个过程来完成,这样每次 pod 及其下所有容器都是全新的、干净的基础环境。
但是我们还是有很多场景需要仅仅更新一个容器或 sidecar 的镜像,如果每次都进行整个 pod 的重建,就会导致引入很多不必要的开销:
- pod 的优雅终止时间(先发 SIGTERM,时间到还没结束则发 SIGKILL)
- pod 可能重新调度到其他节点,导致不必要的全量容器镜像下载时间
- 可能的网络分配或挂载远程盘耗时、导致 pod IP 改变
- 全量容器的启动时间
如果单个 pod 特别复杂,可能是分钟级的启动过程,那么要升级一个成百上千副本的 app 就会导致非常巨大的整体耗时。因此就需要一种方法,能够只升级其中某些容器,而不是重建 pod,也就是原地变更。
k8s 支持原地变更吗?
实际上是支持的。由于还是提倡 pod 作为一个部署单元的不可修改性,所以 k8s 虽然实现了修改 pod spec 下少数字段的能力,但并没有暴露给 sts 这一层,而是在 kubelet 里实现了。
众所周知 kubelet 会处理来自 etcd 的 pod 变动,然后在节点上操作容器运行时。收到 updatePod 指令后,kubelet 会检查 pod 各个 container 的状态和 spec 比较来决定是否要重建整个 pod。具体而言,Kubelet 会计算哪些容器需要启动,哪些容器需要 kill:
- 根据重启策略,考虑将未启动的容器加入启动列表;
- 如果容器的 spec 的 hash 和实际不一致,都需要根据新 spec 重建容器;
- 如果 spec 没有变更,livenessProbe 没问题,则保持不动,否则加入 kill 列表。
所以这里修改 pod spec 后 kubelet 实际上会调用 killContainer 将容器优雅终止,然后下一次循环会发现 spec 定义的容器没启动,于是又调用 startContainer 启动新容器,这样就完成了一次所谓原地升级。
但是需要注意 k8s 允许的 pod 更新范围是有很大限制的。在 apiserver 中会对 pod 对象更新做严格的校验:
// validate updateable fields:
// 1. spec.containers[*].image
// 2. spec.initContainers[*].image
// 3. spec.activeDeadlineSeconds
也就是说,只允许修改 pod.spec的 image 和 activeDeadlineSeconds 字段,否则均会被 apiserver 拒绝。
为什么 sts controller 允许原地变更?
做一个实际测试:
我们针对一个正在运行的 sts,把其中的一个 pod spec 用 kubectl edit 修改其镜像字段,再观察 pod 变化:
NAME READY STATUS RESTARTS AGE
sts-sample-0 1/1 Running 0 3m
sts-sample-1 1/1 Running 1 (2m ago) 3m
sts-sample-2 1/1 Running 0 3m
可以看到这里只是一个 pod 进行了一次 restart,age 不变,说明这个 pod 还是原来的那个 pod,但其容器的镜像已经改变了。
那么问题来了,sts 控制器的目标不是使 pod 实际满足期望吗,为什么 sts 不将新 pod 删除重建呢?
实际上 sts 控制器作为高一层的控制器,其只关注自己该关注的部分,也就是比较 sts 的 updateRevision 和 pod 的 label 中标记的 revision 是否一致。这里我们只修改了 spec,没有修改到 label 中的 revision,并且没有对 pod 的本身的生存状态做任何影响,所以 sts 控制器认为当前状态是满足 spec 要求的,不会做干预。
sts 控制器不以实际镜像名称为依据判断版本,其底层原因也有 spec 中的镜像名不一定和 status 中,也就是主机上上报的镜像名完全一致,因为多个镜像对应同一个 imageID 时,其名字可能是不同的,比如 nginx:latest 和 nginx:mainline。
如何判断原地升级状态?
考虑到我们引入了容器级而非 pod 级操作,所以这里必须要找依据确定原地升级是否完成。除此之外,相比于直接把 pod 删掉这种升级方法,引入容器级操作也要想办法保证在升级过程中在线流量不能打进来。
上面提到,不能比较 spec.image 和 status.image,但 spec 和 status 其实也记录了容器镜像的 imageID,这和镜像的名字无关。因此,我们可以比较 spec 和 status 下各容器的 imageID 来判断升级情况。
要控制流量是否能打进这个 pod,主要是通过其 ready 状态。k8s 判定一个 pod 是否 ready,除了所有的容器都 ready,还会引入一个 readinessGates,可以由用户或 controller 来定义和控制。其中定义的 conditionType 在 pod.status.conditions 中都会有对应的 bool 值,只有它们全部为 true 才认为这个 pod 是 ready 的。所以,我们可以定义一个 conditionType,在升级时将其设为 false,结束后再将其设为 true,使 pod 回到 ready 状态。由于在设置 false 到流量组件观察到 pod 状态变化中间存在时间差,所以可以在实际执行原地变更前引入一个额外的优雅等待时间来保证流量变化是平滑的。
至于判断何时容器已准备好提供服务,从而解除 pod 的 in-place update 对应的 condition,通过实践发现使用 pod.status.containerstatus[x].State.Running.StartedAt 这个时间来和 annotation 中记录的原地变更发生时间戳进行对比效果最好。因此判断时除了比较 annotation 记录的 imageID 和镜像的当前 imgID、容器 ready 和 running 状态,还有其记录的 timestamp 和容器 running 状态的起始时间。
为什么不用容器状态判断原地升级已完成
在一开始的实现版本中,使用了 pod.ready 和 containerStatus.[x].Phase 来判断原地升级状态。但实际测试发现,在本地镜像的情况下容器完成重建的过程非常快,apiserver 甚至没机会发现并完成对这个状态的上报就已经又变回 running 状态了,所以不能以此作为判断原地升级状态的依据,只能使用 containerStatus.[x].imageId 字段和期望镜像 id 字段比较来判断原地升级完成情况。
实现
具体的代码实现可以参考 feat: add in-place update · mcyouyou/unicore@8ad3d7a, 这里只说明一些主要内容。
字段定义
目前对于 pod 变更的实现是:在本次 reconcile 删除 pod,return;下次 reconcile 时发现 pod 不在,create 新的,以此完成 pod revision 更新。实现原地变更,我们就要在这里下文章,改成修改 pod spec,而不能破坏原本的更新顺序。
首先在 /api/... 下定义一些新的稍候会用到的字段:
// Strategy defines the strategies for in-place update.
type Strategy struct {
// GracePeriodSeconds is the timespan between set Pod status to not-ready and update images in Pod spec
// when in-place update a Pod.
GracePeriodSeconds int32 `json:"gracePeriodSeconds,omitempty"`
}
const StatusKey string = "unicore.mcyou.cn/inplace-update-status"
type Status struct {
Revision string `json:"revision,omitempty"`
UpdateTimeStamp metav1.Time `json:"updateTimeStamp,omitempty"`
OldImgIds map[string]string `json:"oldImgIds,omitempty"`
}
考虑到 reconciler 是基于状态机思想设计的,我们也应当使用 inplace.Status 将原地升级相关状态全量保存在 app 的 annotation 中,包括更新到的 revision、更新操作的时间戳、待更新容器对应的老的镜像id(用于判断更新是否完成),这样下次 reconcile 还是通过全量 inplace status 了解状态,保持控制器本身的无状态。
InPlaceUpdatePod
实现的核心是 InPlaceUpdatePod
方法,尝试针对某个 pod 完成原地升级,同时判断其是否符合上述的条件。由于引入了优雅等待时间,还要同时返回对应的秒数,存储后由 reconcile 外层作为 result 以在指定时间唤起下一次 reconcile。
首先拿出新旧两种状态的 revision,由于 revision 包含了资源定义的 json 数据,所以可以使用 jsonpatch 库为两个 json 创建对应的 json patch 格式字符串,表示这两个 json 之间要保持一致所对应的 patch 操作。在这里我们就可以利用其输出的 json patch,准确看到这次改动所对应修改的字段。
拿到 json patch,我们过滤出 replace 操作,提取出对镜像字段的修改,然后提取出最后一个操作数,也就是修改后的镜像名。我们把它按容器名存入一个 map,保存到 inplace status 内,供后续对比。同时,如果发现非 replace 或非镜像字段的操作,说明有不符合原地升级预期的修改,在此时返回错误。
至此已经确定该 pod 可以被原地升级。前文提到需要为升级中的 pod 设置 condition 为 false,这里就需要将注册的 condition(稍后由 state controller 完成注册)修改为 false,刷入 pod 中,然后用 pod 客户端的 updateStatus 方法将其刷入 etcd。接下来就该考虑上面提到的 graceSeconds 了。因为我们的 inplace status 记录了时间戳,所以我们可以判断升级开始时间+优雅时间是否早于当前时间,来决定应当 return 还是正式开始 pod 更新流程。
开始 pod 更新流程。将整个过程放在 retryOnConflict 方法下,重新从 podLister 拉取对应 pod,deep copy。通过给对应 label 赋值来标记当前 pod 的 revision,然后 pod 当前所有的容器和 inplace status 记录的容器镜像变更做对比并替换。同时记录每个容器当前的老镜像id到 inplace status 中方便对比判断变更是否结束。最后将 inplace status 赋值到 pod annotation 中,使用 client.pod.Update 方法提交修改。
IsInPlaceUpdateDone
实现了开始变更的方法,还要实现一个方法判断当前 pod 的变更是否完成。IsInPlaceUpdateDone
方法拉取 annotation 的 inplace status,如果其中变更的镜像和目前 containerStatus 里的 id 不同,且容器状态为 ready+running、running 状态的时间晚于 inplace status 记录的时间戳,则认为变更已完成。
CleanUpInPlaceUpdate
最后再实现一个方法来清理一下遗留下来的 annotation。这里同时要上报一个 condition,将之前记录的 InPlaceUpdateReady 设为 true,然后补上 reason。然后还是用 UpdateStatus 方法,删除 annotation、 set 上 condition,标识本 pod 的原地升级正式结束。
StateController
现在就可以修改 stateController 在 rollingUpdate 发现 revision 不一致的逻辑了。在按顺序从后向前比对 pod revision 时,如果不一致且开启了 inplaceIfPossible,则调用 InPlaceUpdatePod
,根据返回值判断是否能使用原地升级,如果可以则获得拿到等待时间存放到 requeue_duration 全局 map 里,否则 fallback 到普通升级方法。
接下来在原来判断 pod 是否 ready、可以处理下一序号 pod 的逻辑前增加专门针对原地升级状态的判断,调用 IsInPlaceUpdateDone
获取原地升级状态,再决定是直接返回等待升级完毕,还是使用 CleanUpInPlaceUpdate
清理 condition 和 annotation 以结束升级并继续检查下一个 pod。
另外,由于添加了 condition 类型,要在 pod controller 创建 pod 时为其 spec 增加一条 readinessGate。
AppController
最后在生成的 appController.Reconcile 方法下,返回前从 requeue_duration 全局 map 里把之前得到的等待时间 pop 出来,传递给 reconcile.Result,之后 mgr 会在对应时间自动再次调用 reconcile 函数。
测试
创建一个滚动升级策略为 inplaceIfPossible 的 app,然后使用 kubectl edit 修改其镜像:
NAME READY STATUS RESTARTS AGE
app-sample-0 1/1 Running 1 (5s ago) 102s
app-sample-1 1/1 Running 1 (7s ago) 100s
app-sample-2 1/1 Running 1 (9s ago) 99s
其启动过程和普通的 app 是一样的,但 edit 之后其 pod 镜像会由大序号到小序号开始变更,体现的是重启数+1。
接下来一样测试缩容+变更镜像:
NAME READY STATUS RESTARTS AGE
app-sample-0 1/1 Running 2 (7s ago) 3m27s
可以看到一样是先缩容,然后使用原地变更的方式升级了最后一个 pod。
接下来我们试一下 edit 一下 lable,这是原地变更所不允许的操作,检查log:
"can't use in-place update, falling back to ReCreate" err="patch cannot use inplace-update: /metadata/labels/another" app="default/app-sample" pod="default/app-sample-0"
可以看到自动 fallback 到了默认的删除重建的变更方式,重建了一个新的 pod:
NAME READY STATUS RESTARTS AGE
app-sample-0 1/1 Running 0 41s
最后测试下优雅时间。重建一个 app 使其滚动升级策略下的原地升级策略的 gracePeriodSeconds
为10,再进行修改:
NAME READY STATUS RESTARTS AGE
app-sample-0 1/1 Running 1 (18s ago) 63s
app-sample-1 1/1 Running 1 (28s ago) 61s
app-sample-2 1/1 Running 1 (38s ago) 59s
可以看到每个 pod 在 restart 原地升级前都会停留10s的优雅时间,这段时间内不会进行任何操作,只会将其 condition 置为 false,让流量组件停止对其提供流量:
conditions:
- lastProbeTime: null
lastTransitionTime: "2025-01-27T09:34:49Z"
reason: StartInPlaceUpdate
status: "False"
type: InPlaceUpdateReady
并在升级结束后更新状态为 ready:
conditions:
- lastProbeTime: null
lastTransitionTime: "2025-01-27T09:35:00Z"
reason: InPlaceUpdateDone
status: "True"
type: InPlaceUpdateReady
镜像预热
根据经验,在变更或者发布过程中,占据大部分时间的其实并不是容器启动或初始化,而是镜像拉取。特别是对于副本很多的服务,除了灰度过程中的强制等待,变更下发之后还要等待每个节点完成镜像拉取。
但其实,在一批灰度进行中或等待中,我们完全可以先让后一批节点提前完成镜像的拉取,减少后一批变更的时间。这就需要我们实现一个额外的能力,控制节点针对特定镜像的预拉取。
原理
拉取镜像
首先一个问题就是如何让节点拉取特定镜像。现在 k8s 默认都使用 containerd 作为容器运行时,在部署 pod 时 containerd 会检查自己节点是否已有对应的镜像,没有则拉取。正好 k8s 有一个 daemonset 机制,能给每个节点都部署一个 pod,作为我们的代理来操控 containerd。
怎么操控呢?containerd 其实已经提供了 golang 客户端 github.com/containerd/containerd
,我们只需要在 daemonset 创建时挂载上主机的 /run/containerd/containerd.sock
, 不用经过网络协议就可以与本机上的 containerd 守护进程通信。
除此之外,我们还需要配置 daemonset pod 的特权,使其能以 root 身份访问 socket 文件,并和 node 本机位于同一个网络和 PID namespace。这可以通过修改 container spec 模板实现:
spec:
hostNetwork: true
hostPID: true
containers:
- name: daemon
image: unicore-daemon
imagePullPolicy: IfNotPresent
securityContext:
privileged: true
volumeMounts:
- name: containerd-sock
mountPath: /run/containerd/containerd.sock
volumes:
- name: containerd-sock
hostPath:
path: /run/containerd/containerd.sock
type: Socket
securityContext:
runAsUser: 0
描述拉取任务
考虑到 k8s crd 定义都是围绕“期望状态”的思想设计的,在实现“让指定节点拉取指定镜像”这个功能的时候也应遵循这样的设计思路。因此,要设计一个合适的机制来描述拉取任务。
我们引入一个新的 crd 来描述我们期望一个节点应拉取好的镜像列表,叫 ImageList。我们为每个想要满足拉取预期的节点创建一个对应的资源,使用 spec 存储预期的镜像列表,然后由 daemonset pod 作为自己节点对应 imageList 的 controller。这样在设计上就不用考虑任务下发到执行完成的各种混沌状态了,在 daemonset 上使用状态机兜住即可。
需要注意的是,这样设计需要保证每个 daemon pod 每次只会 watch 自己 node 对应的唯一一个 imageList。这里还是使用 k8s 的 watch 功能,但不使用 informer 的 sharedCache,而是手动维护。在传递 listOption 的时候,我们设置一个 FieldSelector
,这是一个在 apiserver 端过滤的 selector,通过设置其选择 metadata.name=node_name 来在服务端过滤针对特定名字的 imageList 的事件,就可以避免 watch 无关示例,牵一发而动全身。除此之外,在 watch 之前要进行一次 list,手动维护 imageList 在本地的初始状态,然后在 watch 时提供当前的 resourceVersion,代表从该版本开始 watch 其变化,这样才能正确维护 nodeList 的变更。
获取节点名字
还剩下一个小问题,在节点上起来的 daemon pod 如何知道自己应该监听哪个 crd 呢?实际上就是需要知道自己节点的名字。但是进入节点查看 env,发现其中默认并没有保存节点的名字,不能直接读环境变量实现。
不过好消息是 daemon pod 的创建模板上允许了通过传递自身 json 的字段作为环境变量,而 nodeName 这一项正巧是作为 pod 的 spec 在实际下发时就决定了,所以可以这样配置来使 nodeName 作为动态的环境变量注入创建的 pod:
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
实现
字段定义
和 app 一样,首先通过 kubebuilder 的生成器功能在 /api 下生成一个新的 ImageList 字段,修改 spec:
// ImageListSpec defines the desired state of ImageList.
type ImageListSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
// map[ImageName]ImageInfo
Images map[string]ImageInfo `json:"images"`
}
type ImageInfo struct {
// if true, exec docker pull for this img every 24h
AlwaysPull bool `json:"alwaysPull,omitempty"`
// expected next pulling time if AlwaysPull is true
NextPull metav1.Time `json:"nextPull,omitempty"`
}
这里引入一个额外的配置,有些镜像可能需要重复拉取(AlwaysPull 策略)来保持最新,所以如果 AlwaysPull 设为 true,配合 NextPull 的时间记录,可以为它们实现每 24 小时拉取一次。
然后使用一样的步骤为其生成 client、lister、informer等。不过其实只用生成 client 就够用了,毕竟我们只使用原生的 list 和 watch 方法。
权限配置
由于我们要让 daemon pod 能够访问我们的自定义资源 imageList,需要额外为其配置一个 role 和 roleBinding:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: unicore
name: imagelist-access
rules:
- apiGroups: ["unicore.mcyou.cn"]
resources: ["imagelists"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: imagelist-access-binding
namespace: unicore
subjects:
- kind: ServiceAccount
name: default
namespace: unicore
roleRef:
kind: Role
name: imagelist-access
apiGroup: rbac.authorization.k8s.io
绑定到 unicore namespace 上,这样才能通过 apiserver 的检查去操作 imageList。
Daemon
我们的 daemon 就是一个独立的程序,有自己的 main 函数。daemon 通过 inClusterConfig 获取集群配置,使用上面提到的 imageList cli 的 list 和 watch 方法来 watch 特定名字的 imageList。
这样创建的 watcher 会提供一个 resultChan,使用 resultChan 就可以接收 watch 来的事件,通过 event.Type 来获得事件信息。类似这样:
for event := range watcher.ResultChan() {
switch event.Type {
case watch.Added:
imageList := event.Object.(*unicore.ImageList)
err := d.PullImages(imageList.Spec.Images)
if err != nil {
klog.ErrorS(err, "pull image failed", "image", imageList.Spec.Images)
}
case watch.Modified:
imageList := event.Object.(*unicore.ImageList)
err := d.PullImages(imageList.Spec.Images)
if err != nil {
klog.ErrorS(err, "pull image failed", "image", imageList.Spec.Images)
}
case watch.Deleted:
d.AlwaysPullImagesLock.Lock()
d.AlwaysPullImages = make(map[string]metav1.Time)
d.AlwaysPullImagesLock.Unlock()
d.RetryLock.Lock()
d.RetryImages = make(map[string]bool)
d.RetryLock.Unlock()
}
}
为了保持容器运行时的可扩展性,这里定义了一个 CRI 接口,可以实现 CheckAndPull
方法来检查并拉取容器运行时中没有的镜像,返回拉取失败的情况。daemon 在收到 watch 事件后从 spec 提取出需要的镜像,调用对应的接口即可。
除此之外,daemon 还维护了一个 retry loop 和 alwaysPull loop,分别做拉取失败的重试以及 AlwaysPull 策略的处理。
Containerd CRI
Containerd cri 实现 CheckAndPull 方法,负责执行具体的镜像检查和拉取任务。
对于 containerd 来说,使用对应的 socket 和 go 客户端即可。首先拉取存在于节点上的所有镜像。由于 containerd 的镜像是有 namespace 的,所以需要在 context 中指明 k8s.io
。将传入的预期镜像与当前存在的镜像作对比,没有的单独执行拉取。拉取时使用 waitGroup 并发完成,针对简写的镜像名(如 nginx)要为其补上默认地址 docker.io/library/
,未填写版本的也要补上默认版本 :latest
,因为 containerd 属于底层操作,不会帮你完成这些步骤。另外, client.Pull 的选项上还需要加上 WithPullUnpack,把拉下来的镜像解包好。
考虑到可能的注入攻击,在拉取用户指定的镜像前,还需要用 regexp 对镜像名做一下校验。
测试
我们为 node dev-worker3 创建一个 nodelist,让它拉取一个 mysql 镜像:
apiVersion: unicore.mcyou.cn/v1
kind: ImageList
metadata:
labels:
app.kubernetes.io/name: deployer
app.kubernetes.io/managed-by: kustomize
name: dev-worker3
namespace: unicore
spec:
images:
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/mysql:8.0:
alwaysPull: false
然后观察 dev-worker3对应的 node 的日志:
I0215 16:23:28.847154 271244 daemon.go:93] watching imageList changes on node dev-worker3
I0215 16:33:44.466254 271244 containerd.go:58] pulling 1 images...
I0215 16:34:03.905526 271244 containerd.go:71] image pulled: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/mysql:8.0
理论上是拉取成功了。再进到节点里(用 kind 的话就用 docker exec 进入节点容器),使用 ctr 查看 containerd 镜像列表,grep 下 mysql:
# ctr -n k8s.io image ls | grep mysql
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/mysql:8.0 application/vnd.docker.distribution.manifest.v2+json sha256:cec0e254fbdc4f0806740d921e3b862cb72bf1f571993adf49c1218dc41b4aca 158.3 MiB linux/amd64 io.cri-containerd.image=managed,source=unicore-daemon
可以看到镜像已经成功被拉取到节点本地了。
发布暂停
在灰度等各种场景下,如果在测试中发现了意料之外的结果,就需要立即暂停发布,重新将旧镜像发布变更从而实现回滚。因此需要实现在变更过程(rolling update)中的发布暂停。
这里的实现很简单,只需要在 rollingUpdate 相关逻辑之前,判断在创建 api 时我们预留的 spec.updateStrategy.rollingUpdate.paused 是否为 true。如果是,直接 return 即可,因为恢复时也是更新了 app spec,等下一轮 reconcile 再检查即可。
这里还是起一个三副本原地变更示例 app,使用 kubectl edit 将其 replicas 修改为2,并修改镜像为 tomcat,同时将 paused 置为 true:
NAME READY STATUS RESTARTS AGE
app-sample-0 1/1 Running 0 8m16s
app-sample-1 1/1 Running 0 6m42s
可以看到控制器在完成数量修改后,并没有直接去修改剩下 pod 的镜像,而是停留在了当前步骤。
用 kubectl 将 paused 设为 false:
NAME READY STATUS RESTARTS AGE
app-sample-0 1/1 Running 1 (3s ago) 9m42s
app-sample-1 1/1 Running 1 (12s ago) 8m8s
可以看到原地变更步骤继续完成了。