ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

TW8中间件项目化部署实战:目录、脚本、配置与回滚

TW8中间件项目化部署实战:目录、脚本、配置与回滚

做中间件运维这十年,我接手过太多“装完能跑、换台机器就废”的 TW8(下文一律用这个简称)现场:安装目录散落在/root下、数据源密码写在控制台里没人知道、应用包版本靠文件名里的日期区分、出问题只能翻三小时前的控制台滚动日志。TW8 是一款国产 Java 应用服务器中间件,本身能力没问题,问题几乎全部出在“部署”这件事没有被当成一个工程对待。所谓TW8 项目化部署,核心就一句话:把一次性的手工安装,变成一套可重复执行、可版本化、可回滚的部署资产——包含目录骨架、配置模板、静默脚本、启停托管、巡检清单五件套。这篇内容面向正在做信创改造、需要批量交付 TW8 实例的运维和实施同学,也适合刚接手中间件、只会点控制台的新手。我会按“为什么这么做—目录怎么摆—环境怎么备—装怎么装—参数怎么算—包怎么发—日志怎么盯—坑怎么排”的顺序讲透,每一步都给出可以直接抄走的结构和脚本,参数部分全部附上推导过程,而不是甩一堆数字让你自己猜。

1. 为什么要把 TW8 部署做成“项目”,而不是“装一次能跑就行”

1.1 从“装好能跑”到“每次装都一样”差在哪

手工装 TW8 的典型画面是这样的:拿到安装包,解压到某个顺手的位置,控制台点下一步,改个端口,启动,应用传上去,能访问,收工。这个过程在单台测试机上完全没问题,甚至很爽。但它埋了四个雷:安装路径不固定导致脚本无法复用;端口、JVM、数据源全靠记忆填,第二台机器必然有偏差;应用包没有版本号,回滚只能靠“我记得上一个包叫 war_new2_final”;日志没有轮转,磁盘满了才发现服务挂了三天。

项目化部署要做的,就是把这四个雷提前拆掉。它不追求“一键全自动”这种听起来很美的东西,而是追求可重现:任意一个环境、任意一台新机器,用同一套资产部署出来的实例,除了 IP 和数据库连接串,其他全部一致。这个目标听起来朴素,但真正做到之后,排查问题的效率会发生质变——因为你终于可以确定“配置不一样”不再是嫌疑对象了。

心得:我判断一个 TW8 部署是否合格,标准很土——换一个从没接触过这个项目的人,只给他部署资产,他能不能在 40 分钟内装出一个可用的实例。做不到,就说明还有东西活在老员工的脑子里。

1.2 项目化部署到底解决了哪几个真问题

具体拆开来,它解决的是五类问题。第一是环境漂移:开发、测试、生产三套环境的 JVM 参数、连接池大小、日志级别各不相同,上线后性能表现完全不同,最后谁都说服不了谁。第二是交付审计:信创项目验收时经常要问“这个实例装了什么版本、改过哪些参数”,没有配置基线就只能现场编。第三是批量扩容:从 2 个实例扩到 8 个,手工装是线性耗时,脚本装是常数耗时。第四是故障恢复:磁盘挂了重建实例,有资产是半小时,没资产是半天。第五是责任边界:配置在版本库里、变更走提交,出了问题能查到是谁在什么时候改了什么。

这五条里面,第四条最容易被低估。我经历过一次机房存储故障,同一批 6 个实例全挂,有部署资产的那套 40 分钟恢复完毕,另一套纯手工的从下午两点搞到晚上九点,最后还是靠人肉回忆才把数据源配回去。从那以后我坚持一个原则:部署脚本和配置模板必须进版本库,和代码同等地位,不能放在某台跳板机的家目录里。

2. 部署包的目录结构与版本约定

2.1 一套自己定的目录骨架

TW8 自身的安装目录结构是固定的,不建议去动它,但在它外面套一层我们自己的项目目录是完全必要的。我惯用的骨架是三层:交付资产层、运行实例层、数据持久层。交付资产层放部署包和脚本,可以整包拷贝;运行实例层是 TW8 实际安装的位置;数据持久层独立挂盘,放日志、上传文件、会话持久化数据,这样重装实例时数据不用动。

/opt/tw8-deploy/ # 交付资产层,进版本库 ├── packages/ # 安装包,按版本建子目录 │ ├── tw8-8.0.x/ │ └── tw8-8.0.y/ ├── templates/ # 配置模板层 │ ├── common/ # 全环境通用 │ ├── env-dev/ # 环境差异化 │ └── env-prod/ ├── scripts/ # 脚本层 │ ├── 01-init-os.sh │ ├── 02-install.sh │ ├── 03-create-domain.sh │ ├── 04-deploy-app.sh │ ├── 05-rollback-app.sh │ └── 06-healthcheck.sh └── inventory/ # 实例清单,一台机器一行 /opt/tw8/ # 运行实例层,安装目录 /data/tw8/ # 数据持久层,独立挂盘 ├── logs/ ├── upload/ └── session/

