资讯动态

手把手编写Docker镜像自动执行脚本,构建容器初始化闭环

发布时间:2026/10/17 15:21:35 来源:尧图企业网站定制
直接上结论这个脚本到底在解决什么痛点很多人第一次看到“从0编写Docker镜像自动执行脚本.sh”这个标题第一反应是这不就是往Dockerfile里塞一段RUN命令吗实际上远不止这么简单。真正想表达的是——你有一个需要容器化部署的Linux应用启动之前要做环境初始化、配置注入、依赖准备、健康检查甚至还要按顺序执行一串迁移和数据预处理任务。这些操作如果全靠手工敲命令第一次可能勉强搞定第二次、第三次换台机器就全乱了。把这一整套流程写进一个可复用、可传参、可容错的.sh脚本再挂到Docker镜像的启动链路里让容器每次从镜像创建出来都能自动、干净、有序地完成初始化任务这才是标题背后真正的需求。这个方案适合谁自己做个人项目的开发者、要在团队里交付标准化镜像的运维工程师、还有正在学习容器化但被一堆“为什么容器起来了服务却不能用”问题困扰的新手。你不需要是脚本专家但你需要理解Shell脚本在Docker构建阶段和运行阶段各自扮演的角色。我后面所有内容都会围绕“一个真实的Linux服务自动化部署场景”来展开从脚本本体写起一路走到Dockerfile集成、镜像构建、容器运行最后附带我实际踩过的坑和排查清单可以直接照着抄。1. 设计思路拆解先分清“构建时执行”和“运行时执行”动手写脚本之前最忌讳的事情就是不加思考地把所有命令堆到一起。Docker镜像的自动化执行按照发生时机可以划成两个完全不同的阶段构建阶段Build Time和运行阶段Run Time。这两个阶段对脚本的要求截然不同搞混了会出现很诡异的故障。1.1 核心原理RUN指令和ENTRYPOINT/CMD的区别先说构建阶段。Dockerfile里的RUN指令每一条都会在当前镜像层之上启动一个临时容器执行完命令后把结果提交成新镜像层。RUN chmod x /scripts/init.sh /scripts/init.sh这种方式适合做那些在镜像打包时就该完成的事装软件包、创建系统用户、清理缓存、预生成配置文件。此时脚本的执行环境和宿主机无关依赖的是基础镜像自带的软件源和工具链。运行阶段则不同。ENTRYPOINT和CMD决定的命令在docker run启动容器时才会执行。这才是真正意义上“每次启动都自动跑一遍”的逻辑。适合放数据库表结构校验、应用启动前依赖服务的等待、动态生成运行时配置、注册服务到注册中心这些动作。这里有个容易弄反的点RUN阶段做的事会被固化进镜像容器启动后也许还需要也许不再需要而ENTRYPOINT里做的事每次启动都会重新来一遍。1.2 方案选型单脚本 vs 多脚本为什么通常用多个.sh文件初学者常犯的一个错误是把所有逻辑塞进一个巨型脚本。我建议把职责拆开build.sh面向镜像构建阶段负责装依赖、预编译、做静态检查通常在宿主机或CI里调用docker build时配合使用。entrypoint.sh面向容器启动阶段是容器的主进程负责初始化运行环境、接收外部传入参数、最终拉起真正的业务进程。healthcheck.sh可选用于容器健康检查定期验证服务是否活着。拆开的理由不只是“代码清晰”更关键的是错误隔离。如果entrypoint.sh因为一个环境变量缺失就退出容器会直接进入Exited状态排查起来非常快如果是全塞在RUN阶段镜像构建成功但运行必挂你连日志都不太好抓。提示我见过不少项目只在Dockerfile里写了一堆RUN所有脚本逻辑都在里面结果镜像体积膨胀到几个GB而且任何一步失败整个构建就废。把“一次性动作”写进RUN、“每次启动动作”写进entrypoint才是符合Docker设计哲学的划分方式。2. 手把手从零写自动执行脚本以真实Linux服务为例为了让整个流程不悬浮我用一个常见场景来走全程某Linux应用服务依赖MySQL数据库启动前需要等待数据库可用、检查配置目录是否存在、根据环境变量动态生成一份配置文件最终拉起Java或Python进程。为了让不熟悉的人也能跟得上脚本部分我会写得详尽一些。2.1 基础环境准备目录结构、依赖工具和权限位先在宿主机上创建项目目录我习惯的目录结构是这样project-root/ ├── Dockerfile ├── scripts/ │ ├── entrypoint.sh │ ├── healthcheck.sh │ └── build.sh ├── app/ │ └── (你的业务代码) └── conf/ └── (静态配置模板)创建完目录后第一时间给脚本加可执行权限chmod x scripts/*.sh这个权限位的重要性排在最前面因为Docker镜像默认以root或指定用户运行如果脚本没有x权限ENTRYPOINT [/scripts/entrypoint.sh]会直接报permission denied而且这个报错在构建阶段不会暴露只有运行容器瞬间才爆发。我还习惯在脚本第一行写死解释器#!/usr/bin/env bash有人用#!/bin/bash也没问题但/usr/bin/env bash的好处是自动从PATH里找bash在部分精简基础镜像上更稳。2.2 脚本骨架变量定义、日志函数、错误处理三板斧直接看我最终定稿的entrypoint.sh骨架你可以按需裁剪#!/usr/bin/env bash set -Eeuo pipefail # ---------- 基础配置 ---------- APP_HOME/opt/app APP_USERappuser CONF_DIR${APP_CONF_DIR:-/etc/app} LOG_DIR${APP_LOG_DIR:-/var/log/app} DB_HOST${DB_HOST:-127.0.0.1} DB_PORT${DB_PORT:-3306} DB_USER${DB_USER:-app} DB_PASSWORD${DB_PASSWORD:-app123} # ---------- 终端颜色/日志函数 ---------- log_info() { echo [$(date %Y-%m-%d %H:%M:%S)] [INFO] $* } log_warn() { echo [$(date %Y-%m-%d %H:%M:%S)] [WARN] $* 2 } log_error() { echo [$(date %Y-%m-%d %H:%M:%S)] [ERROR] $* 2 } # ---------- 等待MySQL就绪 ---------- wait_for_mysql() { local retries60 local wait_seconds2 log_info 等待数据库 ${DB_HOST}:${DB_PORT} 就绪... for i in $(seq 1 $retries); do if nc -z $DB_HOST $DB_PORT 2/dev/null; then log_info 数据库连接成功重试次数$i return 0 fi sleep $wait_seconds done log_error 数据库等待超时退出容器 exit 1 } # ---------- 创建目录并设置权限 ---------- init_directories() { mkdir -p $CONF_DIR $LOG_DIR # 如果应用需要写日志把目录属主改成应用用户 chown -R $APP_USER:$APP_USER $LOG_DIR 2/dev/null || true log_info 配置目录和日志目录就绪 } # ---------- 动态生成配置文件 ---------- generate_config() { local template${CONF_DIR}/app.conf.tpl local output${CONF_DIR}/app.conf if [ -f $template ]; then # 这里用sed做简单替换复杂模板建议用envsubst或m4 sed -e s|{{DB_HOST}}|$DB_HOST|g \ -e s|{{DB_PORT}}|$DB_PORT|g \ -e s|{{DB_USER}}|$DB_USER|g \ -e s|{{DB_PASSWORD}}|$DB_PASSWORD|g \ $template $output log_info 配置文件已生成: $output else log_warn 未找到模板文件 $template跳过配置生成 fi } # ---------- 真正启动业务进程 ---------- start_application() { log_info 启动应用进程... exec java -jar $APP_HOME/app.jar # 如果是Python应用: # exec gunicorn -c /etc/app/gunicorn.py app:app } # ---------- main按顺序执行 ---------- main() { log_info entrypoint 开始执行 init_directories wait_for_mysql generate_config exec_command$1 if [ $exec_command app ]; then start_application else # 允许外部传入覆盖命令例如 bash、python 调试 exec $ fi } main $这个脚本看起来不复杂但每一段都有讲究。set -Eeuo pipefail这行是很多Shell老手必用的组合拳-e任何一条命令返回非零状态立即退出脚本。避免“命令失败了还继续往下跑”的连锁事故。-u使用未定义变量直接报错防止环境变量拼错后悄悄用空值。-o pipefail管道中只要有一个命令失败整条管道的退出码就是失败的。这能防住cmd1 | cmd2里cmd1挂了但cmd2仍然返回0的情况。-E让trap能捕获到ERR信号方便在发生错误时打日志。exec java -jar前面的exec很关键。它会把当前Shell进程替换成Java进程让Java进程成为容器里的1号进程。如果不加execShell会先等Java跑完再退出但容器的生命周期绑定的是Shell进程信号转发和退出码传递都会出问题。加上exec后Java直接接管PID 1docker stop发来的SIGTERM能准确命中业务进程这是优雅停机的前提。2.3 验证脚本语法先本地试跑再进容器脚本写完不要直接扔进Docker先在宿主机上做两件事。第一件是语法检查bash -n scripts/entrypoint.sh没有任何输出就说明语法没问题。第二件是用最安全的方式试跑APP_CONF_DIR/tmp/appconf APP_LOG_DIR/tmp/applog DB_HOST127.0.0.1 \ bash scripts/entrypoint.sh echo hello from entrypoint这里传入的exec_command是echo所以main函数会走exec $分支只做初始化但不拉起业务进程非常适合验证目录创建、日志输出这些逻辑是否正常。试跑完去/tmp/appconf和/tmp/applog里看看产物对不对。注意wait_for_mysql里我用nc -z来判断端口连通性。如果基础镜像里没有nc命令脚本会直接报错。要么在Dockerfile的构建阶段安装netcat-traditional要么改用纯bash自带的/dev/tcp探测方式。后者更省依赖if (echo /dev/tcp/$DB_HOST/$DB_PORT) 2/dev/null; then log_info 数据库端口可达 fi3. Dockerfile里接入自动执行脚本构建镜像全流程脚本本身写得再好Dockerfile写不对也白搭。这一节我完整走一遍build.sh和Dockerfile的编写过程把每一步动作的意图讲明白。3.1 Dockerfile关键指令顺序COPY、RUN、USER与权限的先后关系先看一个经过多次验证的Dockerfile示例# 1. 选择基础镜像 FROM ubuntu:22.04 # 2. 安装依赖合并RUN减少镜像层数 RUN apt-get update \ apt-get install -y --no-install-recommends \ ca-certificates \ curl \ netcat-openbsd \ openjdk-17-jre-headless \ tzdata \ rm -rf /var/lib/apt/lists/* # 3. 创建应用用户和目录 RUN groupadd --system appuser \ useradd --system --gid appuser --home /opt/app appuser \ mkdir -p /opt/app /scripts /etc/app /var/log/app # 4. 拷贝应用和脚本 COPY app/app.jar /opt/app/app.jar COPY conf/app.conf.tpl /etc/app/app.conf.tpl COPY scripts/entrypoint.sh /scripts/entrypoint.sh COPY scripts/healthcheck.sh /scripts/healthcheck.sh # 5. 修改权限 RUN chmod x /scripts/*.sh \ chown -R appuser:appuser /opt/app /etc/app /var/log/app # 6. 切换用户 USER appuser # 7. 声明运行时变量 ENV APP_CONF_DIR/etc/app \ APP_LOG_DIR/var/log/app \ DB_HOST127.0.0.1 \ DB_PORT3306 \ DB_USERapp \ DB_PASSWORDapp123 # 8. 健康检查 HEALTHCHECK --interval30s --timeout5s --start-period40s --retries3 \ CMD [/scripts/healthcheck.sh] # 9. 容器启动入口 ENTRYPOINT [/scripts/entrypoint.sh] # 10. 默认参数 CMD [app]有几个指令的顺序是刻意安排的。COPY的顺序放在RUN之后不是随意的。Docker构建有层缓存机制如果靠前的层没有变化后面层会直接复用缓存。依赖安装的RUN很耗时把它放在COPY之前只要基础镜像和依赖列表不变即使应用代码频繁更新重装依赖这一步也能命中缓存。反过来如果先把COPY app.jar放前面每次代码变动都会导致后面所有层缓存失效构建速度会肉眼可见地变慢。USER appuser放在ENTRYPOINT之前让容器以非root身份运行这是安全基线要求。但注意脚本里如果用了chown、改系统文件这类需要root的操作就必须在做完这些操作之后再切换用户。一旦切换到appuser任何需要写/root、改/etc/passwd的操作都会失败。所以初始化尽量提前到构建阶段做运行阶段的脚本只做应用自身允许做的事。HEALTHCHECK不是必须的但我强烈建议加上。它不影响容器启动但编排平台可以用它判断服务是否存活进而决定要不要重启容器。healthcheck.sh的写法我放在后面讲。3.2 每层操作的意图说明为什么要合并RUN、为什么要设置时区再挑几个容易忽略的细节点。合并RUN apt-get update apt-get install ... rm -rf /var/lib/apt/lists/*这一长串原理是减少镜像层的数量。Docker每一条RUN都会生成一个只读层层数越多镜像拉取和解包越慢历史层里残留的临时文件也会让镜像体积膨胀。把apt-get update和install写在同一层还有一个更实际的原因分开写的话前一层的update结果会残留在层里下一层再install时如果软件源地址变动容易出现“包索引过期”的神秘报错。安装tzdata并设置时区这件事常被忽略。默认容器时区是UTC应用打印日志的时间比北京时间晚8个小时排查线上问题时时间对不上会非常痛苦。建议在Dockerfile里加一段ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone依赖tzdata这个包不然/usr/share/zoneinfo目录可能不存在。3.3 构建镜像的shell脚本build.sh封装完整流程为了体现“自动执行脚本”的完整闭环再写一个build.sh它负责在宿主机上执行构建、打标签、做清理是配合Dockerfile使用的自动化入口#!/usr/bin/env bash set -Eeuo pipefail IMAGE_NAME${IMAGE_NAME:-myapp} IMAGE_TAG${IMAGE_TAG:-$(date %Y%m%d%H%M%S)} DOCKERFILE${DOCKERFILE:-Dockerfile} log_info() { echo [$(date %Y-%m-%d %H:%M:%S)] [INFO] $* } log_info 开始构建镜像: ${IMAGE_NAME}:${IMAGE_TAG} # 1. 校验Dockerfile是否存在 [ -f $DOCKERFILE ] || { echo Dockerfile 不存在: $DOCKERFILE; exit 1; } # 2. 构建镜像使用BUILDKIT可获得更清晰的输出 DOCKER_BUILDKIT1 docker build \ --file $DOCKERFILE \ --tag ${IMAGE_NAME}:${IMAGE_TAG} \ --tag ${IMAGE_NAME}:latest \ . # 3. 镜像瘦身清理悬空镜像 docker image prune -f /dev/null 21 || true log_info 构建完成: ${IMAGE_NAME}:${IMAGE_TAG} log_info 如需推送执行: docker push ${IMAGE_NAME}:${IMAGE_TAG}这里有个容易被新手忽略的点IMAGE_TAG用了date %Y%m%d%H%M%S生成时间戳防止每次构建都覆盖同一个标签。搭配一个latest标签便于日常开发使用而时间戳标签用于发布追溯。执行IMAGE_NAMEmyapp bash scripts/build.sh构建完成后用docker images确认镜像存在再用docker history看一眼镜像层是否合理有没有意外的大文件被带进去。4. 镜像运行与自动执行逻辑的完整闭环镜像构建成功只完成了40%的工作。真正体现“自动执行脚本”价值的是docker run那一刻起脚本如何接管容器生命周期。4.1 首次运行观察日志、验证初始化流程启动容器docker run -d --name myapp-test \ -e DB_HOST192.168.1.100 \ -e DB_PORT3306 \ -e DB_USERapp \ -e DB_PASSWORDa1b2c3d4 \ -p 8080:8080 \ myapp:latest启动后立即查看日志docker logs -f myapp-test正常情况下你会看到类似这样的输出序列[2024-01-15 10:20:31] [INFO] entrypoint 开始执行 [2024-01-15 10:20:31] [INFO] 配置目录和日志目录就绪 [2024-01-15 10:20:31] [INFO] 等待数据库 192.168.1.100:3306 就绪... [2024-01-15 10:20:33] [INFO] 数据库连接成功重试次数2 [2024-01-15 10:20:33] [INFO] 配置文件已生成: /etc/app/app.conf [2024-01-15 10:20:33] [INFO] 启动应用进程...看到最后一行启动应用进程...后日志就交给业务进程接管了。如果中间某一步失败比如数据库端口不通脚本会等满60次重试后打印超时退出容器状态变为Exited。这个模式的最大好处是初始化失败的原因直接暴露在容器日志里不需要进容器手动敲命令排查。对运维人员来说docker logs就把问题定位了一大半。4.2 覆盖默认命令进入容器调试的三种常用姿势日常开发中经常需要进容器看一眼环境或者单独验证脚本逻辑。CMD [app]作为默认参数在docker run时可以覆盖。进容器开一个交互式Shelldocker run -it --rm --entrypoint /bin/bash myapp:latest这样会绕过entrypoint.sh直接得到一个Shell。如果你用的是精简基础镜像没有bash就用/bin/sh。还有一种更贴近实际的方式保留entrypoint但让它执行别的命令docker run -it --rm myapp:latest bash因为脚本末尾有exec $分支传入bash后它会把Shell替换成bash进程这样你既能看到entrypoint先执行初始化又能进入Shell手动查漏补缺。第三种方式是直接覆盖ENTRYPOINT适合临时debug某些跟业务进程无关的内容docker run -it --rm --entrypoint sh myapp:latest提示给容器传密码这类敏感信息时尽量不用-e DB_PASSWORDxxx推荐用--env-file把变量写进文件且设置好文件权限chmod 600。否则密码会出现在宿主机进程列表里和shell历史记录中这是个很实际的安全坑。4.3 什么时候不应该让脚本自动执行脚本自动执行虽爽但也有它不该碰的场景。比如一次性任务容器、数据迁移命令、交互式调试就不该把入口逻辑写成强制自动执行。我的建议是给entrypoint留一个“后门模式”if [ $1 debug ]; then exec /bin/bash fi这样你可以在需要时通过docker run myapp debug直接拿到Shell行为可预期且不会影响正常启动路径。这个习惯帮我避免了很多次“容器启动就挂、又没法进去修”的尴尬。5. 常见故障排查与经验干货脚本写得再稳总会遇到环境差异带来的问题。这一节把我实际踩过的坑按“现象→原因→解决”整理成清单。5.1 典型问题速查表现象可能原因解决办法exec: /scripts/entrypoint.sh: permission denied脚本缺少可执行权限chmod x scripts/*.sh后重新构建容器启动后立刻退出日志显示bad interpreter换行符是CRLF用dos2unix转换或把编辑器改成LF脚本里有$*但结果为空引号使用不当先echo调试正确写法$nc: command not found基础镜像没装netcat改用/dev/tcp或安装netcat-openbsd业务进程收到SIGTERM后没优雅退出启动命令没加exec用exec替换为业务进程让业务进程成为PID 1配置文件乱码字符编码不一致模板和脚本保持UTF-8容器内显式export LANGC.UTF-8docker build缓存不命中构建很慢COPY顺序不当先放依赖相关文件后放源码或采用多阶段构建应用能连库但偶尔连接超时等库就绪逻辑过于简单增加连接测试而不是只测端口使用实际连接命令验证第一条最常见。很多人从Windows编辑环境把脚本上传到Linux权限位没有保留或者直接在Git仓库里把脚本权限弄丢了。在Dockerfile里额外加一行RUN chmod x /scripts/*.sh是个补救办法但更该在宿主机和Git端就把权限维护好。bad interpreter这条也很经典。Windows记事本编辑过的脚本换行符是\r\nLinux执行到第一行就会报错。建议编辑器统一配置为LF换行或在Git仓库根目录加.gitattributes文件强制换行符。5.2 排查经验抓关键日志和容器状态的三步法我总结了一套三步排查法遇到容器启动失败时按顺序做比乱试快很多。第一步看容器状态和重启信息docker inspect myapp-test --format {{.State.Status}} {{.RestartCount}}第二步看标准输出和标准错误docker logs --tail 100 myapp-test如果日志太少检查脚本里所有报错是不是都写进了2。我当时在脚本里定义log_error时就专门用2把错误重定向这样Docker日志系统能更准确地区分输出级别docker logs看时间线也更有条理。第三步检查退出码docker wait myapp-test docker inspect myapp-test --format {{.State.ExitCode}}退出码0表示正常结束1通常是通用错误130表示被SIGINT中断137是SIGKILL。结合退出码能够快速判断脚本是自己主动退出还是被外部杀掉。还有一个容易被忽视的检查点环境变量。docker run -e DB_HOST...传进去的变量在脚本里通过${DB_HOST:-default}引用时如果拼错大小写会被set -u直接拦下来。符合-u严格模式时脚本会在引用未定义变量的位置立刻退出并把变量名带出来这时候错误信息一眼就能看到。5.3 我踩过的三个值得说一说的坑第一个坑是PID 1的信号处理。早期我的entrypoint末尾没有加exec壳进程是PID 1Java只是它的子进程。docker stop发SIGTERM给PID 1Shell没做转发处理Java进程完全感知不到容器等了10秒强制SIGKILL数据丢失风险很高。后来所有启动业务进程的地方一律用exec这个坑彻底消失。第二个坑是等待数据库的脚本过于乐观。最初只测端口连通但数据库容器还在初始化阶段时端口可能已监听应用连上去仍然失败。改成了用mysqladmin ping或实际执行SELECT 1去验证才算真正解决。如果你用的不是MySQL就用对应客户端的探活命令。第三个坑是镜像缓存导致的“假修复”。有时候改完脚本重新构建但因为COPY的内容哈希没变化Docker复用了旧层缓存你改了等于没改。排查时如果发现改动没生效先看构建日志里有没有CACHED字样有的话就得想办法强制重建docker build --no-cache -t myapp:latest .强制重建虽慢但能避免浪费时间怀疑人生。6. 扩展建议这个脚本还能怎么演化基础版能跑通之后不要停留在“能用”的阶段。我建议按项目复杂度做渐进式升级。6.1 从单个脚本演化为完整配置体系环境变量数量多起来后用set -a source .env set a方式批量导入配置比逐个-e传递省事很多。再进一步可以引入envsubst配合模板文件做整文件替换或者用jq处理JSON格式的配置注入。脚本里的配置生成逻辑不再只处理三五个变量而是覆盖整个应用的全部配置项。项目足够大时entrypoint里塞几百行也不可取。建议拆成多个子脚本放在/scripts/lib/目录下entrypoint只负责按顺序source它们。这其实是一个小型的配置框架但灵活度和可维护性都上了一个台阶。6.2 从Shell走向编排平台当你的服务真正上到编排平台脚本自动执行逻辑仍然会保留但它只是最后一道兜底。更完善的方案是把“等待数据库就绪”这类初始化逻辑下沉到编排平台的生命周期钩子里把动态配置注入下沉到配置中心把健康检查交给平台的探活机制。不过即便平台能力再强脚本里保证“容器启动即可运行”仍然是最可靠的底线。我的经验是脚本做兜底、平台做编排、配置中心做动态化这三层配合才能让一套镜像在不同的环境里跑得又稳又省心。最后再分享一个实用技巧如果你也想快速验证自己的entrypoint逻辑可以在本地用一个极简的基础镜像做模拟不需要真实业务代码。比如构建一个镜像只有baseutils和一个空的app.jar占位文件用docker run配合bash -x来看脚本执行细节docker run -it --rm --entrypoint bash myapp:latest -x /scripts/entrypoint.sh appbash -x会把脚本的每一条命令展开并打印变量值、判断过程全都暴露出来。这比自己到处加echo调试快得多。我第一次把所有初始化逻辑串通就是靠这个命令一步步看它怎么走完那十几个函数哪里断掉一眼就清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