私有化部署的最小交付包,拔掉网线还能装起来才算
前面那几篇里的门店 S1、代理商 C1、那张 cfg_order_transition,跑起来的地方有两种:一种是我的账号下那台云主机,几十家客户共用;一种是客户自己的机器。01 那篇结尾留了一句“私有化部署那篇再说”,说的就是后一种。
两种的差别不在代码,在于谁能决定这台机器上装了什么。共用那台的时候,出问题是我在半夜接电话;放进客户机房之后,出问题我得先远程上去,而远程这件事本身可能没被允许。
“私有化”这三个字,客户指的东西不一样
客户说要做私有化的时候,脑子里想的东西差别很大,问一句“这台机器能出公网吗”比问“用什么操作系统”省事得多。这一句的答案基本就把难度定了。
能出公网的机器最好办,装东西时 yum install 还能跑、docker pull 还能拉,交付比 SaaS 多出来的活是配 nginx、绑端口、把回调地址换成客户那个域名。这一档的坑集中在出网白名单:机器能上外网,但防火墙只放行了几个地址,你的服务里去调渠道、调短信网关的那几个 https 出不去,报的是连接超时,运维看了会说“网络是通的,我浏览器能上网”。
这种时候要的是把出站域名和端口列成一张表交给人家去申请,别让他猜。
不能出网的机器,所有要装的东西由介质带进去,U 盘或者内网的文件服务器。这一档开始,“依赖”这个词要从 Java 那层一直列到系统那层:一个 glibc 版本对不上的二进制、一个装不上的字体包,都能让流程停在一个很奇怪的报错上。
内网再加上国产化要求,就要重新选型了。操作系统可能是麒麟或者统信,CPU 可能是飞腾、鲲鹏这类 ARM,数据库可能要求达梦或者人大金仓,中间件可能要求东方通。
这一档里最容易被顺口答应下来的话是“数据库换成达梦没问题”,因为没人愿意替你说这句话。这两家都有各自的兼容模式,能覆盖到哪一层取决于你那些 SQL 到底怎么写的,日期函数、limit 的位置、大小写、group by 里没聚合的列,都是常见分歧点,只能拿真实的语句在目标库上跑一遍才有数。报价之前这一句要收回去。
三档的差别用成本说话更直观:
| 客户给的环境 | 交付包里要多带什么 | 现场最容易卡住的地方 | 大概多几天 |
|---|---|---|---|
| 能出公网的一台机器 | 常规包,不需要带镜像 | 出站白名单没放行、域名和证书没下来 | 1 |
| 内网离线 | 全部镜像 tar、匹配客户系统的 Docker 引擎与 compose 安装包、系统依赖包、介质校验清单 | 装到一半发现缺一个 rpm,得回公司再跑一趟 | 3–4 |
| 内网且要求国产化平台 | 按架构重出的镜像、native 依赖的对应版本、客户实测过的 SQL 清单 | 依赖只发了 x86_64 的 so,或者 SQL 方言在兼容模式下报错 | 一到两周起 |
从第一档到第二档,多的是搬运工作;从第二档到第三档,多的是你自己没验证过的平台。别把后者说成前者,报价和工期都会亏在那里。
第二档多出来的不只是搬运:目标机器上多半连 Docker 都没装,引擎、containerd、compose 三件要按客户的 OS 和版本出安装包,el7 和 el8 的依赖都不通用。引擎装不上,介质里的镜像一个都进不去,这是第一道门。
等保测评怎么过、密码怎么合规这类事不归工程这边,客户一般有专门的合规服务商,你要做的只是回答“你的系统监听哪些端口、存哪些字段、日志留多久”这张问卷。
依赖清单要在还连着网的时候拉
清单凭记忆列一定漏,要用一台断网的机器逼出来。做法很笨但有效:装一个和交付目标尽量接近的最小系统虚拟机,把网拔了,跑一遍安装脚本,它报缺什么你就往包里加什么。跑三轮,剩下的缺项就很少了。