这个骨架的关键设计在templates的分层。common放所有环境都一样的配置(比如协议、字符集、静态资源缓存策略),env-*只放差异项(数据库地址、日志级别、JVM 堆大小)。部署时按“先 common 后 env”的顺序合并覆盖,生成最终配置文件。这样做的直接好处是:改一个通用参数只需要动一个文件,而不是去 8 台机器上分别改。

注意:不要把packages跟着版本库一起塞进 Git 仓库,安装包动辄几百兆,仓库会迅速膨胀。正确做法是仓库里只放包的校验值(sha256)和下载路径说明,CI 环节或部署机上单独同步包。

2.2 配置文件的分层:模板层、环境层、实例层

三层配置的分工要说清楚,否则维护一段时间就会乱。模板层是“这个中间件应该长什么样”,由架构或中间件负责人维护,改动需要评审。环境层是“这套环境有什么特殊”,由环境负责人维护,比如测试环境日志级别设成 DEBUG、生产环境关掉自动部署避免误传。实例层是“这一台机器有什么特殊”,只在极少数情况下使用,比如某台机器内存特别大,堆可以给到 8G。

实例层要慎用、要留痕。我的做法是实例级覆盖必须在一个overrides.properties里,且文件头强制要求写注释说明原因和申请人。没有注释的覆盖项,巡检脚本直接告警。这个约束听起来麻烦,但它防止的是“某天有人临时改了个参数忘了改回来”这种最恶心的事故——这类问题排查起来毫无线索,因为配置文件本身看起来是正常的。

配置合并的实现不用搞得很复杂,一段 shell 就够:先拷贝 common 下的文件到实例目录,再用 env 目录下的同名文件覆盖,最后用 overrides 覆盖。合并完成后计算一次配置文件指纹(比如md5sum汇总),把指纹记录到部署日志里。将来要对比两台机器的配置差异,比指纹就行,不用逐个文件 diff。

2.3 版本号与回滚位的设计

版本号不要用日期,用语义化版本:8.0.x-app1.2.3。前半段是中间件版本,后半段是应用版本,两段独立演进。实例目录里保留current和previous两个软链接,current指向当前运行的应用目录,previous指向上一个可用版本。发布时新建一个版本目录,写好之后把current切过去,重启生效;回滚时把current切回previous,重启即可。

软链接切换这个做法,比“覆盖原文件”强太多。覆盖式发布的问题是:一旦新包有问题,旧包已经被覆盖了,你手上只剩一个坏包。而目录式发布保留了完整的上一版,回滚是秒级的。代价是磁盘占用翻倍,但这个代价完全值得——一个应用包通常几十到几百兆,比起事故停机时间,这点磁盘不值一提。

版本目录的保留策略也要定:至少保留最近 3 个版本,超过的按时间清理。清理脚本要写成“只删不在 current 和 previous 里的目录”,防止手滑把正在跑的删掉。这个脚本我建议加个 dry-run 模式,先打印要删的目录,人工确认后再执行。

3. 环境准备:装之前必须确认的六件事

3.1 操作系统架构与 JDK 的匹配

TW8 运行在 JVM 上,所以第一步永远是确认 JDK。三件事必须核对:架构对不对(x86_64 还是 aarch64)、位数对不对(必须 64 位)、版本对不对。TW8 8 版本线一般支持 JDK 8 和 JDK 11 两条路,部分版本支持到 17,具体以你拿到的发布说明为准。信创环境里 ARM 架构服务器很常见,这里最容易出的问题是:装了 x86 的 JDK 包,java -version直接报“cannot execute binary file”,然后开始怀疑人生。

确认命令很简单,但要养成习惯,别跳过:

uname -m # 输出 x86_64 或 aarch64 getconf LONG_BIT # 必须输出 64 java -version 2>&1 # 确认版本与供应商 which java # 确认用的是哪个 java,避免被旧版本抢先

which java这一步经常被忽略。真实场景是:机器上原本装了系统自带的 OpenJDK 8,你又装了一个 JDK 11 到/usr/local,但 PATH 没改,结果 TW8 启动时用的还是 8,然后某些特性不生效,你查了半天配置文件。解决办法是在启停脚本里显式指定JAVA_HOME,不依赖环境的 PATH。这一条我写进了所有项目的启停脚本模板里,属于血泪换来的固定动作。

