公司动态
从零构建Docker自动化部署脚本:Shell脚本实现CI/CD最后一公里
1. 从手动到自动为什么我们需要一个部署脚本如果你和我一样经历过在凌晨三点睡眼惺忪地登录服务器手动敲下一连串docker pull,docker stop,docker rm,docker run命令来更新一个线上服务那么你一定会对“自动化部署”这个词有切肤之痛。一次两次尚可忍受但当你的服务数量增长到两位数更新频率变成一天数次时这种重复、枯燥且极易出错的手工操作就成了运维效率和系统稳定性的巨大瓶颈。今天要聊的就是如何用一段简洁但功能完备的 Shell 脚本将 Docker 镜像的部署过程彻底自动化把我们从繁琐的重复劳动中解放出来。这个脚本的核心价值远不止是“省事”。想象一下这样的场景你的 CI/CD 流水线比如 Jenkins、GitLab CI在成功构建并推送一个新的 Docker 镜像到仓库后如何让它在生产或测试环境里跑起来手动登录服务器操作显然不现实。此时一个部署脚本就是连接“构建”与“运行”的最后、也是最关键的一公里。它确保了部署动作的一致性和可重复性——无论是在开发者的笔记本上还是在测试服务器或是生产集群的某个节点上执行同一个脚本得到的结果是完全一致的。这极大地减少了“在我本地是好的”这类环境差异问题。更进一步一个设计良好的部署脚本本身就是一个清晰的部署文档。它明确定义了运行一个服务所需的所有参数镜像版本、端口映射、环境变量、数据卷挂载、网络配置等等。新成员接手项目或者你需要重建一个环境时不再需要去翻聊天记录或凭记忆拼凑命令直接运行脚本即可。所以编写这个脚本不仅是为了“偷懒”更是为了提升团队协作的规范性、系统的可维护性和部署的可靠性。接下来我们就从零开始构建一个既实用又健壮的 Docker 自动部署脚本。2. 脚本蓝图核心功能与设计考量在动手写第一行代码之前我们必须想清楚这个脚本到底要做什么以及如何做得优雅、健壮。一个基础的 Docker 部署流程可以抽象为以下几个核心步骤我们的脚本将围绕它们展开拉取指定版本的最新镜像从镜像仓库如 Docker Hub、私有 Harbor获取我们要运行的软件包。停止并清理旧容器如果同名容器已经在运行需要先安全地停止并移除它为启动新容器腾出位置。启动新容器使用拉取的新镜像以预定义的配置端口、卷、环境变量等启动一个新的容器。健康检查与状态反馈容器启动后需要确认它是否真的健康运行起来了而不是瞬间崩溃并将结果清晰地反馈给执行者。这听起来很简单但魔鬼藏在细节里。一个直接按这个顺序写的“裸奔”脚本在真实环境中会非常脆弱。因此我们在设计时必须融入以下几个关键考量2.1 幂等性脚本可以安全地重复执行这是自动化脚本的黄金法则。无论脚本执行前系统处于什么状态容器不存在、容器正在运行、容器已停止脚本执行后系统都应达到预期的目标状态指定名称的容器以指定镜像和配置运行。这意味着我们的脚本需要具备状态判断能力。例如在尝试停止容器前先检查该名称的容器是否存在在拉取镜像前或许可以检查本地是否已存在目标版本避免不必要的网络流量虽然为了保证一致性通常建议每次都拉取。2.2 可配置性关键参数应从脚本中剥离把镜像名称、标签、端口号等硬编码在脚本里是糟糕的做法。一旦需要部署另一个服务或者修改端口就得去修改脚本本身容易出错且难以管理。正确的做法是使用配置文件、环境变量或命令行参数来传递这些可变项。这样一个脚本模板就能复用于多个不同的服务。我们将采用 Shell 变量结合外部传参的方式来实现。2.3 健壮性完善的错误处理与日志记录脚本不能遇到错误就悄无声息地失败或者把一堆晦涩的 Docker 错误信息抛给用户。我们需要在每一个可能失败的步骤如拉取镜像失败、停止容器失败、启动容器失败后检查上一条命令的返回值$?一旦失败就打印明确的错误信息并退出脚本避免在错误的状态下继续执行更危险的操作。同时将关键操作和结果输出到日志文件或标准输出便于事后追溯和排查问题。2.4 安全性敏感信息的处理永远不要在脚本中明文写入密码、密钥等敏感信息。对于需要传递给 Docker 容器的环境变量如数据库密码应该通过 Docker Secrets、配置文件在运行宿主机上妥善保管或从安全的变量存储中获取。在我们的示例脚本中会强调这一点并给出安全的处理建议。基于以上设计原则我们的脚本将从一个简单的骨架开始逐步加固最终形成一个可用于生产环境原型的部署工具。3. 步步为营脚本核心逻辑逐行实现现在让我们打开文本编辑器创建一个名为deploy.sh的文件并赋予它执行权限 (chmod x deploy.sh)。我们将分模块构建脚本。3.1 脚本头与基础配置#!/bin/bash # # Docker 容器自动部署脚本 # 版本: 1.0 # 作者: [你的名字] # 描述: 用于拉取、更新并运行指定Docker镜像 # set -euo pipefail#!/bin/bash指定解释器为 Bash。set -euo pipefail这是一个非常重要的安全设置。-e一旦任何命令执行失败返回非零状态脚本立即退出。-u遇到未定义的变量时视作错误并退出。-o pipefail在管道命令中只要任何一个环节失败整个管道就视为失败。这能避免管道中前面命令失败但后面命令成功导致脚本继续执行的诡异情况。3.2 参数定义与输入验证我们不使用硬编码而是通过变量和命令行参数来配置。# 可配置变量 (可通过命令行参数覆盖) IMAGE_NAME${1:-nginx} # 镜像名称默认为 nginx IMAGE_TAG${2:-latest} # 镜像标签默认为 latest CONTAINER_NAME${3:-my-nginx} # 容器名称默认为 my-nginx HOST_PORT${4:-80} # 宿主机映射端口默认为 80 CONTAINER_PORT${5:-80} # 容器内部端口默认为 80 # 衍生变量 FULL_IMAGE_NAME${IMAGE_NAME}:${IMAGE_TAG}这里使用了 Bash 的参数扩展语法{1:-default_value}。意思是如果第一个命令行参数 ($1) 未提供或为空则使用默认值nginx。这样脚本可以非常灵活地调用./deploy.sh部署最新的nginx镜像容器名为my-nginx映射宿主机80端口。./deploy.sh myapp v1.2.3 backend-app 8080 8080部署myapp:v1.2.3容器名为backend-app映射宿主机8080端口到容器8080端口。接下来添加一些基本的输入验证# 输入验证 if [[ -z $IMAGE_NAME ]] || [[ -z $IMAGE_TAG ]]; then echo 错误: 镜像名称和标签不能为空。 echo 用法: $0 [IMAGE_NAME] [IMAGE_TAG] [CONTAINER_NAME] [HOST_PORT] [CONTAINER_PORT] exit 1 fi # 简单验证端口是否为数字 if ! [[ $HOST_PORT ~ ^[0-9]$ ]] || ! [[ $CONTAINER_PORT ~ ^[0-9]$ ]]; then echo 错误: 端口号必须为数字。 exit 1 fi echo echo 开始部署容器 echo 镜像: $FULL_IMAGE_NAME echo 容器名称: $CONTAINER_NAME echo 端口映射: $HOST_PORT-$CONTAINER_PORT echo 3.3 核心操作函数拉取镜像我们将每个主要操作封装成函数使主流程更清晰也便于复用和测试。# 函数拉取 Docker 镜像 pull_image() { echo [步骤1] 正在拉取镜像 $FULL_IMAGE_NAME ... if docker pull $FULL_IMAGE_NAME; then echo 成功拉取镜像 $FULL_IMAGE_NAME else echo 错误: 拉取镜像 $FULL_IMAGE_NAME 失败请检查镜像名称、标签及网络连接。 exit 1 fi }docker pull命令会返回执行状态。我们利用if...then判断其是否成功。失败时打印错误并退出得益于set -e这里显式退出是为了给出更友好的错误信息。3.4 核心操作函数停止并移除旧容器这是实现幂等性的关键。我们不能直接docker stop因为容器可能不存在。# 函数停止并移除指定名称的容器如果存在 remove_old_container() { local container_name$1 echo [步骤2] 处理旧容器 $container_name ... # 检查容器是否存在无论运行与否 if docker ps -a --filter name^/${container_name}$ --format {{.Names}} | grep -q ^${container_name}$; then echo 发现已存在的容器 $container_name # 检查容器是否正在运行 if docker ps --filter name^/${container_name}$ --filter statusrunning --format {{.Names}} | grep -q ^${container_name}$; then echo 正在停止运行中的容器... if docker stop $container_name; then echo 容器已停止。 else echo 警告: 停止容器失败但将继续尝试移除。 fi # 给容器一点时间优雅停止 sleep 2 fi echo 正在移除容器... if docker rm $container_name; then echo 旧容器 $container_name 已移除。 else echo 错误: 移除容器 $container_name 失败 exit 1 fi else echo 未找到名为 $container_name 的容器无需清理。 fi }这里有几个细节docker ps -a --filter name^/${container_name}$-a列出所有容器包括已停止的。--filter使用正则^/${container_name}$精确匹配容器名/是 Docker 容器名默认前缀。这比用grep直接过滤docker ps -a的输出更精确可靠。先判断是否存在再判断是否在运行。如果容器存在但已停止则直接进行docker rm。停止容器后等待2秒 (sleep 2)这是一个简单的容错给应用一些处理终止信号的时间对于简单服务通常足够。3.5 核心操作函数运行新容器这是部署的最终步骤。我们在此处定义容器的运行配置。# 函数运行新的 Docker 容器 run_new_container() { local image_name$1 local container_name$2 local host_port$3 local container_port$4 echo [步骤3] 正在启动新容器 $container_name ... # 在此处添加运行容器时需要的其他参数例如 # -e 环境变量 -v 数据卷 --network 网络等 run_commanddocker run -d \ --name $container_name \ -p $host_port:$container_port \ --restart unless-stopped \ $image_name echo 执行命令: $run_command if $run_command; then echo 新容器 $container_name 启动命令执行成功。 else echo 错误: 启动容器 $container_name 失败 exit 1 fi }-d后台运行守护进程模式。--restart unless-stopped设置重启策略。unless-stopped意味着除非用户显式执行docker stop否则容器退出后 Docker 守护进程会自动重启它。这对于服务可靠性非常重要。你也可以根据需求改为always或on-failure。我们将命令拼接成字符串并打印出来便于调试。实际执行时直接执行这个字符串。这是一个基础模板。实际应用中你很可能需要添加更多参数如-e KEYVALUE设置环境变量。-v /host/path:/container/path挂载数据卷。--network my-network加入自定义 Docker 网络。--env-file .env从文件读取环境变量注意文件安全。3.6 健康检查与状态确认容器启动成功不代表服务就绪。我们需要一个简单的健康检查。# 函数检查容器状态 check_container_status() { local container_name$1 local max_checks10 local check_interval3 echo [步骤4] 检查容器 $container_name 运行状态... for ((i1; imax_checks; i)); do if docker ps --filter name^/${container_name}$ --filter statusrunning --format {{.Names}} | grep -q ^${container_name}$; then # 获取容器ID前12位 container_id$(docker ps --filter name^/${container_name}$ --format {{.ID}} | head -c 12) echo 成功容器 $container_name (ID: $container_id) 正在运行。 echo 可以使用 docker logs $container_name 查看日志或 docker stats $container_name 查看资源使用情况。 return 0 else echo 等待容器启动... ($i/$max_checks) sleep $check_interval fi done echo 错误: 在 ${max_checks} 次检查后容器 $container_name 仍未进入运行状态。 echo 请使用 docker logs $container_name 检查容器日志以排查问题。 exit 1 }这个函数会循环检查最多10次每次间隔3秒直到容器状态变为running。如果超时仍未运行则判定为部署失败并提示用户查看日志。这是一种简单的“就绪探针”实现。3.7 主执行流程最后我们将所有函数串联起来形成主流程。# 主函数 main() { echo 部署流程开始于: $(date) # 1. 拉取镜像 pull_image # 2. 清理旧容器 remove_old_container $CONTAINER_NAME # 3. 运行新容器 run_new_container $FULL_IMAGE_NAME $CONTAINER_NAME $HOST_PORT $CONTAINER_PORT # 4. 检查状态 check_container_status $CONTAINER_NAME echo 部署流程结束于: $(date) echo echo 部署成功容器 $CONTAINER_NAME 已启动并运行。 } # 执行主函数 main至此一个具备基础功能、错误处理和状态检查的自动部署脚本就完成了。你可以直接运行./deploy.sh来部署一个默认的 Nginx 服务。4. 进阶加固让脚本更专业、更安全上面的脚本已经可以工作但对于生产环境或团队协作我们还需要考虑更多。下面是一些进阶的加固和优化点。4.1 日志记录不只是输出到屏幕将脚本的运行日志保存到文件对于审计和排错至关重要。我们可以在脚本开头定义日志文件并使用exec重定向。# 在主函数定义之前添加 LOG_FILE./deploy_${CONTAINER_NAME}_$(date %Y%m%d_%H%M%S).log exec (tee -a $LOG_FILE) 21 echo “日志将同时输出到屏幕并保存至: $LOG_FILE”exec (tee -a “$LOG_FILE”) 21这行命令将脚本后续所有标准输出和标准错误都同时输出到屏幕 (tee) 和追加到指定的日志文件中。4.2 配置分离使用外部配置文件当部署参数变得复杂时命令行参数会很长且难以管理。使用一个独立的配置文件是更好的选择。例如创建一个deploy.conf文件# deploy.conf IMAGE_NAMEmy-web-app IMAGE_TAGv2.1.0 CONTAINER_NAMEproduction-webapp HOST_PORT“8080” CONTAINER_PORT“80” # 更多复杂配置 DOCKER_NETWORKapp-network DATA_VOLUME/opt/app/data:/app/data ENV_FILE.env.production然后在脚本中读取它CONFIG_FILE${CONFIG_FILE:-./deploy.conf} if [[ -f $CONFIG_FILE ]]; then echo “使用配置文件: $CONFIG_FILE” # 安全地加载配置文件避免执行恶意代码 # 注意这种方法要求配置文件中是简单的 KEYVALUE 对且值不包含空格或特殊字符或已转义。 # 对于复杂配置建议使用专门的解析工具或语言如 Python, yq, jq。 while IFS‘’ read -r key value; do # 去除可能的空白和注释 key$(echo “$key” | sed ‘s/^[[:space:]]*//; s/[[:space:]]*$//’) value$(echo “$value” | sed ‘s/^[[:space:]]*//; s/[[:space:]]*$//’) [[ -n “$key” “$key” ! \#* ]] declare “$key”“$value” done “$CONFIG_FILE” else echo “警告: 未找到配置文件 $CONFIG_FILE将使用默认值或命令行参数。” fi注意在 Shell 中直接source一个来自外部的配置文件是危险的如果文件内容被恶意篡改可能导致任意代码执行。上述while read循环是一种更安全的、只进行变量赋值的方法但它对配置文件的格式有要求。对于生产环境更推荐使用yaml或json格式的配置并用yq、jq等工具解析或者直接用 Python 等更安全的语言来编写部署逻辑。4.3 敏感信息管理环境变量文件与 Docker Secrets绝对不要在脚本或配置文件中明文写入密码、API密钥。推荐做法使用--env-file创建一个.env文件里面存放环境变量KEYVALUE。在docker run命令中通过--env-file .env传入。务必确保.env文件不被提交到版本控制系统在.gitignore中添加它并且其文件权限设置正确如chmod 600 .env。使用 Docker Secrets在 Docker Swarm 模式下可以使用docker secret来管理敏感数据容器内可以通过/run/secrets/路径访问。对于单机 Docker可以通过绑定挂载一个仅限 root 读取的文件来模拟。在脚本中可以这样安全地处理# 在 run_new_container 函数中修改 run_command local env_file_path“.env” run_command“docker run -d \ --name $container_name \ -p $host_port:$container_port \ --restart unless-stopped” # 如果存在环境变量文件则添加 if [[ -f “$env_file_path” ]]; then run_command“$run_command --env-file $env_file_path” fi run_command“$run_command $image_name”4.4 镜像标签策略与回滚我们的脚本默认使用latest标签这在生产环境是危险的因为latest是流动的。最佳实践是始终使用明确的版本标签如myapp:v1.2.3。更进一步我们可以实现简单的回滚功能在部署新版本前给当前运行的旧容器打一个标签备份。# 在 remove_old_container 函数中停止容器之前 BACKUP_TAG“backup_$(date %Y%m%d_%H%M%S)” echo “正在为旧容器镜像创建备份标签: $FULL_IMAGE_NAME - ${IMAGE_NAME}:${BACKUP_TAG}” docker commit $container_name ${IMAGE_NAME}:${BACKUP_TAG} 2/dev/null echo “备份创建成功。” || echo “备份创建失败或跳过可能无旧容器。”docker commit会将当前容器的状态保存为一个新的镜像。这样如果需要回滚只需修改脚本参数将IMAGE_TAG指定为备份的标签即可重新部署。当然更完善的回滚应该与你的镜像仓库和 CI/CD 流程结合。4.5 集成到 CI/CD 流水线这个脚本是 CI/CD 流水线中“部署”阶段的完美候选。以 Jenkins 为例你可以在一个“Execute shell”构建步骤中这样调用#!/bin/bash # Jenkins 或其他 CI 环境通常会将构建号、Git标签等作为环境变量 APP_IMAGE“my-registry.com/myteam/myapp” APP_TAG“${BUILD_NUMBER}” # 或 ${GIT_TAG} CONTAINER_NAME“myapp-prod” HOST_PORT“3000” # 将构建好的镜像推送到仓库 docker push ${APP_IMAGE}:${APP_TAG} # 在目标服务器上执行部署脚本 # 假设脚本已通过 SCP 或 Ansible 等方式分发到服务器 ssh userproduction-server “cd /opt/deploy ./deploy.sh ${APP_IMAGE} ${APP_TAG} ${CONTAINER_NAME} ${HOST_PORT} 3000”注意在生产环境中使用 SSH 直接执行命令需要做好密钥认证和安全配置。更成熟的做法是使用 Ansible、SaltStack 等配置管理工具或 Kubernetes 的kubectl set image命令如果你用的是 K8s。5. 避坑指南实战中常见问题与排查即使脚本写得再完善在实际部署中依然会遇到各种问题。这里分享几个我踩过的坑和排查思路。5.1 端口冲突问题错误信息通常类似Error response from daemon: driver failed programming external connectivity on endpoint...: Bind for 0.0.0.0:80 failed: port is already allocated.原因宿主机上的 80 端口已被其他进程可能是另一个 Docker 容器也可能是 Nginx、Apache 等原生服务占用。排查在宿主机上运行sudo netstat -tulpn | grep :80查看是哪个进程PID占用了 80 端口。如果是另一个 Docker 容器使用docker ps查看是哪个容器考虑是否需要停止它或者为当前服务更换一个宿主机端口如-p 8080:80。如果是系统服务需要决定是停止该服务还是更换部署服务的端口。脚本增强可以在运行容器前增加端口检查。if ss -tuln | grep -q “:${HOST_PORT} “; then echo “错误: 宿主机端口 ${HOST_PORT} 已被占用。” exit 1 fiss命令是netstat的现代替代品。5.2 镜像拉取失败错误信息Error response from daemon: pull access denied for repository, repository does not exist or may require ‘docker login’或网络超时。原因镜像名称拼写错误或标签不存在。访问的是私有仓库但未登录。网络问题无法访问 Docker Hub 或私有仓库地址。排查手动执行docker pull 镜像名:标签确认问题。对于私有仓库先执行docker login 仓库地址。检查网络连接和 DNS 解析。国内用户可能因为网络原因拉取 Docker Hub 镜像缓慢或失败可以考虑配置国内镜像加速器。注意此处仅讨论技术概念不涉及任何具体工具或服务名称通常是在 Docker 守护进程配置中修改registry-mirrors。脚本增强在pull_image函数中可以对特定错误码给出更明确的提示。5.3 容器启动后立即退出这是最常见也最令人头疼的问题之一。脚本显示容器启动成功但check_container_status函数一直等不到容器进入运行状态最终超时失败。原因容器内的主进程ENTRYPOINT或CMD指定的命令启动失败并退出了。排查查看容器日志是第一步也是最重要的一步执行docker logs 容器名。日志通常会直接告诉你错误原因比如配置文件语法错误、依赖的服务如数据库连接不上、必要的环境变量缺失、权限问题等。如果日志没有输出可以尝试以交互模式运行一次docker run -it --rm 镜像名:标签 /bin/sh然后手动尝试启动应用进程观察输出。检查docker run命令是否遗漏了必要的参数如环境变量 (-e)、卷挂载 (-v) 等。脚本增强我们的check_container_status函数在失败时已经提示查看日志这很好。还可以考虑在失败后自动打印最后几行日志echo “正在获取容器日志...” docker logs --tail 50 “$container_name” 215.4 磁盘空间不足错误信息可能比较隐晦如no space left on device或在拉取镜像、创建容器时失败。原因Docker 的默认存储区域通常是/var/lib/docker磁盘空间耗尽。排查使用df -h查看磁盘使用情况。使用docker system df查看 Docker 自身的磁盘使用详情镜像、容器、卷、缓存等。清理docker system prune -a危险这会清理所有未被使用的镜像、容器、网络和卷未被任何容器引用的。生产环境慎用建议先docker system df -v查看详情。清理特定资源docker image prune(无用镜像)docker container prune(停止状态的容器)。最根本的解决方案是规划好存储或者将 Docker 的数据目录 (/var/lib/docker) 挂载到更大的磁盘分区上。5.5 权限问题错误信息permission denied常见于尝试挂载宿主机目录或容器内进程试图写入某些路径时。原因宿主机文件权限容器内进程通常以非 root 用户运行出于安全考虑。如果你将宿主机的一个属主为root:root且权限为600的目录挂载到容器容器内的用户很可能没有写入权限。SELinux/AppArmor在一些 Linux 发行版如 CentOS/RHEL上SELinux 可能会阻止容器进程访问宿主机文件系统。解决调整宿主机目录的权限和属组使其对容器内用户或所有用户可读/写。例如chmod 755 /host/data。在docker run时使用-u参数指定用户 ID但需确保该用户在宿主机上有权限。对于 SELinux可以在挂载时添加:z或:Z后缀来重新标记文件上下文如-v /host/data:/container/data:z。:z是共享标签:Z是私有非共享标签。注意:Z会改变宿主机文件的 SELinux 上下文可能导致宿主机其他服务无法访问该目录请谨慎使用。如果只是为了开发测试最粗暴但不安全的临时方案是在宿主机上临时禁用 SELinux (setenforce 0)但生产环境不推荐。编写自动化脚本就像搭积木从满足最基本的需求开始然后根据实际遇到的挑战一块一块地添加上错误处理、日志、配置管理、健康检查等模块。最终它会成为一个可靠、省心的部署伙伴。希望这个从零构建的指南和其中分享的经验能帮助你打造出适合自己的 Docker 自动化部署流程。记住最好的脚本不是一开始就完美无缺的而是在一次次解决实际问题的过程中迭代出来的。