☰
Docker容器启动总翻车?用Shell脚本统一入口告别启动隐患
2026/10/18 6:44:16 网站建设 项目流程

Docker项目打包做到最后,最难的一公里往往不是Dockerfile怎么写,而是容器启动那一瞬间到底发生什么。很多人图省事,Dockerfile最后一行写CMD ["java", "-jar", "app.jar"],本地构建、本地运行都正常,一上生产就露馅:环境变量没注入、JVM参数没调、健康检查不过、容器退出码莫名其妙。我自己踩过几次坑之后,才老老实实把启动逻辑收进一个shell脚本,用ENTRYPOINT把脚本作为容器唯一入口。这篇就围绕“Docker项目打包之shell启动项目”这个场景,从基础镜像选型、脚本设计、Dockerfile集成到线上排障,把整个链路讲透,代码都是可以直接抄去改的。

1. 为什么我坚持用shell脚本做容器入口

1.1 直接CMD启动,坑全藏在细节里

先看最常见的一种写法,很多人第一次写Dockerfile都是这么干的:

FROM eclipse-temurin:17-jre COPY app.jar /app.jar EXPOSE 8080 CMD ["java", "-jar", "/app.jar"]

本地跑没问题,因为本地环境是固定的。但当你把镜像推到测试环境、预发布环境,问题就一个接一个。

第一个坑是参数写死。测试环境内存小,生产环境内存大,JVM参数完全不一样。有人把-Xmx4g写死进CMD,结果部署到小规格机器直接启动失败。稍微变通一点的人会写成CMD ["sh", "-c", "java -jar /app.jar -Xmx${JVM_OPTS}"],但这样容器里的PID 1就变成sh,后面信号处理又是一堆麻烦事。

第二个坑是依赖服务还没就绪。你的应用需要连接数据库、注册中心、配置中心,直接java -jar启动时如果这些服务没起来,启动就会失败。开发环境可能运气好,生产环境多实例同时拉起,数据库连接池还没准备好,应用就崩了。

第三个坑是目录、权限、日志路径这些琐事。容器默认是root用户,挂载出来的日志目录权限乱掉,或者日志文件没落到/var/log/app而是写到容器内部,一重启全丢了。这些逻辑如果都堆在Dockerfile里写,Dockerfile会变成一个没法维护的怪物。

所以我的习惯是:把启动逻辑完全从Dockerfile里抽出来,放进一个shell脚本。Dockerfile只负责描述镜像内容,启动脚本负责描述“怎么把应用拉起来”,各管各的。

1.2 shell脚本作为入口,带来三张关键底牌

用shell脚本做容器入口,真正解决的是灵活性、可控性和可调试性三个问题。

先看灵活性。环境变量可以在启动时动态拼参数,比如根据容器内存限制自动算-Xmx,根据部署环境自动加不同的Spring Profile,给运维同学留出APP_EXTRA_OPTS这样的扩展变量入口。这些用写死的CMD很难优雅实现。

再看可控性。脚本里可以做启动前置检查:jar包是否存在、配置文件是否完整、关键环境变量是否为空、依赖端口是否可达。检查不通过就带着明确报错退出,而不是让Java启动到一半才抛一个莫名其妙的异常。

最后是可调试性。容器起不来时,通过docker logs能直接看到脚本打印的完整启动参数、环境变量、当前时间和路径信息。排查问题的时候,这几行日志往往比看半天代码有用得多。

2. 动手前先定两件事:基础镜像和目录规范

2.1 基础镜像怎么选,影响的是整个环境

基础镜像选得对不对,直接决定你的shell脚本能不能跑得顺。这里有个很多人踩过的大坑:为了追求镜像小巧,选了基于alpine的镜像,结果脚本和Java程序都出问题。

alpine用的是musl libc,而大多数Java发行版和原生库依赖glibc。用alpine跑Java不是不能跑,但你在本地开发环境碰不到的兼容性问题,到了容器里可能集中爆发,比如DNS解析异常、文件锁失效、加密组件报错。我自己就曾在alpine镜像上折腾过一整天,最后老老实实换回基于jammy也就是Ubuntu 22.04的镜像。