JDK 选择上还有一个小细节:优先选与中间件官方发布说明匹配的版本,别追新。我见过为了用某个新 GC 特性把 JDK 升到很新的版本,结果中间件某些内部 API 反射失败,报一堆看不懂的错,最后退回原版本解决。中间件这类基础软件,稳定压倒一切。

3.2 用户、目录、权限与内核参数

TW8 绝对不能用 root 跑。标准做法是建一个专用系统用户,比如twuser,家目录设成安装目录的上级,禁止登录 shell。目录权限按“用户可写、组可读、其他不可访问”来设,日志目录和数据目录单独授权。

groupadd -r twgrp useradd -r -g twgrp -d /opt/tw8 -s /sbin/nologin twuser mkdir -p /opt/tw8 /data/tw8/{logs,upload,session} chown -R twuser:twgrp /opt/tw8 /data/tw8 chmod 750 /opt/tw8 /data/tw8

内核参数这边,主要关注三样:文件句柄数、进程数、TCP 相关参数。文件句柄数是最容易踩的,默认 1024 在高并发下分分钟打满,表现是“Too many open files”,然后连接建立失败。我一般直接给到 65535,在/etc/security/limits.conf或者 systemd 的 unit 里配LimitNOFILE=65535。后者的好处是只对这一个服务生效,不影响系统全局。

TCP 参数里,net.ipv4.tcp_tw_reuse和somaxconn是常用项,但要说明白:前者影响的是主动关闭连接后的端口复用,后者影响的是 accept 队列长度。参数值不要照抄网上的“万能优化”,要看你的实际连接模型。短连接多、QPS 高的场景,somaxconn给到 1024 甚至更大有意义;长连接为主的内部服务,调大了也没什么感知。

注意:改limits.conf之后必须重新登录才生效,而 systemd 托管的服务根本不读这个文件。如果你用 systemd 起服务,改完limits.conf发现没效果,不是改错了,是压根没生效——得去 unit 文件里配。

3.3 端口规划表(含冲突检查)

端口规划要在装机前定死,写进清单。TW8 涉及的主要端口有这么几类:

用途建议端口说明是否对外
HTTP 业务端口8080应用对外服务主端口是
HTTPS 业务端口8443需要 TLS 时启用是
管理控制台9060部署、配置、监控入口内网限定
集群通信9100多实例组集群时使用内网
JMX 监控9999接监控采集用内网
AJP 转发8009前置 Web 服务器转发时用内网

端口冲突检查别只查netstat,因为可能被其他命名空间或容器占着。我的做法是装之前跑一遍探测脚本,对每个计划端口尝试 bind 一次,能 bind 成功才算可用:

for p in 8080 8443 9060 9100 9999 8009; do if timeout 1 bash -c "echo >/dev/tcp/127.0.0.1/$p" 2>/dev/null; then echo "PORT $p OCCUPIED" else echo "PORT $p FREE" fi done

还要提醒一句:同一台机器上跑多个 TW8 实例(多 domain)时,上面所有端口都要按实例偏移,比如实例 1 用 8080/9060,实例 2 用 8081/9061。这个偏移规则要写进清单,不能临场心算,因为一旦算错,两个实例抢同一个端口,后启动的那个直接失败,而日志里的报错信息往往只写“Address already in use”,不告诉你是哪个实例。

4. 静默安装与实例初始化

4.1 安装包解压式部署与静默参数

TW8 的安装包通常提供图形化和命令行两种方式。生产环境一律走命令行,因为图形化需要 X11 转发,既不安全也不可复现。命令行安装的核心是一个响应文件(也叫静默安装配置文件),把安装路径、JDK 路径、管理端口、管理员账号这些参数一次性写进去,然后非交互执行。

# silent-install.properties INSTALL_PATH=/opt/tw8 JAVA_HOME=/usr/local/jdk11 HTTP_PORT=8080 CONSOLE_PORT=9060 ADMIN_USER=twadmin ADMIN_PASSWORD_FILE=/opt/tw8-deploy/inventory/admin.pwd INSTALL_MODE=console

这里有两个细节值得说。第一,密码不要明文写在响应文件里,用ADMIN_PASSWORD_FILE指向一个权限 600 的文件,脚本读取后注入。第二,INSTALL_MODE选 console 而不是 GUI,避免安装程序去探测显示环境。参数名在不同小版本之间可能有出入,拿到包之后先用--help或查看包内的模板文件确认,别硬套。

心得:安装完第一件事不是启动,是把安装目录整个tar一份留作基线。后面如果配置改乱了,可以直接对照基线找差异,比对着文档猜快得多。我一般把这份基线放在/opt/tw8-deploy/packages/tw8-8.0.x/baseline.tar.gz。

4.2 实例(domain)创建与最小可用验证

