容器是一个轻量化的环境
在传统系统环境下运行的进程,和在容器中运行的环境是截然不同的。
完美的实现是:业务进程自己处理和自身相关的一切事物。例如:
- 日志轮转及清理。
- 定时任务。
- 监控。
当然在成熟的事情场景下,这些往往通过sidecar实现。这里不多过多解释。
但现实情况是,这些任务都有成熟的第三方工具替代(如 cron、logrotate等)。
在传统环境下,这些不用业务进程进行过多考虑,因为系统本身提供init进程,例如Upstart,Systemd 和SysV,系统内进程的回收都是由init进程处理。
而在容器环境下,则需要考虑这些第三方程序的退出处理机制。
对僵尸进程的处理也很简单,比如go语言的实现。
func reapZombies() {
for {
pid, _ := syscall.Wait4(-1, nil, syscall.WNOHANG, nil)
if pid <= 0 {
break
}
}
}
// 调用
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGCHLD)
go func() {
for {
select {
case sig := <-sigs:
logInfo(fmt.Sprintf("received signal SIGCHLD: %v", sig))
reapZombies()
}
}
}()
对于一些不容易修改代码的程序。也有轻量级init进程项目:
- tini
- dumb-init
为何需要一个init系统
通常,当你启动Docker容器时,你正在执行的进程将变为PID 1,从而赋予它作为容器的init系统所带来的怪癖和责任。这提出了两个常见问题:
- 在大多数情况下,信号将无法正确处理
- 当进程在普通Linux系统上发送信号时,内核首先检查进程为该信号注册的自定义信号处理器,如果不存在则回退到默认行为(在SIGTERM上终止进程)。
- Linux内核对作为PID 1运行的进程应进行特殊的信号处理。如果接收信号的进程是PID 1,内核会进行特殊处理;如果它没有为信号注册处理器,内核将不会回退到默认行为且不做任何反应。换句话说,如果你的进程没有明确处理这些信号的信号处理器,发送SIGTERM信号给它将完全没有效果。
- 孤儿僵尸进程无法被适当的方式捕获
- 通常,父进程会立即调用wait()系统调用来避免产生常驻僵尸进程。
- 如果父进程在其子进程之前退出,则该子进程就变成“孤儿”,并会在PID 1下重新挂载其他父进程。
- 因此,init系统负责调动 wait() 处理孤儿僵尸进程。因为,大多数进程都不会碰巧被重新挂载的随机父进程调用 wait(),因此,容器通常以几个根植于PID 1的僵尸进程结束。
如果你打算自己实现一个init进程,比如使用bash脚本实现,需考虑两个因素:
- 进程的回收。
- 信号的传递。
如果你的容器中存在着不同于业务进程的其他进程,例如定时任务,日志处理以及监控进程。则需要考虑这些进程退出时的处理。
另外,由于是一个bash脚本最为init进程运行,那么容器的退出也需要考虑。
docker stop信号相关
当执行docker stop命令时,Docker首先会发送SIGTERM信号给容器的PID 1进程,以允许容器优雅地停止运行。SIGTERM信号是一种可以被进程捕捉并进行处理的终止信号,这允许进程在退出前进行清理工作,如关闭文件描述符、释放资源等。如果容器在接收到SIGTERM信号后的一定时间内(默认10秒)没有停止,Docker会发送SIGKILL信号来强制终止容器。SIGKILL信号不能被捕捉或忽略,它会立即终止进程。
如果你的bash脚本没做这些处理,那么就会存在以下情况。
docker stop的时候,Docker向init进程(即shell)发送sigterm信号,但是bash没有发送SIGTERM给它的子进程!
等待10s后发送sigkill强制终止容器!!
这样一来,会导致以下情况产生:
- 正在写磁盘,突然停了,轻则数据出现错误,导致脏数据,重则导致磁盘分区表出现问题。
- 如果该容器是集群中的节点,退出是没有及时从集群中摘除,该节点依旧被认为是健康的,进而出现错误。(网上有太多这样的案例)
另外,仅仅是给子进程发送信号是不够的:init进程在终结自己前必须等待子进程终结。如果init进程过早的结束了,所有的子进程又没有干净的被内核终结。