如果追求体积,我的建议顺序是这样的:能用jre镜像就不要用jdk镜像,能接受50MB左右的差距就不要折腾musl全家桶。eclipse-temurin:17-jre-jammy是我用得最多的一款基础镜像,glibc环境、时区组件齐全、Java运行时干净,后续要装点排查工具也方便。想再小一点可以用slim变体,想完全去掉系统壳可以用distroless,但distroless连shell都没有,和本文主题正好冲突,不建议在这个场景用。

另一个容易被忽略的原因是安全补丁。基础镜像不能几个月不更新,镜像仓库里常看看有没有新版本,FROM后面别把版本写死成latest,要锁定具体tag,保证可重复构建。

2.2 应用目录与脚本放置规范,早点统一省掉一堆权限问题

目录规范这件事,越早定越省事。我常用的布局长这样:

/opt/app/ 应用主目录 /opt/app/app.jar 可执行程序 /opt/app/scripts/ 启动脚本 /var/log/app/ 日志目录

把应用装在/opt/app是Linux比较传统的做法,也方便运维同学上手就猜。日志单独放/var/log/app,和程序代码分离,后续做日志收集、挂载持久化卷都很清晰。

这里有个关键决策:容器内不用root跑生产应用。虽然容器有namespace隔离,但root用户在容器内的权限依然很大,一旦Web应用被人通过漏洞拿到RCE,整个容器就沦陷了。所以在镜像里创建专用用户,并且把应用目录、日志目录的所有者都切给这个用户,启动脚本里所有文件操作也都要考虑到这个用户的权限。

常见的做法是固定uid,比如appuser用户uid设置为10001。固定uid的价值在于:宿主机挂载日志卷时,你只需要拿宿主机上uid 10001的用户和你容器内的用户对上账,就不会出现写不了日志的问题。

3. 编写可上生产的shell启动脚本

3.1 固定解释器,开启严格模式

脚本先写顶部三行,这三行能帮你挡掉大半低级错误:

#!/bin/bash set -euo pipefail

第一行指定解释器。我推荐用bash而不是sh,因为sh在不同发行版上可能链接到dash,数组、[[ ]]、${VAR:-default}这类语法表现不一致,脚本明明本地跑得好好的,进容器就报语法错误。

第二行set -euo pipefail是三个开关的组合,含义分别是:

  • -e:任何命令返回非零退出码,脚本立即退出
  • -u:使用未定义的变量直接报错,防止变量拼错导致启动参数里多出一截空字符串
  • -o pipefail:管道中只要有一个命令失败,整个管道返回失败

这三个开关组合起来,脚本行为会变得特别诚实:有问题就停在有问题的地方,而不是顶着错误往下跑,最后启动一个残缺的进程。当然这会带来一个副作用,后面某些命令允许失败时要显式加|| true,这个我会在示例里标出来。

3.2 配置校验、默认值与启动前的初始化

脚本的价值主要体现在启动前的那几十毫秒。我写了一个可以直接改用的模板:

#!/bin/bash set -euo pipefail APP_HOME="/opt/app" LOG_DIR="/var/log/app" APP_NAME="app.jar" # 应用环境,如 dev / test / prod APP_ENV="${APP_ENV:-default}" # 对外服务端口 SERVER_PORT="${SERVER_PORT:-8080}" # JVM 参数,默认按容器内存比例分配 JAVA_OPTS="${JAVA_OPTS:--XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom}" # Spring 额外启动参数 APP_ARGS="${APP_ARGS:-}" # 1. 初始化日志目录 mkdir -p "${LOG_DIR}" chown 10001:10001 "${LOG_DIR}" 2>/dev/null || true # 2. 校验核心文件 if [ ! -f "${APP_HOME}/${APP_NAME}" ]; then echo "[ERROR] ${APP_HOME}/${APP_NAME} not found" exit 1 fi # 3. 校验必填环境变量 : "${DB_URL:?Environment variable DB_URL is required}" : "${REDIS_HOST:?Environment variable REDIS_HOST is required}" # 4. 开始启动 echo "========================================" echo "Start time: $(date '+%Y-%m-%d %H:%M:%S')" echo "App env : ${APP_ENV}" echo "Port : ${SERVER_PORT}" echo "JVM opts : ${JAVA_OPTS}" echo "Extra args: ${APP_ARGS}" echo "========================================" exec java ${JAVA_OPTS} \ -Dspring.profiles.active="${APP_ENV}" \ -Dserver.port="${SERVER_PORT}" \ -jar "${APP_HOME}/${APP_NAME}" ${APP_ARGS}