一个安装目录下可以创建多个独立实例,每个实例有自己的配置、应用、日志和端口。项目化部署里,实例划分要跟业务划分对齐:一个业务线一个实例,还是按部署单元划分,这个决定要提前定。我倾向按部署单元划分,因为不同应用对 JVM 参数、连接池的要求差别很大,混在一起只能取折中值,谁都不满意。

创建实例的命令行方式大致如下(参数名以实际版本为准):

/opt/tw8/bin/domain.sh create \ --name app01 \ --base /opt/tw8/domains/app01 \ --http-port 8080 \ --console-port 9060 \ --jvm-opts "-Xms4g -Xmx4g -XX:MetaspaceSize=256m"

实例创建完,先做最小可用验证,不要急着传应用包。验证顺序是:启动实例 → 看启动日志有没有 ERROR → 访问控制台 → 访问默认页 → 检查端口监听 → 检查日志目录是否正常写入。这五步任何一步不过,都要停下来解决,不要抱着“先传应用试试”的心态。因为一旦应用也传上去,出了问题你就分不清是中间件的问题还是应用的问题。

启动日志的检查有个技巧:不要用grep -i error一把梭,会抓到一堆无害的告警。更靠谱的是看“服务器启动完成”这类标志性日志有没有出现,以及从启动开始到完成用了多久。正常情况下冷启动在几十秒级别,如果超过两三分钟,说明在某个环节卡住了(常见是熵池不足、DNS 解析慢、类扫描过多)。

4.3 一个可复用的初始化脚本骨架

把上面的步骤串起来,就是一个初始化脚本。写脚本的要点是每一步都要可重入:重复执行不产生副作用。比如创建用户前先判断是否存在,创建目录用mkdir -p,写配置前先备份。这样脚本跑到一半失败,修完可以直接从头再跑一遍,不用手工清理残局。

#!/usr/bin/env bash set -euo pipefail INSTANCE=${1:?usage: $0 <instance-name>} BASE=/opt/tw8/domains/${INSTANCE} TPL=/opt/tw8-deploy/templates ENV=${DEPLOY_ENV:-prod} # 1. 目录准备(可重入) mkdir -p "${BASE}"/{conf,logs,temp} mkdir -p /data/tw8/logs/${INSTANCE} # 2. 配置合并:common -> env -> overrides cp -f "${TPL}"/common/*.properties "${BASE}/conf/" cp -f "${TPL}/env-${ENV}"/*.properties "${BASE}/conf/" if [[ -f "${BASE}/conf/overrides.properties" ]]; then echo "using instance overrides" fi # 3. 记录配置指纹 md5sum "${BASE}"/conf/*.properties | sort -k2 > "${BASE}/conf/.fingerprint" # 4. 创建实例并启动 /opt/tw8/bin/domain.sh create --name "${INSTANCE}" --base "${BASE}" /opt/tw8/bin/domain.sh start "${INSTANCE}" # 5. 等待健康 for i in $(seq 1 60); do if timeout 2 bash -c "echo >/dev/tcp/127.0.0.1/8080" 2>/dev/null; then echo "instance ${INSTANCE} is up"; exit 0 fi sleep 2 done echo "start timeout" >&2; exit 1

这个骨架里最值得学的是第 5 步的健康等待。很多脚本启动完就echo 成功,其实服务还在初始化。加一段带超时的轮询,才能真正判断成功与否。轮询间隔 2 秒、最长 120 秒,这个取值是基于大多数 Java 应用冷启动时间来的,如果你的应用特别重(比如启动时加载几万个类的规则引擎),可以调到 300 秒。

5. 应用上线前的关键配置:数据源、JVM、线程

5.1 数据源与连接池参数怎么算

连接池大小是 TW8 部署里最容易被拍脑袋决定的参数。给小了,高并发下请求排队等连接,响应时间飙升;给大了,数据库端连接数爆掉,反而拖垮全局。正确的算法是从数据库侧倒推:

设单实例峰值 QPS 为 Q,单次请求平均持有连接时间为 T 秒(单位换算注意),则理论上需要的并发连接数约为Q × T。举例:Q = 300,T = 0.02 秒(即 20 毫秒),需要 6 个连接。但这是理论值,实际要给冗余,因为 T 会有长尾。经验做法是在理论值基础上乘以 3 到 5,得到初始值,然后压测验证。所以这个例子初始值给 20 到 30 比较合理。

再往上一层是数据库总连接数的约束。假设数据库max_connections是 800(实际可用要扣掉管理连接和其他系统占用,按 700 算),你的应用有 8 个实例,那么每实例的上限就是700 / 8 ≈ 87,还要留一部分给批处理和其他应用,所以每实例给到 40 到 50 是安全区。这个约束必须写进配置注释里,否则将来扩容实例时没人会想起来回头改连接池,直到数据库拒绝连接。

