容器中的1号进程
总结写在前面
必须让容器里面的应用程序作为1号进程运行,方便接收处理外接停止信号,让程序优雅关闭。
Dockerfile启动命令两种写法(此处不讨论tini启动方式):
- exec写法:
CMD ["java", "-jar", "app.jar"]
如果启动命令包含变量,或者比较长,这种写法不方便,比如:java $JAVA_OPTS -jar app.jar
,那么可以将启动命令放shell脚本里面:
CMD ["/entrypoint.sh"]
entrypoint.sh脚本内容(调用exec用java进程替换sh进程,让java进程变为1号进程):
#!/bin/sh
exec java $JAVA_OPTS -jar app.jar
背景
这几天面试的时候面试官问了我一个docker相关的问题,我自以为Docker已经学的很好了,竟然没能答上来。
问题:我们都知道,不管是什么类型的项目,在通过容器运行的时候,都应该将程序启动命令设置为1号进程,这样程序才能够正确接收外接的SIGTERM信号,然后优雅终止,避免被SIGKILL信号强制杀死。
但是生产环境里面,经常有开发把java镜像的启动命令写为shell格式,导致bash(或sh)进程变成了1号进程,java进程变成了2号进程,那么当外界比如(kubelet)给容器内部发送SIGTERM信号的时候,bash(或sh)进程是无法处理的,也不会把该信号转发给java进程,最终java进程可能会因为达到最大退出时间被强制杀死,达不到优雅关闭的效果。
当然我们可以写成exec格式,但是有时候开发伙伴会把JAVA_OPTS
变量放到启动命令里面,那么EXEC格式又会有问题,无法加载变量内容,该如何处理。
科普Dockerfile启动命令两种写法
-
shell格式:
CMD java -jar app.jar
-
exec格式:
CMD ["java", "-jar", "app.jar"]
两种写法各自的优势和不足:
-
shell格式:
- 优势:bash(或sh)作为1号进程,可以加载shell环境变量。比如我将
JAVA_OPTS
变量放到启动命令里面,这种写法是可以读取的 - 不足:bash(或sh)作为1号进程,无法接受和处理外接的信号
- 优势:bash(或sh)作为1号进程,可以加载shell环境变量。比如我将
-
exec格式:
- 优势:java进程作为1号进程,可以接受和处理外接的信号
- 不足:无法加载环境变量。如果启动命令包含了某些变量的话。还有如果启动命令选项参数非常多,这种写法会比较累。
docker官方是推荐exec格式写法的
bash进程无法处理9号和15号信号
bash作为1号进程会忽略SIGKILL信号。无法处理SIGTERM信号是因为bash没有注册SIGTERM的handler,因此不响应SIGTERM信号
[root@VM-16-9-centos ~]# docker run --rm -it ubuntu:22.04 bash
root@cd63231daf11:/# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 1 16:13 pts/0 00:00:00 bash
root 9 1 0 16:13 pts/0 00:00:00 ps -ef
root@cd63231daf11:/# kill 1
root@cd63231daf11:/# kill -9 1
root@cd63231daf11:/#
shell格式的启动命令
#一段java代码,打包成docker镜像
[root@VM-16-9-centos TimeTask]# ls
Dockerfile Main.java
[root@VM-16-9-centos TimeTask]# cat Dockerfile
FROM jdk:17
WORKDIR /root
COPY Main.java ./Main.java
RUN javac Main.java
CMD java Main
[root@VM-16-9-centos TimeTask]# cat Main.java
import java.util.Date;
import java.util.Timer;
import java.util.TimerTask;
public class Main {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println(new Date());
}
}, 0, 60 * 1000);
}
}
#打包成docker镜像
[root@VM-16-9-centos TimeTask]# docker build -t timetask:v1 .
#运行为容器
[root@VM-16-9-centos TimeTask]# docker run -d --name timetask timetask:v1
#查看容器里面的进程,sh作为1号进程,无法kill掉
[root@VM-16-9-centos TimeTask]# docker exec -it timetask bash
root@09b43dae0549:~# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 00:16 ? 00:00:00 /bin/sh -c java Main
root 7 1 0 00:16 ? 00:00:00 java Main
root 27 0 1 00:17 pts/0 00:00:00 bash
root 35 27 0 00:17 pts/0 00:00:00 ps -ef
root@09b43dae0549:~# kill 1
root@09b43dae0549:~# kill -9 1
exec格式的启动命令
[root@VM-16-9-centos TimeTask]# cat Dockerfile
FROM jdk:17
WORKDIR /root
COPY Main.java ./Main.java
RUN javac Main.java
CMD ["java", "Main"]
#打包成docker镜像
[root@VM-16-9-centos TimeTask]# docker build -t timetask:v2 .
#运行为容器
[root@VM-16-9-centos TimeTask]# docker run -d --name timetask2 timetask:v2
#查看容器里面的进程,java进程作为1号进程,可以接受外接SIGTERM信号
[root@VM-16-9-centos TimeTask]# docker exec -it timetask2 bash
root@f1f85df2ebe0:~# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 00:19 ? 00:00:00 java Main
root 26 0 1 00:19 pts/0 00:00:00 bash
root 33 26 0 00:19 pts/0 00:00:00 ps -ef
root@f1f85df2ebe0:~# kill 1
root@f1f85df2ebe0:~# [root@VM-16-9-centos TimeTask]#
如果非要使用shell格式的启动命令怎么解决
比如改项目启动命令特别长,特别复杂,又需要某些环境变量,必须要放到shell脚本里面那种。
看下redis官方镜像怎么制作的
https://github.com/docker-library/redis/blob/d77143afb3dc8d0b05225ab23b001cf6c41e1b62/7.0/Dockerfile
这个脚本里面ENTRYPOINT指定了一个shell脚本,那么正常1号进程应该为sh或者bash才对
.......
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 6379
CMD ["redis-server"]
运行一个redis容器看下
[root@VM-16-9-centos ~]# docker run -d --name redis-test redis:7-alpine
查看容器的进程。redis进程镜像是1号进程,和想像的不一样
[root@VM-16-9-centos ~]# docker exec -it redis-test sh
/data # ps -ef
PID USER TIME COMMAND
1 redis 0:02 redis-server *:6379
22 root 0:00 sh
28 root 0:00 ps -ef
/data # kill 1
/data # [root@VM-16-9-centos ~]#
这是为什么呢?看下redis镜像docker-entrypoint.sh
的内容是什么
#!/bin/sh
set -e
......
exec "$@"
原来使用了exec命令,exec是bash里面的一个内建命令,系统调用exec是用新的进程去替换原来的进程,但是PID保持不变
参考:https://blog.csdn.net/caimengyuan/article/details/121458227
改写Dockerfile
[root@VM-16-9-centos TimeTask]# cat Dockerfile
FROM jdk:17
WORKDIR /root
COPY Main.java ./Main.java
COPY entrypoint.sh /entrypoint.sh
RUN javac Main.java
CMD ["/entrypoint.sh"]
[root@VM-16-9-centos TimeTask]# cat entrypoint.sh
#!/bin/bash
cd /root
exec java Main
#构建镜像
[root@VM-16-9-centos TimeTask]# docker build -t timetask:v3 .
#运行容器
[root@VM-16-9-centos TimeTask]# docker run -d --name timetask3 timetask:v3
#进入到容器里面,java是1号进程了,可以正常接收外接信号了
[root@VM-16-9-centos TimeTask]# docker exec -it timetask3 bash
root@f69be70f2f16:~# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 00:34 ? 00:00:00 java Main
root 26 0 1 00:34 pts/0 00:00:00 bash
root 34 26 0 00:34 pts/0 00:00:00 ps -ef
root@f69be70f2f16:~# kill 1
root@f69be70f2f16:~# [root@VM-16-9-centos TimeTask]#