运行时要按架构拿。JDK 的 tar 包分 x86_64 和 aarch64,你机器上那个装到 ARM 机器上直接起不来,报的是“无法执行二进制文件”,跟 Java 跨平台这句常识看起来矛盾,所以第一次遇到的人会去查 Java 的版本号。
镜像要落成 tar。docker save -o 出来一个文件,进去之后 docker load -i,不碰任何 registry。tar 的体积不小,中间件加你的应用层动辄几个 G,介质要用 sha256sum 出一个校验清单,U 盘拷贝掉字节这种事真会发生,而一个截断的 tar 报的错是 unexpected EOF,看不出跟传输有关。
Maven 那层有两条路。客户只跑不建,就交一个 fat jar,构建产物带 git commit 和一个版本号,连 jar 里 META-INF 的构建时间一起看,能确认现场那个包就是你出的那一个。
客户要能自己二开(17 那篇讲的就是这种需求),构建机上没有仓库,那就得把依赖打包带走,常用的是 mvn dependency:go-offline 之后把本地仓库压进介质。依赖一多这个包很难完整,因为父 pom、插件、settings.xml 里的镜像地址也会参与解析,离线构建要在离线机器上验证过才算数。
字体和 CA 根证书链这两样,最小化镜像里默认都是缺的,而它们的报错都很难猜。缺字体的时候用 Java 画图、生成 PDF 或者把 Excel 转成图片,中文全变成方块,客户截图发过来,看上去像乱码 bug,实际 fc-list :lang=zh 返回空。字符集问题也长得很像,库表用 utf8mb4、连接串没指定、或者终端本身不是 UTF-8,三种表现一样,处理时要先确认是哪一种,别一上来就改连接串。
缺证书链的时候服务里每加一个 https 外部依赖就多一条能断的地方,报的是 PKIX path building failed,意思是这条链在 JVM 的信任库里不可信。Java 不读系统的证书目录,用的是自己的 cacerts,第一步是 keytool -importcert 把链导进去,OS 的 ca-certificates 只管 curl 这类非 JVM 调用。内网自签的证书同理,导进 JVM 信任库。-Djavax.net.ssl.trustStore 指一个自造的 store 也能让报错消失,代价是以后所有证书问题都得先怀疑它,写文档时要说明这是在验内网自签证书,不是常规配置。
时区要连着看:系统时区、MySQL 的时区、连接串里那个时区参数(8.x 的驱动把它改过名字,按你实际用的版本查一次)。08 那篇里账单按东八区切日,前提是这几处对齐;容器基础镜像默认 UTC,客户机房那台机器是不是 +08 你交付时说了不算,所以安装脚本里应该把它读出来打一遍。
客户端版本低于服务端会拦住
MySQL 的默认认证插件在 8.0 换成了 caching_sha2_password,客户端驱动太老会报“认证插件加载不了”,表现是密码是对的但登不上;客户已经有一个现成实例、版本由不得你的时候,就要新建一个用老插件的账号,别去改服务端全局默认值——那台机器上可能还有别的应用在连。先看服务端版本:8.4 起 mysql_native_password 默认禁用,要显式开启才能建这种账号,9.x 干脆移除了,那条路走不通时就把连接驱动升上去,多数老驱动的 8.0 兼容版都能直接解决。
mysqldump 的版本对不上时会拒绝工作而不是给个警告,恢复时也是。把“备份用哪个客户端版本、恢复用哪个客户端版本”写进安装说明,省掉一次半夜电话。
有些库跟着 CPU 架构走
jar 本身与架构无关,但它里面可能塞了 native 库。netty 的 epoll 传输、内嵌数据库带的 .so,都是按架构编译的,在 ARM 上加载不到 x86_64 的那份;表现有的当场抛 UnsatisfiedLinkError,有的安静地退回 NIO,性能变了但没人报错。要查就两条命令,一条在依赖里搜:
find ~/.m2/repository -name '*.jar' \
-exec sh -c 'unzip -l "$1" 2>/dev/null | grep -q "\.so" && echo "$1"' _ {} \;
另一条看产物里那份是什么架构:
file $(find . -name 'lib*.so' -o -name '*.so' | head -20)
输出里的 x86-64 和 aarch64 要和目标机器 uname -m 的结果一致。这一处没有巧,只能把所有带 native 的依赖列一张表,换平台时逐个确认有没有对应版本;漏掉的那一个,会等到某个特定功能被点开的时刻才暴露。
环境检查要把当前值打出来
安装脚本的第一段应该是客户机器的体检报告,而且它的输出要能直接转给我看。运维在现场的判断力取决于他能不能把屏幕上的东西原样发给你,所以“检查失败”这种四个字毫无用处,“当前 12 个可用文件描述符,要求 65535”才有用。
echo "os : $(grep PRETTY_NAME /etc/os-release | cut -d'"' -f2)"
echo "arch : $(uname -m)"
echo "cores : $(nproc)"
echo "mem : $(awk '/MemAvailable/ {printf "%.0fG", $2/1048576}' /proc/meminfo)"
echo "disk : $(df -Ph . | awk 'NR==2 {print $5" used, "$4" free"}')"
echo "time : $(date '+%F %T %Z') ntp_synced=$(timedatectl show -p NTPSynchronized --value)"
echo "fd : $(ulimit -n)"
echo "charset : $(mysql -N -B -e 'select @@character_set_server, @@collation_server, @@lower_case_table_names')"
里面每一项都是有人踩过才加进去的。
时钟那条最容易被认为无所谓。内网机器经常没有 ntp,BIOS 电池没电、或者装完系统就没管过,时间偏个几十秒到几天都可能。偏了以后先出问题的是签名校验和回调验签:渠道报文带时间戳,窗口不过,你收到的回调全被丢掉,钱进来了但账不动;再晚一点发现的是日志时间轴和数据库时间轴对不上。时间不准不会报错,它只是让后面每一件事都变得难解释,所以这一行要打在开头。
端口占用要在起服务之前查,用 ss -lntp,别在脚本里假设 ifconfig 存在,最小系统里 net-tools 经常没装,ip 才是默认有的那个。
lower_case_table_names 要单独看。Linux 上 MySQL 默认区分表名大小写,很多开发机是 Windows 或者 Mac 默认不区分,跑得好好的代码到客户机器上报“表不存在”,原因是 OrderItem 和 orderitem 在两种配置下是两回事。更要紧的是这个参数在 InnoDB 上建好库之后不能随便改,改了启动会失败,所以它属于“检查出来不对要当场说”的那几项,别等数据进去了再回头改。
检查脚本里那些阈值,内存留多少、磁盘要多少个 G,按你实际那套服务的占用填,别照网上抄一个通用值。宁可把要求写高一点:客户给你的机器如果连最低配都不过,当场就报出来,比装完跑三天磁盘满了再回来谈便宜。
ulimit 里的文件描述符上限,会被附件、导出、长连接这几件事碰到,默认 1024 的机器在国内环境里很常见。
库结构和种子数据不该塞进同一个 SQL
一个 init.sql 里既有建表又有几十条 INSERT,看起来省事,实际把两种完全不同性质的东西绑在了一起:建表要能重跑,种子数据只许跑一次。
建表脚本写成可重复执行(CREATE TABLE IF NOT EXISTS)只解决了一半,真正的重复执行是同一份脚本跑两遍之后表结构还是一个样、索引没多出来、建表语句也不报错。结构在客户库里变了怎么办:让变更带版本。我用的是 Flyway,脚本打在 jar 里,启动时按版本号顺序执行未跑过的,客户库里多一张 flyway_schema_history。离线环境对它没有任何特殊要求,因为迁移脚本本来就在制品里;这点比依赖外部平台的工具省事。
不用 Flyway 也行,但客户库里那张版本表要有。它的价值在出问题时体现:客户 DBA 手改了一个字段,版本表没有那次变更记录,于是新版启动时报“字段重复”或者“缺索引”,你可以直接指着这张表说这条变更不是我们执行的。少这张表的时候,同样的事会变成一场没有证据的讨论。
种子数据要区分两种:一种是不插就跑不起来的数据,角色定义、权限点、状态迁移表里那些行;一种是演示数据,几个门店、几十条商品。前者必须幂等,撞唯一键就跳过,用 INSERT IGNORE 或者按业务键 ON DUPLICATE KEY UPDATE 都不改变语义;后者别进交付包,客户第一次登录看见自己的系统里躺着别人名字的门店,会觉得这套东西是从别人那里拿来的。
密钥不在包里。.env、settings.zip、带生产地址和口令的配置文件、demo 账号的密码,这些东西在介质上走一遍就再也收不回来。给一个 config.example,所有敏感值留空,启动时读不到就明确失败并提示去哪填。配置从外面注入用 --spring.config.additional-location,把客户能改的东西放在 jar 外面同级的 config/ 目录,客户改配置不需要重新打包。这条对交付太重要了,因为交付之后你会反复遇到“把那个地址改一下”这种需求,值 200 块的工时不会付给你,但你也不想再跑一趟。
在你机器上跑通不算,找台干净的重装一遍
交付前那轮验证要在一个和你开发机没关系的环境里做。我一般用一个最小系统的虚拟机,断网,插介质,完全按客户那边会执行的顺序走一遍,包括那些“明显不需要”的步骤。01 那篇讲导出时说过程序路径和交付路径不是一回事,这里是同一个道理:要测的是客户实际会经历的那条路径。