其他几个参数的含义和取值建议:

参数含义建议取值说明
初始连接数实例启动时预建连接最大值的 1/4避免启动瞬间批量建连
最大连接数连接池上限按上面公式算与数据库容量联动
最小空闲数保底空闲连接最大值的 1/8防止流量骤降后频繁重建
获取连接超时等连接的最长时间3000~5000 ms超过就快速失败,别无限等
空闲检测间隔探活周期60~120 s配合验证语句使用
连接最大存活时间强制回收时间1800 s避开数据库侧超时

最后那个“最大存活时间”很关键,但常被漏掉。数据库和中间的网络设备(负载均衡、防火墙)通常有连接空闲超时,比如 30 分钟。如果连接池里的连接活得比它还久,就会拿到一个“看起来活着实际已断”的连接,报错五花八门。把连接最大存活时间设得比网络设备超时短一些(比如 1800 秒对 3600 秒),让应用自己主动换连接,就能绕开这个坑。

注意:验证语句不要用复杂 SQL,用最简单的SELECT 1之类。有些连接池在获取连接时会执行验证语句,如果用重查询,等于每次取连接都多跑一次业务查询,性能直接砍半。

5.2 JVM 堆与元空间的经验取值

JVM 参数没有万能公式,但有一批稳定的经验规则可以起步。第一条:-Xms和-Xmx设成一样大。理由很实在——堆动态扩缩本身有成本,而且在容器或虚拟化环境里,堆反复增长容易触发操作系统层面的内存分配抖动。一次给够,行为最可预测。

第二条:堆大小取决于机器内存和应用特性。通用比例是堆占物理内存的 50% 到 60%,留出空间给元空间、线程栈、堆外内存(Netty 直接内存、NIO buffer)和操作系统页缓存。比如 16G 内存的机器,堆给 8G 到 10G 比较稳,不要给到 14G,那样一旦有点堆外内存泄漏就是 OOM Killer 直接杀进程。

第三条:GC 选择。JDK 8 上,堆小于 4G 用默认的并行收集器就够,堆大于 4G 建议上 G1,配合-XX:MaxGCPauseMillis=200这个目标停顿时间。JDK 11 及以后默认就是 G1,不用显式指定,但可以考虑加-XX:+UseStringDeduplication省点内存(代价是少量 CPU)。

元空间的设置经常被忽略。默认元空间无上限,一旦应用大量生成类(比如热部署、动态代理、脚本引擎),元空间会一直涨,最终触发 Full GC 甚至 OOM。稳妥做法是设初始值 256M、上限 512M,并且配合-XX:+HeapDumpOnOutOfMemoryError保留现场。

# 8G 堆 / 16G 内存机器的参考配置 -Xms8g -Xmx8g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/tw8/logs/heapdump -Xlog:gc*:file=/data/tw8/logs/gc.log:time,uptime:filecount=5,filesize=50M

最后一行日志参数在 JDK 8 上写法不同(要用-Xloggc加-XX:+PrintGCDetails),选参数前先确认 JDK 大版本。堆转储路径要确保目录存在且用户有写权限,否则真出事的时候转储文件生成失败,等于白配。

5.3 线程池与超时的联动关系

业务线程池大小的经典估算:线程数 ≈ QPS × 平均响应时间(秒)。500 QPS、平均 RT 200 毫秒,算出来是 100 个线程。但这只是平均值,长尾请求可能耗时几秒,所以实际要留冗余,或者更聪明的做法是按分位数算:用 P99 响应时间代替平均值。500 QPS × P99 2 秒 = 1000 线程,这个数字显然不现实,说明系统在长尾上已经有问题了,加大线程池只会掩盖问题。

所以线程池调优的正确顺序是:先看 RT 分布,找出长尾在哪里;再决定是优化慢查询、加缓存,还是加线程。盲目加线程的后果是线程上下文切换开销上升,吞吐量反而下降,而且内存会吃紧(每个线程栈默认 1M)。

超时设置要和线程池联动。假设线程池 200 个线程,请求超时设 30 秒,那么最坏情况下 200 个线程全部卡住 30 秒,新的请求全部排队,服务表现就是“假死”。合理做法是把超时压到业务能接受的最短时间,比如 5 秒,这样线程被占用 5 秒就释放,系统有自我恢复能力。这个原则在中间件层面同样适用:连接超时、读超时、请求超时都要设,且要一层比一层短,形成梯度。

心得:我排查“服务假死”类问题,第一件事是抓线程栈,看有多少线程卡在同一个地方。如果 200 个线程全卡在一个数据库读上,那不是线程池的问题,是那个查询或者那个连接池的问题。堆栈永远是最好的线索。

