我们在使用 Kubernetes 时遇到了设置 --grace-period 参数不生效的问题,从 kubelet 日志看是 kubelet 接受到 Pod DELETE 事件后在同一秒内又接受到了 REMOVE 事件,所以 Pod 立刻就会删掉了。经过比较曲折的排查最后终于解决了这个问题,下面分享一下 kubernetes grace period 相关的一些概念和理论然后再介绍一下我们踩到的坑。
根据 kubernetes 官方文档《Termination of Pods》这一节的介绍可知 Kubernetes 删除 Pod 时是可以配置 --grace-period 参数的,而且即使没设置这个参数它也有 30 秒的默认值。那么:
在解答这些问题前我们从优雅下线的作用、 Pod 中容器的生命周期 和 Pod 删除的流程说起。
我们知道集团的应用都有 online 和 offline 的操作。online 是在应用启动后可能会做一些注册服务或者开启告警之类的操作,offline 是在了停容器前会做关闭告警或者注销服务的操作。所以 online 和 offline 对于一个复杂的分布式集群来说是必不可少的操作。
Kubernetes 给 Pod 中的 container 添加了两个 hook 点(官方文档看这里):容器启动后和容器停止前。容器启动后正是做 online 的好时机,容器停止前正是做 offline 的好时机。
PostStart 执行的时机是在容器启动以后,但是并不是等容器启动完成再执行。容器启动以后和容器启动完成的区别是什么呢?我们先看一下 Docker 官网的 Entrypoint 和 CMD 的配置可知容器有可以通过 Entrypoint 和 CMD 配置启动指令,如果 Entrypoint 和 CMD 都做了配置那么 CMD 会作为 Entrypoint 的参数由 Entrypoint 来决定如何使用 CMD。但是 Entrypoint 执行之后是不会结束的,如果容器的一号进程结束容器也就退出了。所以在标准的容器玩法中是不知道容器什么时候启动成功的,只知道容器已经启动了。所以 kubelet 是在执行 Entrypoint 之后就会立即执行 PostStart hook,而不是等 Entrypoint 执行完再去执行的。所以理论上来说 PostStart 和 Entrypoint 是并行执行的。
这个 hook 点是执行应用 online 好时机,PostStart 可以探测应用是否启动成功,如果应用启动成功就执行 online 的动作
PreStop 是在 Pod 销毁前 kubelet 对容器执行的指令,可以是到容器中执行一个命令也可以是向容器的某个端口发起一个 HTTP 请求。PreStop 的作用是做一些下线前的准备工作,比如集团的精卫应用再下线前需要从 zk 中注销当前的服务实例。
这个 hook 是执行 offline 的好时机,可以在下线前做一些清理动作。
当发起一个删除 Pod 的指令时 Pod 的删除逻辑是这样的:
上面这些过程也可以查看Kubernetes 官方文档,官方文档也有详细的说明。从这里可以判断出来 Pod 下线现在 kube-apiserver 中标记为删除状态,然后 kubelet 执行完正真的删除动作才会真的删除 Pod。
优雅下线时间也可以设置成零,零的意思是立即从 Kubernetes 中删除此 Pod。参数设置方法是:--forc --grace-period=0
这两个参数同时被设置
grace-period 的作用是让 kubelet 可以在删掉 Pod 前优雅的下线掉 Pod 中的服务,尽量做到服务无损的摘掉 Pod。通过前面的介绍可知 kubelet 在停止 Pod 前会尝试执行 PreStop 操作。而 PreStop 就是为了应用优雅下线而设计的 hook。
注意:即使 PreStop 执行失败 kubelet 还是会继续执行 docker stop 的,kubelet 并不会因为 PreStop 执行失败就停止 Pod 的清理,所以在集团内部使用对 PreStop 非常敏感的话就需要修改这部分逻辑
如果执行 PreStop 之后 grace-period 还有剩余的时间那么剩余的时间就是 docker stop -t 的超时时间否则 docker stop -t 的超时时间就是默认的 2 秒,所以 grace-period 的长短可以影响到 docker stop -t 参数的超时时间,从而影响到容器进程对 term 信号的处理
Kubernetes 中 Pod 如果对状态敏感就应该设置合理的 PreStop 操作和 grace-period 超时时间。保证 PreStop 可以在 grace-period 时间内完成,这样 docker stop -t 2 也没有问题
我们使用的版本是 1.7 版本,当时发现设置 --grace-period 参数指定的时间不生效,而且默认的 30 秒也没有生效。 现象是只要执行 kubectl delete pod 命令 Pod 就会立刻被删除掉,并不会执行优雅下线的操作。
为了测试进程响应 term 信号后是否等待 30 秒再响应 kill 信息,专门写了一个 Go 的小程序:
package main
import (
"fmt"
"time"
"os"
"os/signal"
"syscall"
)
var isTerm = false
func main() {
go signalFunc()
for {
if isTerm {
fmt.Println("term is received")
} else {
fmt.Println("not have term ")
}
time.Sleep(time.Second * 1)
}
}
func signalFunc() {
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigs
fmt.Println()
fmt.Println(sig)
isTerm = true
done <- true
}()
fmt.Println("awaiting signal")
<-done
fmt.Println("exiting")
}
上面这段代码每秒打印一条 not have term 这样的日志。当接受到 term 信号以后每秒打印一条 term is received 这样的日志。那么如果删除 Pod 时 pod 的容器有 30 条 term is received 日志就说明优雅下线成功了。
理想情况应该是打印向下面这样的日志,接收到 term 信号后还会打印 30 条日志
实际情况是在删除 Pod 时几乎是立即就把容器杀死的,我看到的日志是这样的:
我把这段代码编译成二进制再做成镜像部署成 Pod 进行测试发现优雅下线并没有生效。term is received 这条日志大多数打印一次容器就挂掉了。最多的时间打印 2 次,有的时候一次都没打印容器就挂掉了。
发现实际情况和文档介绍的不一样就开始排查这个问题。因为线上的 kubelet 是我们自己编译的,第一个想到的可能性就是我们的编译环境有问题导致 kubelet 异常,所以我首先在我的 Mac 上面编译了一下 kubelet 然后传到 ECS 里面做测试发现是正常的 --grace-period 可以正常生效 。
至此基本确定了是编译环境有问题导致 kubelet 异常,编译环境到底哪里有问题毫无头绪。编译过 Kubernetes 二进制的都知道官方提供了一个编译镜像,我们的二进制就是通过官方提供的 gcr.io/google_containers/kube-cross:v1.8.3-1 这个镜像编译的。而且我们的编译环境环境和我的 Mac 都是通过这个镜像编译的,Golang 语言本身也是所有依赖都编译到二进制里面的,所以理论上不会有差别的。大家知道 Mac 下的 Docker 是在虚拟机里面执行的,我不确定是不是这个虚拟机的环境也有问题。所以我又启动了我本地的 VirtualBox 虚拟机,在 CentOS 7 里面又编译了一次,发现我在 VirtualBox 里面编译出来的 kubelet 也是有问题的。接着我找到江博同学在他的 Mac 编译一次还是有问题的。少数服从多数此时我已经不关心为什么我的 Mac 上编译是正常的了,我觉得我的 Mac 可能什么地方有问题了。因为我在自己的 Mac 上面编译过 1.10 的 kubelet,也许是我的电脑上缓存了什么信息导致编译出来的 kubelet 可能是新版本的,所以就不再考虑了。
紧接着我又做测试了一下最新两个版本的 kubelet。当时猜想可能是 1.7 版本比较老有 BUG,所以我就立即从社区的 release 页面直接下载了 1.10 版本的 kubelet 和 1.9.4 版本的 kubelet 进行测试测试。测试结果是这两个高版本的 kubelet 都可以执行优雅下线动作,所以当时断定是 1.7 版本的 Bug。然后开始排查这个 Bug。
最初猜测可能是 kubelet 垃圾回收机制异步执行的逻辑有问题导致的 BUG,所以第一个排查的就是垃圾回收机制。最后排查问题并不是垃圾回收的问题,因为垃圾回收只清理死掉的 Pod 的信息,而不会主动杀死 Pod。
接着就从 kubelet 的日志开始找线索,从日志中发现 kubelet 收到 Pod DELETE 操作以后又立刻收到了 Pod REMOVE 操作。代码文件路径是 pkg/kubelet/kubelet.go 代码片段如下:
那么问题来了:DELETE 和 REMOVE 的区别是什么?为什么有了 DELETE 还需要 REMOVE 呢?
这里简要说明一下,DELETE 和 RMEOVE 这两个“动作”不是 kube-apiserver 直接下发的,kube-apiserver 下发的是 Pod 状态的变迁,这两个动作是 Kubelet 根据自己缓存的 Pod 信息和从 kube-apiserver 监听到的最新的信息 Merge 出来的(具体代码参见 pkg/kubelet/config/config.go merge 函数)。
先分析 DELETE 逻辑的代码。kubelet 接受到 DELETE 动作以后执行 HandlePodUpdates 操作。这个操作的逻辑很简单,先在 podManager 中更新 Pod 的状态然后通过 dispatchWork 把当前 Pod 状态变迁的动作传递下去。
// HandlePodUpdates is the callback in the SyncHandler interface for pods
// being updated from a config source.
func (kl *Kubelet) HandlePodUpdates(pods []*v1.Pod) {
start := kl.clock.Now()
for _, pod := range pods {
kl.podManager.UpdatePod(pod)
if kubepod.IsMirrorPod(pod) {
kl.handleMirrorPod(pod, start)
continue
}
// TODO: Evaluate if we need to validate and reject updates.
mirrorPod, _ := kl.podManager.GetMirrorPodByPod(pod)
kl.dispatchWork(pod, kubetypes.SyncPodUpdate, mirrorPod, start)
}
}
podManager 是 kubelet 在本地缓存 Pod 信息的数据结构,podManager 是 kubelet 比较核心的组件,kubelet 很多操作都会用到。
kubelet 获取 Pod 的信息有几个途径:
- 通过 podManager 获取本地实时的 Pod 信息
- 通过 kube-apiserver 获取 Pod 信息
- 静态 Pod 通过配置文件或者 url 获取
- 通过本地 Container 列表反向演算 Pod 信息(kubelet 重启的时候就是通过这种方式判断本机上有哪些 Pod 的)
- 通过 cgroup 演算 Pod 信息(kubelet 就是通过对比 cgroup 设置的信息和 podManager 的信息判断哪些 Pod 是孤儿 Pod 的,孤儿 Pod 的进程会被 kubelet 立即通过 kill 信号杀进程,非常强暴)
继续分析 dispatchWork 会发现最后会调用到 func (kl *Kubelet) syncPod(o syncPodOptions) error {
这个函数。syncPod 这个函数是 kubelet 核心处理函数,大多数 Pod 状态的变化都会经过此函数处理一次。
看一下 syncPod 的代码如下图代码片段所示:
可见当 Pod 的 DeletionTimestamp 字段设置时 syncPod 就会执行 killPod 进行清理。
继续跟进 killPod 最后定位到 pkg/kubelet/kuberuntime/kuberuntime_container.go 的 killContainer 函数。killContainer 的代码片段如下:
通过这段代码可以看出这就是官方文档中描述的 kubelet 终止 Pod 的两个步骤 1. 执行 PreStop 2. 停止容器。至此我们大概清楚了 kubelet DELETE Pod 的大概流程:
好的代码总是能给人有用的指引,通过上面 killContainer 这个代码片段可知只要这个函数执行就会打印箭头指向的那样一行日志。不妨先分析一下 kubelet 的日志看看情况。我在 kubelet 日志中搜索 cat kubelet.log |grep "Killing container|grep "second grace period" 这样的关键字发现 killContainer 执行了两次,并且第一次执行时优雅下线时间是 30 秒,第二次却是零秒。至此看起来问题比较明朗了,是因为第二次的超时时间是零秒,所以执行了 docker stop -t 2 命令立即向 container 发送了 kill 信号,所以优雅下线就没有生效。
现在 DELETE 的逻辑清楚了,那么 REMOVE 的逻辑是怎样的呢?会不会第二次 killContainer 的调用是 REMOVE 动作发起的呢?接下来我又分析了一下 REMOVE 的逻辑,代码片段如下所示:
REMOVE 首先从 podManager 中删除掉当前 Pod 的信息,然后执行 deletePod 函数,代码片段如下:
这个函数最后把 Pod 的信息发送到了 podKillingCh 这个 channel。kubelet 启动的时候会启动一个 goroutine 监听这个 channel。当发现 channel 中有信息是就取出 Pod 然后执行 killPod 操作。所以最后会走到 killContainer 的逻辑。那么上面的猜测是成立的,第二次的 killContainer 调用是 Remove 发起的。
在这个函数排查的时候发现 REMOVE 默认传进来的 gracePeriod 参数是零秒。结合上面介绍的 Pod 强制删除的参数可知 REMOVE 操作中的零秒超时时间其实就是 --force 和 --grace-period=0 参数同时设置的结果,也就是说这是一个强制删除操作。下面总结一下 DELETE 和 REMOVE 这两个动作的区别:
好,既然这样的话是不是强制在 killContainer 这个函数中把超时时间改成 30 秒就一定会生效呢?如下图所示,于是我就强制把 gracePeriod 设置成 30 进行测试。让人失望的是虽然已经设置成 30 但是还是没有生效。
虽然强制设置成 30 秒没有生效。也就是说 REMOVE 动作的强制删除并不是通过 killContainer 设置零秒超时时间生效的,那么到底是怎么生效的呢?
为此我对 REMOVE 逻辑的各个环节进行了大量的调试,最终发现一个现象:只要不从 podManager 中删除当前 Pod 优雅下线就会生效,一旦从 podManager 中删除 Pod 那么及时不执行 killContainer Pod 也会被强制杀死。当时通过分析 kubelet 代码认为 kubelet 只要停止容器就会调用 killContainer 函数, killContainer 会调用 runtime 的 stopContainer 函数。如下所示:
containerManager 的接口可知 kubelet 杀死一个容器只有 StopContainer 和 RemoveContainer 两个途径。于是我就在这两个函数里面守株待兔添加各种调试信息,最终还是没有逮到兔子。难道 kubelet 管理 container 还有其他途径?
貌似很清晰的线索现在又没有头绪了。接下来又回到了 kubelet 日志,看看日志中是否还有什么线索。
回到 kubelet 的日志仔细分析日志,因为现在对 kubelet DELETE 和 REMOVE 已经比较了解了,所以把上面代码排查确定的一些过程的日志排除掉之后发现了下面这样奇怪的日志:
果然上面守株待兔没有逮到是因为 kubelet 管理 Pod 真的还有其他的途径,而且看起来貌似是非常强暴的方式,直接 kill 进程。通过 ”Attempt to kill process with pid“ 这个关键字找到代码的位置然后反向推演调用过程最后发现是 kubelet 删除孤儿 Pod 时进行的操作。上面提到了 REMOVE 操作的时候如果注释掉从 podManager 中删除 Pod 的代码优雅下线就会生效,否则就不会生效。通过分析 kubelet 删除孤儿 Pod 的逻辑证实了这个现象。
如下所示孤儿 Pod 的清理是这个分支。这个清理动作每 2 秒就会执行一次。
清理的逻辑是遍历系统的 cgroup 信息信息,然后根据 cgroup 信息计算当前系统中实际运行的 pod 的数量和 pod 名称、ID 等基本信息。然后再和 podManager 中缓存的信息进行对比。如果发现哪个 cgroup 中的 Pod 在 podManager 中没有就认为这是一个孤儿 pod,然后直接通过非常强暴的 kill 信号强制杀死孤儿 Pod 的进程。
好,分析了这么多 kubelet 的代码,DELETE 和 REMOVE 的逻辑也大体清晰了。现在还有一个问题:根据官方文档的描述 Pod 删除时在 kube-apiserver 中只是标记然后,最后真正确认删除的是 kubelet,那么为什么 kubelet 会被动收到一个强制删除的动作(REMOVE)呢?
我们知道 Kubernetes 所有的数据变动都会经过 kube-apiserver 的,接下来只能分析 kube-apiserver 的逻辑来排查为什么 kubelet 会收到强制删除的指令了。通过分析 kube-apiserver 的日志发现如果使用我们编译的 1.7 的 kubelet 删除 Pod 的时候 node-controller 在 Pod 删除前就会疯狂的调用删除 Pod 的接口。如果用从社区下载的 1.9.4 和 1.10 的 kubelet 二进制 node-controler 就不会调用删除 Pod 的接口。问题分析到这里虽然不知道 Bug 的原因是什么,但是可以隐约感觉到 node-controller 能够感知到 kubelet 的一些信息,这些信息可能是 kubelet 启动时注册上来的,node-controller 在 Pod 删除时可能会根据 kubelet 不同的信息做了判断。具体是什么信息要分析 node-controller 的代码才能知道。所以接下来就开始分析 node-controller 的代码。
因为已经知道是 node-controller 调用删除 Pod 的接口导致的问题,所以 node-controller 这块排查起来还是比较快的。到代码里面直接搜索 c.Core().Pods(pod.Namespace).Delete 函数的调用并且 DeleteOptions 中指定了 GracePeriodSeconds 为零的调用(因为是强制删除,所以 GracePeriodSeconds 一定是零)。最后在 pkg/controller/node/controller_utils.go 的 maybeDeleteTerminatingPod 函数中发现了线索。
如下所示 maybeDeleteTerminatingPod 的代码片段
首先会判断 kubelet 的版本号是否符合语法规范,如果不符合就强制 kill Pod。如果符合再继续判断是否小于 1.1.0 版本,如果小于默认认为是不支持优雅下线操作的,所以强制删掉 Pod。
接下来我分别看了一下正确的 kubelet 的版本号和错误的 kubelet 的版本号,如下所示:
[root@c46a091f6d7874538b52a55e5a57f019b-node6 ~]# ./ok-kubelet --version
Kubernetes v1.7.3-beta.0.444+4139877e213ead
[root@c46a091f6d7874538b52a55e5a57f019b-node6 ~]# ./err-kubelet --version
Kubernetes v1.7.3-beta.0+$Format:%h$
[root@c46a091f6d7874538b52a55e5a57f019b-node6 ~]#
可以看到有错误的 kubelet 的版本号是Kubernetes v1.7.3-beta.0+$Format:%h$
明显是模板字符串没有替换成变量。因为版本号字符串不合法所以 Pod 就强制被删掉了。
好现在汇总一下从头到尾所有这些过程的分析:
从错误的版本号中我们获取了一个关键字符串v1.7.3-beta.0+$Format:%h$
通过这个字符串在代码中定位到了 pkg/version/base.go 这个文件,如下图所示:
由此推断出了 GitVersion 关键字。编译过 Golang 的人可能知道 Golang 是可以通过 ldflage 设置版本号的,所以初步推断是编译时设置没有成功设置版本号导致的异常。
编译 kubelet 的命令是:KUBE_BUILD_PLATFORMS=linux/amd64 && ./build/run.sh make WHAT=cmd/kubelet,那接下来从 build/run.sh 结合 GitVersion 关键字进行分析。
如下图所示,最后定位到是 hack/lib/version.sh 执行 git describe --tags 命令获取 tag 信息的时候报错(吐槽一下错误信息居然被丢弃了),从而导致 KUBE_GIT_VERSION 变量为空。
如下所示,如果这个变量为空就不会设置 gitVersion 这个 ldflags 参数,所以 kubelet 的版本号就异常了。
那么为什么 git describe --tags 异常呢?查看 git 文档可知因为我们的私有代码仓库没有设置 tag 所以执行此命令会就报错。
那么还有一个问题: 为什么我的 Mac 上面编译的 kubelet 就没问题,而别的环境编译的 kubelet 就有问题呢?经过进一步分析发现因为 gitlab 上面的 Kubernetes 代码是我从 github 上面 clone 下来又推上去的,所以本地有所有的 tag 信息,所以 kubelet 的版本是正常了,也就没有优雅下线的 BUG 了。
既然知道问题出在 tag 这块那么也非常容易解了,我只要在本地执行一下 git push --tags 命令然后别的环境执行一下 git pull 再编译就正常了。
这个 Bug 的排查过程非常曲折,但最终定位到的原因又显得非常不可思议,万万没想到 Kubernetes 体系会根据版本号做这种业务操作。也没有想到社区代码的 tag 没有推到 gitlab 上面会导致这种问题。既然 Kubernetes 是这样使用 tag 的那么可能别人也会这样用。看来以后我们向 gitlab 推代码的时候记得一定要把 tag 也推上去。
注:在最新的 node-controller 代码中判断 kubelet 版本的合法性,如果 kubelet 版本不合法就强制 kill Pod 的逻辑已经去掉了
在排查这个 Bug 的过程中对 kubelet 删除 Pod 流程的各个边界情况探索的比较清晰,从而看到了一些问题
在Kubernetes 官方文档 中可以看见 kube-proxy 在监测到 Terminating 状态的 Pod 时会从 Service 的 Endpoint 中摘掉当前 Pod。而 kubelet 监听到此 Pod 的状态为 Terminating 的时候会通过优雅下线杀死 Pod。那么问题来了,仔细分析一下这个过程 kubelet 杀死 Pod 只需要相应的一个 kubelet 正常的感知到此 Pod 然后杀死 Pod 就行了,但是 kube-proxy 从 endPoint 中删掉 Pod 实例需要所有的 kube-proxy 都感知到这个变化,所以理论上会出现 Pod 已经被 kubelet kill 掉但是 kube-proxy 还向 Pod 导流的情况。所以即使业务上不需要对应用进行 offline 操作也要在 PreStop 中执行一下 sleep 操作,让 kubelet 强制等几秒,等待 kube-proxy 全都摘掉 Pod 实例之后再执行 stopContainer。当然如果设置了 PreStop 操作执行时间太短的话也是有风险的,所以在集团内部建议判断一下,如果 PreStop 没设置或者执行时间太短就强制 sleep 一下。
kubelet 接收到 REMOVE 动作以后会先从 podManager 中删除此 Pod。然后把 Pod 信息发送到 channel,接着 PodKiller goroutine 感知到 channel 中有一个 Pod 需要删除,就调用 killPod 执行删除。killPod 最终使用超时时间为 2 秒的参数调用 stopContainer。通过执行顺序可知先从 podManager 中同步的删除 Pod 然后发送到 channel,最终执行的时候还使用了 2 秒的超时整体的 stopContainer 时间肯定是大于 2 秒的。而一旦 Pod 从 podManager 中删除孤儿 Pod 定时清理任务在 2 秒内就会感知到,然后就直接发送 kill 信号给进程非常粗暴的强制杀死进程。所以 Pod REMOVE 的正常 killPod 是不会被执行到的,这里是有 Bug 的。