这段脚本里有两个地方值得展开讲。

第一个是${JAVA_OPTS:-默认值}这种写法。它和${JAVA_OPTS:?报错信息}正好是一个对立面:前者允许为空,不传就用默认值;后者变量必须存在且非空,否则脚本直接退出并打印报错。像数据库地址、注册中心地址这种没有默认值可言的环境变量,就该用:?强制校验,宁可在容器启动的瞬间报错,也不要让应用带着空配置跑起来,留下一堆看不懂的NPE。

第二个是命令后面的2>/dev/null || true。因为脚本开了set -e,假设目录已经属于appuser,chown再执行一次会因为权限不够返回非零,整个脚本就会退出。加|| true是明确告诉脚本:这条命令允许失败,失败也不影响启动。在严格模式下,你要学会区分“必须成功”和“允许失败”的命令,这是熟悉shell脚本落地的一个分水岭。

3.3 exec启动:让信号直达Java进程

上面脚本最后一行用了exec,这是整个启动脚本里最重要、也最容易被忽略的关键字。

exec的作用是用新进程替换当前shell进程,也就是说,exec java ...执行后,原来的bash进程被Java进程直接顶替,Java进程成为容器的PID 1。这样做的好处是:docker stop时向容器内PID 1发送的SIGTERM信号,会直接送达Java进程,Spring Boot会走正常的优雅停机流程,先拒绝新请求、再处理完存量请求、最后关闭连接池。

如果你把exec去掉,脚本就会变成:bash作为PID 1先启动,然后bash再fork一个Java子进程。当docker stop发来SIGTERM时,bash默认不把信号转发给子进程,Java根本收不到停机信号,只能干等。默认情况下Docker会在10秒后强制发送SIGKILL,直接杀掉进程,结果就是大量未完成请求被中断、数据库连接没释放、数据一致性出问题。生产环境一次不优雅停机,可能就要运营同学去手工补数据。

还有一种场景需要额外处理:启动脚本里除了Java还起了别的后台任务,比如日志转发、文件清理,不能简单用exec顶掉bash。这时候就要手动做信号转发:

#!/bin/bash set -euo pipefail cleanup() { echo "Receive SIGTERM, forward to child process" kill -TERM "$PID" 2>/dev/null || true wait "$PID" } trap 'cleanup' SIGTERM SIGINT java -jar /opt/app/app.jar & PID=$! wait "$PID"

这个模板的核心是:Java进程放到后台,记录PID,然后用wait阻塞等待;同时用trap监听SIGTERM,一旦收到就转发给Java进程。需要注意wait返回后脚本要立即退出的问题,建议在wait后面根据退出码显式exit,避免trap触发后脚本流程又往下乱走。

4. Dockerfile集成与镜像打包实操

4.1 一份可以直接抄的Dockerfile

有了启动脚本,Dockerfile就变得非常干净。我的习惯是Dockerfile只负责三件事:拷贝文件、创建用户、声明启动入口。

FROM eclipse-temurin:17-jre-jammy ENV TZ=Asia/Shanghai \ LANG=C.UTF-8 \ APP_HOME=/opt/app \ LOG_DIR=/var/log/app RUN groupadd -r appuser -g 10001 && \ useradd -r -g appuser -u 10001 -m appuser WORKDIR ${APP_HOME} COPY --chown=appuser:appuser app.jar ${APP_HOME}/app.jar COPY --chown=appuser:appuser scripts/start.sh ${APP_HOME}/scripts/start.sh RUN chmod +x ${APP_HOME}/scripts/start.sh USER appuser ENTRYPOINT ["/opt/app/scripts/start.sh"]

