容器中的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号进程,无法接受和处理外接的信号
  • 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]#