6. 应用包部署与自动化发布

6.1 三种部署方式的选择

TW8 支持三种应用部署路径:控制台上传、自动部署目录投放、命令行部署。三者各有适用场景,别混用。

控制台上传适合调试和紧急修复,可视化,能立刻看到部署结果和错误信息。缺点是没法自动化,也不留痕。自动部署目录适合开发自测,把包扔进autodeploy目录,中间件自动加载。缺点是行为不够可控,扫描周期、加载顺序都可能影响结果,而且生产环境开着自动部署是很危险的——万一有人误传一个包进去,直接就上线了,没有任何审批。命令行部署适合生产,可以写进脚本、进流水线,结果可判断。

我的项目里,生产环境一律关掉自动部署,只走命令行。开发环境可以开着,方便。测试环境视情况,如果测试团队需要频繁换包,开着更省事,但要在文档里写明。

6.2 发布脚本与回滚脚本

发布脚本的核心逻辑是:新建版本目录 → 拷贝应用包 → 检查包完整性 → 停止实例(如果必须) → 切换current软链接 → 启动 → 健康检查 → 失败自动回滚。写成脚本大概是这个形状:

#!/usr/bin/env bash set -euo pipefail APP=${1:?need app name} PKG=${2:?need package path} VER=${3:?need version} ROOT=/opt/tw8/apps/${APP} NEW=${ROOT}/${VER} # 校验包完整性 sha256sum -c "${PKG}.sha256" || { echo "package broken"; exit 1; } mkdir -p "${NEW}" unzip -q -o "${PKG}" -d "${NEW}" [[ -f "${NEW}/WEB-INF/web.xml" ]] || { echo "bad package structure"; exit 1; } # 记录上一个版本 PREV=$(readlink -f "${ROOT}/current" || true) [[ -n "${PREV}" ]] && ln -sfn "${PREV}" "${ROOT}/previous" # 切换 ln -sfn "${NEW}" "${ROOT}/current" /opt/tw8/bin/domain.sh restart app01 || true # 健康检查 for i in $(seq 1 30); do code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/health || true) [[ "${code}" == "200" ]] && { echo "deploy ok"; exit 0; } sleep 3 done # 回滚 echo "health check failed, rolling back" [[ -n "${PREV}" ]] && ln -sfn "${PREV}" "${ROOT}/current" /opt/tw8/bin/domain.sh restart app01 exit 1

几个地方值得解释。包完整性校验不是形式主义,网络传输截断导致 war 包损坏的情况我遇到过不止一次,表现是部署报“zip 文件格式错误”,如果没有校验,你会怀疑是中间件有问题。检查WEB-INF/web.xml存在是在验证包结构,防止有人传了个空的或者目录结构不对的包。自动回滚则是最后一道保险,宁可回滚也不要让一个有问题的版本在线跑着。

回滚脚本本身要简单到“闭着眼睛也能跑”。我的回滚脚本只有十几行:读previous,切current,重启,健康检查。不加任何花哨逻辑,因为回滚往往发生在半夜、人手紧张的时候,脚本越简单越可靠。

6.3 灰度与并行目录切换

如果业务允许,灰度发布比全量重启友好得多。做法是同一台机器上起两个实例,端口不同,前置负载均衡按权重分流,先给新实例 10% 流量,观察一段时间再逐步加。TW8 支持多实例共存,只要端口和实例目录分开就行。

灰度的关键是会话处理。如果应用把会话存在本地内存,那么用户在两台实例之间跳转就会掉登录。解决方案有两个:一是会话粘滞(负载均衡按 cookie 或源 IP 转发),简单但不够优雅;二是会话共享,把会话持久化到共享存储或做成无状态 token,更彻底但要改应用。

我一般建议新项目从一开始就做无状态,别依赖本地会话;老项目改造代价大的,先用粘滞过渡。这个决定要写在部署文档里,因为它是“部署架构”层面的约束,不是某个实例的参数。

并行目录切换还有一个好处是回滚成本极低。新旧实例同时在跑,切回去只是改一下负载均衡的权重,连重启都不用。这也是我做重要版本发布时最愿意采用的方案。

7. 启停托管、日志与巡检

7.1 systemd 托管与优雅停机

用nohup起中间件是能跑,但不管重启、不管资源限制、不管优雅停机。生产环境应该用 systemd 托管。一个可用的 unit 大概长这样:

[Unit] Description=TW8 app01 After=network.target [Service] Type=forking User=twuser Group=twgrp Environment=JAVA_HOME=/usr/local/jdk11 LimitNOFILE=65535 ExecStart=/opt/tw8/bin/domain.sh start app01 ExecStop=/opt/tw8/bin/domain.sh stop app01 TimeoutStopSec=90 Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target

