暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

如何预防 k8s 容器内的僵尸进程

元坑昊思迹 2021-10-31
2778

之前对僵尸进程确实是一知半解,没有好好研究过。这次本着学习的目的,梳理了僵尸进程的有关知识点以及在 k8s 容器中的应用。分享给大家,希望大家也能有所了解,别像我之前那样云里雾里。


本文主要是介绍僵尸进程以及在容器中预防僵尸进程的一些方法。大概分为以下几部分:

  • 僵尸进程的介绍

  • 容器内为什么更容易产生僵尸进程

  • 容器内如何预防僵尸进程

  • k8s pause 容器如何处理僵尸进程


首先来介绍下僵尸进程是什么。

僵尸进程的介绍

为了更好地理解僵尸进程,我们先来看下进程结束后的流程,如下图所示:


图1 进程退出流程


一个正常的进程退出流程是这样的:

  • 子进程退出后,给父进程发送一个SIGCHLD的信号

  • 父进程收到这个信号后,会通过wait系统调用来回收子进程


这里有个术语叫“僵死状态”,指的是子进程退出后,在父进程使用wait对它进行回收之前的状态。所以,僵尸进程就比较好理解了,即在子进程退出后,父进程没有回收它,它一直处于僵死状态,就成为了一个僵尸进程。


僵尸进程在linux下长什么样?应该大部分同学都遇到过,如下图所示:


