部署这件事,在很多团队里是“看着简单,做着难受”的典型。
如果只部署一次,手动敲几条命令确实够用。可一旦遇到换机器、升级版本、重新初始化环境,或者想把部署过程交给另一个不太熟 Linux 的同事执行,手动操作的问题就暴露了:命令顺序靠记忆、执行结果没有日志、出了问题不知道回滚到哪个版本。
单机部署虽然不像分布式集群那样涉及复杂的网络拓扑和高可用设计,但它同样是生产环境的一部分,同样需要可重复、可审计、可回滚。脚本部署方式,就是用最小的成本把这三点补齐。这篇文章不讨论某个具体软件怎么安装,而是讲一套通用的“Linux 单机脚本部署”方法论,包含环境准备、目录规划、脚本设计、完整示例、验证方法和排错清单。读完你可以直接套用到绝大多数单体应用、中间件或基础服务上。
1. 单机脚本部署到底在解决什么问题
先回到最原始的部署场景:拿到一台干净的 Linux 机器,手动下载安装包、解压、改配置、启动服务、验证端口。这种流程看起来没什么问题,但真正踩过坑的人会知道,痛苦从第二次部署才开始。
第一个痛点是不可重复。第一次部署成功后,很少有人会把每一步命令完整记录下来。一个月后要在另一台机器上部署同样环境,只能靠聊天记录和回忆拼接操作步骤,漏掉一个配置项就可能让服务启动后行为异常。
第二个痛点是不可审计。手动执行命令时,谁在什么时间执行了什么操作,基本无据可查。出了问题只能对着屏幕猜,而生产环境最怕的就是“不知道改了什么”。
第三个痛点是不可回滚。升级版本时发现新版本有严重问题,想退回旧版本,却发现旧版本的目录和配置已经被覆盖,或者根本找不到上一个版本的安装包。
脚本部署方式解决的就是这三个问题。所谓脚本部署,就是把“环境检查、安装、配置、启动、验证、回滚”这些步骤固化成一个可重复执行的脚本或者一组脚本。它的价值不只是“少敲几次命令”,而是把部署从“手工操作”变成“可执行文件”,让部署结果稳定、过程留痕、行为可预期。
一个判断:如果你的部署操作需要执行超过五条命令,或者需要写超过一屏的文档来说明操作步骤,那你就应该认真考虑脚本化。脚本化不是为了炫技,而是为了把不确定变成确定。
2. 脚本部署的几种形态与选型建议
单机场景下,“脚本部署”并不是只有一种形态。不同团队和不同应用适合的方式不一样,先看清楚再动手,比直接写脚本更重要。
| 部署方式 | 核心思路 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 纯 Shell 脚本 | 用 bash 封装安装、启停、验证 | 依赖少,任何 Linux 都能跑,容易理解 | 逻辑复杂后维护成本上升,跨系统能力弱 | 单机应用、中间件,快速标准化 |
| Script + Systemd | 脚本负责安装与配置,systemd 负责服务生命周期 | 开机自启、崩溃重启、日志统一由 systemd 管理 | 需要保证服务有明确的启动/停止命令 | 单机常驻服务,生产环境推荐 |
| Ansible 等配置管理工具 | 使用声明式或命令式配置管理目标机器 | 幂等性好,支持批量,适合多机协同 | 需要额外安装控制端,学习成本更高 | 从单机走向多机时的自然演进 |
| Docker 容器化部署 | 将应用与依赖打包进镜像 | 环境一致性最好,分发和升级方便 | 需要理解镜像、存储、网络等概念 | 新项目或者团队容器经验较好时 |
这篇文章聚焦在“Script + Systemd”这一种形态。原因很直接:它是纯 Shell 脚本的增强版,既保留脚本的简单直接,又把服务管理交给 systemd 这个 Linux 标配组件,是最容易在存量 Linux 环境中落地的方案。如果你以后要迁移到 Ansible 或 Docker,这套脚本中的安装逻辑、目录规范、健康检查思路仍然可以复用,不会白做。
3. 环境准备与部署前的目录规划
脚本部署的第一步不是打开编辑器写脚本,而是先把目标机器的环境和目录结构定好。很多脚本出错,最终都源于目录规划不合理或环境假设不明确。
3.1 前置条件
开始之前,需要确认以下内容:
- 操作系统:CentOS 7 以上、Rocky Linux、Ubuntu 20.04+ 等主流 Linux 发行版均可,脚本使用 bash 编写,依赖 systemd。
- 执行权限:需要一个具有 sudo 权限的普通用户,或者 root 用户。建议使用普通用户 + sudo,避免直接用 root 操作。
- 部署用户:确认应用以哪个系统用户运行。生产环境建议为应用单独创建专用用户,例如
demo-app,不要用 root 直接跑应用服务。 - 网络与端口:确认目标端口未被占用,数据库或外部依赖可以从当前机器访问到。
- 安装包:准备好应用的发布包,例如
demo-app-1.0.0.tar.gz,放到统一目录,例如/data/packages。
3.2 推荐的目录规划
目录规划直接决定脚本的可维护性。这里给出一套比较通用的单机部署目录布局,后续脚本示例都基于它:
/opt/app/demo-app/ ├── current -> /opt/app/demo-app/releases/demo-app-1.0.0 ├── releases/ │ ├── demo-app-1.0.0/ │ └── demo-app-1.0.1/ ├── config/ ├── logs/ └── backup/这套布局有三个设计点:
- releases 目录按版本存放。每次部署不是覆盖旧文件,而是新建一个版本目录,升级失败时可以快速回退到上一个版本目录。
- current 是一个软链接。指向当前要运行的版本目录。业务启动命令始终面向 current,目录切换即版本切换。
- config 与 logs 独立于版本目录。配置和数据不随版本变化,升级时不会被覆盖,日志也有固定位置,方便统一处理。
在实际项目中,你还需要为脚本准备一个日志文件,例如/var/log/demo-app-deploy.log,与应用的业务日志分开,避免部署日志和运行日志混杂。
4. 部署脚本的整体设计思路
写部署脚本之前,先定设计原则。否则脚本写到一半会发现命令越堆越多,却没有形成可维护的结构。
单机部署脚本建议遵循四个原则。
幂等性。同一个脚本重复执行多次,结果应该是一致的。实现方式:安装前检查目标版本目录是否已存在,存在则跳过解压;启动前检查服务是否已经在运行;配置生成前清理旧的临时文件。幂等不是可选项,它是脚本能安全反复执行的前提。
可分步执行。不要把一个脚本写成“从检测到启动”一条路走到底。将安装、启动、状态检查拆成独立函数或独立子命令,让使用者可以单独执行某一步。这样排查问题时不需要从头跑一遍。
日志可追溯。脚本的每次关键操作都必须输出带时间戳的日志。建议统一封装一个log函数,打印到终端的同时追加到日志文件。
失败即停止。脚本开头启用set -euo pipefail,让未捕获的错误直接中断执行,避免“看起来执行完了、实际某个环节失败了”的假象。
下面是脚本模块划分的参考:
| 模块 | 职责 | 关键动作 |
|---|---|---|
| 环境检查 | 确认系统状态 | 检查用户权限、端口占用、安装包存在性 |
| 安装部署 | 将发布包落地 | 创建目录、解压、生成配置、创建软链接 |
| 服务注册 | 写入 systemd | 生成 service 文件、daemon-reload |
| 服务控制 | 启停操作 | start / stop / restart / status |
| 健康验证 | 确认服务可用 | 轮询 HTTP 健康检查接口或端口探测 |
| 回滚处理 | 恢复上一个版本 | 切换软链接、重启服务 |
一个容易被忽略的点是配置外部化。脚本不应该把 IP、端口、版本号等信息硬编码在几十行代码里。建议在脚本开头定义变量区域,并允许通过环境变量覆盖默认值。部署的人可以在命令行临时指定版本号或端口,而不需要改脚本本身。
5. 完整部署脚本示例与核心代码实现
下面给出一套可以直接复用的 Shell 脚本。示例使用demo-app作为应用名,假设发布包内包含bin/start.sh和bin/stop.sh两个启动控制脚本。如果你的应用不是这种启动方式,替换ExecStart和ExecStop即可。
5.1 目录结构
/opt/scripts/ └── deploy-demo-app.sh整个部署流程只需要这一个主脚本,systemd 服务文件由脚本在安装时动态生成,这样简化了管理。
5.2 主脚本实现
#!/usr/bin/env bash # 文件路径: /opt/scripts/deploy-demo-app.sh # 功能: 单机部署 demo-app 应用服务 # 用法: sudo bash deploy-demo-app.sh {install|start|stop|restart|status|rollback} set -euo pipefail # 基础变量定义,可通过环境变量覆盖 APP_NAME="${APP_NAME:-demo-app}" APP_VERSION="${APP_VERSION:-1.0.0}" APP_USER="${APP_USER:-demo-app}" APP_GROUP="${APP_GROUP:-demo-app}" APP_PORT="${APP_PORT:-8080}" BASE_DIR="/opt/app/${APP_NAME}" RELEASES_DIR="${BASE_DIR}/releases" CURRENT_LINK="${BASE_DIR}/current" BACKUP_DIR="${BASE_DIR}/backup" CONFIG_DIR="${BASE_DIR}/config" LOG_DIR="${BASE_DIR}/logs" VERSION_DIR="${RELEASES_DIR}/${APP_NAME}-${APP_VERSION}" PACKAGE_SRC="${PACKAGE_SRC:-/data/packages/${APP_NAME}-${APP_VERSION}.tar.gz}" SERVICE_UNIT="/etc/systemd/system/${APP_NAME}.service" DEPLOY_LOG="/var/log/${APP_NAME}-deploy.log" HEALTH_CHECK_URL="${HEALTH_CHECK_URL:-http://127.0.0.1:${APP_PORT}/actuator/health}" log() { local level="$1" shift local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" echo "[${ts}] [${level}] $*" | tee -a "${DEPLOY_LOG}" } check_env() { if [[ ${EUID} -ne 0 ]]; then log ERROR "请使用 root 用户或通过 sudo 执行该脚本" exit 1 fi if [[ ! -f "${PACKAGE_SRC}" ]]; then log ERROR "发布包不存在: ${PACKAGE_SRC}" exit 1 fi if id "${APP_USER}" &>/dev/null; then log INFO "用户 ${APP_USER} 已存在,跳过创建" else useradd -r -s /sbin/nologin "${APP_USER}" log INFO "已创建系统用户 ${APP_USER}" fi if ss -lntp | grep -q ":${APP_PORT} "; then log ERROR "端口 ${APP_PORT} 已被占用,请检查是否存在其他服务" exit 1 fi log INFO "环境检查通过" } install_app() { mkdir -p "${RELEASES_DIR}" "${CONFIG_DIR}" "${LOG_DIR}" "${BACKUP_DIR}" chown -R "${APP_USER}:${APP_GROUP}" "${BASE_DIR}" if [[ -d "${VERSION_DIR}" ]]; then log INFO "版本目录已存在: ${VERSION_DIR},跳过解压" else tar -zxf "${PACKAGE_SRC}" -C "${RELEASES_DIR}" log INFO "已解压安装包到 ${RELEASES_DIR}" fi chown -R "${APP_USER}:${APP_GROUP}" "${VERSION_DIR}" if [[ -L "${CURRENT_LINK}" ]]; then cp -rL "${CURRENT_LINK}" "${BACKUP_DIR}/previous-$(date +%Y%m%d%H%M%S)" log INFO "已备份当前版本到 ${BACKUP_DIR}" fi ln -sfn "${VERSION_DIR}" "${CURRENT_LINK}" log INFO "已切换软链接 current -> ${VERSION_DIR}" } generate_service_unit() { cat > "${CONFIG_DIR}/${APP_NAME}.service" <<EOF [Unit] Description=${APP_NAME} Application Service After=network.target [Service] User=${APP_USER} Group=${APP_GROUP} WorkingDirectory=${CURRENT_LINK} ExecStart=${CURRENT_LINK}/bin/start.sh ExecStop=${CURRENT_LINK}/bin/stop.sh Restart=on-failure RestartSec=5 StandardOutput=append:${LOG_DIR}/app.log StandardError=append:${LOG_DIR}/app.log [Install] WantedBy=multi-user.target EOF cp "${CONFIG_DIR}/${APP_NAME}.service" "${SERVICE_UNIT}" systemctl daemon-reload log INFO "已生成 systemd 服务文件并 reload" } start_service() { systemctl enable "${APP_NAME}" systemctl restart "${APP_NAME}" log INFO "服务已启动: ${APP_NAME}" } health_check() { local retry="${1:-30}" local url="${HEALTH_CHECK_URL}" log INFO "开始健康检查: ${url}" for i in $(seq 1 "${retry}"); do if curl -fsS "${url}" >/dev/null 2>&1; then log INFO "健康检查通过,尝试次数: ${i}" return 0 fi sleep 2 done log ERROR "健康检查失败,已连续尝试 ${retry} 次" return 1 } rollback() { local latest_backup latest_backup="$(ls -1t "${BACKUP_DIR}" | head -n 1 || true)" if [[ -z "${latest_backup}" ]]; then log ERROR "没有找到可回滚的备份目录" exit 1 fi ln -sfn "${BACKUP_DIR}/${latest_backup}" "${CURRENT_LINK}" log INFO "已回滚软链接指向: ${BACKUP_DIR}/${latest_backup}" systemctl restart "${APP_NAME}" health_check } case "${1:-}" in install) check_env install_app generate_service_unit start_service health_check ;; start) start_service ;; stop) systemctl stop "${APP_NAME}" log INFO "服务已停止" ;; restart) systemctl restart "${APP_NAME}" health_check ;; status) systemctl status "${APP_NAME}" ;; rollback) rollback ;; *) echo "用法: sudo bash deploy-demo-app.sh {install|start|stop|restart|status|rollback}" exit 1 ;; esac5.3 脚本关键逻辑说明
这个脚本里最需要理解的有几个地方。
set -euo pipefail这一行不是摆设。-e让脚本遇到第一个返回非零的命令就退出,-u避免使用未定义变量,-o pipefail让管道中的失败不会被吞掉。没有这三项配置,脚本很容易在中间步骤失败后继续往下跑,最终给你一个“假成功”。
check_env中的端口检查用了ss -lntp。它比netstat更现代,效率也更高。如果端口已被占用,脚本直接终止,避免后续部署出一个“启动失败”的僵尸服务。检查发布包是否存在也是同理。
install_app中使用了“版本目录 + 软链接切换”的机制。每次安装新版本都会先检查版本目录是否已存在,保证重复执行不会重复解压;随后把当前版本复制到 backup 目录,再把 current 软链接指向新版本。这种做法的好处是:升级失败时,回滚只需要把软链接指回备份目录,然后重启服务,比“重新解压旧包重装配置”要快得多,也安全得多。
generate_service_unit用 here-doc 生成 systemd 配置。注意其中引用了${APP_NAME}、${CURRENT_LINK}等变量,它们会在生成文件时被替换为实际值。如果你需要在 service 文件里保留美元符号,就需要使用EOF(带引号)而不是EOF。这里因为要插入变量,所以使用不带引号的EOF。生成后复制到/etc/systemd/system/并执行daemon-reload,让 systemd 发现新服务单元。
health_check是生产级部署脚本不可省略的环节。很多人部署完只看“进程起来了”就算成功,但进程存在不等于服务可用。脚本默认请求http://127.0.0.1:8080/actuator/health,你可以根据实际应用调整。健康检查失败返回 1,install 流程会因set -e直接退出,但服务已经启动,所以失败时你需要手动执行 rollback,或者临时关闭set -e来自动回滚。对于单机场景,先手动确认原因再回滚,比自动化回滚更稳妥。
6. 执行部署与效果验证
脚本写好后,按下面的顺序执行。
# 将脚本放到统一目录 sudo mkdir -p /opt/scripts sudo cp deploy-demo-app.sh /opt/scripts/deploy-demo-app.sh sudo chmod +x /opt/scripts/deploy-demo-app.sh # 执行完整安装流程 sudo bash /opt/scripts/deploy-demo-app.sh install如果一切正常,终端输出大致如下:
[2025-01-01 12:00:01] [INFO] 环境检查通过 [2025-01-01 12:00:02] [INFO] 已解压安装包到 /opt/app/demo-app/releases [2025-01-01 12:00:03] [INFO] 已切换软链接 current -> /opt/app/demo-app/releases/demo-app-1.0.0 [2025-01-01 12:00:04] [INFO] 已生成 systemd 服务文件并 reload [2025-01-01 12:00:06] [INFO] 服务已启动: demo-app [2025-01-01 12:00:06] [INFO] 开始健康检查: http://127.0.0.1:8080/actuator/health [2025-01-01 12:00:10] [INFO] 健康检查通过,尝试次数: 3看到健康检查通过才表示部署成功。如果脚本在 installing 阶段报错退出,第一步先看部署日志:
tail -n 100 /var/log/demo-app-deploy.log之后的日常管理都通过脚本封装好的子命令完成:
sudo bash deploy-demo-app.sh status sudo bash deploy-demo-app.sh restart也可以直接使用 systemd 命令查看服务运行细节:
systemctl status demo-app journalctl -u demo-app --since "5 minutes ago"判断部署是否成功的标准,不只是 systemd 显示 active,而是要同时满足三个条件:进程在运行、健康检查通过、日志无持续报错。三者缺一都可能说明部署有隐患。
7. 常见问题与排查思路
单机部署脚本涉及权限、端口、systemd、网络四个主要环节,问题基本集中在它们之间。下面按现象逐一拆解。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
执行脚本报Permission denied | 脚本没有执行权限,或当前用户无权访问发布包 | 查看文件权限ls -l | 给脚本加执行权限,chmod +x;用 sudo 执行 |
tar解压报Cannot open: No such file or directory | 发布包路径不存在,或路径写错 | 检查PACKAGE_SRC变量与实际路径 | 修正发布包路径,或重新放置安装包 |
| 脚本执行到一半退出,没有后续输出 | set -e遇到某个命令执行失败 | 查看部署日志中最后一条 ERROR | 根据具体报错定位,常见是端口冲突或健康检查失败 |
systemctl start报Job for demo-app.service failed | 启动命令错误,或应用自身启动异常 | 执行journalctl -u demo-app -n 50查看错误日志 | 检查ExecStart指向的脚本是否存在、是否有执行权限 |
| 服务启动成功但健康检查超时 | 应用启动慢,或健康检查 URL 不对 | 手动 curl 健康检查地址,查看应用日志启动耗时 | 调大健康检查重试次数,或修改HEALTH_CHECK_URL |
| 重复执行 install 后配置被重置 | 脚本中的配置生成逻辑没有做幂等处理 | 检查generate_service_unit是否每次覆盖文件 | 保留当前写法,重复生成 service 文件不会影响运行中服务,但需要确认配置覆盖逻辑是否符合预期 |
| 脚本在 Windows 上编辑后执行报错 | 文件包含 CRLF 换行符 | 执行file deploy-demo-app.sh查看格式 | 使用sed -i 's/\r$//' deploy-demo-app.sh转换 |
| 端口被占用导致部署中断 | 机器上已有其他服务监听该端口 | 使用ss -lntp | grep 8080查看占用进程 | 确认是否旧服务残留,或修改APP_PORT重新部署 |
这里特别提醒一个常见误区:在 Windows 上用记事本或部分编辑器编写 Shell 脚本,然后传到 Linux 执行,经常会出现-bash: ./deploy.sh: /bin/bash^M: bad interpreter的报错。这就是 CRLF 换行符问题。脚本代码在 Linux 上编辑,或者用 VS Code 等工具开启“LF”换行模式,可以避免这个坑。
8. 最佳实践与工程建议
脚本部署虽然简单,但工程上的细节很多是“看起来没区别,出事差很多”的地方。
8.1 不要用 root 运行业务服务
脚本中用useradd -r -s /sbin/nologin创建了专用系统用户,systemd 配置里也指定了User和Group。这样可以限制应用被攻破后的影响范围。如果应用需要访问某些目录,只给专用用户最小权限即可,不要图方便直接给 chmod 777。
8.2 幂等性是脚本的生命线
再次强调:好的部署脚本必须能安全地重复执行。这里的重复执行不只是“再跑一次 install”,还包括“install 失败后修复问题再跑一次”。install_app中先判断版本目录是否存在,就是这个目的。如果你的脚本每次执行都把目录删了重建,一旦中途失败,旧版本也可能被破坏。
8.3 设置日志轮转
应用日志和部署日志都要考虑大小问题。systemd 的StandardOutput=append:会持续往同一个文件写入,时间长了会撑爆磁盘。配置一个 logrotate 规则是必要的:
# 文件路径: /etc/logrotate.d/demo-app /opt/app/demo-app/logs/app.log { daily rotate 7 compress missingok notifempty copytruncate }copytruncate适用于应用进程一直持有文件句柄的场景,复制后清空原文件,进程不需要重启。
8.4 升级和回滚要形成闭环
脚本的install流程中,已经在上线新版本前把旧版本备份到 backup 目录。这意味着升级到新版本后如果发现严重问题,可以执行:
sudo bash /opt/scripts/deploy-demo-app.sh rollback脚本会把 current 软链接切回最近一次备份的目录,然后重启服务。这个机制在你还没有引入 CI/CD 或蓝绿发布之前,是单机环境最实用的回滚方案。建议在正式环境上线前,自己完整演练一次“升级到新版本然后回滚”的过程,确保备份和回滚路径真的可用。
8.5 配置与代码分离
脚本中的config/目录用于存放应用配置文件,但生产环境的数据库地址、账号密码等敏感信息,不应放进发布包或脚本仓库。实际项目中,建议结合环境变量或本地配置文件模板,在安装时通过envsubst或 sed 替换占位符。敏感信息本身要由运维单独管理,避免进入版本控制。
8.6 脚本本身也要做版本管理
/opt/scripts/deploy-demo-app.sh应该提交到 Git 仓库。脚本的变更同样要走评审和测试流程,不能在生产机器上直接改完就执行。每次部署前先确认脚本版本与当前项目要求的脚本版本一致,避免出现“脚本更新了,部署的行为却和文档对不上”的情况。
8.7 执行过程要允许人为确认
对于危险操作,例如覆盖数据或切换生产版本,脚本中可以在确认前加一个交互等待。但要注意:交互输入不适合完全无人值守的自动化场景。一个折中方案是增加--confirm参数,强制执行前必须手动传入。
9. 总结与后续学习方向
单机脚本部署的完整思路可以浓缩成一句话:把部署拆成环境检查、安装、配置、启动、验证五步,用脚本固化每一步,用 systemd 接管服务生命周期,用版本目录加软链接实现回滚。
这篇文章真正想讲清楚的,不是哪一条 shell 命令怎么写,而是三个关键判断:第一,脚本部署的目标不是“一次跑通”,而是“重复跑通”;第二,目录设计和软链接切换决定了你能否低成本回滚;第三,健康检查才是部署完成的真正标志,进程起来了不代表服务可用。
建议你从手头最常部署的一个应用开始,按文章中的目录布局和脚本结构改写一版,先在自己测试环境跑通 install、restart、rollback 三个流程,再考虑加入更多参数化配置。脚本部署再往前走,就是配置管理工具 Ansible 的幂等逻辑、CI/CD 流水线里的构建产物管理、以及容器化后的镜像不可变部署。它们解决的是更高层的“多机、多环境、多团队”问题,但底层思想——可重复、可审计、可回滚——和今天这套脚本是完全一致的。