docker的shell命令和exec两种格式解析
一、shell命令
1、 操作系统界面打开的「命令行终端」与/bin/sh 与/bin/bash三者的区别
1、操作系统界面打开的「命令行终端」
本质:你在桌面 / SSH 登录后看到的 $/# 提示符界面,是「交互式 Bash/Sh 进程」,专门给人手动输入命令用。
核心特点:
✅ 实时交互:输一个命令,回车执行,立刻返回结果,能看到报错 / 输出;
✅ 环境完整:继承当前登录用户的所有环境变量(PATH/HOME/JAVA_HOME 等)、别名、历史命令;
✅ 生命周期:你不关闭终端,进程就一直存在;关闭终端,进程终止。
例子:SSH 登录服务器后输入 ls -l、cd /tmp、spark-submit 等,都是交互式命令行执行。
2、/bin/bash 脚本名.sh(执行 Shell 脚本,非交互式 Bash)
本质:显式指定用 bash 解释器执行一个脚本文件,是「非交互式 Bash 进程」,专门跑批量命令。
核心特点:
❌ 无交互:脚本里写什么命令,就按顺序执行什么,不会等你手动输入;
⚠️ 环境有限:默认只继承少数系统级环境变量,不会继承登录用户的别名 / 自定义环境变量(除非脚本里手动加载);
✅ 独立进程:执行脚本时启动一个新的 bash 进程,脚本执行完,进程立刻终止;
✅ 兼容性:bash 是 sh 的超集,支持更多语法(if [[ ]]、数组、{a…z} 等),Linux 绝大多数场景用 bash。
例子:/bin/bash /opt/script/start_kafka.sh,专门执行脚本里的批量操作。
3、/bin/sh -c “命令”(执行单条 / 多条命令,非交互式 Sh)
本质:用 sh 解释器「直接执行字符串形式的命令」,无需写脚本文件,是「非交互式 Sh 进程」,适配极简命令执行场景。
核心特点:
❌ 无交互:执行 -c 后紧跟的字符串命令,执行完进程就终止;
📌 极简:无需创建脚本文件,适合单条 / 少量命令(如定时任务、程序调用);
⚠️ 语法限制:sh 是 POSIX 标准的基础 Shell,不支持 bash 的扩展语法(如 [[ ]]、数组);
✅ 环境更精简:比 bash 脚本的环境变量更少,仅保留最基础的系统环境。
例子:/bin/sh -c “echo ‘hello’ && ls /tmp”,直接执行两条命令,不用写脚本。
4、总结
核心差异:交互性(人控 vs 自动)、环境完整性(全 vs 有限 vs 极简)、语法支持(bash 扩展 vs sh 基础);
关键原则:
手动操作选「交互式命令行」;
批量脚本选 /bin/bash 脚本.sh;
极简命令选 /bin/sh -c “命令”(注意语法兼容);
避坑重点:非交互式执行时,环境变量和语法兼容是最大坑,需手动加载环境或用兼容语法。
简单说:交互式给人用,非交互式给程序 / 脚本用;bash 功能全,sh 更精简兼容。
2、Shell 格式(有中间 Shell,不推荐生产用)
写法:直接写命令字符串,无需数组,如:
# Dockerfile 示例
CMD java -jar /opt/app/app.jar
# 或
ENTRYPOINT echo "hello"
底层执行逻辑:Docker 会自动启动一个 Shell 进程(默认 /bin/sh -c) 作为「主进程(PID 1)」,然后这个 Shell 进程再 ** fork 出你的应用进程 ** 作为子进程。
进程树如下:
PID 1: /bin/sh -c "java -jar /opt/app/app.jar"
└── PID 2: java -jar /opt/app/app.jar # 你的应用是子进程
二、exec格式
1、写法
命令写成字符串数组,第一个元素是可执行文件路径,后面是参数,如:
# Dockerfile 示例
CMD ["java", "-jar", "/opt/app/app.jar"]
# 或
ENTRYPOINT ["echo", "hello"]
2、底层执行逻辑
Docker 跳过中间 Shell,直接启动你的应用进程作为「主进程(PID 1)」,没有任何中间层。
进程树如下:
PID 1: java -jar /opt/app/app.jar # 你的应用直接是主进程
3、Exec 格式解决的第一个核心问题:信号丢失(优雅退出失效)
1、什么是「信号」?为什么需要它?
Linux 中,信号是进程间通信的一种方式,用于通知进程发生了某个事件(如终止、暂停、重启)。
最常用的终止信号:
SIGTERM(信号 15):优雅终止信号,告诉进程「请你清理资源、保存数据后退出」,是 docker stop 默认发送的信号;
SIGKILL(信号 9):强制终止信号,直接杀掉进程,无法被忽略或处理,是 docker stop 等待超时(默认 10 秒)后发送的「最后通牒」。
生产环境中,应用必须能收到 SIGTERM 并优雅退出(如 Kafka 停止前提交 offset、数据库停止前刷盘),否则会导致数据不一致、脏数据甚至服务不可用。
2、Shell 格式为什么会导致「信号丢失」?
核心原因:中间 Shell 进程(如 /bin/sh)默认不会把信号转发给子进程。
我们用 Shell 格式启动一个 Java 应用,看进程树和信号传递:
# 1. 用 Shell 格式启动容器
docker run -d --name shell-test my-java-app:shell
# 2. 进入容器看进程树
docker exec -it shell-test ps aux
# 输出(关键):
# PID 1: /bin/sh -c "java -jar /opt/app/app.jar" # 主进程是 Shell
# PID 2: java -jar /opt/app/app.jar # 应用是子进程
当你执行 docker stop shell-test 时:
Docker 向容器的 PID 1 进程(Shell) 发送 SIGTERM;
Shell 进程收到 SIGTERM 后,直接自己退出了,根本不会把信号转发给子进程(Java 应用);
Java 应用收不到 SIGTERM,继续运行,不会优雅退出;
Docker 等待 10 秒超时后,向容器发送 SIGKILL,强制杀掉所有进程(包括 Java 应用);
结果:Java 应用被暴力终止,可能丢失数据、留下脏数据。
这就是「信号丢失」—— 你的应用根本没收到「请优雅退出」的通知,就被强制杀掉了。
4、Exec 格式解决的第二个核心问题:Shell 注入风险
这是安全层面的致命问题 —— 用 Shell 格式启动容器时,如果命令中包含用户输入的变量,攻击者可以通过构造恶意输入,让 Shell 执行任意命令,导致容器被控制、数据被窃取。
1、什么是「Shell 注入」?
Shell 注入的本质是:攻击者通过构造包含 Shell 特殊字符(如 ;、&&、|、$()、>)的输入,让 Shell 解析器执行预期之外的命令。
比如,你有一个 Docker 镜像,用 Shell 格式启动,需要接收一个用户输入的 USER_NAME 变量:
# 危险的 Shell 格式写法
FROM alpine
ENV USER_NAME="default"
CMD echo "Hello, $USER_NAME" # Shell 格式,会解析变量
你以为用户输入 Alice 会输出 Hello, Alice,但如果攻击者输入:
# 恶意输入:包含 ; rm -rf /
USER_NAME="Alice; rm -rf /"
用 Shell 格式启动容器:
docker run -e USER_NAME="Alice; rm -rf /" my-dangerous-image:shell
底层执行逻辑:
①、Docker 启动 Shell 进程:/bin/sh -c “echo “Hello, $USER_NAME””;
②、Shell 解析变量,把 $USER_NAME 替换成恶意输入,实际执行的命令变成:
echo "Hello, Alice; rm -rf /"
③、Shell 把 ; 当作命令分隔符,先执行 echo “Hello, Alice”,再执行 rm -rf /;
④、结果:容器内的所有文件被删除,容器彻底崩溃。
2、Exec 格式如何彻底避免「Shell 注入」?
Exec 格式下,命令不经过 Shell 解析,直接执行可执行文件,变量不会被 Shell 展开,恶意输入也就无法生效。
# 安全的 Exec 格式写法
FROM alpine
ENV USER_NAME="default"
CMD ["echo", "Hello, $USER_NAME"] # 注意:这里有个小坑,后面讲
等等,这里有个小问题:Exec 格式下,Shell 不会解析变量,所以 $USER_NAME 会被当作字面量字符串输出,而不是替换成变量值。如果需要使用变量,应该在应用内部解析,或者用 ENTRYPOINT 配合脚本(但脚本也要注意安全)。
# 最安全的 Exec 格式:应用自己处理变量
FROM openjdk:11
COPY app.jar /opt/app/
CMD ["java", "-jar", "/opt/app/app.jar"] # 应用内部读取 USER_NAME 环境变量
此时,即使攻击者输入恶意的 USER_NAME:
docker run -e USER_NAME="Alice; rm -rf /" my-safe-image:exec
底层执行逻辑:
①、Docker 直接启动 Java 应用作为 PID 1,没有中间 Shell;
②、Java 应用读取 USER_NAME 环境变量,得到字符串 “Alice; rm -rf /”;
③、Java 应用把这个字符串当作普通的用户名处理,不会执行 rm -rf /;
④、结果:安全无风险,恶意输入失效。
三、生产最佳实践(必看)
1、永远优先用 Exec 格式
无论是 CMD 还是 ENTRYPOINT,生产环境必须用 Exec 格式,彻底避免信号丢失和 Shell 注入。
2.、如果必须用 Shell(比如需要变量展开),请用「Exec 格式 + 安全脚本」
如果你的启动逻辑需要 Shell 的变量展开、管道等功能,不要直接用 Shell 格式,而是:
1、写一个安全的启动脚本(如 start.sh),在脚本里处理 Shell 逻辑;
2、脚本开头加上 exec,让脚本执行完后「替换成应用进程」(避免中间 Shell);
3、Dockerfile 用 Exec 格式执行这个脚本。
# start.sh(安全脚本)
#!/bin/sh
# 处理变量展开
echo "Starting app with USER_NAME: $USER_NAME"
# 关键:用 exec 替换当前 Shell 进程为应用进程
exec java -jar /opt/app/app.jar
# Dockerfile(Exec 格式执行脚本)
FROM openjdk:11
COPY start.sh /opt/app/
COPY app.jar /opt/app/
RUN chmod +x /opt/app/start.sh
# Exec 格式执行脚本
CMD ["/opt/app/start.sh"]
此时的进程树:
# 脚本执行前:
PID 1: /opt/app/start.sh
# 脚本执行到 exec 后:
PID 1: java -jar /opt/app/app.jar # Shell 被替换成应用,无中间层
既用了 Shell 的变量展开功能,又避免了信号丢失和 Shell 注入!
3.、注意:PID 1 进程需要处理信号
Exec 格式下,应用是 PID 1 进程,而 Linux 中 PID 1 进程有特殊的责任:它需要处理所有孤儿进程,并且如果它不处理某个信号,内核会忽略该信号。
所以,你的应用必须显式处理 SIGTERM 等终止信号,否则即使收到信号,也不会优雅退出。
1、Java 应用:通过 Runtime.getRuntime().addShutdownHook() 处理;
2、Python 应用:通过 signal.signal(signal.SIGTERM, handler) 处理;
3、Go 应用:通过 os.Signal 处理。
更多推荐
所有评论(0)