图2 僵尸进程展示(图片来源https://cloud.tencent.com/developer/article/1722245)


僵尸进程是kill不掉的,所以遇到这种进程,不了解的同学就会感觉很无助,为什么进程还kill不掉。那为什么僵尸进程kill不掉呢?因为它实际上并没有在运行了,只是在进程表内占了个坑(进程号)。而这个坑必须由它的父进程来回收,不然它就会永远占着坑。


有同学可能会认为,既然都不占资源(CPU、Memory等),那僵尸进程也没什么危害。实际上,还是有危害的。在linux中进程号是有限的,也就是进程表的大小是有限的。如果坑被占满了,就创建不了新的进程。即使资源充足,那也没有坑给新进程。所以,如果因为程序bug会不断产生僵尸进程,那最后系统也会被打挂。


既然有危害,又kill不掉,那应该怎么处理僵尸进程?有两个办法:

  • 粗暴一点,重启机器。这个方法很简单,但代价也大。不宜经常使用。

  • 找到僵尸进程的父进程,把父进程kill掉,僵尸进程就会变成孤儿进程,从而会被linux的init进程给回收了。


不过,笔者也遇到过父进程不好kill的情况,处理起来就很麻烦。所以,要尽量避免僵尸进程的产生,提前做好预防。虽然linux有保底的init进程,但也需要有办法让僵尸进程的父进程变成init进程。另外,如果僵尸进程是在容器内产生的,就更好不处理了。


容器内为什么更容易产生僵尸进程

接下来讲讲容器内的僵尸进程怎么不好处理。


以 Docker Container 为例,容器创建后,默认情况下是不会共享宿主机的 PID Namespace,它会自己创建一个 PID Namespace。这就是为什么在容器内执行 ps -ef 命令,只会看到容器内的进程,看不到宿主机的进程。容器就是要从宿主机上隔离出来一个命名空间,否则不就能轻易地侵入宿主机。如下图所示:


图3 容器内的进程


当我们执行docker run命令创建一个容器时,这个容器会有一个自己的 PID Namespace,而这个 PID Namespace 的1号进程就是我们创建容器时指定的entrypoint。图中为 node run.js。


根据linux进程回收的原理,孤儿进程都会被init进程接管,并由init进程来回收。那在docker容器自己创建出来的 PID Namespace 中,init 进程就是指定的 entrypoint 产生的进程。所以,孤儿进程的父进程都会变成 entrypoint 产生的进程。如果这个进程不会主动去回收僵尸进程,那一个会产生僵尸进程的容器,里面的僵尸进程就一直得不到回收。这就是容器内为什么会有僵尸进程的原因。


实际上,如果容器使用不当,是很容易产生僵尸进程的。因为很多命令都不具备主动回收僵尸进程的能力。不像在宿主机,只要僵尸进程被init进程接管就能得到回收,在容器内还要看init进程是谁。所以,使用容器需要特别注意预防僵尸进程。


容器内如何预防僵尸进程

我们知道了容器内为什么更容易产生僵尸进程,接下来我们讲讲预防手段。知道了原理,预防方式就比较简单:让具备僵尸进程回收能力的进程充当容器的init进程


方法有三:

  • 用 bash 命令来启动实际要运行的 entrypoint

  • 借助专门的init进程,docker自带这个能力

  • 借助成熟好用的 tini,官方地址:https://github.com/krallin/tini

下面说说三种方式的区别。


首先,直接使用 bash。bash 是自带僵尸进程回收能力的,不了解的同学可能会说,我创建的容器为什么就没有出现僵尸进程呢?可能是你已经用了 bash。所以,比较简单的方式就是直接用 bash 去启动容器,僵尸进程就会被 bash 回收。


后面两种方式可以一起说下。实际上,从 Docker 1.13 版本开始,docker 的 init 进程用的就是 tini。


第二种方式就是启动容器的时候带上个 --init 参数就好,docker 就会用它自带的 init 进程作为容器的1号进程。


第三种方式和 bash 类似,就是通过 tini 来启动实际要运行的 entrypoint。那跟 bash 的区别是什么?主要是容器能否做到优雅关闭。bash 不会将收到的信号传递给它的子进程,它只管自己接收就完事了。这样在容器收到停止信号的时候,bash 只管自己退出,不会去通知子进程先退出。而 tini 是会的,这就是用 tini 可以做到容器优雅退出的原因,它会传递信号到子进程。


tini更多的好处可以参考:https://github.com/krallin/tini/issues/8


笔者在实际工作中,也是使用 tini 来包装了容器启动命令,解决了容器内会产生僵尸进程的问题。


k8s pause 容器如何处理僵尸进程

说到容器,自然要说到 k8s。


这里默认大家对 k8s pod 是有所了解的,不了解的可以看下笔者之前的文章:Kubernetes 批调度。一个 pod 是一组容器的集合,容器会共享 pod 的网络命名空间。


那 pod 是如何做到让所有容器共享网络命名空间的呢?这里需要提到 pause 这个容器。pod 会默认创建一个初始容器,即 pause 容器。在 k8s 集群中的任意节点,我们执行 docker ps 命令都会看到一堆 pause 容器。

$ docker ps
CONTAINER ID IMAGE COMMAND ...
...
3b45e983c859 gcr.io/google_containers/pause-amd64:3.0 "/pause" ...
...
dbfc35b00062 gcr.io/google_containers/pause-amd64:3.0 "/pause" ...
...

pause 容器创建后,会创建一个属于自己的网络命名空间,并向 k8s 集群申请一个 ip 地址,即 pod ip。在这个 pod 内的其他容器,启动时会共享 pause 容器的网络命名空间,命令如下:

$ docker run -d --name ghost --net=container:pause ghost

所以,在 pod 内的所有容器会共用一张虚拟网卡,可以通过 127.0.0.1 进行容器间的通信。


在笔者了解僵尸进程时,还看到了 pause 的另一个作用:回收僵尸进程。这是笔者之前从未了解过的知识点。


我们来看看 pause 的代码:

/*
Copyright 2016 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/


#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>


static void sigdown(int signo) {
psignal(signo, "Shutting down, got signal");
exit(0);
}


static void sigreap(int signo) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}


int main() {
if (getpid() != 1)
/* Not an error because pause sees use outside of infra containers. */
fprintf(stderr, "Warning: pause should be the first process\n");


if (sigaction(SIGINT, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0)
return 1;
if (sigaction(SIGTERM, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0)
return 2;
  if (sigaction(SIGCHLD, &(struct sigaction){.sa_handler = sigreap, .sa_flags = SA_NOCLDSTOP}, NULL) < 0)
return 3;


for (;;)
pause();
fprintf(stderr, "Error: infinite loop terminated\n");
return 42;
}


可以看到,代码很简单。pause 会占用 PID 1,也就是作为 init 进程。另外,当它收到 SIGCHLD 信号后,会通过 waitpid 系统调用去回收子进程,即 sigreap 函数做的事情。


waitpid 的功能非常强大,方法定义如下:

#include <sys/wait.h>
pid_t waitpid(pid_t pid,int *statloc,int options);
pid 和 options 都提供了非常灵活的用法,参考如下:
pid:
< -1: 取该 pid 的绝对值,如果任何子进程的进程组ID等于该值,则该进程组的任一子进程中的进程状态发生变化,都会触发`waitpid`的回调;
== -1: 监听范围扩大到任意子进程,也就是 wait(status);
== 0: 监听进程组ID和父进程一样的子进程;
> 0: 监听该pid的子进程;


options:
WNOHANG:调用时,指定的 pid 仍未结束运行,则 wait 立即返回 0;
WUNTRACED:当子进程被暂停时,则立即返回子进程的 pid;
WCONTINUED: 当被暂停的子进程,又被信号恢复时,则立即返回子进程的pid;

所以,pause 中 sigreap 函数做的事情就是监听所有子进程,当它变成僵尸进程时,回收它。


不过,这里需要注意的是,pod 内其他容器默认情况下不会共享 pause PID Namespace。如果不共享 PID Namespace,那其他容器内的僵尸进程就无法将其父进程变成 pause,pause 自然也回收不了其他容器的僵尸进程。


k8s 也为大家考虑到这点了,在 PodSpec 中有个配置是 ShareProcessNamespace。我们来看看它的定义:


图4 ShareProcessNamespace 的定义


默认情况下,PodSpec 中的这个配置是关掉的,所以 pause 并不能回收其他容器内的僵尸进程。如果需要通过 k8s pause 自身机制来回收僵尸进程就需要 pod 内的所有容器去共享 PID Namespace。


对 k8s pause 的介绍就这么多。实际上,笔者觉得这种方式也需要慎用。要利用 pause 回收僵尸进程,就意味着容器要共享 PID Namespace。在不能共享 PID Namespace 的情况下,pause 回收僵尸进程的机制作用就不大。笔者还是推荐使用 tini 来封装容器的启动命令


one more thing

对于 tini 回收僵尸进程,笔者还想多说一点。tini 并不能回收所有的僵尸进程,如下图所示:


图5 tini 能回收的僵尸进程


tini 只能回收它的曾孙僵尸进程,即图中的【进程2】。当【进程1】挂掉后,【进程2】变成孤儿进程会被 tini 接管并回收。还有一种情况,如下图所示:


图6 tini 回收不了的僵尸进程


如果 entrypoint 产生的子进程变成了僵尸进程,那 tini 是回收不了的。因为要让【进程1】被 tini 接管,就必须把它的父进程 entrypoint 杀掉,而杀掉 entrypoint 也就意味着容器需要被重启。所以,这种情况下的僵尸进程 tini 是处理不了的。这种情况,也只能通过重启容器来清理僵尸进程。


从一个系统的健壮性角度看,对容器内僵尸进程的监控是必要的。即使是用了 tini,能处理大部分的僵尸进程,但也不能保证能处理 100% 的僵尸进程。因为僵尸进程能被 tini 回收的前提条件是,僵尸进程的父进程挂掉了。


总结

本文介绍了僵尸进程以及如果在 k8s 容器内预防僵尸进程的方法。


笔者之前对僵尸进程也是一知半解,这次在空闲时间对僵尸进程进行了了解与梳理,终于算是对僵尸进程有了更深入的了解。这种感觉很赞,当我觉得自己掌握了这个知识点后,还是有点小兴奋的,我想这就是技术的魅力吧。一起加油吧,各位。




相关文章:Kubernetes 批调度

上一篇文章:突破固有思维

文章转载自元坑昊思迹,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论