1. 这篇文章真正要解决的问题
很多开发者第一次接触 Linux 服务器时,都会经历这样一个阶段:手动敲命令部署应用。cd、tar、cp、chmod、systemctl restart,一条一条命令敲进去,运气好一次通过,运气不好中途报错,然后对着终端发呆。
手动部署最致命的问题不是慢,而是不可复现。今天在上线前漏了一步配置文件覆盖,明天忘了给新目录授权,后天发现这次部署的目录名和上次不一致。这些问题单次看不严重,但叠加到团队协作和频繁发版场景下,会成为线上事故的重要来源。
所以“脚本部署方式”并不是把几行命令塞进.sh文件那么简单。它的本质是:把部署过程从“人肉记忆 + 临时敲命令”变成“可重复、可检查、可回滚的工程动作”。
这篇文章讲的是 Linux 单机场景下的脚本化部署,适合以下读者:
- 刚接触 Linux 运维,想建立规范化部署习惯的开发者。
- 负责小团队测试服务器或生产服务器,不想引入太重型 CI/CD 平台的工程师。
- 需要给非运维同事写一套“点一下就能部署”脚本的负责人。
- 准备面试 DevOps 或运维开发岗位,需要讲清楚部署流程设计和脚本细节的求职者。
读完这篇文章,你能掌握一套通用的单机部署脚本框架,包括目录规范、备份策略、健康检查、日志记录和回滚方案。它不绑定具体技术栈,Java 服务、Node 服务、Python 服务都可以套用,要改的只是应用启动命令那几行。
2. 脚本部署的核心概念与适用边界
2.1 什么算“脚本部署”
脚本部署是指:把应用的获取、解压/同步、配置覆盖、进程停止、进程启动、健康检查等步骤,封装到一个或多个 Shell 脚本中,通过执行脚本来完成一次标准化的部署动作。
它与手动部署的区别如下:
| 对比项 | 手动部署 | 脚本部署 |
|---|---|---|
| 执行路径 | 凭记忆逐条输入命令 | 脚本内固化标准流程 |
| 可记录性 | 终端输出易丢失 | 自动写入部署日志 |
| 错误处理 | 依赖人眼发现 | 脚本检查退出码并中断 |
| 回滚 | 重新手动处理 | 调用备份/回滚逻辑 |
| 新手可用性 | 需要熟悉命令 | 按要求执行即可 |
| 审计 | 难以追踪 | 日志可追溯 |
2.2 它的适用边界
脚本部署最适合的是单机、有固定目录结构、发布频率中等的场景。比如:
- 一台测试服务器上部署 Spring Boot 服务。
- 一台轻量云服务器上部署 Nginx 静态站点。
- 一台内网机器上部署 Python 定时任务或 API 服务。
它不适合的场景包括:
- 多台服务器同时发布,且需要逐个同步或灰度控制 —— 这该交给 Ansible、SaltStack 这类自动化工具。
- 大规模微服务集群,需要镜像构建、编排调度 —— 这是 Kubernetes 和 CI/CD 平台的工作。
- 频繁滚动发布的线上核心业务 —— 需要更完善的发布系统和监控体系。
这里要强调一个容易误判的点:很多人以为“脚本部署”和“自动化平台”是取代关系,实际上它们是演进关系。脚本部署是底层能力的沉淀,自动化平台是在脚本之上做的流程封装。即使公司已经有 Jenkins 或 GitLab CI,底层执行的往往还是一段脚本。所以脚本部署不是过时技能,它是运维工程的基石。
2.3 Shell 脚本为什么够用
有人会问:为什么不是 Python?为什么不用 Ansible?
从单机部署的需求看,Shell 脚本的优势是依赖最少。几乎任何 Linux 发行版都自带 Bash,不需要安装 Python 依赖,不需要额外维护 Playbook 语法。配合set -euo pipefail做执行保护,配合函数封装做代码复用,Shell 脚本在单机场景下的表现足够可靠。
此外,Shell 脚本可以很方便地被各种上层工具调用。Jenkins 能执行它,crontab 能调度它,用户手动执行也只需要一条命令。这种“低摩擦”特性,是它长盛不衰的根本原因。
3. 环境准备与前置约定
3.1 环境要求
实际操作前,先明确环境。本文示例在一台 Ubuntu 22.04 LTS 服务器上验证,使用 Bash 5.x。对于 CentOS 7/8、Rocky Linux 等系统,脚本逻辑一致,个别命令写法可能略有差异,例如包管理器从apt换成yum。
你需要准备:
- Linux 服务器一台,建议最少 1C2G 配置。
- 部署用户,本文使用
deploy用户,配置 sudo 权限。 - 待部署的应用包或构建产物。
- 确保
curl或wget可用,用于健康检查。
尽量不用root直接执行部署。原因后面会展开,但先记住这个约定:运行时用业务用户,提权时才用 sudo。
3.2 目录结构约定
脚本部署最容易乱的地方是目录结构。推荐按下面这套约定:
/opt/project/ ├── app/ │ ├── bin/ # 启动停止脚本 │ ├── conf/ # 配置文件 │ ├── libs/ # 依赖包或应用 jar 包 │ └── logs/ # 应用日志 ├── backup/ # 每次发布前备份 ├── releases/ # 历史版本包 └── deploy/ # 部署脚本存放处这里面的逻辑是:
app是运行时目录,应用真正使用的文件都在这。backup保存上一个可用版本,用于回滚。releases按时间戳归档每次发布的包,便于追溯。deploy存放部署脚本,与业务目录分开,避免误删。
这样的分层设计,能让“部署”和“回滚”都变成目录操作,而不是辗转反侧的临时命令。
3.3 部署用户说明
创建部署用户并授权:
# root 权限下执行 useradd -m -s /bin/bash deploy passwd deploy # 创建目录结构 mkdir -p /opt/project/{app/{bin,conf,libs,logs},backup,releases,deploy} chown -R deploy:deploy /opt/project从材料的角度看,这一步其实是很多脚本部署项目“跑不起来”的隐藏原因。权限不对,后面脚本执行时各种 Permission denied,又不敢随便chmod 777。所以前置规划值得多花两分钟。
4. 一套通用部署脚本的设计思路
4.1 脚本设计目标
在动手写脚本之前,先确定设计目标:
- 幂等性:同一版本的包重复执行部署,不会产生叠加副作用。
- 可回溯:每一步执行有日志,失败时能看懂卡在哪。
- 可回滚:执行时自动备份旧版本,回滚时能一键恢复。
- 安全兜底:关键步骤检查退出码,失败立即停止。
4.2 脚本主流程
一个标准部署脚本的流程如下:
- 加载配置:定义应用名、目录、端口、进程匹配关键字。
- 参数解析:支持
--version、--env等可选参数。 - 前置校验:检查包文件是否存在、端口是否被占用。
- 停止应用:按 PID 文件或进程关键字优雅停止。
- 备份现有版本:复制当前
app目录到backup。 - 解压/同步新版本:把新包内容释放到
app目录。 - 覆盖配置:将
conf下的环境配置重新应用。 - 启动应用:执行启动命令,并等待进程拉起。
- 健康检查:检查端口和 HTTP 状态,判定成功或失败。
可以看到,脚本部署并不只是“拷文件 + 重启”,它还承担了发布前的风险控制和发布后的验证。
4.3 日志与退出码策略
Shell 脚本最容易被忽略的是“失败了要停下来”。默认情况下,bash在一条命令失败后还会继续往下跑,这非常危险。所以必须在脚本开头加上:
set -euo pipefail解释一下:
-e:命令失败时立即退出。-u:使用未定义变量时报错退出。-o pipefail:管道命令中任何一段失败,整个管道返回失败。
配合日志函数,就可以做到“失败看得见,路径可追踪”。
5. 通用部署脚本完整实现
5.1 主部署脚本 deploy.sh
下面是一份经过项目验证的通用部署脚本框架。以部署一个 Spring Boot 的 jar 包为例,服务名设为demo-app。
#!/usr/bin/env bash # # 文件路径: /opt/project/deploy/deploy.sh # 功能: 单机应用部署脚本,支持 tar.gz 包发布 # 用法: ./deploy.sh --pkg=/data/packages/demo-app-1.0.0.tar.gz # set -euo pipefail # 基础配置 APP_NAME="demo-app" BASE_DIR="/opt/project" APP_DIR="${BASE_DIR}/app" BACKUP_DIR="${BASE_DIR}/backup" RELEASES_DIR="${BASE_DIR}/releases" LOG_DIR="${APP_DIR}/logs" DEPLOY_LOG="${BASE_DIR}/deploy/deploy.log" PID_FILE="${APP_DIR}/run/${APP_NAME}.pid" HEALTH_URL="http://127.0.0.1:8080/actuator/health" # 解析参数 PKG_PATH="" usage() { echo "用法: $0 --pkg=/path/to/app.tar.gz" echo "示例: $0 --pkg=/data/packages/demo-app-1.0.0.tar.gz" exit 1 } while [[ $# -gt 0 ]]; do case "$1" in --pkg=*) PKG_PATH="${1#*=}" shift ;; *) usage ;; esac done # 日志函数 log_info() { echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] $*" | tee -a "${DEPLOY_LOG}" } log_error() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] $*" | tee -a "${DEPLOY_LOG}" >&2 } # 前置校验 pre_check() { if [[ -z "${PKG_PATH}" ]]; then log_error "未指定包路径,请使用 --pkg 参数" usage fi if [[ ! -f "${PKG_PATH}" ]]; then log_error "部署包不存在: ${PKG_PATH}" exit 1 fi mkdir -p "${LOG_DIR}" "${BACKUP_DIR}" "${RELEASES_DIR}" "${APP_DIR}/run" } # 停止应用 stop_app() { log_info "开始停止应用: ${APP_NAME}" if [[ -f "${PID_FILE}" ]]; then local pid pid=$(cat "${PID_FILE}") if kill -0 "${pid}" 2>/dev/null; then kill "${pid}" log_info "已发送停止信号给进程: ${pid}" for i in {1..30}; do if ! kill -0 "${pid}" 2>/dev/null; then break fi sleep 1 done fi # 强制结束残留进程 if kill -0 "${pid}" 2>/dev/null; then log_error "进程 ${pid} 未能优雅退出,执行强制结束" kill -9 "${pid}" fi else # 兜底:按进程名停止 local pids pids=$(pgrep -f "${APP_NAME}" || true) if [[ -n "${pids}" ]]; then log_info "按进程名匹配并停止: ${pids}" pkill -f "${APP_NAME}" || true fi fi sleep 2 log_info "应用停止完成" } # 备份当前版本 backup_app() { if [[ ! -d "${APP_DIR}/libs" && ! -d "${APP_DIR}/bin" ]]; then log_info "未检测到已有应用文件,跳过备份" return fi local backup_path="${BACKUP_DIR}/${APP_NAME}_$(date '+%Y%m%d_%H%M%S')" log_info "备份当前版本到: ${backup_path}" cp -a "${APP_DIR}" "${backup_path}" } # 发布新版本 deploy_pkg() { local timestamp timestamp=$(date '+%Y%m%d_%H%M%S') local release_dir="${RELEASES_DIR}/${APP_NAME}_${timestamp}" log_info "解压部署包到: ${release_dir}" mkdir -p "${release_dir}" tar -xzf "${PKG_PATH}" -C "${release_dir}" # 清理运行时目录中的旧文件,保留 logs、run 等运行目录 find "${APP_DIR}" -mindepth 1 ! -name 'logs' ! -name 'run' ! -path '*/logs/*' ! -path '*/run/*' -exec rm -rf {} + log_info "同步新版本文件到: ${APP_DIR}" cp -a "${release_dir}/." "${APP_DIR}/" # 如果包内没有 conf 目录,则保留旧配置 if [[ ! -d "${APP_DIR}/conf" ]]; then log_warn "部署包中未包含 conf 目录,请确认配置策略" mkdir -p "${APP_DIR}/conf" fi } # 启动应用 start_app() { log_info "启动应用: ${APP_NAME}" # 根据实际应用调整启动命令,这里以 java jar 为例 nohup java -jar "${APP_DIR}/libs/${APP_NAME}.jar" \ --spring.config.location="file:${APP_DIR}/conf/application.yml" \ --logging.file.path="${LOG_DIR}" \ > "${LOG_DIR}/stdout.log" 2>&1 & local pid=$! echo "${pid}" > "${PID_FILE}" log_info "应用已启动,PID: ${pid}" } # 健康检查 health_check() { log_info "等待应用启动完成" for i in {1..30}; do if curl -sf "${HEALTH_URL}" >/dev/null 2>&1; then log_info "健康检查通过: ${HEALTH_URL}" return 0 fi sleep 2 done log_error "健康检查超时,请检查应用日志: ${LOG_DIR}" return 1 } # 主流程 main() { log_info "========== 开始部署 ${APP_NAME} ==========" pre_check stop_app backup_app deploy_pkg start_app if health_check; then log_info "部署成功" else log_info "部署失败,准备回滚" rollback fi log_info "========== 部署流程结束 ==========" } # 简单的回滚逻辑 rollback() { local latest_backup latest_backup=$(ls -td "${BACKUP_DIR}"/${APP_NAME}_* | head -n1 || true) if [[ -z "${latest_backup}" ]]; then log_error "没有找到备份,无法回滚" exit 1 fi log_info "从备份恢复: ${latest_backup}" cp -a "${latest_backup}/." "${APP_DIR}/" start_app } main "$@"5.2 脚本关键点解读
这个脚本有几处值得细看的地方。
首先是set -euo pipefail。它保证了中间任何一步失败,脚本不会“带伤继续”。部署这类操作,尽早失败远比晚失败成本低。
其次是备份策略。每次部署前把当前app目录整个复制到backup,并用时间戳区分。回滚时取最近一份备份恢复,简单粗暴但有效。如果你后续要接更完整的发布系统,这套逻辑也能平滑迁移。
然后是部署包同步逻辑。这里用tar -xzf 解压 + cp -a 同步,而不是“删掉整个 app 目录再解压”。这样logs和run目录不会因为发布而丢失日志或 PID 上下文。很多新手部署脚本做得粗糙,直接rm -rf app/*,结果把日志也清了,排查问题时一无所获。
最后是健康检查。用 HTTP 请求判断应用是否真正可用,而不是只看进程在不在。注意,这要求应用本身提供健康检查端点。Spring Boot 的 Actuator、Nginx 的 stub_status 页面、自定义的/ping接口都可以。
5.3 常见部署包结构
上面的脚本假设部署包demo-app-1.0.0.tar.gz内部结构是这样的:
demo-app-1.0.0/ ├── libs/ │ └── demo-app.jar ├── conf/ │ └── application.yml └── bin/ ├── start.sh └── stop.sh如果你的项目是 Nginx 静态站点,包内结构是:
dist/ ├── index.html ├── css/ ├── js/ └── assets/那么deploy_pkg函数里的同步逻辑不用变,只要指定APP_DIR为 Nginx 的站点目录,并调整start_app为nginx -s reload即可。框架是通用的,特定应用的差异都被隔离在函数边界内。
6. 一键回滚脚本的实现
部署脚本解决了“怎么把新版本放上去”,回滚脚本解决“新版本有问题怎么退回来”。很多团队没有单独的回滚脚本,靠着从 backup 目录复制文件回来,这种操作在紧张状态下很容易出错。
下面给出一个独立的rollback.sh:
#!/usr/bin/env bash # # 文件路径: /opt/project/deploy/rollback.sh # 功能: 回滚应用到最近的备份版本 # 用法: ./rollback.sh [--to=/backup/path] # set -euo pipefail APP_NAME="demo-app" BASE_DIR="/opt/project" APP_DIR="${BASE_DIR}/app" BACKUP_DIR="${BASE_DIR}/backup" LOG_DIR="${APP_DIR}/logs" PID_FILE="${APP_DIR}/run/${APP_NAME}.pid" DEPLOY_LOG="${BASE_DIR}/deploy/deploy.log" TARGET="" log_info() { echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] $*" | tee -a "${DEPLOY_LOG}" } log_error() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] $*" | tee -a "${DEPLOY_LOG}" >&2 } stop_app() { if [[ -f "${PID_FILE}" ]]; then local pid pid=$(cat "${PID_FILE}") if kill -0 "${pid}" 2>/dev/null; then kill "${pid}" for i in {1..30}; do if ! kill -0 "${pid}" 2>/dev/null; then break fi sleep 1 done fi if kill -0 "${pid}" 2>/dev/null; then kill -9 "${pid}" fi fi pkill -f "${APP_NAME}" || true sleep 2 } choose_backup() { if [[ -n "${TARGET}" ]]; then if [[ ! -d "${TARGET}" ]]; then log_error "指定的备份目录不存在: ${TARGET}" exit 1 fi else TARGET=$(ls -td "${BACKUP_DIR}"/${APP_NAME}_* | head -n1 || true) if [[ -z "${TARGET}" ]]; then log_error "未找到任何备份" exit 1 fi fi log_info "使用备份: ${TARGET}" } rollback_files() { log_info "清理当前应用目录(保留 logs 和 run)" find "${APP_DIR}" -mindepth 1 ! -name 'logs' ! -name 'run' ! -path '*/logs/*' ! -path '*/run/*' -exec rm -rf {} + log_info "恢复备份文件" cp -a "${TARGET}/." "${APP_DIR}/" } start_app() { nohup java -jar "${APP_DIR}/libs/${APP_NAME}.jar" \ --spring.config.location="file:${APP_DIR}/conf/application.yml" \ --logging.file.path="${LOG_DIR}" \ > "${LOG_DIR}/stdout.log" 2>&1 & local pid=$! echo "${pid}" > "${PID_FILE}" log_info "回滚后应用启动,PID: ${pid}" } main() { log_info "========== 开始回滚 ${APP_NAME} ==========" choose_backup stop_app rollback_files start_app log_info "========== 回滚流程结束 ==========" } main "$@"回滚脚本要注意的是:回滚后需要重新进行健康检查。上面的脚本没有加健康检查,因为回滚通常是紧急动作,先把服务拉起来是第一优先级,健康检查可以手动验证。在更完善的体系中,可以把health_check提取成公共函数,部署和回滚都调用同一份逻辑。
这里专门提一个实际教训:不要为了省事,在回滚脚本里直接cp -a backup/* app/。如果应用目录下有软链接或特殊文件,cp的行为可能和你预期不一样,而且旧配置文件、旧版本 jar 残留会导致“版本混装”。先清理再恢复,虽然多跑几秒,但结果干净可靠。
7. 部署脚本的实际执行与验证
7.1 执行准备
先准备好一个模拟部署包。假设你手上有一个编译好的 Spring Boot jar 和一个application.yml,按下面的命令打包:
mkdir -p /data/packages/demo-app-1.0.0/{libs,conf,bin} cp demo-app.jar /data/packages/demo-app-1.0.0/libs/ cp application.yml /data/packages/demo-app-1.0.0/conf/ cat > /data/packages/demo-app-1.0.0/bin/start.sh << 'EOF' #!/usr/bin/env bash nohup java -jar /opt/project/app/libs/demo-app.jar --spring.config.location=file:/opt/project/app/conf/application.yml > /opt/project/app/logs/stdout.log 2>&1 & EOF chmod +x /data/packages/demo-app-1.0.0/bin/start.sh cd /data/packages tar -czf demo-app-1.0.0.tar.gz demo-app-1.0.0然后执行部署脚本:
cd /opt/project/deploy chmod +x deploy.sh rollback.sh ./deploy.sh --pkg=/data/packages/demo-app-1.0.0.tar.gz7.2 预期输出
正常情况下的输出大致如下:
2025-01-15 10:00:01 [INFO] ========== 开始部署 demo-app ========== 2025-01-15 10:00:01 [INFO] 开始停止应用: demo-app 2025-01-15 10:00:01 [INFO] 未检测到 PID 文件,按进程名匹配并停止 2025-01-15 10:00:04 [INFO] 应用停止完成 2025-01-15 10:00:04 [INFO] 未检测到已有应用文件,跳过备份 2025-01-15 10:00:04 [INFO] 解压部署包到: /opt/project/releases/demo-app_20250115_100005 2025-01-15 10:00:05 [INFO] 同步新版本文件到: /opt/project/app 2025-01-15 10:00:05 [INFO] 启动应用: demo-app 2025-01-15 10:00:05 [INFO] 应用已启动,PID: 12345 2025-01-15 10:00:07 [INFO] 等待应用启动完成 2025-01-15 10:00:09 [INFO] 健康检查通过: http://127.0.0.1:8080/actuator/health 2025-01-15 10:00:09 [INFO] 部署成功 2025-01-15 10:00:09 [INFO] ========== 部署流程结束 ==========如果看到“部署成功”,说明新版本已经正常拉起并且健康检查通过。
验证目录状态:
# 查看应用目录 ls -l /opt/project/app/libs/ ls -l /opt/project/app/conf/ # 查看备份目录 ls -lt /opt/project/backup/ # 查看发布历史 ls -lt /opt/project/releases/7.3 验证失败时的首要排查路径
如果部署脚本中断或健康检查失败,不要慌,按下面的顺序排查:
- 看部署日志:
tail -n 100 /opt/project/deploy/deploy.log。 - 看应用标准输出:
tail -n 100 /opt/project/app/logs/stdout.log。 - 看进程是否真实存在:
ps -ef | grep demo-app。 - 看端口是否监听:
ss -lntp | grep 8080。 - 看健康检查 URL 返回内容:
curl -v http://127.0.0.1:8080/actuator/health。
有一个很隐蔽的问题值得提醒:如果健康检查用 127.0.0.1 通了,但外部访问不通,要检查服务的监听地址配置。Spring Boot 默认监听0.0.0.0,但如果你在application.yml里写了server.address=127.0.0.1,外部自然访问不到。这不是部署脚本的错,但部署时经常被误判为“部署失败”。
8. 常见问题与排查思路
下面的表格列出了单机脚本部署中最常见的几类问题,按概率排序。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
脚本执行报Permission denied | 没有执行权限或运行用户不对 | 检查脚本权限和当前用户 | chmod +x deploy.sh,用 deploy 用户执行 |
tar解压失败,提示不是 gzip 格式 | 部署包损坏,或实际是 zip | file 包名查看真实类型 | 检查打包流程,统一包格式 |
| 进程启动后立即退出 | jar 包损坏、Java 版本不匹配、端口被占 | 查看logs/stdout.log和进程退出码 | 调整 Java 版本,或释放被占端口 |
| 旧版本没停干净,新进程起不来 | 停止逻辑只处理了 PID 文件,遗漏了其他进程 | pgrep -f 应用名查看残留 | 增加pkill -f兜底逻辑 |
| 健康检查一直超时 | 应用启动慢,或健康检查 URL 不对 | 手动 curl 健康检查地址 | 延长健康检查等待时间,或修改 URL |
| 部署后配置还是旧的 | 部署包内没有 conf,或覆盖配置的逻辑没执行 | 检查app/conf文件时间戳 | 调整deploy_pkg中的配置同步逻辑 |
| 回滚后版本不对 | 备份目录被多次覆盖 | 查看ls -lt backup里的备份时间 | 检查备份函数的cp -a是否成功 |
| 脚本中途退出但服务还在跑 | set -e触发了退出,但没有调用清理 | 看最后一条日志 | 在trap中增加异常处理,明确是失败退出还是继续回滚 |
这里挑两个重点展开。
第一个是“进程没停干净”的问题。PID_FILE是应用自己写出的,如果上次进程是被kill -9强杀的,它来不及清理 PID 文件,下次启动时脚本读完文件发现进程不存在,然后正常启动新进程,逻辑没问题。但如果你同时用nohup启动多个实例,或应用有 sidecar 子进程,单靠 PID 文件可能停不干净。所以脚本里保留pgrep/pkill兜底是必要的。
第二个是“配置覆盖”的问题。脚本中有一段逻辑:如果部署包内没有conf目录,就保留旧的配置。这个设计有取舍——好处是部署时不覆盖配置,坏处是如果新版本需要新增配置项,光靠脚本是发现不了的。建议在实际项目中,把配置单独管理,部署包内只放libs和bin,conf由脚本从配置目录复制到运行目录。这样既保证配置可追溯,又避免部署包误带环境差异。
9. 最佳实践与工程化建议
9.1 坚持最小权限原则
整个部署链路涉及的目录,属主都应该是deploy用户,而不是root。脚本内部如果需要执行systemctl或写系统级目录,使用sudo并限制在少数命令上,或者通过visudo配置白名单。
不要图省事:
chmod -R 777 /opt/project这么做相当于把应用的运行时目录开放给所有用户。服务器上任何一个被攻陷的低权限进程,都可以直接篡改你的应用文件。正确做法是:
chown -R deploy:deploy /opt/project chmod -R u+rwX,go+rX /opt/project9.2 编码规范与日志规范
脚本中的函数要职责单一。比如stop_app只负责停止进程,不负责解压文件。主流程只做编排,不做具体实现,这样后续维护和调试会轻松很多。
日志建议统一格式:
2025-01-15 10:00:01 [INFO] 消息内容 2025-01-15 10:00:02 [ERROR] 错误内容同时保留部署日志和应用日志两份。部署日志记录“操作了什么”,应用日志记录“应用内部发生了什么”,排查问题时缺一不可。
另外,关键命令执行前先输出日志,执行失败时输出退出码,这样团队其他人接手时也能快速定位。比如:
if ! tar -xzf "${PKG_PATH}" -C "${release_dir}"; then log_error "解压失败,退出码: $?" exit 1 fi9.3 配置与代码分离
脚本部署最容易埋雷的是把环境配置写死在包里。最佳实践是:每次发布同一个包,环境配置从外部注入。
推荐两种方式:
- 把
application.yml放在/opt/project/app/conf/下,由部署脚本单独复制。 - 使用环境变量或配置中心,启动命令中通过
--env或系统属性动态指定。
这样测试环境和生产环境可以复用同一份构建产物,避免“测试通过、生产爆炸”的经典问题。
9.4 发布包的按时间归档
部署脚本已经把版本包解压到了releases目录,但这还不够。建议将原始的 tar 包按版本号和时间戳归档:
/opt/project/releases/demo-app_1.0.0_20250115_100005.tar.gz好处是回滚时不光能恢复文件,还能精确定位“这次发布用的是什么包”。
9.5 健康检查要真实有效
很多团队的“健康检查”只是确认了端口能连,没有确认业务是否真正可用。如果应用是一个 API 服务,健康检查最好请求一个能验证数据库连接、缓存连接、关键依赖状态的端点。如果应用本身没有这样的端点,可以在部署流程后加一个“业务探针”,模拟一个真实请求,例如登录接口或列表接口,能返回预期响应才认为部署成功。
9.6 与 CI/CD 工具衔接
脚本部署不是 CI/CD 的对立面。实际工程中,可以把这段脚本嵌入到 Jenkins 任务中:
ssh deploy@server "cd /opt/project/deploy && ./deploy.sh --pkg=/data/packages/demo-app.tar.gz"也可以在 GitLab CI 的.gitlab-ci.yml中调用。脚本保持“无交互、参数化、可重复”的特征,是能被上层工具无缝调用的基础。
10. 总结与后续学习方向
脚本部署这件事,技术门槛不高,但对工程素养的要求不低。它要求你提前规划目录、写清楚日志、考虑失败场景、保留回滚路径。整个过程中,Shell 只是载体,真正的核心是部署流程的设计能力。
这篇文章给出了一个通用框架,包括主部署脚本、独立的回滚脚本,以及围绕目录规范、权限控制和健康检查的工程建议。你可以直接拿这套框架改造自己的部署流程,也可以在此基础上,逐步补充以下能力:
- 学习 Ansible,把单机脚本迁移到多机集中管理。
- 学习 CI/CD 流水线,把部署动作嵌入到代码提交之后自动触发。
- 学习容器化部署,将应用镜像化,用 Docker Compose 或 Kubernetes 管理生命周期。
- 学习监控体系,部署后接入 Prometheus 和告警规则,让“部署成功”这件事可以被持续确认。
从“手动敲命令”到“脚本部署”,再到“平台化发布”,是一个逐步演进的过程。先把单机脚本做扎实,打好基础,后续无论是引入工具还是平台,都只是在这份能力之上做封装而已。