docker 容器的优雅重启方案
当我们将编写的程序部署到服务器之后,免不了会面临未知 bug 导致的程序崩溃退出问题,这个时候快速的将程序进行重启,显得尤为重要,特别是那种在后端和用户之间维持会话的服务。这篇文章就是讲一下,如果你的程序部署到 docker 后,如何做到优雅重启。
1. 自带解决方案
docker 本身在启动的时候,会可以加参数做到容器崩溃后自动重启的,在 docker run
的时候增加 --restart always
即可。但是并不是在所有情况下,docker 的重启策略都会生效,官方还给出了以下几点要求
重启只能是在容器启动成功后才能生效,并且给出了容器启动成功的指标,那就是容器起码正常启动 10s,在这期间没有发生退出。
如果你手动使用 docker stop
命令关闭了容器,那么重启策略也会失效。
还有一点是跟 docker swarm 相关的,由于这个组件现在用的比较少,这里略过。
下面是一个例子用来演示在给定的时间后异常退出当前进程。
当前例子的代码都可以从这里找到
代码 1.1 delay_exit.go
我们制作一个 dockerfile (详细代码参见链接),指定 entrypoint 脚本内容如下
代码 1.2 start.sh
将 代码 1.1 构建,生成可执行文件,放置于 /opt/delay-exit 。我们在 docker run 的时候可以指定任意一个进程退出的等待时间,如果不指定则 11s 后退出。
代码 1.3 test_always.sh
代码 1.3 是我们准备好的测试文件,其中 $TAG_LATEST
是制作好的,含有 delay_exit.go 的编译生成可执行程序的镜像。
首先看正常 11s 退出的情况,./test_always.sh
即可启动容器,然后通过 docker logs always-test
来看启动容器的运行日志:
exit 语句下面是 /start.sh 脚本中 date
命令的输出内容,可以看到容器退出到重启在 1s 之内完成,说明还是挺高效的。
使用 docker stop always-test && docker rm always-test
,删除当前容器,重新启动,这次增加一个启动参数 ./test_always.sh 2
,即更改默认退出等待时间为 2s 。再次查看容器日志:
会发现启动时间不足 10s,docker 不会立即重启退出的容器,随着重启次数增多,会逐渐拉长启动时间。
2. 宿主机守护进程
docker 容器启动时,会在启动 docker 容器的宿主机上产生一个进程,如果 docker 容器异常退出,这个进程也会退出,所以我们也可以在宿主机上使用 systemd upstart supervisor 等工具通过监听进程状态来达到重启目的,但是从使用便捷性上来说不如直接使用 docker run
的 --restart always
参数。
3. 容器内部守护进程
还有最后一个途径,就是直接将进程重启监听放置到 docker 容器内部,这个做法官方是不推荐的,因为这样子 docker 守护进程本身无法感知到应用的运行状态。但是考虑到这种情况,为了保障服务的高可用性,我们一般会配合使用日志收集、异常启动报警、性能指标采集、服务发现等组件。如果你使用 Kubernetes 这种容器编排系统的时候,可以把上述组件每个组成单独的容器,然后和应用容器共享同一个 pod 的模式来进行统一管理。但是目前很多中小型公司,并没有在使用 Kubernetes;再考虑一种更复杂的情况,有一些公司的部署结构是混合的,一部分位于 Kubernetes 集群中,另一部分运行在老旧的、不支持编排的容器系统中,但是出于通用性和可维护性的考量,又不想引入太多异构的部署模型。这些上述描述情况,看上去最合适的解决方案就是将这些组件也内置到容器内部,那么使用容器内部的进程重启监控程序,就显得更加适合。这里我们选择使用 supervisor,因为这个程序是在 docker 中安装方便,只需要有 python 即可,如果使用 systemd 的话,需要 docker 开启特殊权限,并且有一些公司的运维出于安全考虑还会禁用掉特权模式。
首先是构建 supervisor 的基础镜像
本小节中的代码都能从这里找到源代码
代码 3.1
supervisor 是一个 python 2.7 编写的工具,由于 python 2.7 已经处在停止维护阶段,这里选择安装了 python 3。但是发现如果不设置 LC_ALL
LANG
这两个环境变量的话,会报错:
所以在 代码 3.1 中专门设置了这两个环境变量。
接着看入口文件 entrypoint.sh:
代码 3.2 entrypoint.sh
首先看最后一行 exec 的用法。我们使用 supervisor 作为守护进程,也就是说对于使用这个镜像的容器来说,它所运行的应用是 supervisor,那么在 supervisor 异常退出之后,当前容器是不可用的,也就是说我们应该让 docker 感知到当前 supervisor 是否可用。docker 感知容器是否正常的方式,就是容器内部 pid 为 1 的进程是否退出,这个 pid 为 1 的进程,就是通过 ENTRYPOINT (如果没有 ENTRYPOINT 的话,是CMD )指令指定的程序运行后产生的。显然这里我们要做的是要将 supervisord 的运行进程 ID 置为 1。如果我们在 entrypoint.sh 脚本中运行 supervisord 时没有使用 exec 的话,进程 ID 为 1 的进程,将是 entrypoint.sh ,而使用 exec 后,entrypoint.sh 进程将会被 supervisord 替代,也就是说 supervisord 就会成为 1 号进程。最终也就实现了 supervisord 进程退出能被 docker 感知到的目的。
我们将所有通过 supervisor 收录的应用的配置文件统一放置在目录 /etc/supervisor.d 中,在我们的镜像中安装了 crontabs ,并通过 supervisor 对其守护,下面是它的配置文件:
代码 3.3 crontab.ini
首先要留意 command 属性,我们在启动 crond 程序的时候添加了 -n
参数,这代表 crond 要在前台运行,也就是说如果你手动在命令行中运行 crond -n
时,当前命令行不退出,必须手动执行 CTRL + C 才能退出当前程序。supervisor 是应用级别的守护进程,跟 systemd 这种系统级别的守护进程还是有区别的,后者启动程序后在后台运行也能识别出来运行状态,但是 supervisor 如果启动程序在后台运行,它是识别不出来运行的程序是哪个的,这也导致你的应用必须得在前台运行。具体到 代码 3.3,我们给 crond 的启动加 -n
参数,就是这个原因。再举一个例子,如果你用 supervisor 启动 nginx 的话,也需要指定参数 -g "daemon off;"
让其在前台启动,否则你就会发现 supervisor 会报 nginx 启动失败。
接着就是日志配置,supervisor 默认支持日志拆分功能,这里我们将其禁用掉(stdout_logfile_maxbytes
和 stdout_logfile_backups
都设置为零),因为我们在系统中添加了 logrotate,我们将日志切分工作交给了 logrotate 来处理。
最终如果你开发了一个新的应用做部署的时候,可以基于这个 supervisor 镜像来制作一个子镜像,然后将配置文件放置到 /etc/supervisor.d 目录下即可。如果感觉编译麻烦也可以直接用笔者制作好的镜像:registry.cn-hangzhou.aliyuncs.com/whyun/base:supervisor-latest。