如何在容器中执行多条指令并能优雅退出

本文主要围绕k8s command展开讨论。(deployment.spec.template.spec.containers[n].command)
主要聊聊平台在接入用户业务时,如何保证满足业务基本需求情况下增强平台易用性。

最初是由bash启动进程引起的业务进程无法接收sigterm优雅退出问题。解决过程中逐渐回归为如何在k8s command定义多条指令

文章目录

      • 原生K8S-Command规范
      • 实例(pod)生命周期
      • 初版设计
      • 利用postStart
      • 引入Init进程
      • k8s支持init


原生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来管控实例,其控制循环功能可自动部署、自动恢复,将任务状态永远调整向期望状态。
例如

  1. 用户声明deployment.spec(期望实例模板) 及 replicas(实例数)交给k8s;
  2. 在deploymentController部分的控制逻辑中,将生成ReplicasSet;
  3. ReplicasSetController监听资源处理,生成Pod;
  4. Pod被kube-scheduler监听处理,为其分配合适的node;
  5. kubelet(此组件安装在slave node上)监听到pod绑定信息,在node上实例化pod信息。

创建

  1. 创建sanbox容器
  2. 拉取镜像并创建init容器
  3. 创建普通容器 (拉取镜像,创建容器,启动首启动进程,执行postStart)
    当init容器执行完成退出后,启动所有普通容器。根据livenessreadiness配置情况探测并确定容器是否ready。所有容器ready时pod状态更新为Ready。

创建普通容器
code位于pkg/kubelet/kuberuntime/kuberuntime_cotainer.gostartContainer函数

// 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	

以上可得

  1. 容器首启动命令 与 postStart 先后发起,但异步执行。
  2. postStart 命令调用接口创建与运行容器session并执行指令。 - 容器必须为运行态,postStart才能执行成功。
  3. 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. 全程只看到给1号进程发送信号,但实际上现象是容器退出后相关进程会全部消失
    查阅资料后,了解到由于PID=1进程的特殊性,1号进程退出后,由其而生的PID-namespace被销毁,内核将向该namespace下所有子进程发送SIGKILL信号。
    注意这里 子进程们是直接被kill的,不存在优雅结束的机会。
  2. 进程被kill后,如何被回收
    dockerDaemon发起创建容器请求,由containerd接收并创建containerd-shimcontainerd-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包裹。此时问题转化为:

  1. init传递SIGTERM信号给bash而不是业务进程。
  2. 非1号进程的bash收到SIGTERM会立即退出进而引起init退出,init退出即容器退出。

解决

  1. init 可配置 TINI_KILL_PROCESS_GROUP ,配置后,SIGTREM信号将传递给子进程所在进程组的所有进程(即由bash而生的进程可收到信号)。
  2. bash 通过 -i 参数可开启交互模式,开启后bash收到sigterm不作为。

如上,容器开启init,设置环境变量TINI_KILL_PROCESS_GROUP,并使用bash -ic $command格式启动业务进程,即可使容器首进程命令执行更加自由,并不会影响信号接收。

例如开启init时,启动命令["bash", "-ic", "cd . && sleep 10d"],此时进程视图为:
如何在容器中执行多条指令并能优雅退出_第1张图片
正常启动时,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后很快完成容器退出

你可能感兴趣的:(K8S,docker)