在Ubuntu服务器上做应用部署,手动执行ssh登录、cd到项目目录、git pull、重新安装依赖、重启服务这一连串操作,不仅耗时而且容易出错。尤其是当项目同时包含前端构建、后端迁移、静态资源同步等多步骤时,人工操作只要漏掉一步,线上环境就可能出现代码与依赖不一致的问题。自动化部署脚本的核心价值就是把重复操作固化下来,减少人为失误,同时让每次部署过程都有日志可查、可回滚。

一、部署脚本的基础结构与执行策略
编写部署脚本的第一步是确定执行环境。在Ubuntu中,Bash是默认的登录Shell,直接使用#!/usr/bin/env bash作为脚本首行可以保证在不同用户环境下都能正确解释脚本。紧接着需要设置严格的错误处理选项,其中set -euo pipefail是最常用的一组参数。-e表示任何命令返回非零状态时立即退出脚本,-u会在引用未定义变量时报错,-o pipefail则让管道命令中任意一个环节失败都触发退出。这三个选项组合起来能避免很多因为命令失败但脚本继续执行而导致的部署事故。
除了错误处理,变量和日志函数也是脚本的基础。把应用名称、部署目录、Git仓库地址、分支名、服务名等信息集中定义成变量,后续修改时只需要改动一处。日志函数建议统一封装,输出内容同时写到终端和日志文件,这样既能实时观察部署进度,也能事后排查问题。下面是一个基础模板:
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="my-app"
DEPLOY_DIR="/var/www/${APP_NAME}"
GIT_REPO="git@github.com:user/my-app.git"
BRANCH="main"
LOG_FILE="/var/log/deploy_${APP_NAME}.log"
log() {
echo "[$(date '+%F %T')] $1" | tee -a "$LOG_FILE"
}
log "开始部署 ${APP_NAME}"
if [ ! -d "$DEPLOY_DIR" ]; then
git clone "$GIT_REPO" "$DEPLOY_DIR"
else
cd "$DEPLOY_DIR"
git fetch origin
git reset --hard "origin/${BRANCH}"
fi
log "代码更新完成"
这个模板虽然简单,但已经体现了自动化部署脚本的两个核心原则:任何步骤失败立即停止,以及每一步都有日志记录。实际生产环境中还会加入目录权限检查、磁盘空间检查等前置条件,不过这些可以在基础结构之上逐步扩展。
二、完整的部署流程:从拉取代码到重启服务
一个完整的部署流程通常包含代码更新、依赖安装、资源构建、服务重启四个阶段。在Ubuntu上使用Systemd管理应用服务时,重启服务一般通过systemctl restart完成。部署脚本需要根据项目类型判断使用什么包管理器,例如存在package.json就执行npm安装和构建,存在requirements.txt就执行pip安装。
下面这个示例整合了Node.js项目和Python项目常见的依赖处理逻辑。脚本假设项目已经提前克隆到服务器,部署时只做增量更新,避免每次重新克隆带来的网络开销。构建完成后调用Systemd重启服务,整个流程一条命令执行完毕。
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/opt/my-app"
SERVICE_NAME="my-app"
LOG_FILE="/var/log/deploy-my-app.log"
log() {
echo "[$(date '+%F %T')] $1" | tee -a "$LOG_FILE"
}
log "开始部署"
cd "$APP_DIR"
log "拉取最新代码"
git fetch origin
git reset --hard origin/main
log "安装项目依赖"
if [ -f package.json ]; then
npm ci
npm run build
fi
if [ -f requirements.txt ]; then
pip install -r requirements.txt
fi
log "重启应用服务"
sudo systemctl restart "$SERVICE_NAME"
log "部署完成"
这段脚本适合大多数单体应用。如果项目使用Docker部署,可以把systemctl restart替换成docker compose up -d --build,本质仍然是拉取最新代码后重新构建镜像并启动容器。对于前后端分离的项目,可以分别维护两个部署脚本,或者在一个脚本里按目录分别执行前端和后端的构建命令。关键是流程要清晰,每一步之间有明确的依赖关系,避免并行执行导致状态不一致。
三、错误捕获与自动回滚设计
部署脚本最大的风险不是失败,而是失败后留下一个半更新状态,导致线上服务不可用且难以快速恢复。为了避免这种情况,生产级部署脚本需要加入错误捕获和回滚机制。Bash提供了trap命令,可以捕获ERR信号,在脚本遇到错误时执行清理或回滚操作。结合版本目录和软链接切换,可以实现比较可靠的回滚方案。
回滚设计的基本思路是保留上一个可用版本,部署时先将新代码克隆到一个独立目录,构建完成后通过软链接切换当前版本。如果构建或重启失败,脚本自动将软链接指向旧版本并恢复服务。下面是一个带版本目录管理功能的示例:
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="/opt/my-app"
RELEASES_DIR="$APP_DIR/releases"
CURRENT_DIR="$APP_DIR/current"
SERVICE_NAME="my-app"
LOG_FILE="/var/log/deploy-my-app.log"
log() {
echo "[$(date '+%F %T')] $1" | tee -a "$LOG_FILE"
}
cleanup() {
if [ $? -ne 0 ]; then
log "部署失败,开始回滚"
if [ -d "$CURRENT_DIR" ]; then
if [ -d "$RELEASES_DIR/previous" ]; then
rm -rf "$CURRENT_DIR"
mv "$RELEASES_DIR/previous" "$CURRENT_DIR"
sudo systemctl restart "$SERVICE_NAME"
log "回滚完成"
else
log "没有可用的回滚版本,请人工介入"
fi
fi
fi
}
trap cleanup ERR
log "创建新版本目录"
mkdir -p "$RELEASES_DIR"
NEW_RELEASE="$RELEASES_DIR/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 git@github.com:user/my-app.git "$NEW_RELEASE"
log "构建新版本"
cd "$NEW_RELEASE"
if [ -f package.json ]; then
npm ci
npm run build
fi
log "切换软链接并重启服务"
if [ -L "$CURRENT_DIR" ]; then
mv "$CURRENT_DIR" "$RELEASES_DIR/previous"
fi
ln -sfn "$NEW_RELEASE" "$CURRENT_DIR"
sudo systemctl restart "$SERVICE_NAME"
log "部署成功"
这个方案的核心是利用时间戳创建独立版本目录,每次部署都保留旧版本。软链接current始终指向正在使用的版本,切换操作是原子性的,不会出现中间状态。即使新版本构建失败,由于trap cleanup ERR的存在,脚本会自动把旧版本重新链接回来并重启服务。这种设计在中小规模项目中足够使用,如果项目规模更大,可以考虑引入专门的发布工具,但原理是相通的。
四、多环境配置与定时自动部署
同一个项目往往需要部署到开发、测试、生产等多个环境,不同环境的数据库连接、API地址、域名等配置各不相同。把环境差异从脚本中剥离出来,通过外部配置文件注入,可以让同一套部署脚本在多个环境复用。Ubuntu下常见的做法是在项目目录放置.env文件,脚本在执行前加载该文件中的变量。
加载.env文件时使用set -a和set +a可以让文件中的所有变量自动导出到当前Shell环境,方便后续命令使用。这样生产环境的.env文件可以与代码仓库分离,只存在于服务器本地,避免敏感信息进入版本控制。
ENV_FILE="$APP_DIR/.env" if [ -f "$ENV_FILE" ]; then set -a source "$ENV_FILE" set +a fi
对于需要定期发布的场景,可以把部署脚本加入crontab定时任务。例如每天凌晨3点自动执行部署,适合内容更新频率较高的应用。将以下内容写入当前用户的crontab即可:
0 3 * * * /usr/local/bin/deploy.sh
定时任务执行时没有交互式终端,因此脚本中的所有命令都必须保证非交互模式可用,例如git操作需要使用SSH密钥认证而不是密码认证,sudo命令需要配置NOPASSWD或者使用具备相应权限的系统用户执行。同时日志文件要设置合理的轮转策略,避免长时间运行后日志无限增长占用磁盘空间。