1. 为什么开发人员要搞一套Shell自动编译部署脚本
1.1 手动操作背后的真实痛点
先说个我自己的经历。早几年维护一个多模块的Java项目,每次发测试环境都是同样的流程:本地或者跳板机上敲mvn clean package -DskipTests,等两分钟构建,然后在target目录里翻那个几十MB的jar包,用scp传到服务器,ssh登进去,先ps -ef | grep java找到进程号,kill -9干掉,再nohup java -jar xxx.jar &启动。运气好一次过,运气不好端口没释放、包传错目录、环境变量没生效,折腾半小时家常便饭。
这套流程最大的问题不是慢,而是每一步都在依赖人肉记忆。哪个模块的包要传哪台机器、启动参数是什么、配置文件放哪、日志怎么看,全在脑子里,或者散落在一堆聊天记录里。换个人来操作,基本两眼一抹黑。而且手动操作没有一致性——今天加了个参数,明天忘了;这次先kill再传包,下次先传包再kill,出问题都不知道从哪里排查。
所以Shell自动化的核心价值,并不是把命令“变成脚本”这么简单,而是把部署过程变成可重复、可追溯、可交给任何人执行的标准操作。这也是为什么我强烈建议,哪怕团队已经有Jenkins或者云效这类平台,开发人员自己也应该掌握一套Shell自动化部署的写法。平台是公司资产,脚本是自己的手艺,排查问题时两边配合着看,效率完全不一样。
1.2 为什么是Shell而不是Jenkins、Ansible
有读者会问,现在CI/CD工具那么多,Jenkins配置一个Pipeline也很快,Ansible写Playbook更“正规”,为什么还要用Shell?我的看法是:工具各有各的适用边界,Shell恰恰是那个最接近底层、最不容易被环境绑架的方案。
Jenkins这类平台解决了“触发”和“调度”的问题,但构建完之后的产物怎么处理、目标机器上的环境什么样、服务怎么拉起,最后还是要在脚本或者命令行里落地。而且Jenkins服务器本身也是要维护的,Master挂了大家就都发不了包。Ansible则更适合大规模集群的配置管理,为了一台测试服务器单独维护一套Inventory和Playbook,投入产出比不高。
Shell脚本的优点很朴素:只要有一台能跑命令的Linux机器,有SSH权限,就能用。不需要额外装Agent、不需要维护平台、不依赖图形界面。尤其对开发人员来说,本机就是天然的脚本运行环境,Git提交钩子、IDE外部工具、命令行别名,随时可以调用同一套部署脚本。真出了问题,bash -x deploy.sh一执行,每一步干了什么看得清清楚楚。
另外还有一个很现实的原因:很多部署环境是内网隔离的,外网工具装不进去,或者出于安全策略禁止开放额外的端口。SSH和Shell是内网环境里几乎必然存在的通道,用这套方案不会触发额外的合规问题。
1.3 这套方案适合谁、解决什么问题
这篇内容适合三类人:
- 日常需要手动发布Java服务的开发人员,尤其是Spring Boot单体和微服务场景,希望把重复操作收敛成一个命令;
- 刚转DevOps或运维开发的工程师,想理解自动化部署的底层逻辑,而不只是会点按钮;
- 小团队的技术负责人,没有专职运维,希望用最低成本建立一套基本的发布规范。
它能解决的问题包括:消除手动操作的漏步骤、统一各环境的部署参数、让新同学也能独立发布、发布过程有日志可以回溯。至于极端复杂的场景——比如几十个节点的滚动发布、灰度流量切换、数据库迁移联动——那确实超出Shell的舒适区了,需要Kubernetes或专业发布系统来支撑。但基础的单机或几台机器的发布场景,Shell绰绰有余。
2. 自动化脚本的整体设计思路
2.1 核心流程拆解:编译、打包、传输、部署、检查
任何自动化方案的第一步都不是写代码,而是把流程画出来。Java项目部署其实就五个阶段,我习惯用一个串行流程来描述:
- 编译:拉取指定分支代码,执行Maven/Gradle构建,产出可执行产物;
- 打包:定位到具体jar/war文件,做重命名、归档、备份;
- 传输:将构建产物以及配套的配置文件分发到目标服务器的指定目录;
- 部署:远程停止旧进程、替换新产物、调整权限、启动服务;
- 检查:验证端口是否监听、健康检查接口是否返回正常、日志是否报错。
每个阶段之间是强依赖关系,前一步失败就必须中止后续动作。这一点在脚本设计上是头等大事,绝不能因为某个步骤失败还继续往下跑,最后部署了一个残缺的包。
有了这个流程拆解,脚本的骨架也就出来了。我习惯把脚本设计成单一入口、多函数模块的结构,主流程就五六个函数调用,每个函数内部保护好局部变量。这样不管功能怎么扩展,主入口始终清晰。
2.2 脚本分层:入口脚本与函数库分离
很多初写脚本的人喜欢把所有内容堆在一个文件里,一次性来个300行。短期看方便,但用两周后就会发现问题:改一个服务器的IP,要在整个文件里搜索;想复用某个函数,只能复制粘贴。
我的做法是拆两个文件:
deploy.sh:主入口脚本,负责参数解析、流程编排、日志输出。这个文件要尽量“薄”,只做调度,不做具体实现。deploy_lib.sh:函数库,包含build_project、find_artifact、upload_server、remote_deploy、health_check这些函数。函数之间通过参数和全局环境变量松耦合,方便复用。
函数库的本质和代码里的工具类一样,把通用逻辑收拢到一个地方。比如log_info这个函数,可以统一输出带时间戳的日志格式,后续要加颜色或者日志落文件,只改一处就够。die函数则统一处理失败退出,附带错误信息和退出码,避免每个函数里重复写exit 1。
2.3 参数化设计:环境、服务名、端口都得可配置
脚本最忌讳写死。所谓参数化,就是把环境相关的信息和逻辑相关的信息分开:
- 环境相关:目标服务器IP、SSH用户名、部署目录、服务端口、环境变量;
- 逻辑相关:Maven命令、JVM参数模板、日志检查关键字的处理方式。
环境相关的信息放到独立的profile文件里,比如profile_test.conf、profile_prod.conf,脚本按需加载。这样新增一套环境,只需要复制一个配置文件改改值,不需要碰主脚本。
这个设计解决了一个很关键的问题:避免环境信息污染逻辑。我见过不少脚本直接在代码里写死server=192.168.1.10,等IP变更或者新增环境时,只能复制一份脚本出来改,时间一长就有了五六个版本各不相同的“deploy脚本”。用profile文件收敛后,脚本永远只有一份,差异全在配置里。
2.4 执行流程概览
整个脚本的执行过程可以用一句话概括:读取profile配置,进入项目代码目录执行构建,定位产物文件,推送到远程机器,远程执行停服换包启服,最后返回健康检查结果。每一步都有实时日志输出,任何一步异常立即退出并附带明确提示。
这个流程设计看起来简单,但里面每一个环节都有不少值得深挖的细节,接下来我逐个展开讲。
3. 编译与打包环节:Shell如何与Maven高效协作
3.1 封装Maven构建命令
Shell调用Maven本身不复杂,但要做得健壮就有讲究。先看基础版本:
build_project() { local branch="$1" cd "${PROJECT_HOME}" || die "项目目录不存在: ${PROJECT_HOME}" git fetch origin "${branch}" git checkout "${branch}" git pull origin "${branch}" mvn clean package -DskipTests -P"${MAVEN_PROFILE}" -q local exit_code=$? if [ "${exit_code}" -ne 0 ]; then die "Maven构建失败,退出码: ${exit_code}" fi log_info "Maven构建成功" }这里有几个容易被忽略的点:
第一,-q参数要不要加。构建日志极其冗长,不加-q在CI日志里大海捞针找错误。但加了-q之后,Maven只输出错误信息和必要提示,观感清爽很多。如果调试时想看详细内容,可以在脚本里留一个DEBUG开关,通过参数控制是否追加-X。
第二,退出码检查不能省。mvn命令执行失败时,如果脚本没有检查退出码就往下走,后续所有步骤都会拿着一个不存在的jar包硬跑。所以构建阶段的关键就是拿到退出码并立即失败退出。
第三,多模块项目的parent聚合关系。如果项目是<modules>聚合结构,在根目录执行mvn clean package会把所有子模块都构建一遍。但有些场景只想构建某个子模块,就需要用-pl和-am参数:
mvn clean package -pl order-service -am -DskipTests-pl指定要构建的模块,-am表示同时构建它依赖的其他模块。这个用法在微服务项目里非常实用,避免每次全量构建浪费时间。
3.2 精准定位打包产物
Maven构建完成后,产物在target目录下。Spring Boot项目通常会产生两个jar:一个是xxx-1.0.0.jar(可执行jar),一个是xxx-1.0.0.jar.original(Maven原始jar,Spring Boot插件重打包前的文件)。要是直接ls ${PROJECT_HOME}/order-service/target/*.jar把.original也匹配进去,后面传到服务器上启动必然报错。
所以定位产物不能靠简单的通配符,要按名字和类型来筛。我一般这么写:
find_artifact() { local module_name="$1" find "${PROJECT_HOME}/${module_name}/target" -maxdepth 1 -type f -name "*.jar" ! -name "*.original" ! -name "*sources.jar" ! -name "*javadoc.jar" | head -1 }find的-maxdepth 1限制只查target目录本身,避免深入到内部lib目录。排除.original和sources.jar是为了确保拿到的就是可执行的Spring Boot fat jar。如果没有找到,函数返回空字符串,主流程需要对这个做判断并中止。
这里要提醒一个细节:多模块项目里,可执行jar往往只存在于有spring-boot-maven-plugin的模块,其余模块产出的普通jar不能直接java -jar启动。所以写脚本前要搞清楚哪个模块是可启动的服务模块,脚本里配置一个SERVICE_MODULE变量来标记。
3.3 版本号与时间戳归档
部署最怕“不知道当前跑的是哪个包”。为了避免这个问题,我对构建产物做两条处理:
一是把最终的jar改造成带部署时间和构建标识的名字:
DEPLOY_TIME=$(date +%Y%m%d_%H%M%S) SERVICE_JAR="order-service.jar" DIST_JAR="order-service-${DEPLOY_TIME}.jar" cp "${ARTIFACT_PATH}" "${DIST_DIR}/${DIST_JAR}" || die "复制产物失败"二是维护一个current符号链接,指向当前正在运行的版本:
ln -sfn "${DIST_DIR}/${DIST_JAR}" "${DIST_DIR}/${SERVICE_JAR}"符号链接的好处是启动命令永远引用order-service.jar这个固定的名字,而真实的文件带时间戳可以追溯。回滚时只需要把符号链接重新指向上一个版本,不需要动启动命令,也不用改脚本。
3.4 构建机的缓存与依赖问题
这类脚本跑多了,会遇到一个隐蔽问题:上次构建的target目录里残留了旧的产物文件。比如你这次构建失败,但旧的jar还在target目录里,find命令找到了旧包,脚本继续往下传,部署的其实是上一个版本。
为了避免这种问题,构建命令里要保留clean阶段,也就是mvn clean package而不是mvn package。clean会先删除target目录,从干净状态开始构建,这样能找到的jar一定是最新构建的。
另外,如果公司内网有私有Nexus或者Harbor仓库,建议在profile里配置好Maven的settings.xml路径,使用-s参数指定,避免每台机器各自一套默认配置导致依赖解析不一致。
4. 远程传输与部署:从本地到服务器的落地细节
4.1 scp传输与免密登录配置
构建产物准备好后,接下来就是传到目标服务器。最常见的方式是scp,命令很简单:
scp -P "${SSH_PORT}" "${LOCAL_JAR}" "${SSH_USER}@${SERVER_IP}:${REMOTE_DIR}/${DIST_JAR}"但实际用起来要解决的问题不只是“传文件”。比如免密登录必须提前配置好,否则传大文件时中途询问密码,脚本就卡死了。配置免密的标准做法是:
- 本地执行
ssh-keygen -t ed25519生成密钥对; - 执行
ssh-copy-id -p 端口 用户@服务器IP把公钥装到目标机器的~/.ssh/authorized_keys里; - 验证
ssh 用户@服务器IP 'hostname'能无提示输出主机名。
注意生产服务器上建议用专门的部署账号,而不是root。脚本里虽然配置了SSH用户,但最好限制部署账号只能操作特定目录,减少安全风险。
另一个实际经验是传输前先检查远端目录是否存在,不存在就自动创建:
ssh -p "${SSH_PORT}" "${SSH_USER}@${SERVER_IP}" "mkdir -p ${REMOTE_DIR} ${REMOTE_LOG_DIR} && ln -sfn ${REMOTE_DIR}/${DIST_JAR} ${REMOTE_DIR}/${SERVICE_JAR}"有人说rsync比scp好,支持断点续传和增量传输。对于几百MB的jar包,如果网络不稳定,scp中断就得重传,rsync确实更稳:
rsync -avz --partial -e "ssh -p ${SSH_PORT}" "${LOCAL_JAR}" "${SSH_USER}@${SERVER_IP}:${REMOTE_DIR}/${DIST_JAR}"--partial参数保留断点文件,网络抖动恢复后可以继续传。我个人倾向rsync,但scp更通用,如果你不确定目标机器有没有装rsync,用scp更保险。
4.2 远程执行环境变量的坑
ssh执行远程命令时,默认不会加载/etc/profile、~/.bashrc这些配置文件,因为非交互式shell读的环境文件有限。这就导致一个经典问题:ssh过去发现java命令找不到、JAVA_HOME没设置。
解决办法是远程命令的第一行显式引入环境变量:
ssh -p "${SSH_PORT}" "${SSH_USER}@${SERVER_IP}" "source /etc/profile && bash ${REMOTE_DIR}/deploy_remote.sh"或者更直接,把JAVA_HOME和PATH写死在远程脚本里:
export JAVA_HOME=/usr/local/java/jdk17 export PATH=${JAVA_HOME}/bin:${PATH}这里还有一个细节:如果服务器的JAVA_HOME路径不一致,最好在profile文件里配置REMOTE_JAVA_HOME变量,而不是让远程脚本写死,保持和环境信息集中管理的原则。
4.3 停服、换包、启动三步曲
远程部署的核心逻辑我单独拆成一个deploy_remote.sh脚本,它负责在目标服务器上完成停服换包启动的动作。核心代码大概长这样:
#!/bin/bash SERVICE_NAME="order-service" PID_FILE="/var/run/${SERVICE_NAME}.pid" APP_HOME="/opt/app/${SERVICE_NAME}" JAR_NAME="${SERVICE_NAME}.jar" LOG_FILE="${APP_HOME}/logs/${SERVICE_NAME}.log" stop_service() { if [ -f "${PID_FILE}" ]; then local pid pid=$(cat "${PID_FILE}") if kill -0 "${pid}" 2>/dev/null; then kill "${pid}" for i in $(seq 1 30); do if ! kill -0 "${pid}" 2>/dev/null; then break fi sleep 1 done if kill -0 "${pid}" 2>/dev/null; then kill -9 "${pid}" echo "进程 ${pid} 未在30秒内停止,强制终止" fi fi rm -f "${PID_FILE}" fi # 兜底检查:按服务名匹配进程 local remain_pid remain_pid=$(pgrep -f "${JAR_NAME}" || true) if [ -n "${remain_pid}" ]; then kill -9 "${remain_pid}" fi } start_service() { cd "${APP_HOME}" || exit 1 nohup java ${JVM_OPTS} -jar "${JAR_NAME}" > "${LOG_FILE}" 2>&1 & echo $! > "${PID_FILE}" echo "服务启动,PID: $(cat ${PID_FILE})" }这里面的细节值得逐条说:
优先使用PID文件而不是ps + grep。通过PID文件拿到的是精确的进程号,不容易误杀。但PID文件有可能失效(比如服务器重启过,PID文件没清理),所以还要加一个按服务名匹配的兜底检查。pgrep -f "${JAR_NAME}"匹配的是完整命令行,命中更精确,grep java这种模糊方式太容易误杀同机的其他Java进程。
停止服务要用优雅终止优先。直接kill -9会把Spring Boot的优雅停机机制完全绕过,导致正在处理的请求中断、数据库连接池没释放、本地缓存没落盘。我上面这段逻辑是先kill(发SIGTERM),等30秒,等不到再kill -9。Spring Boot 2.3以上还支持配置优雅停机的超时时间,server.shutdown=graceful配合spring.lifecycle.timeout-per-shutdown-phase=60s,给足了收尾时间。
启动命令里的JVM参数放到profile配置里。堆内存、GC参数、远程调试端口这些应该由环境决定,我通常这样组织:
JVM_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -Dspring.profiles.active=${APP_ENV}"本地联调时可以额外加-Dspring.cloud.nacos.discovery.enabled=false之类的开关,但这些属于“临时覆盖”,建议通过脚本的扩展参数传入,不要写死。
4.4 健康检查:端口、接口、日志三层验证
服务启动后,不能马上宣告成功,必须等进程真正就绪。Spring Boot应用从进程拉起到端口监听、再到应用完全初始化完成,中间可能隔了十几秒甚至更久。我习惯做三层验证:
第一层:端口监听检查。
check_port() { local port="$1" for i in $(seq 1 30); do if ss -tln | grep -q ":${port} "; then log_info "端口 ${port} 已监听" return 0 fi sleep 2 done die "端口 ${port} 监听超时" }检查端口有很多方式:netstat、ss、lsof。我优先用ss -tln,它是iproute2包提供的工具,现代Linux发行版基本都装了,输出格式稳定。
第二层:健康检查接口。Spring Boot Actuator如果开了,默认有个/actuator/health端点,返回JSON含"status":"UP"。用curl验证:
check_health() { local url="$1" local result result=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "${url}") if [ "${result}" -eq 200 ]; then local body body=$(curl -s "${url}") if echo "${body}" | grep -q '"UP"'; then log_info "健康检查通过" return 0 fi fi die "健康检查失败,HTTP_CODE=${result}" }这里的技巧是用-w "%{http_code}"先拿状态码,再拉body验证内容,两层条件都满足才返回成功。有的服务健康检查依赖数据库连接,数据库没就绪时接口会返回503,这套检查逻辑能提前暴露问题。
第三层:日志关键字检查。端口和接口都过了,不代表业务就绪。有些错误只出现在日志里,比如配置加载失败导致某个Bean没初始化,进程其实启动了但服务不可用。在远程脚本最后加一段:
if grep -i "ERROR\|Exception" "${LOG_FILE}" | tail -50 | grep -v "Expected" | grep -q "ERROR\|Exception"; then echo "检测到异常日志,请手动检查 ${LOG_FILE}" fi注意这里我加了grep -v "Expected"去过滤一些已知的预期异常。有的框架会在启动时打印“expected”的错误但实际不影响启动,强行匹配会把正常启动误判为失败。这个过滤清单按项目实际情况维护,属于脚本上线后期不断打磨出来的部分。
日志检查不能太严格,否则会把误报当成部署失败;也不能太宽松,否则起不到预警效果。建议初期以“是否有ERROR级别日志”为底线,后面再逐步精调。
5. 常见问题与排查技巧实录
5.1 端口明明释放了,新进程却起不来或没绑定
这是Java部署中最常见的诡异问题。旧进程杀掉后,端口处于TIME_WAIT状态,新进程用默认SO_REUSEADDR反而可能绑定失败(取决于内核参数和应用是否设置了SO_REUSEADDR)。Java的ServerSocket默认是设置了SO_REUSEADDR的,一般不会报地址被占用,但确有部分框架或网络库行为不一致。
我的排查步骤固定如下:
ss -tlnp | grep <端口>确认端口监听状态和占用进程;- 如果发现端口还是旧进程占用,等一两秒再查,因为SIGTERM到进程真正退出有延迟;
- 实在不行,看启动日志里的具体报错,是
BindException: Address already in use还是其他错误。
为了彻底避开这个问题,我在启动脚本里加了启动前的“端口三方检查”:
check_port_free() { local port="$1" if ss -tln | grep -q ":${port} "; then die "端口 ${port} 仍被占用,请检查是否有残留进程" fi }这个函数要在旧进程停止后、新进程启动前调用。如果检查不通过,直接中断部署,而不是盲目启动然后让服务Crash。
5.2 杀进程的连环事故
初版脚本里很多人会这样写:
pkill -9 -f xxx.jar这行命令有个隐患:如果服务器上其他无关进程的命令行里也包含xxx.jar字样,会被一并杀掉。更严重的是,有的服务器上Java进程的启动参数里没有jar包名,而是-Dloader.path指定的方式,pkill -9 -f xxx.jar根本匹配不到。
我踩过两次坑之后,最终方案是:优先PID文件,辅助pgrep -f精确匹配,杀掉后回查一次确认进程真的没了。杀完进程再ps -ef | grep -v grep | grep xxx.jar查一遍,这个步骤别省。
另一个隐蔽问题是:用pkill杀掉进程后,旧进程的孤儿线程可能还在写日志文件,导致新进程启动时发现日志文件被占用(其实Linux下可以共存,但日志输出会混乱)。所以换包之前,最好对日志目录做一次归档清理,把当前日志改名留档,让新进程写新文件。
5.3 Bash的set -e并不是万能的
很多教程会告诉你脚本开头加set -e,遇到错误就退出。但真实世界里set -e有非常多反直觉的边界行为:
- 管道命令中,最后一个命令成功则整条管道返回成功,比如
grep xxx | awk '{print $1}',grep没匹配到内容并不影响awk退出码,于是错误被吞掉; - 条件判断语境下的命令不受
set -e控制,比如if grep -q xxx; then里的grep即使失败也不会触发退出; - 函数内的命令也会被
set -e影响,但有时子shell里的退出码传不到父shell。
所以我在设计脚本时没有依赖set -e,而是在每个关键步骤显式检查退出码。写起来是麻烦一点,但好处是退出时能带上清晰的中文错误提示,而不是脚本默默停在一行难以定位。
处理管道错误我会使用set -o pipefail,让管道中任何一个命令失败都反映为整条管道失败,再结合手动检查退出码,整体健壮性高很多。
5.4 远程执行脚本时的EOF和引号地狱
写过远程命令的人都有体会:把一段带引号、变量、嵌套命令的bash脚本塞进ssh user@host "...",很容易因为引号转义问题导致远程执行的结果和本地完全不一样。
我的解决方案是:把远程执行的逻辑单独写成一个脚本文件,scp传过去,再ssh调用。这样本地只有一行简单调用:
scp deploy_remote.sh "${SSH_USER}@${SERVER_IP}:${REMOTE_DIR}/" ssh -p "${SSH_PORT}" "${SSH_USER}@${SERVER_IP}" "bash ${REMOTE_DIR}/deploy_remote.sh ${SERVICE_NAME}"这个改动的收益非常大。远程脚本里不再需要考虑引号转义,本地脚本里也不再出现一堆奇怪的\"。调试时直接在服务器上手动执行bash deploy_remote.sh order-service,问题定位飞快。
5.5 nohup输出日志无限增长
用nohup java -jar xxx.jar > app.log 2>&1 &启动服务,跑几个月后日志文件可能膨胀到几个GB,占用磁盘不说,grep排查日志时也慢得让人崩溃。
我的经验是在远程脚本里结合日志切割策略:
- 启动前检查日志文件大小,超过100MB就把旧日志压缩归档:
mv app.log app.log.$(date +%Y%m%d) && gzip app.log.$(date +%Y%m%d); - 更彻底的做法是建议项目集成
logback的SizeAndTimeBasedRollingPolicy,让应用自身按天和大小切割,Shell只做启动前的兜底归档。
部署脚本里处理日志,重点不是做一个完美的日志管理方案,而是保证长时间运行后磁盘不会因为日志爆掉。这方面的兜底动作,花10分钟写进脚本,省的是未来某天凌晨磁盘告警的折腾。
6. 安全性与工程化:让脚本变成团队资产
6.1 脚本放哪里、怎么维护版本
Script写完之后,我建议直接放进项目仓库的deploy/目录,和源码一起走Git管理。这样每个版本对应的脚本是什么样,可以精确回溯。脚本里的profile文件要小心处理:测试环境的IP、密码、密钥放仓库一般问题不大,但生产环境的高权限信息不要入库,建议由运维单独维护在服务器上,脚本里通过source /etc/deploy_secret.conf加载。
如果团队同时对多个项目使用同一套脚本,可以把公共的deploy_lib.sh单独抽成独立仓库,各项目通过git submodule或复制特定版本引入。这个做法初期不必过度设计,团队大了自然知道怎么抽公共层。
6.2 备份与回滚机制
部署方案必须配套回滚方案,否则上线出问题只能干瞪眼。我在远程部署脚本里加了这样一个函数:
backup_current() { if [ -f "${APP_HOME}/${SERVICE_JAR}" ]; then cp "${APP_HOME}/${SERVICE_JAR}" "${APP_HOME}/backup/${SERVICE_JAR}.$(date +%Y%m%d_%H%M%S)" fi }回滚时只需要找到备份目录里最新的文件,重新执行“停服-替换-启动”三步即可。因为启动命令固定引用${SERVICE_JAR}这个符号链接名称,回滚时就把符号链接指回备份版本:
ln -sfn "${APP_HOME}/backup/${SERVICE_JAR}.xxx" "${APP_HOME}/${SERVICE_JAR}"这个机制的要点是回滚动作和发布动作走同一套流程,只是产物来源从“新构建的包”换成“备份目录里的旧包”。只要停服、替换、启动这个过程稳定,回滚就稳定。
6.3 并发与重入保护
有一次团队两个同事同时执行部署脚本,一个在构建,一个在传包,最后产物互相覆盖,服务的状态彻底不可控。解决方案是在脚本开头加一个简单的锁机制:
LOCK_FILE="/tmp/deploy_${SERVICE_NAME}.lock" exec 9>"${LOCK_FILE}" if ! flock -n 9; then die "已有部署任务在执行,请等待完成或手动清理 ${LOCK_FILE}" fi利用flock命令拿文件锁,拿不到就退出。这个实现简单有效,解决了多个人同时操作同一服务的问题。跨机器的并发锁需要更复杂的实现,一般团队阶段用文件锁就足够了。
6.4 通知集成:脚本跑完让全员知道
部署结果应该主动通知到团队协作工具里,而不是让大家自己去服务器上看。在脚本的finish和die位置接入一个send_notify函数,用curl调webhook即可:
send_notify() { local message="$1" curl -s -X POST "${WEBHOOK_URL}" \ -H 'Content-Type: application/json' \ -d "{\"msgtype\": \"text\", \"text\": {\"content\": \"[${SERVICE_NAME}] ${message}\"}}" }通知文案里带上构建时间、目标环境、部署结果、日志路径,方便事后追溯。这一步虽然是锦上添花,但能显著提高团队对自动化部署的信任感——脚本不再是个“黑盒命令”,而是有反馈闭环的工具。
6.5 从Shell脚本向流水线的演进方向
最后聊聊大家迟早会遇到的问题:Shell脚本写好了,团队规模扩大,要不要迁移到Jenkins/GitLab CI?我的建议是先让Shell脚本稳定跑起来,再考虑平台化。
Shell脚本本身就是流水线的一个“执行单元”,Jenkins里的Pipeline最终执行的无非也是类似的命令。反过来说,如果Shell脚本的边界不清、错误处理混乱、配置混乱,搬上任何平台都是复制混乱。所以顺序应该是:先用Shell脚本把发布流程彻底理顺,再把同样的步骤编排到CI平台,利用平台的能力做定时触发、权限管理、流水线可视化和产物溯源。
我见过不少团队反着来,Jenkins配置得非常花哨,底层脚本一塌糊涂,最终发布还是经常翻车。工具平台只是把过程可视化,真正让部署可靠的是脚本本身的质量。
动手之前的一个小建议
整套方案讲下来,看着内容不少,但真正落地实现时,我建议先从一个最小可用的版本开始:先写一个只包含“构建+scp+远程重启+端口检查”的脚本,跑通一条最简单的部署链路。等这条链路稳定了,再逐步加入健康检查、日志检查、备份回滚、通知集成。
我在实际使用中最大的体会是:自动化部署脚本的价值不在于一开始就完美,而在于每次部署失败时你能顺着日志快速找到问题,然后修复脚本让它下次不再犯。脚本是活的,它是你和你的部署环境长期磨合的产物。第一次写出来能用,比写得很“全”但运行不了重要得多。
如果你打算现在就动手,可以从复制本文里的deploy_remote.sh开始,在你的服务器上手动执行一遍,确认停服、启动、端口检查这三步都正常,然后把它放进主脚本里跑完整链路。跑通第一版,后面的事情都是水到渠成的。