本文主要围绕k8s command展开讨论。(deployment.spec.template.spec.containers[n].command)
主要聊聊平台在接入用户业务时,如何保证满足业务基本需求情况下增强平台易用性。
最初是由bash启动进程引起的业务进程无法接收sigterm优雅退出问题。解决过程中逐渐回归为如何在k8s command定义多条指令
原生K8S-Command规范
填写格式
field | type | comment |
---|---|---|
container.command | []string | 对应Dockerfile中Entrypoint指令字段 |
container.args | []string | 对应Dockerfile中Cmd字段 |
生效规则:
填写command
时,command[0]
为首启动命令执行文件,command[1:] 及 args[:]
均为启动参数。
未填写command
时,args[0]
为首启动命令执行文件,args[1:]
为启动参数。
实例(pod)生命周期
创建前
生产环境中我们一般不会单独创建pod,而是利用kube-controller-manager的组件deployment、daemonSet等API来管控实例,其控制循环功能可自动部署、自动恢复,将任务状态永远调整向期望状态。
例如
- 用户声明deployment.spec(期望实例模板) 及 replicas(实例数)交给k8s;
- 在deploymentController部分的控制逻辑中,将生成ReplicasSet;
- ReplicasSetController监听资源处理,生成Pod;
- Pod被kube-scheduler监听处理,为其分配合适的node;
- kubelet(此组件安装在slave node上)监听到pod绑定信息,在node上实例化pod信息。
创建
- 创建sanbox容器
- 拉取镜像并创建init容器
- 创建普通容器 (拉取镜像,创建容器,启动首启动进程,执行postStart)
当init容器执行完成退出后,启动所有普通容器。根据liveness
、readiness
配置情况探测并确定容器是否ready。所有容器ready时pod状态更新为Ready。
创建普通容器
code位于pkg/kubelet/kuberuntime/kuberuntime_cotainer.go
的 startContainer
函数
// Step 1: pull the image.
// Step 2: create the container.
// Step 3: start the container.
err = m.runtimeService.StartContainer(containerID)
// Step 4: execute the post start hook.
if container.Lifecycle != nil && container.Lifecycle.PostStart != nil {
kubeContainerID := kubecontainer.ContainerID{
Type: m.runtimeName,
ID: containerID,
}
msg, handlerErr := m.runner.Run(kubeContainerID, pod, container, container.Lifecycle.PostStart)
注意这里step3,4: 先StartContainer(启动首启动进程-即上面的command、args信息);然后在向容器发送postStart指令,注意此处postStart。
(这里着重看postStart 是由于 有用postStart来实现容器内自定义多进程的想法)
runner.Run()调用处为
func (hr *HandlerRunner) Run(containerID kubecontainer.ContainerID, pod *v1.Pod, container *v1.Container, handler *v1.Handler) (string, error) {}
Run()中将调用RunInContainer
func (m *kubeGenericRuntimeManager) RunInContainer(id kubecontainer.ContainerID, cmd []string, timeout time.Duration) ([]byte, error) {
stdout, stderr, err := m.runtimeService.ExecSync(id.ID, cmd, timeout)
return append(stdout, stderr...), err
}
ExecSync
函数为
func (r *RemoteRuntimeService) ExecSync(containerID string, cmd []string, timeout time.Duration) (stdout []byte, stderr []byte, err error) {
...
resp, err := r.runtimeClient.ExecSync(ctx, req)
if resp.ExitCode != 0 {
err = utilexec.CodeExitError{
Err: fmt.Errorf("command '%s' exited with %d: %s", strings.Join(cmd, " "), resp.ExitCode, resp.Stderr),
Code: int(resp.ExitCode),
}
}
}
return resp.Stdout, resp.Stderr, err
以上可得
- 容器首启动命令 与 postStart 先后发起,但异步执行。
- postStart 命令调用接口创建与运行容器session并执行指令。 - 容器必须为运行态,postStart才能执行成功。
- postStart本身同步执行,等待到
exitCode=0
后才退出创建容器函数,之后容器才可进行running和Ready判断。
创建后
容器正常启动后,使用docker exec contaienrID bash
进入容器后,使用ps
命令,一般有两个特殊进程:
- 1号进程 为容器首启动进程,其余进程基本都是首启动进程的子孙进程。
- 0号进程 为1号进程的父进程,也为
docker exec....
携带指令的父进程(即从外部向running容器内发起的指令)。
整个进程视图与所在宿主机隔离。
简单了解下容器pidNamespace隔离
容器调用最终是创建一个特殊进程,如下
//此处只放本篇要聊的宏,实际涉及隔离的宏很多
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
如上,调用clone
函数并传递CLONE_NEWPID
宏,详见 clone() 。
clone函数是作为创建进程的系统调用,所以调用此函数实际上也是创建一个进程,加了CLONE_NEWPID
后此进程拥有独立的进程视图,且在视图内PID=1
退出
发起pod退出指令后,pod DeleteTimestap被置位,进入Terminating态。
kubelet调用容器运行时发起删除容器请求。containerd-shim
将向容器首进程发送SIGTERM
信号,等待10s(默认可改)后发送SIGKILL
信号。中间的等待时间给用户提供了优雅退出(graceful stop)机制。应用内可捕获SIGTERM
后执行一些清理资源操作。
这里有两个问题需要注意:
- 全程只看到给1号进程发送信号,但实际上现象是容器退出后相关进程会全部消失
查阅资料后,了解到由于PID=1
进程的特殊性,1号进程退出后,由其而生的PID-namespace
被销毁,内核将向该namespace下所有子进程发送SIGKILL
信号。
注意这里 子进程们是直接被kill的,不存在优雅结束的机会。 - 进程被kill后,如何被回收
dockerDaemon
发起创建容器请求,由containerd
接收并创建containerd-shim
,containerd-shim
即上面提到的0号进程。所以实际的创建容器、容器内执行指令等都是此进程在做。 同时,containerd-shim
具有回收僵尸进程的功能,容器1号进程退出后,内核清理其下子孙进程,这些子孙进程被containerd-shim
收养并清理。
注意:如果1号进程不被Kill,那么其下进程如果有僵尸进程,是无法被处理的。所以用户开发的容器首进程要注意回收退出进程。
所有容器清理后,pod删除。
(pod删除过程也包含preStop的执行等,本篇暂时把重点放在容器上)
初版设计
如上,正常使用中容器首启动进程应为单条指令,然后进程可接收SIGTERM
信号优雅退出。
但在使用中,现有并不满足用户使用习惯
- 形为
cd /home/work/bin && npm run start
的指令,包含多条指令并顺序执行。 - 需要在容器启动crond进程
crond && /home/work/hello.py
,多条指令但不必顺序执行。
为提高易用性,我们后台通过bash -c
统一包裹命令,用户在终端测试OK的命令可以直接交给平台。
暴露问题及原因
用户反映,每次发版过程中,pod会在Terminating状态停留很久。而且配置在进程内的SIGTERM
处理并未生效(不是preStop)。
(这里由于deployment滚动更新时,旧版本可删除pod会被立刻置位DeleteTimestamp,所以退出慢并不影响更新速度。)
原因在于bash进程。 bash进程会接收SIGTERM
信号,但并不会传递信号给业务进程,直到等待超时时间后收到SIGKILL
信号而退出。这里说明下,普通bash进程收到SIGTERM
会退出,可能是由于容器首启动进程执行默认开启tty,这里不确定,有清楚的同学借一步说话。
利用postStart
实例(pod)生命周期 的 创建 部分有提到postStart为外部在容器内发起的进程,可用来在容器启动后向容器内发起,deploymentYaml配置如下:
command:
/home/work/hello.py
lifecycle:
postStart:
exec:
command:
- /bin/bash
- -c
- crond
如上,容器内多进程可实现。但需注意postStart
不可为前台进程,并且必须在启动超时时间内执行完成并正常退出,否则将影响pod的正常启动。
但是postStart方式仅可在 业务进程与postStart进程不必顺序执行时使用,依旧无法解形如 cd /home/work/bin && npm run start
的指令执行问题,由此引入init进程。
引入Init进程
docker原生提供init开关,可自定义是否引入init进程。在指定init后,将init代码嵌入容器中,并作为首启动进程,特点如下:
- 作为容器1号进程,并创建用户定义的业务进程
- 默认将信号传递给子进程,也支持更多传递方式
- 监听子进程退出并回收
- 跟随最初创建的业务进程的退出而退出
如果使用init
的缺省功能,进程退出行为为:
正常情况下删除容器,init进程收到SIGTERM
信号后,会向子进程传递此信号。并等待进程退出后退出,从而容器退出,容器空间清理。
问题及解决
但是init启动业务命令的规则k8s启动一致,正常仅支持一条指令。如果要支持普通的shell指令,还是要用bash -c包裹。此时问题转化为:
- init传递
SIGTERM
信号给bash而不是业务进程。 - 非1号进程的bash收到SIGTERM会立即退出进而引起init退出,init退出即容器退出。
解决
- init 可配置
TINI_KILL_PROCESS_GROUP
,配置后,SIGTREM
信号将传递给子进程所在进程组的所有进程(即由bash而生的进程可收到信号)。 - bash 通过
-i
参数可开启交互模式,开启后bash收到sigterm不作为。
如上,容器开启init,设置环境变量TINI_KILL_PROCESS_GROUP
,并使用bash -ic $command
格式启动业务进程,即可使容器首进程命令执行更加自由,并不会影响信号接收。
例如开启init时,启动命令["bash", "-ic", "cd . && sleep 10d"]
,此时进程视图为:
正常启动时,init作为1号进程,bash进程作为1号子进程,业务进程又作为bash进程的子进程
容器正常退出时,init收到SIGTERM
信号,传递信号给其子进程(6号)所在进程组的所有进程(6和16),bash处于交互模式忽略信号不作为, 业务容器接受SIGTERM
信号,处理后退出,bash紧随业务进程退出。
容器异常退出时,业务进程(16)异常退出,bash紧随业务进程退出。 init进程接受到子进程(6号bash)退出信号SIGCHILD,退出容器。
k8s支持init
走到上一步,基本算解决了用户易用性并保证业务正常接收信号。但k8s目前还未提供init
开关参数。这里提供两种方案:
全局使用
可在/etc/docker/daemon.json
文件中添加:
{
"init": true,
}
并在启动容器时添加TINI_KILL_PROCESS_GROUP
环境变量。 即k8s创建的所有容器都将开启init
。
开关模式
需要修改K8s代码,最终决定使用container.Env来设置init开关,原因:
annotation和label均为pod级别,而pod下支持多个容器,全局设置不够灵活。故写入环境变量,作为container级别的配置。
(理想状态是将 init 作为pod.spec.containers[n].init字段交由使用者配置)
注意: (如果有同学想用label或annotation做init标记,需要注意代码修改比env多一些,因为在构造容器config时,label和annotation不会继承pod的,而env是会完整复制pod内定义的)
代码修改比较简单,在pkg/kubelet/dockershim/docker_container.go
文件中添加
init := false
for i, _ := range config.Envs {
if config.Envs[i].Key == "CONTAINER_S_INIT" {
init = true
}
}
createConfig := dockertypes.ContainerCreateConfig{
... // 一些容器参数的设置
HostConfig: &dockercontainer.HostConfig{
...
Init: &init,
},
}
END
有执行多条指令的需求的用户可使用bash -ic
包裹业务指令,并在容器的Env中添加:
CONTAINER_S_INIT = true
TINI_KILL_PROCESS_GROUP = true
如此:
- bash所带指令正常启动
- pod退出时业务进程可处理
SIGTERM
后很快完成容器退出