Docker容器启动脚本编写指南:从CMD/ENTRYPOINT到生产级实践
1. 为什么容器启动时需要运行脚本一个被忽视的入口点如果你用过 Docker大概率遇到过这样的场景你拉取了一个官方镜像比如mysql:latest直接docker run之后数据库服务就自动跑起来了。你可能会想这背后发生了什么镜像里难道预装了一个后台服务一启动就自动运行其实这背后正是“容器启动时运行脚本”这个机制在起作用。对于很多刚接触 Docker 的朋友来说这似乎是个黑盒但一旦你需要定制自己的镜像比如在启动时初始化数据库、配置环境变量、或者启动多个进程理解并掌握这个机制就变得至关重要。简单来说Docker 容器在启动时默认会执行镜像中定义的一个“入口点”Entrypoint。这个入口点可以是一个简单的命令比如/bin/bash也可以是一个复杂的 Shell 脚本.sh文件。通过运行脚本我们能在容器生命周期的起点注入自定义逻辑这是将静态的镜像转化为动态的、可用的服务实例的关键一步。今天我们就来彻底拆解这个看似简单实则藏着不少“坑”和技巧的操作。我会结合我这些年部署和维护上百个容器的经验从最基础的CMD和ENTRYPOINT讲起到如何编写健壮的启动脚本再到处理那些让人头疼的权限、信号传递和初始化顺序问题。无论你是想为自己的 Spring Boot 应用制作一个“开箱即用”的镜像还是想解决nacos、mysql在 Docker 中启动报错的疑难杂症这篇文章都能给你一套清晰的、可复现的方法论。2. CMD 与 ENTRYPOINT厘清容器启动的“双引擎”在深入脚本之前我们必须先理解 Dockerfile 中两个最核心的指令CMD和ENTRYPOINT。很多人对它们的区别感到混淆而这恰恰是编写正确启动逻辑的基础。你可以把它们想象成容器的“双引擎”控制系统。ENTRYPOINT定义了容器启动时必定执行的可执行文件。它设定了容器的“根本身份”。例如一个mysql镜像的ENTRYPOINT很可能就是/usr/local/bin/docker-entrypoint.sh。无论用户在docker run时追加什么参数这个入口点脚本都会首先被执行。CMD则提供了ENTRYPOINT的默认参数。它更像是给“根本身份”的一个默认行为指令。在mysql镜像的例子中CMD可能是[mysqld]。这意味着如果用户运行docker run mysql:latest那么容器实际执行的命令是/usr/local/bin/docker-entrypoint.sh mysqld。如果用户在运行时指定了其他命令如docker run mysql:latest bash那么CMD的默认值mysqld会被覆盖最终命令变为/usr/local/bin/docker-entrypoint.sh bash。2.1 Shell 格式与 Exec 格式一个影响信号接收的关键选择在 Dockerfile 中定义CMD和ENTRYPOINT有两种格式这个选择会直接影响你的脚本是否能正确处理 Unix 信号如 SIGTERM这也是很多服务在容器内无法优雅退出的根源之一。Shell 格式CMD /app/start.sh或ENTRYPOINT /app/start.sh工作原理Docker 会以/bin/sh -c “你的命令”的形式来执行。你的脚本会成为/bin/sh的子进程。主要问题Unix 信号如docker stop发送的 SIGTERM会发送给/bin/sh进程但这个 shell 进程通常不会将信号传递给它的子进程你的脚本及其启动的服务。这导致你的应用收不到停止信号只能等待超时后被 Docker 强制杀死SIGKILL可能造成数据丢失或状态不一致。网络热词关联这解释了为什么有些服务在容器内“关不掉”与docker stop的交互不顺畅。Exec 格式推荐CMD [“/app/start.sh”]或ENTRYPOINT [“/app/start.sh”]工作原理Docker 会直接执行指定的可执行文件不使用 shell 作为中介。你的脚本进程 PID 为 1。核心优势作为 PID 1 的进程它能直接接收并处理 Docker 发送的信号从而实现服务的优雅停止。这是生产环境的最佳实践。注意事项使用 Exec 格式时脚本文件必须具有可执行权限chmod x并且脚本首行需要正确的 shebang如#!/bin/bash。注意在绝大多数生产场景中对于启动脚本我们都应该使用Exec 格式来定义ENTRYPOINT。这是确保容器生命周期管理符合预期的基石。2.2 组合使用模式与最佳实践根据不同的需求CMD和ENTRYPOINT有几种常见的组合模式模式一使用ENTRYPOINT执行脚本CMD提供默认参数这是最通用、最推荐的模式。ENTRYPOINT指向你的启动脚本CMD提供脚本所需的默认参数。# Dockerfile 示例 FROM ubuntu:22.04 COPY entrypoint.sh /usr/local/bin/ RUN chmod x /usr/local/bin/entrypoint.sh ENTRYPOINT [“/usr/local/bin/entrypoint.sh”] CMD [“serve”] # 默认以服务模式启动用户运行docker run my-app会执行entrypoint.sh serve运行docker run my-app health-check则会执行entrypoint.sh health-check非常灵活。模式二只用CMD适用于简单的、单一用途的镜像比如运行一个一次性任务。CMD [“python”, “/app/main.py”]模式三只用ENTRYPOINT当你希望容器像一个独立的可执行程序用户提供的所有参数都作为该程序的参数时使用。ENTRYPOINT [“curl”]运行docker run my-curl -s http://example.com就相当于直接执行curl -s http://example.com。理解了这些基础我们就知道要让容器启动时运行我们的sh脚本核心就是通过ENTRYPOINT或CMD以 Exec 格式指向它。接下来我们看看这个脚本本身该怎么写。3. 编写健壮的容器启动脚本从“能跑”到“稳如老狗”一个合格的容器启动脚本绝不仅仅是把命令罗列出来。它需要处理环境配置、依赖检查、信号捕获、多进程管理等一系列问题。下面我以一个典型的 Web 应用比如一个 Spring Boot 的 JAR 包启动脚本为例拆解每个部分。3.1 脚本基础骨架与 shebang首先创建一个文件例如docker-entrypoint.sh。首行 shebang 必须指明解释器。#!/usr/bin/env bash # 使用 ‘/usr/bin/env bash’ 比直接 ‘/bin/bash’ 兼容性更好能适应更多基础镜像。 set -euo pipefail # -e: 任何命令失败返回非零值则立即退出脚本。防止错误累积。 # -u: 遇到未定义的变量时报错并退出。避免因变量名拼写错误导致诡异问题。 # -o pipefail: 管道命令中任何一个失败整个管道就视为失败。这是严谨性的体现。这个开头组合set -euo pipefail是我经过无数次踩坑后固定下来的最佳实践它能极大提高脚本的健壮性在早期发现潜在问题。3.2 环境检查与依赖等待在启动主程序前脚本经常需要检查环境或等待依赖服务如数据库就绪。# 示例等待 MySQL 服务可用 wait_for_db() { local host${DB_HOST:-localhost} local port${DB_PORT:-3306} echo “正在等待数据库 ${host}:${port} 就绪...” # 使用 nc (netcat) 检测端口超时设置 60 秒 while ! nc -z ${host} ${port} /dev/null 21; do sleep 1 done echo “数据库已就绪” } # 只有在需要时执行等待 if [[ -n “${DB_HOST:-}” ]]; then wait_for_db fi这里用nc -z检测端口连通性比单纯 sleep 一段时间更精确。${VAR:-default}语法提供了默认值增强了脚本的灵活性。这个模式在微服务架构中启动顺序管理时非常有用。3.3 配置文件生成与动态替换很多时候我们需要根据环境变量来动态生成应用的配置文件。这是实现“一次构建多处运行”的关键。# 示例根据环境变量替换配置文件中的占位符 if [[ -f “/app/config/application.yml.template” ]]; then echo “正在生成应用配置文件...” # 使用 envsubst 替换所有环境变量 export APP_PORT${APP_PORT:-8080} export JAVA_OPTS${JAVA_OPTS:-“-Xms256m -Xmx512m”} envsubst /app/config/application.yml.template /app/config/application.yml # 检查生成结果调试用 if [[ “${DEBUG:-false}” “true” ]]; then cat /app/config/application.yml fi fienvsubst命令通常包含在gettext包中是完成这项工作的利器。它比用sed一条条替换更清晰、更安全。3.4 主程序启动与信号捕获核心中的核心这是脚本最核心的部分。我们必须确保主程序能以 PID 1 的形式运行并能正确响应 Docker 的停止信号。# 定义主程序启动命令 APP_CMD“java ${JAVA_OPTS} -jar /app/my-application.jar” # 定义一个信号处理函数用于优雅停止 graceful_shutdown() { echo “收到终止信号正在优雅停止应用...” # 向 Java 进程发送 SIGTERM kill -TERM “$pid” 2/dev/null # 等待进程结束超时 30 秒 wait “$pid” 2/dev/null local exit_status$? echo “应用已停止退出码: ${exit_status}” exit ${exit_status} } # 捕获 SIGTERM 和 SIGINT 信号并调用处理函数 trap ‘graceful_shutdown’ TERM INT # 在后台启动主程序 echo “启动主程序: ${APP_CMD}” eval ${APP_CMD} # 获取后台进程的 PID pid$! # 等待后台进程结束 wait “$pid”这段代码的每一个细节都值得深究trap ‘graceful_shutdown’ TERM INTtrap命令用于捕获指定的系统信号。TERMSIGTERM是docker stop默认发送的信号INTSIGINT对应CtrlC。当收到这些信号时脚本会调用graceful_shutdown函数而不是立即退出。和wait我们将主程序启动在后台然后立即用pid$!获取它的进程号。接着脚本在前台使用wait命令等待这个后台进程结束。这是实现信号传递的关键。因为此时脚本本身Shell是 PID 1它收到了TERM信号并在graceful_shutdown函数中手动将TERM信号转发给了真正的应用进程 ($pid)。graceful_shutdown函数它向应用进程发送TERM然后wait等待其自然结束。这给了应用一个进行清理工作如关闭数据库连接、保存状态的机会。超时逻辑可以更复杂这里简化了。为什么不用exec你可能见过另一种写法直接exec java -jar ...。exec会用 Java 进程替换当前的 Shell 进程使其成为 PID 1。这样 Java 进程能直接接收信号如果它本身能处理 SIGTERM比如 Spring Boot 应用可以那也很好。但使用exec后脚本中trap之后的所有代码包括信号处理函数都将无法执行。我们上面这种“后台启动等待”的模式给了脚本在应用生命周期前后执行自定义逻辑如发送通知、记录日志的机会控制力更强。3.5 完整的脚本示例与 Dockerfile 集成将以上部分组合起来一个相对健壮的启动脚本如下#!/usr/bin/env bash set -euo pipefail # 功能1等待依赖 wait_for_dependencies() { # ... 具体等待逻辑 : } # 功能2准备配置 setup_configuration() { # ... 配置生成逻辑 : } # 功能3信号处理与主程序启动 run_main_process() { local main_cmd“$1” echo “启动命令: ${main_cmd}” graceful_shutdown() { echo “[$(date)] 收到停止信号” kill -TERM “$pid” 2/dev/null wait “$pid” } trap ‘graceful_shutdown’ TERM INT # 启动 eval “${main_cmd}” pid$! wait “$pid” } # 主执行流 main() { echo “容器启动初始化开始” wait_for_dependencies setup_configuration # 从环境变量或参数获取启动命令 local app_cmd${APP_CMD:-“java -jar /app/app.jar”} run_main_process “${app_cmd}” } # 执行主函数 main “$”对应的 Dockerfile 需要将脚本复制进去并设置为入口点FROM openjdk:11-jre-slim WORKDIR /app COPY target/my-app.jar /app/app.jar COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod x /usr/local/bin/docker-entrypoint.sh # 可以安装脚本需要的工具如 wait-for-it, envsubst RUN apt-get update apt-get install -y netcat-openbsd gettext rm -rf /var/lib/apt/lists/* ENTRYPOINT [“/usr/local/bin/docker-entrypoint.sh”] # CMD 可以作为默认参数传递给脚本这里脚本内部用了 APP_CMD所以 CMD 非必须 # CMD [“java”, “-jar”, “/app/app.jar”]构建并运行docker build -t my-app . docker run -e DB_HOSTmysql -e APP_PORT8080 my-app4. 高级场景与疑难杂症排查掌握了基础写法我们来看看更复杂的场景和那些容易踩坑的地方。4.1 初始化脚本与主启动脚本分离有时初始化操作如数据库迁移flyway、静态文件收集只需要在容器首次启动时执行一次而主程序如 Django Gunicorn每次都要启动。一种常见的模式是使用“初始化容器”或是在启动脚本中做判断。#!/bin/bash set -e # 检查是否需要执行数据库迁移 if [[ ! -f /data/.db_initialized ]]; then echo “执行数据库迁移...” python manage.py migrate python manage.py loaddata initial_data.json touch /data/.db_initialized echo “数据库初始化完成。” fi # 检查是否需要收集静态文件 if [[ ${COLLECT_STATIC_ON_START:-“false”} “true” ]]; then echo “收集静态文件...” python manage.py collectstatic --noinput fi # 启动主程序 exec gunicorn myproject.wsgi:application --bind 0.0.0.0:8000这里用了一个标记文件/data/.db_initialized来避免重复迁移。注意最后用了exec来启动 Gunicorn因为 Django 的迁移是前置一次性任务之后我们愿意让 Gunicorn 直接成为 PID 1 来接收信号。4.2 处理容器内多进程使用进程管理工具官方建议一个容器只运行一个进程。但现实有时骨感比如你可能需要在同一个容器里运行应用和一个 sidecar 日志收集器如nginxphp-fpm也算一种多进程。手动用启动多个后台进程并管理它们的生命周期非常容易出错。解决方案是使用一个轻量的进程管理工具作为 PID 1让它来管理所有子进程。常用的有Supervisor功能强大配置化。适合相对固定的多进程场景。TiniDocker 官方推荐极简。它就是一个有效的 init 进程主要解决信号转发和僵尸进程回收问题不负责复杂的进程配置。S6-Overlay更现代、更强大的初始化系统适合复杂应用。以Tini为例使用非常简单# Dockerfile # 安装 Tini RUN apt-get update apt-get install -y tini # 将 Tini 设为入口点你的脚本作为参数 ENTRYPOINT [“tini”, “--”, “/usr/local/bin/docker-entrypoint.sh”] CMD [“serve”]这样tini会成为 PID 1它会将接收到的信号正确地转发给你的启动脚本或主进程并回收僵尸进程。这是处理信号问题最省心、最标准的方式之一。4.3 常见故障排查与“避坑指南”结合网络热词中提到的各种错误我们来分析几个典型问题docker desktop failed to start because virtualisation support wasn’t detected这不是脚本问题而是 Docker Desktop 的宿主机环境问题。通常是因为 Windows 的 Hyper-V 或 WSL 2 支持未开启或者 BIOS 中的虚拟化技术Intel VT-x / AMD-V被禁用。这需要在宿主机层面解决与容器内的启动脚本无关。应用程序-特定 权限设置并未向在应用程序容器 不可用 sid (不可用)中运行的地址这个看起来像是 Windows 系统或特定 Windows 容器下的错误与安全标识符SID和权限有关。在 Linux 容器环境下很少见。如果遇到重点检查容器内运行进程的用户身份是否以 root 运行、文件挂载的权限-v挂载的宿主机目录权限是否过严以及可能的 SELinux/AppArmor 安全策略。脚本执行失败/bin/sh: 1: /app/start.sh: not found或Permission deniednot found首先确认文件是否真的复制到了镜像中的指定路径。使用docker run -it my-image /bin/sh进入容器检查。其次检查脚本的行尾符。如果在 Windows 下编辑了脚本CRLF放到 Linux 容器中执行可能会出问题。可以在 Dockerfile 中用RUN sed -i ‘s/\r$//’ /app/start.sh转换或确保编辑器使用 LF 行尾符。Permission denied这就是忘记给脚本添加可执行权限了。务必在 Dockerfile 中或构建后执行chmod x /path/to/your-script.sh。容器启动后立即退出Exited (0) 或 Exited (1)这是最常见的问题之一。检查脚本是否以 Exec 格式运行如果使用 Shell 格式且脚本最后没有长期运行的进程比如只做了初始化就结束了那么容器任务完成自然退出。检查脚本中的set -e如果脚本中某条命令失败返回非零set -e会导致脚本立即退出。使用docker logs container-id查看退出前的日志定位失败命令。检查入口点脚本的最终进程确保你的脚本最后启动的是一个前台进程。如果主程序是以后台服务启动的而脚本没有像我们之前例子那样用wait挂起那么脚本会立刻执行完毕容器也就退出了。使用docker run -it --entrypoint /bin/sh your-image进行调试这样可以覆盖入口点直接进入容器的 Shell然后手动执行你的启动脚本观察每一步的输出。curl -fsSL https://ollama.com/install.sh | sh这种模式的安全风险很多安装指南喜欢用curl | sh这种“管道到 shell”的方式。这在 Dockerfile 的RUN指令中极其危险。因为如果下载被劫持或者服务器被攻破你将直接在构建环境中执行未知代码。在 Docker 中更安全的做法是先curl -o下载脚本到本地。审查脚本内容如果可能。再用sh执行本地文件。 或者最好寻找提供官方 Docker 镜像或可靠安装包的项目。5. 实战为 Spring Boot 应用构建一个生产级启动镜像让我们把所有知识点串起来为一个假设的 Spring Boot 应用构建一个完整的、生产可用的 Docker 镜像。这个应用需要连接外部 MySQL并根据环境变量配置服务器端口。项目结构my-springboot-app/ ├── Dockerfile ├── docker-entrypoint.sh └── 你的 Spring Boot app.jar 在 target/ 目录下docker-entrypoint.sh#!/usr/bin/env bash set -euo pipefail # 等待数据库就绪使用 wait-for-it 脚本更健壮 wait_for_db() { if [[ -n “${DB_HOST:-}” ]]; then local host${DB_HOST} local port${DB_PORT:-3306} local timeout${DB_WAIT_TIMEOUT:-30} echo “等待数据库 ${host}:${port} 最多 ${timeout} 秒...” # 这里假设已将 wait-for-it.sh 脚本复制到镜像中 /usr/local/bin/wait-for-it.sh --timeout${timeout} --host${host} --port${port} --strict -- if [[ $? -eq 0 ]]; then echo “数据库连接成功。” else echo “错误等待数据库超时” 2 exit 1 fi fi } # 生成应用配置文件从模板 generate_config() { local config_template“/app/config/application.yml.template” local config_output“/app/config/application.yml” if [[ -f “${config_template}” ]]; then echo “根据环境变量生成配置文件...” # 确保 envsubst 命令可用 envsubst “${config_template}” “${config_output}” echo “配置文件生成完毕: ${config_output}” fi } # 优雅停止处理 shutdown_handler() { echo “收到终止信号正在关闭 Spring Boot 应用...” if [[ -n “${APP_PID:-}” ]]; then kill -TERM “${APP_PID}” 2/dev/null # 等待最多 25 秒 local wait_time25 while kill -0 “${APP_PID}” 2/dev/null [[ ${wait_time} -gt 0 ]]; do sleep 1 ((wait_time--)) done # 如果还没退出强制杀死 if kill -0 “${APP_PID}” 2/dev/null; then echo “应用未在超时时间内停止强制终止。” kill -KILL “${APP_PID}” 2/dev/null fi fi exit 0 } # 主函数 main() { echo “ 容器启动初始化 # 1. 等待依赖 wait_for_db # 2. 生成配置 generate_config # 3. 设置信号捕获 trap ‘shutdown_handler’ TERM INT # 4. 构建启动命令 local java_opts${JAVA_OPTS:-“-Xms256m -Xmx512m -Djava.security.egdfile:/dev/./urandom”} local app_cmd“java ${java_opts} -jar /app/app.jar” echo “启动命令: ${app_cmd}” # 5. 启动应用后台运行但脚本会等待它 eval “${app_cmd}” APP_PID$! # 6. 等待应用进程结束 wait “${APP_PID}” } # 执行入口 main “$”Dockerfile# 使用官方镜像作为基础带标签锁定版本 FROM eclipse-temurin:11-jre-focal as builder # 安装必要的工具wait-for-it, envsubst, curl(用于健康检查) RUN apt-get update apt-get install -y \ curl \ netcat-openbsd \ gettext-base \ rm -rf /var/lib/apt/lists/* # 下载 wait-for-it 脚本 RUN curl -o /usr/local/bin/wait-for-it.sh https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh \ chmod x /usr/local/bin/wait-for-it.sh # 最终运行阶段 FROM eclipse-temurin:11-jre-focal # 安装 Tini 作为 init 进程 RUN apt-get update apt-get install -y tini rm -rf /var/lib/apt/lists/* # 从 builder 阶段拷贝工具 COPY --frombuilder /usr/local/bin/wait-for-it.sh /usr/local/bin/ COPY --frombuilder /usr/bin/envsubst /usr/bin/ # 创建非 root 用户运行应用安全最佳实践 RUN groupadd -r spring useradd -r -g spring spring WORKDIR /app # 复制应用和脚本 COPY --chownspring:spring target/app.jar /app/app.jar COPY --chownspring:spring docker-entrypoint.sh /usr/local/bin/ COPY --chownspring:spring config/application.yml.template /app/config/ # 设置权限 RUN chmod x /usr/local/bin/docker-entrypoint.sh # 切换到非 root 用户 USER spring # 声明健康检查可选但推荐 HEALTHCHECK --interval30s --timeout3s --start-period40s --retries3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 # 使用 Tini 作为入口点 ENTRYPOINT [“tini”, “--”, “/usr/local/bin/docker-entrypoint.sh”] # 可以不加 CMD因为脚本里已经定义了默认启动命令。或者加一个作为提示。 # CMD [“java”, “-jar”, “/app/app.jar”]构建与运行# 构建镜像 docker build -t my-company/my-spring-app:1.0.0 . # 运行容器传递环境变量 docker run -d \ --name my-app \ -p 8080:8080 \ -e DB_HOSTmysql-server \ -e DB_PORT3306 \ -e JAVA_OPTS“-Xmx1g” \ -e APP_PORT8080 \ my-company/my-spring-app:1.0.0这个实战案例融合了最佳实践使用多阶段构建减少镜像大小安装tini处理信号使用非 root 用户提升安全性通过wait-for-it脚本可靠地等待依赖利用envsubst动态配置并设置了健康检查。你的启动脚本不再是简单的命令执行器而是一个具备生产级鲁棒性的容器生命周期管理器。回过头看让 Docker 容器启动时运行一个sh脚本远不止是在 Dockerfile 里写一行ENTRYPOINT那么简单。它涉及对容器进程模型、信号机制、环境管理和故障排查的深入理解。从选择正确的ENTRYPOINT格式到编写能处理优雅关闭的脚本再到集成进程管理工具和健康检查每一步都需要根据你的具体应用场景仔细考量。我个人的经验是在项目初期就采用类似上面的模板化脚本和 Dockerfile虽然前期配置稍多但能为后续的部署稳定性、运维可观测性打下坚实基础避免很多深夜救火的烦恼。下次当你再遇到容器启动报错、无法停止或者配置不生效时希望这篇文章能帮你快速定位到那个隐藏在sh脚本中的关键细节。