这里有几个细节要注意。

USER appuser必须放在所有需要root权限的操作之后,包括创建用户、修改文件权限。有些镜像还会在启动时动态修改/etc/hosts或者写某个系统目录,这种情况下非root用户会直接失败,遇到这种需求就得考虑用docker run --user配合capability来做,而不是在镜像里开root。

COPY --chown=appuser:appuser比COPY之后单独RUN chown更好,因为构建层更少、缓存更有效。chmod +x不能省,别以为ENTRYPOINT里写的是路径就能执行,文件的执行权限是独立的,忘了这一步容器会直接报permission denied。

ENTRYPOINT和CMD的关系也容易搞混。ENTRYPOINT是固定的程序入口,CMD是默认参数。如果写成ENTRYPOINT ["/opt/app/scripts/start.sh"],那么docker run myimage arg1 arg2里的arg1 arg2会作为参数传给脚本,脚本里可以用$1$2拿到。所以我上面脚本里留了APP_ARGS这个变量,配合docker run附加参数就能做很多灵活操作,而不用改镜像。

4.2 多阶段构建,把打包过程也收进Docker

如果你的项目是Maven工程,我强烈推荐用多阶段构建,把编译打包环节也放进Docker,这样CI流水线上只需要一个Docker命令,不需要额外安装JDK和Maven。

FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src . RUN mvn package -DskipTests -B FROM eclipse-temurin:17-jre-jammy ... COPY --from=build /build/target/app.jar /opt/app/app.jar ...

第一阶段只负责编译,第二阶段只负责运行。dependency:go-offline这一步先把依赖下载并把中间产物缓存到镜像层里,后续如果只改了源码,重新构建时可以命中依赖缓存,构建速度快很多。

还有两个文件要养成习惯写:

.dockerignore里至少要把target、*.log、*.git、logs目录排除掉,不然构建上下文会把几百MB的开发产物一起发给Docker daemon,构建速度慢还容易把敏感文件的结尾带进镜像层。

pom.xml里最好显式指定最终jar包名,避免每次构建自动加版本号,导致Dockerfile里的COPY路径总变。如果jar名不固定,脚本里的APP_NAME就不好写死,可以做成环境变量:

APP_NAME="${APP_NAME:-app.jar}"

构建时通过-e APP_NAME=my-service-1.0.0.jar来覆盖,灵活性更高。

4.3 构建后的本地验证清单

镜像构建成功不等于容器能跑起来。我每次打完镜像,都会按下面这张清单走一遍,缺一个都不放心上环境。

第一步,docker build用固定tag构建,并确认最后几步没有警告输出,尤其是COPY的文件路径是否存在。

第二步,docker run时显式注入关键环境变量:

docker run -d --name app-test \ -e APP_ENV=test \ -e DB_URL="jdbc:mysql://10.0.0.2:3306/demo" \ -e REDIS_HOST="10.0.0.3" \ -p 18080:8080 \ -m 1g \ app-demo:latest

故意指定-m 1g的内存限制,就是要在本地验证-XX:MaxRAMPercentage=75.0这类参数有没有被正确解析。如果Java进程因为内存设置错误启动失败,logs里会有明确报错,这时候调整比到生产环境再去调整代价小得多。

第三步,docker exec -it app-test ps -ef确认PID 1是不是Java进程,如果看到bash还在,说明exec没生效,要回去改脚本。

第四步,docker logs app-test检查有没有启动关键日志,比如Spring Boot的端口监听日志、数据库连接成功的日志。

第五步,调用健康检查接口,确认服务对外可用。最后docker stop app-test,在另一个终端里观察日志里是否出现优雅停机记录,退出时间应该在10秒以内。

5. 上线之后:常见问题与排查技巧实录

5.1 容器起不来,先怀疑脚本自己

生产环境容器启动失败时,我从来不第一时间怀疑应用代码,而是先看启动日志。有几次都是日志里只看到一两个莫名其妙的关键字,一度以为是Java层面问题,结果最后定位到是shell脚本的坑。