latest 在你机器上永远指向你刚构建的那个,离线环境里它指向一个不存在的镜像名,报“找不到镜像”。所有 tag 钉版本,构建时写进去,compose 里引用同一个值,两个文件里的版本号要对得上。跨架构构建时显式指定平台:
docker buildx build --platform linux/amd64 -t app-server:1.4.2 --load .
compose 文件里数据目录一律外置到宿主机的相对路径,./data/mysql、./data/redis、./logs。日志写进容器可写层的话,容器一旦被删就没了,而这正是现场最需要它的时刻。Redis 那几个默认值(不绑地址、不设密码、protected-mode)在开发机上很常见,出了门就是给客户的内网添一个可写的服务,安装脚本里检查一遍端口,未授权能连的一律拦下来。
容器网络在 Linux 上没有 host.docker.internal,要显式加 --add-host=host.docker.internal:host-gateway,或者干脆按宿主机 IP 配。私有 registry 用 https 自签证书的话,得把 CA 放到 /etc/docker/certs.d/<registry>/ca.crt;图快改用 insecure-registries 也能跑,只是这个改动要在文档里留一句“这台机器信任了一个明文 registry”。
容器里的 JVM 要按 limit 拿堆。基础镜像的入口脚本如果写死了 -Xmx,docker update --cpus 和 mem_limit 都影响不到它,堆超出容器限额会被内核直接 kill,日志里最后一行是普通的业务日志,运维看到的是“容器反复重启,没有异常”。要么在脚本里把参数换成 -XX:MaxRAMPercentage,要么至少把生效的那条 java 命令打在启动日志里,让人能看到真实的堆配置。
日志切割也别指望系统里会有 logrotate,最小机器上它可能压根没装,而你一个应用能写几十个 G。应用侧配好按大小滚动、留几天,容器侧的 json 日志也设上限,两处都要设,只设一处的那部分会先把磁盘吃满。磁盘满在客户机房里不会报错,只会数据库起不来、上传全失败,而且客户会先怀疑你的系统。
打包之前这几条自己核一遍。上面那张图是到了现场怎么验收,这张清单是发货前包里该有什么,两份都过完再发介质:
- [ ] 用一台从没装过这套系统的最小虚拟机,断网、插介质,从执行安装脚本第一步走到底,中途没上网查过任何东西
- [ ] 交付介质里有
sha256sum.txt,逐项校验通过 - [ ] 所有镜像 tag 是版本号,compose 里引的版本号和构建脚本里的对得上
- [ ] JDK、native 依赖、镜像的架构和目标机器
uname -m一致 - [ ] 环境检查脚本在目标机器上跑过,输出是一张带数字的表,而且这张表被打进交付包
- [ ] 引擎包在一台没装过 Docker 的目标系统虚拟机上离线装过一次
- [ ] 交付包里没有
.env、没有私钥、没有 demo 账号密码 - [ ] 应用侧和容器侧的日志上限都设了,数据目录都在宿主机上
- [ ] 拿这份包和介质清单,换一个同事在另一台干净机器上重装一次,不需要问我任何问题
最后那条是判定标准。前八条都是我能替你完成的,最后那条完成不了,说明包里还有只在我脑子里的东西。
升级包要连同回滚一起交
装完只是开始。三个月后你发了 1.5,客户环境里那份 1.4.2 要升级,这时候客户手上真正需要的不是一个新的 jar。
一个升级包应该带:新镜像或者新 jar、这次要跑的增量脚本(就是 db/migration/ 里那两个新文件)、以及一段说人话的说明——这次动了哪几张表、有没有字段是从有到无、要不要清缓存、升级窗口允许停多久。说明那段是给客户的运维看的,他能看懂就会自己做;他看不懂就只能请你去,而请你去是要算钱的,这个账你们两边都清楚。
版本兼容关系要写死在交付文档里:1.4.x 配 db_v14(数据库结构的版本号,和产品版本是两套编号,结构不变时产品可以连升几个小版本),1.5 要求 MySQL 8 的最低小版本到多少、Redis 到多少,不满足会怎样。范围写窄一点没关系,写了范围你就有依据说“这台机器不在支持范围里”,没写范围的话所有兼容问题都变成你的 bug。
回滚方案要有,而且要在演练里跑过一次。旧镜像别从机器上删,docker images 里把上一个版本的 tag 留着;数据库的降级脚本经常写不出来,所以真实可行的回滚是“起旧镜像 + 恢复到升级前那个备份”,那就要在安装说明里有一条自动备份、并且明确写了备份留在哪个目录、留几份。回滚要测的是恢复路径能不能跑通,不是备份文件在不在。
合同附件里那段部署范围
私有化最容易失控的地方在纸面上。现在签合同我会附下面这一段,没有一个技术词,但省下来的全是在客户机房里的时间。
介质与安装
交付物以介质(或内网文件服务器路径)移交,附介质清单与校验值。
在客户提供的机器上由我方执行安装一次,安装前的环境检查输出作为验收附件。
环境检查未通过时,交付日期顺延到检查项补齐为止。
环境与责任分界
操作系统版本、CPU 架构、数据库与缓存的版本号由客户书面确认。
支持范围即上述三个版本值,以及双方已验证过的配置组合。
客户在支持范围外更换其中任一项,视为一次新的适配工作,另行排期和计费。
远程支持
远程方式提前约定:跳板机账号、屏幕共享或者到现场,三者选定一种。
需要出网调用的域名和端口列成清单由客户放行。
远程操作由客户侧人员陪同并开启录屏;日志用于排障时按天提供,提供前做脱敏。
范围写清楚的那次,验收当场不会有人说“这个也应该算在里面”。没写清楚的那次,后面每追加一项都要重新谈一遍,而那时东西已经交出去了,你手上没有筹码。
系列:上一篇 账不平时要能说出错在哪一层 | 下一篇 数据迁移完了,没人敢关旧系统 | 相关 两周交付一个行业系统:范围冻结、里程碑与验收