第4章 部署脚本编写与调试

部署脚本这东西,看似简单,写起来全是坑。我见过太多团队用一段helloworld级别的shell脚本就去上线,结果环境一变就跑飞了。本章咱们聊聊怎么写出靠谱的部署脚本,以及出问题了怎么快速定位。

4.1 脚本结构:先搭骨架再填肉

写脚本跟盖房子一样,先要有结构。我个人习惯分成五块:前置检查、配置加载、执行动作、结果验证、清理回收。每块之间用明确的退出点隔开。

推荐结构模板:
#!/bin/bash
set -euo pipefail   # 严格模式,我每回都加

# ---------- 前置检查 ----------
check_prereqs() {
    command -v docker >/dev/null 2>&1 || { echo "需要docker"; exit 1; }
}

# ---------- 配置加载 ----------
load_config() {
    source "${DEPLOY_ENV:-./config/default.env}"
}

# ---------- 执行动作 ----------
deploy() {
    docker compose -f docker-compose.prod.yml up -d
}

# ---------- 结果验证 ----------
verify() {
    curl -sf http://localhost:8080/health | grep -q '"status":"OK"'
}

# ---------- 清理回收 ----------
cleanup() {
    echo "清理临时文件..."
}

# 主流程
check_prereqs
load_config
deploy
verify
cleanup
我的经验:早期时我习惯把所有代码平铺写,出错了得从头看到尾。后来改成了函数式+主流程调用,半年后自己再看都能秒懂哪里断了。

4.2 错误处理:别让脚本偷偷失败

很多人写脚本默认继续执行,哪怕上一步已经失败了。这在部署场景里非常危险——你以为部署成功了,实际中间断了,最后留下一堆半残状态。

我总结了三个必须做的点:

  • set -e:任何非零退出码立刻终止。但注意有些命令本身会返回非零(比如grep没找到),需要用 || true 处理。
  • set -u:变量未定义直接报错,避免用空值执行危险操作。比如 rm -rf $TARGET/,TARGET为空就成灾难了。
  • trap:捕获 ERR 和 EXIT,在脚本异常退出时执行清理。
#!/bin/bash
set -euo pipefail

cleanup() {
    echo "脚本退出,执行清理..."
    rm -f /tmp/deploy_lock
}
trap cleanup EXIT

echo "开始部署..."
# 正常流程
...
曾经踩过的坑:有一次我漏了 set -u,脚本里引用了 $ENVIRONMENT,但配置文件未加载。结果 rm -rf /${ENVIRONMENT}/deploy 直接变成了 rm -rf /deploy,还好只是dev环境。从那以后我写脚本第一行必加这三个开关。

4.3 参数化设计:别把密码写死在脚本里

部署脚本最忌讳硬编码。环境地址、版本号、密钥,都应该通过外部传入。这样一来,同一套脚本可以在开发、测试、生产环境复用。

参数类型推荐方式反例
环境配置环境变量或 .env 文件在脚本里写 DEV_URL='192.168.1.100'
版本号命令行参数 ./deploy.sh v2.3.1在脚本里写 VERSION='latest'
敏感信息密钥管理服务或 vault把密码写在脚本里 commit 到git

我一般这样组织参数加载顺序:优先级从高到低 → 命令行参数 → 环境变量 → 默认值。

#!/bin/bash
DEPLOY_VERSION="${1:-${DEPLOY_VERSION:-latest}}"
DEPLOY_ENV="${2:-${DEPLOY_ENV:-dev}}"

echo "部署版本: $DEPLOY_VERSION"
echo "部署环境: $DEPLOY_ENV"

4.4 日志与调试:脚本出问题怎么查?

脚本执行时一片安静,出错了只有一行"命令不存在",这种体验我受够了。后来我养成了写日志的习惯。

  • 带时间戳的日志:echo "[$(date +'%Y-%m-%d %H:%M:%S')] 开始部署..."
  • 分级别输出:INFO/WARN/ERROR,便于grep过滤
  • 保留上一次的输出:每次部署前将旧日志归档,别覆盖
  • set -x 用于调试:在出问题的函数前面加一行 set -x,执行完再 set +x
一个小窍门:在脚本开头定义日志函数,整个脚本统一调用。
log() { echo "[`date`] $*"; }
info() { log "INFO: $*"; }
warn() { log "WARN: $*"; }
error() { log "ERROR: $*"; exit 1; }

4.5 安全合规检查点:脚本里不能踩的红线

合规要求不仅是最终环境要检查,脚本本身也要过审计。我整理了一张清单,每次写完脚本对着跑一遍。

检查项要求检查方法
无明文密码不能出现密码、、证书明文grep -i 'password\|secret\|' deploy.sh
文件权限最小化部署脚本不能是777ls -l deploy.sh 应为 755 或 700
不执行未校验的文件下载远程脚本前要校验checksumshasum -c checksums.txt
不绕过sudo认证不能写死NOPASSWD检查 /etc/sudoers 及脚本里的sudo调用
日志不记录敏感信息环境变量里的密码不能echo审计日志输出,加密或脱敏

我记得有一次合规审计发现我们脚本里有个 wget http://internal.server/script.sh | bash,审计师直接给了高危。后来我们改成了先下载,校验签名,再执行。这个习惯一直保留到现在。

4.6 调试实战:一个典型的故障定位过程

假设你写好的部署脚本在生产环境报错,但是没有详细日志。你会怎么做?我分享一下我的排查流程:

  1. 先看退出码:运行 echo $?,缩小范围
  2. 加 set -x 重跑:能看到每一步执行的命令和展开后的变量值
  3. 检查依赖命令是否存在:sometimes docker-compose vs docker compose 在新机器上不一致
  4. 检查环境变量:echo $DEPLOY_ENV,是不是加载了错误的配置文件
  5. 检查权限:当前用户有没有权限写目标目录、启动服务
  6. 检查网络连通性:curl -v 内部仓库,是不是防火墙阻断

你看,大部分部署脚本问题其实都不是代码逻辑问题,而是环境差异。所以我在写脚本时尽量多做强校验,把各种异常情况在脚本里提前拦住。

又一个教训:我曾经写了个脚本,本机测试全通过,上线时发现容器跑的数据库连不上。折腾两小时,最后发现是脚本里用了 localhost,而生产环境容器网络模式下要用服务名。从那以后我固定推荐用环境变量注入数据库地址,而不是在脚本里硬编码。

4.7 本章核心知识结构

下面这张图总结了部署脚本编写与调试的主要环节和关系。你可以把它当作一个检查清单。

部署脚本编写与调试知识体系 1. 脚本结构设计 2. 错误处理 3. 参数化设计 4. 日志与调试 5. 安全合规检查点 6. 调试实战流程 验证确认 & 持续改进 核心原则:先检查再执行、最小权限、日志可追溯、失败快速停止 每次部署后都要回顾脚本,持续打磨

这张图从左到右、从上到下串起了整个脚本的生命周期。不是说你必须每一步都做到完美,但至少心里要有这根弦。当你遇到脚本报错时,按这个框架挨个排查,大多数问题都能在十分钟内定位。