有两个参数要注意。TimeoutStopSec默认是 90 秒,但有些应用的优雅停机(等正在处理的请求完成、关闭连接池、刷盘)需要更长时间,如果超时时间太短,systemd 会直接SIGKILL,表现为日志里没有停机记录、数据可能有残留。我一般设 120 到 180 秒,具体看应用最长的请求处理时间。

LimitNOFILE必须显式配,前面提过,systemd 不读limits.conf。这个坑很隐蔽,因为你用ulimit -n在登录 shell 里看到的是 65535,但服务进程实际拿到的是 1024,只有查看/proc/<pid>/limits才能发现。

注意:Type=forking要求启动脚本正确地 fork 并交出主进程 PID,否则 systemd 追踪不到真实进程。如果发现systemctl status显示 active 但进程实际已经挂了,多半是这里的问题,改成Type=simple配合前台启动参数通常能解决。

7.2 日志分级、切割与保留策略

日志要分三类:中间件日志(启动、部署、异常)、应用日志(业务输出)、访问日志(请求记录)。三类放在不同目录,用不同策略切割。混在一起是灾难,因为业务日志量大,会把中间件的错误信息淹没。

切割策略:中间件日志按天或按大小切,保留 15 到 30 天;访问日志按天切,保留 7 到 15 天;应用日志按天切,保留 7 天。这些数字不是随便定的,取决于磁盘容量和排查需求。如果一块 200G 的盘,日增日志 5G,那最多只能留 30 天,还得留出余量应对异常时日志暴涨。

切割工具有两种选择:用中间件自带的轮转能力(如果支持),或者用系统层面的定时任务 + 脚本。我倾向于统一用系统层的方案,好处是策略一致、好统一管理,坏处是需要写脚本。脚本核心就是按日期重命名 + 删除超期文件 + 通知进程重开文件句柄(如果用logrotate的copytruncate模式则不用通知,但会有丢日志风险)。

磁盘水位必须监控。我的经验阈值是 80% 告警、90% 严重告警。因为日志写满磁盘导致的故障,是所有故障里最冤的一种——它完全可预防,而且一旦发生,中间件会因为写日志失败而出现各种奇怪的行为,排查起来方向完全跑偏。

7.3 每日巡检清单

巡检不是为了写报告,是为了提前发现问题。清单要短,长了没人做。我一般定六项:

检查项方法阈值处置
实例进程存活进程检查必须存在不存在立即上报
端口监听端口探测必须监听检查启动日志
磁盘使用率df< 80%清理日志
堆使用率JMX 或 GC 日志< 75%观察趋势,准备扩容
错误日志增量当日 ERROR 计数无突增抽样查看
健康接口HTTP 探测200分析应用日志

这里面“错误日志增量”最容易做出价值。做法是每天固定时间统计前 24 小时的 ERROR 数量,和过去 7 天的均值比较,如果涨了 3 倍以上就告警。这样能抓到那种“每天多几条,一周后爆发”的慢性问题。单纯的“有没有 ERROR”告警太粗,正常的应用也不可能零错误,告警疲劳之后就被无视了。

8. 常见问题与排查实录

8.1 启动阶段的高频故障

启动阶段的问题相对好查,因为有明确的时间点和日志。最典型的三类:

第一类是端口占用,报错很直白,Address already in use。排查方法是ss -lntp找到占用进程。但要注意,有时候占用者是你自己上一次没停干净的实例。所以启停脚本里要有“启动前检查是否已有同实例进程”的逻辑,发现残留就先处理,而不是硬启。

第二类是权限问题,表现为日志目录写不进去、临时目录不可写。Permission denied报错有时候藏在几百行日志中间。快速定位方法是用同一用户身份手工执行一次启动,看哪个路径报错。

第三类是类加载失败,报ClassNotFoundException或NoClassDefFoundError。这个问题的根因通常是依赖冲突——应用自带了一个某个库的版本,中间件自身也带了一个,类加载器优先级导致加载了错误的版本。解决办法是通过中间件的类加载配置把某些包设为“优先使用应用侧”或“优先使用中间件侧”。这也是为什么我一直建议用干净的环境部署,别把测试机的lib目录混进来。

心得:遇到类冲突,先别急着改配置。把-verbose:class打开,看看到底加载了哪个 jar 里的类、从哪个路径来的。这一步能省掉 80% 的猜测。

8.2 运行期的内存、连接与慢请求

运行期问题难查,因为没有明确的失败点,表现往往是“变慢”或“偶发失败”。内存问题看 GC 日志:如果老年代回收后占用仍持续上升,说明有对象泄漏;如果 Full GC 频繁但回收后能降下来,说明堆不够。前者要找代码,后者要加堆。