遇到最多的是换行符问题。在Windows上编辑过脚本后,文件里会被塞进\r\n,Linux下执行时bash会报bash: /opt/app/scripts/start.sh: /bin/bash^M: bad interpreter: No such file or directory。这类问题的特征是报错信息里带^M,而且脚本第一行报错。解决方式很简单,在构建镜像前把脚本转成Unix换行:

sed -i 's/\r$//' scripts/start.sh

我把这个命令直接写进了CI流程里,防止有人从Windows机器提交脚本上来。

第二个高发问题是没有执行权限。ENTRYPOINT里的脚本路径写得再对,只要文件没有x权限,Docker启动就会报permission denied。排查时先ls -l确认一下权限位,少了就补chmod +x。

第三个高发问题藏在符号链接和路径上。有人习惯脚本里写相对路径,比如jar/app.jar,但容器的工作目录一旦不是预期目录,相对路径就突然失效。脚本里所有路径都要用绝对路径,并且启动时用cd或WORKDIR固定工作目录,双保险。

5.2 停不干净:信号处理和僵尸进程

容器停止时的表现,最能反映启动脚本质量。如果你发现docker stop总是要等到默认超时才杀掉容器,日志里完全找不到优雅停机记录,那基本可以断定是信号没传到应用进程。

前面说过,根因就是没有用exec,或者自己写的trap信号转发逻辑有漏洞。我补一个扩展场景:如果你的服务结构是Java进程接收流量,同时还有几个辅助脚本进程在跑,而你又需要让Java进程作为PID 1,那就要注意僵尸进程回收问题。Java进程作为PID 1不会主动回收孤儿进程,一旦辅助脚本退出后留下僵尸状态,僵尸进程越积越多,系统资源会紧张。

这个问题的常规解法是引入一个轻量init进程作为PID 1,比如tini,然后在它后面启动你的脚本。这个方案会稍微改变容器的进程编排,适合进程模型比较复杂的服务。如果你的服务只有一个Java进程,那保持简单,exec就够了。

5.3 时区、编码、日志权限,全是小事但要命

容器默认时区是UTC,日志里看到的时间和本地区时间差了8小时,排查问题时一眼看错。解决方式我在上面Dockerfile里已经写了:ENV TZ=Asia/Shanghai。如果是基于Debian系的镜像,最好再装上tzdata,不然单纯设环境变量不一定生效。

编码问题更隐蔽。Java应用读取外部配置文件时,文件编码是GBK而容器默认UTF-8,读出来全是乱码,接口返回的数据也乱。除了在Dockerfile里设LANG=C.UTF-8,更稳妥的是在启动脚本里追加一个参数:

JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8"

JAVA_TOOL_OPTIONS是所有Java进程启动时都会自动读取的环境变量,不需要改代码就能生效,非常推荐在容器里设置。

日志权限的话,核心是把日志目录的所有者、运行用户、宿主机挂载用户三个角色对齐。最省心的方案是让应用直接把日志写到标准输出。Spring Boot默认只把日志打到控制台,把logging.file.name配置项去掉,让日志走 stdout,然后由Docker日志驱动统一收集,这样既不需要处理挂载卷权限,也不需要在容器里起一个tail -F的额外进程。除非你有明确的多文件日志切割归档需求,否则我强烈建议所有容器应用都把日志直接打到stdout。

关于shell脚本启动项目,我个人最深的体会是:脚本本身的语法并不难,难的是把容器运行时的所有不确定性都提前想到。比如环境变量校验、信号转发、文件权限、路径规范、日志输出方式,这些单拎出来都是小问题,但叠加在一起就能让一个看起来正常的镜像在生产环境反复翻车。只要把启动脚本做成一个受控的入口,所有输入在启动那一刻被显式校验、显式打印,很多问题在日志里一眼就能定位到。这套方案我反复用了很久,省下的排查时间远超写脚本本身花掉的时间,推荐你直接按这个结构去搭自己项目的容器入口。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询