连接问题看连接池指标:活跃连接数长期贴着上限,说明池子小了或者有慢 SQL 占着连接不放;活跃连接数很低但请求超时,说明获取连接这一步就卡住了,可能是数据库侧有问题。这两个现象看起来都是“慢”,但根因完全相反,不能一概而论。

慢请求的排查工具是线程栈采样。连续采三到五次,每次间隔几秒,如果多次采样里同一批线程都卡在同一个方法上,那就是热点。这个方法的成本极低(jstack一条命令),但效果非常好,尤其在没法接 APM 的环境里。

# 连续采样,观察线程堆积位置 for i in 1 2 3 4 5; do jstack -l $(pgrep -f 'tw8.*app01' | head -1) > /tmp/js-$i.txt sleep 5 done grep -A3 'java.lang.Thread.State' /tmp/js-*.txt | grep 'at ' | sort | uniq -c | sort -rn | head -20

这段脚本跑完,你会拿到出现频次最高的调用栈位置。如果前面是数据库驱动的方法,那问题在 SQL;如果是某个业务方法自旋,那是代码逻辑;如果是Object.wait或者park,那是在等锁或者等 IO。

8.3 排查速查表

把常见现象和可能原因整理成表,方便现场快速定位:

现象优先排查常见根因
启动超时无响应启动日志末尾类扫描过多、DNS 解析慢、熵池不足
启动报端口占用ss -lntp残留进程、端口规划冲突
部署报包格式错误包 sha256传输截断、打包工具不兼容
应用能起但 404部署目录结构上下文路径不匹配、包结构不符
偶发连接超时连接池指标池太小、慢 SQL、连接失效
内存持续上涨GC 日志对象泄漏、缓存无上限
线程数触顶jstack 采样下游超时未设、锁竞争
磁盘写满df -h日志未轮转、堆转储文件堆积

速查表的价值在于把“从哪开始看”这个最耗时的决策标准化。现场排障最怕的不是不会解决,而是不知道先看什么,东看一下西看一下,半小时就没了。

9. 升级、回滚与交付物清单

9.1 小版本升级该按什么顺序走

中间件小版本升级(比如从 8.0.x 到 8.0.y)看起来简单,但操作顺序错了照样出事。我固定的顺序是:先在测试环境用生产配置的副本完整走一遍,包括应用部署和压力验证;确认没问题后,在生产环境按实例逐个升级,绝不批量操作;每个实例升级前先备份配置目录和当前应用版本目录;升级后跑健康检查,通过再动下一个。

逐个升级而不是批量,是因为万一新版本有兼容性问题,你只损失一个实例的可用性,还有回旋余地。批量升级一旦出问题,就是全站故障,而且这时候你连回滚都要排队做。

升级前的备份要说清楚备什么:配置目录(含所有 properties)、应用版本目录、JDK 路径信息、systemd unit 文件。这四样齐了,理论上可以在半小时内完全恢复到升级前状态。至于中间件安装目录本身,如果包不大也一并备份,更保险。

9.2 交付物清单与验收要点

一个合格的 TW8 项目化部署交付物,我通常列这么几项,缺一项都算没交付完:

交付物内容验收要点
部署资产包脚本、模板、清单能在干净机器上一次跑通
配置基线各环境最终配置及差异说明配置指纹可复现
端口与资源规划表端口、内存、磁盘分配与实际情况一致
启停与巡检脚本systemd unit、巡检清单实际执行过并留记录
故障处理手册常见问题与处置步骤至少覆盖速查表全部条目
版本记录中间件版本、应用版本、变更历史可追溯每次变更

验收时我最看重的不是文档写得多漂亮,而是能不能现场复现。让交付方在一台新机器上按资产包跑一遍,跑得通才算数。文档里写“修改配置文件中的数据库地址”,但没说改哪个文件、哪个键名,这种交付物在真实场景里毫无价值——接手的人还是要打电话问。

还有一条容易被忽略:清单文件里要记录每台机器的实例名、端口、内存分配、所属环境、负责人。这份清单是运维日常工作的索引,比任何文档都常用。我的习惯是把它做成一个简单的 CSV 或者 properties 文件,跟着资产包一起进版本库,每次扩容或变更都提交一次,形成天然的变更历史。

最后分享一个我踩过不止一次的坑:部署脚本里千万别用相对路径。cd到某个目录再执行脚本,看似没问题,但只要有人从别的目录调用,或者被 CI 以不同工作目录执行,所有相对路径全部失效,报错还特别难懂。所有路径用绝对路径,或者在脚本开头强制cd "$(dirname "$0")"。这行代码很短,但能省掉很多莫名其妙的“文件找不到”。

返回列表