附录:一次真实搭建的全过程记录与故障复盘——那些教程不会告诉你的 11 个坑
前面 9 篇讲的是"应该怎么做"。这一篇讲的是"实际发生了什么"。
我用 4 台华为云 ECS,在不到 2 小时的窗口期里从零搭一套 GitLab + Jenkins + Ansible 的持续交付���台。
过程中失败了 11 次,其中 3 次是方案级返工(整个安装路线推倒重来),1 次是把我彻底挡在门外的网络故障。我把每一次失败的原始报错、排查路径、根因判断和最终解法都原样记下来。
如果你只想看"一键成功"的教程,前 9 篇够了;如果你要在真实环境里干这件事,这一篇的价值可能比前 9 篇加起来还大。
一、约束条件:为什么这是一次"高压实战"
先说清楚前提,否则很多决策看起来会很奇怪。
| 约束 | 具体情况 | 带来的影响 |
|---|---|---|
| 时间窗口 | 机器可用时长1 小时 50 分钟 | 没有"慢慢查文档"的余地,失败要在 2 分钟内决定"修还是换方案" |
| 带宽 | 公网 5 Mbit/s | GitLab 1.3 GB 的包理论下载 ≥ 35 分钟,占掉三分之一窗口 |
| 网络位置 | 本地 Windows → 公网 → 华为云北京四 | 每一次交互都受运营商链路影响 |
| 操作方式 | 纯 SSH,无控制台、无 VNC | 一旦 SSH 不通,等于完全失联 |
| 机器规格 | FlexusX 8 vCPU / 16 GB × 4,Ubuntu 24.04 | 内存够,但 GitLab 全家桶默认配置仍然吃紧 |
关键决策一:所有操作脚本化,不手敲。
时间窗口太短,手敲命令的最大问题不是慢,而是失败后无法精确重放。所以第一件事不是装 GitLab,是写一个远程执行器。
关键决策二:所有脚本幂等。
因为一定会失败,一定会重跑。不幂等的脚本重跑一次就会制造新的脏状态。
二、真实时间线
22:15 拿到 4 台机器,开始探活 22:21 写完 rx.py(paramiko 并行执行器)+ 00-base.sh,4 节点并行初始化 —— 成功 22:22 GitLab apt 源方案 —— 失败 #1 #2 22:25 n3/n4 目标运行环境(JRE/Nginx/systemd) —— 成功 22:28 GitLab 改 deb 直装方案,开始下载 1.3G —— 进行中 22:29 n2 生成 ed25519 部署密钥并分发到 n1/n3/n4 —— 成功 22:34 Jenkins apt 源方案 —— 失败 #4 22:36 n4 蓝绿双实例(blue 8081 / green 8082) —— 成功 22:37 Jenkins deb 装完但起不来 —— 失败 #5 22:38 Java 21 适配 —— 成功,Jenkins 8080 返回 200 22:40 Jenkins CLI 装插件 —— 失败 #6 #7 #8 22:43 GitLab deb 包校验发现截断 —— 失败 #3 22:45 断点续传 + SHA256 校验重下;update-center 手动注入 22:52 **本地到 4 台机器全部失联** —— 失败 #9 #10 #11 23:05 链路诊断确认:安全组来源白名单静默丢包 23:1x 机器停机/释放,转入交付整理一句话总结:基础设施层(网络、目录、systemd、密钥、蓝绿骨架)100% 完成;软件安装层因为镜像源和网络问题反复返工;最后倒在了网络出口 IP 变更上。
三、失败 #1 / #2:GitLab 的 apt 源方案
现象
配置好镜像源后:
apt-getupdate# ...# W: GPG error: https://<mirror>/gitlab/gitlab-ce/ubuntu noble InRelease:# The following signatures were invalid: NODATA 1 NODATA 2# E: The repository '... noble InRelease' is not signed.apt-getinstall-ygitlab-ce# E: Unable to locate package gitlab-ce排查
两个报错其实是同一个因果链:
NODATA说明InRelease文件下载到了,但内容不是有效的 PGP 签名数据——大概率是镜像站返回了一个 HTML 错误页或空文件;- 签名校验失败 → apt 拒绝使用该仓库 → 包索引根本没建立 →
Unable to locate package是必然结果。
用curl -I直接看那个 InRelease 的 URL,返回的Content-Type不是application/pgp-signature,实锤。
根因
镜像站对gitlab-ce这个第三方仓库的同步不完整(或 noble 发行版目录未建立)。这类第三方 repo 的镜像同步质量普遍不如官方 Ubuntu 源。
解法与思考
放弃 apt,改 deb 直装。
这个决策的依据是:GitLab Omnibus 包是自包含的——它把 PostgreSQL、Redis、NGINX、Puma、Sidekiq 全打进了一个 deb 里,几乎不依赖系统 apt 源。既然 apt 唯一的价值(依赖解析)在这里不成立,那就没必要为一个坏掉的源纠缠。
# 南京大学镜像,直接取 debURL=https://mirror.nju.edu.cn/gitlab-ce/ubuntu/pool/noble/main/g/gitlab-ce/gitlab-ce_18.10.3-ce.0_amd64.debcurl-fSL-o/tmp/gitlab-ce.deb"$URL"dpkg-i/tmp/gitlab-ce.deb经验:遇到第三方 apt 源签名问题,先判断"我到底需不需要 apt"。自包含的大包(GitLab、Jenkins、Zabbix)直接下 deb/rpm 往往比修源更快。
四、失败 #3:1.3 GB 的包,下载"成功"却是坏的
这是本次最有代表性的一个坑。
现象
dpkg-i/tmp/gitlab-ce.deb# dpkg-deb: error: <decompress> subprocess returned error exit status 2# lzma error: unexpected end of input# dpkg: error processing archive /tmp/gitlab-ce.deb (--install)curl没有报错,退出码 0,文件也确实存在。
排查
stat-c%s /tmp/gitlab-ce.deb# 1078448480# 对比服务端声明的大小curl-sI"$URL"|grep-icontent-length# Content-Length: 1402522986差了整整 324 MB。下载在中途被截断,但 curl 认为连接是"正常结束"的。
根因
curl -fsSL -o file url这种最常见的写法有三个隐患:
- 没有
-C -:连接中断后无法续传,只能从头再来; - 没有校验:curl 的退出码只表示"HTTP 层面没出错",服务端提前关闭连接(尤其在 5 Mbit/s 长连接、经过多层 CDN 时)不一定被判定为失败;
-s静默:把进度和异常都吞了,人看不到异常。
在 35 分钟量级的大文件下载上,这三点叠加就是灾难。
解法:下载三板斧
写进12-gitlab-resume.sh的通用片段,任何大文件下载都建议照抄:
URL="https://mirror.nju.edu.cn/gitlab-ce/ubuntu/pool/noble/main/g/gitlab-ce/gitlab-ce_18.10.3-ce.0_amd64.deb"DEB=/tmp/gitlab-ce.debEXPECT_SIZE=1402522986EXPECT_SHA=e46618ac47e9459133c029f8a19f21f18765f147540afeb5356d386f6ac336eb# 板斧 1:断点续传 + 自动重试curl-C--fL--retry8--retry-delay5--retry-all-errors\--speed-limit10240--speed-time60\-o"$DEB""$URL"# 板斧 2:字节数比对(最快的第一道关)SIZE=$(stat-c%s"$DEB")echo"SIZE=$SIZEEXPECT=$EXPECT_SIZE"["$SIZE"="$EXPECT_SIZE"]||{echo"SIZE_MISMATCH";exit11;}# 板斧 3:SHA256 摘要校验(防止内容错乱而非仅截断)SHA=$(sha256sum"$DEB"|awk'{print $1}')echo"SHA=$SHA"["$SHA"="$EXPECT_SHA"]||{echo"CHECKSUM_MISMATCH";exit12;}echo"CHECKSUM=ok, start dpkg"dpkg-i"$DEB"几个细节值得说:
--speed-limit 10240 --speed-time 60:连续 60 秒速度低于 10 KB/s 就判定失败并触发重试,避免"挂着不动但也不断开"的僵死连接白白耗掉窗口期;--retry-all-errors:默认--retry只对部分错误重试,加上这个才覆盖连接层错误;- 字节数比对放在 SHA256 之前:算 1.3 GB 的 SHA256 要十几秒,先用
stat一秒排除掉最常见的截断。
经验:任何超过 100 MB 的下载,都必须"续传 + 校验"。
curl 退出码 0不等于文件是完整的。
五、失败 #4 / #5:Jenkins 的两连击
#4:apt 源同样不可用
apt-getupdate# Err:x https://<mirror>/jenkins/debian-stable binary/ Packages# 404 Not Foundapt-getinstall-yjenkins# E: Unable to locate package jenkins根因和 GitLab 类似:镜像站的 Debian 风格仓库目录(binary/Packages)没同步。解法一致——直接下 deb:
curl-C--fL--retry5-o/tmp/jenkins.deb\"https://mirrors.tuna.tsinghua.edu.cn/jenkins/debian-stable/jenkins_2.568.1_all.deb"dpkg-i/tmp/jenkins.deb||apt-get-finstall-y#5:装完起不来——Java 版本
systemctl status jenkins# Active: failed (Result: exit-code)journalctl-ujenkins --no-pager-n30# Running with Java 17 from /usr/lib/jvm/java-17-openjdk-amd64,# which is older than the minimum required version (Java 21).# Supported Java versions are: [21, 25]Ubuntu 24.04 的default-jre是 Java 17,而 Jenkins 2.5xx 新版本线已经把最低要求提到 Java 21。
解法有两步,缺一不可:
# 步骤 1:装 Java 21 并切换系统默认apt-getinstall-yopenjdk-21-jdk-headless update-alternatives--setjava/usr/lib/jvm/java-21-openjdk-amd64/bin/java# 步骤 2:显式给 jenkins 服务指定 JAVA_HOME(关键!)mkdir-p/etc/systemd/system/jenkins.service.dcat>/etc/systemd/system/jenkins.service.d/override.conf<<'EOF' [Service] Environment="JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64" Environment="PATH=/usr/lib/jvm/java-21-openjdk-amd64/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" EOFsystemctl daemon-reload systemctl restart jenkins为什么只做步骤 1 不够?因为 systemd 服务不走交互式 shell 的初始化流程,它的环境变量来自 unit 定义和/etc/default/jenkins。update-alternatives改的是/usr/bin/java软链,Jenkins 的 systemd unit 里如果显式引用了$JAVA_HOME或某个绝对路径,改软链就无效。改 service 的 Environment 才是确定性的做法。
这里还顺带解决了另一个问题:应用本身用 Spring Boot 3.3.5 + Java 17 编译,而 Jenkins 本体要 Java 21。两套 JDK 必须共存——系统默认给 Jenkins 用 21,Maven 编译时通过JAVA_HOME或 toolchain 指到 17。这不是问题,是常态。
验证:
curl-s-o/dev/null-w'%{http_code}\n'http://127.0.0.1:8080/login# 200六、失败 #6 / #7 / #8:Jenkins CLI 与插件的三连坑
#6:Jenkins URL is not configured
java-jarjenkins-cli.jar-shttp://127.0.0.1:8080/-authadmin:*** list-jobs# ERROR: anonymous is missing the Overall/Read permission ... 403# 或# Jenkins URL is not configured根因:Jenkins 全局配置里的Jenkins URL 为空,CLI 的根 URL 校验直接拒绝。
解法——用启动时执行的 Groovy 脚本固化(Configuration as Code 的轻量版):
// /var/lib/jenkins/init.groovy.d/02-location.groovyimportjenkins.model.JenkinsLocationConfigurationdefloc=JenkinsLocationConfiguration.get()loc.setUrl("http://124.70.29.36:8080/")loc.setAdminAddress("admin@cd-lab.local")loc.save()println"--> Jenkins URL configured:${loc.getUrl()}"#7:Unexpected request origin (check your reverse proxy settings)
配好 URL 后再调 CLI,换了个 403:
ERROR: Unexpected request origin (check your reverse proxy settings). ... 403这个坑非常隐蔽。根因是:CLI 连的地址(http://127.0.0.1:8080/)与 Jenkins 全局配置的 URL(http://124.70.29.36:8080/)不一致,触发了 Jenkins 的 origin 校验(防 CSRF / DNS rebinding)。
解法一行字:CLI 必须用与 Jenkins URL 完全一致的地址调用,包括协议、主机、端口和结尾的斜杠。
# 错误java-jarjenkins-cli.jar-shttp://127.0.0.1:8080/...# 正确java-jarjenkins-cli.jar-shttp://124.70.29.36:8080/...经验:Jenkins 里凡是遇到莫名其妙的 403,先检查"我访问的 URL"和"Jenkins 自己认为的 URL"是不是同一个。
#8:No update center data is retrieved
java-jarjenkins-cli.jar-s$JURL-auth$AUTHinstall-plugingitworkflow-aggregator# ERROR: git is neither a valid file, URL, nor a plugin artifact name in the update center# No update center data is retrieved, so plugin installation is impossible.根因:Jenkins 无法访问updates.jenkins.io,本地一份 update-center 元数据都没有,install-plugin git这种按名字装的方式无从解析。
前两次尝试(改hudson.model.UpdateCenter.xml、直接 sed 替换default.json)都因为 Jenkins 已缓存/未加载而没生效。最终可用的方案是 postBack 注入:
UC=https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.jsonMIRROR=https://mirrors.tuna.tsinghua.edu.cn/jenkins/plugins# 1. 下载 JSONP 格式的 update-center.jsoncurl-fsSL-o/tmp/uc.jsonp"$UC"# 2. 剥掉 JSONP 外壳 updateCenter.post( {...} ); 并把下载地址换成镜像python3 -<<'PY' import re, io raw = open('/tmp/uc.jsonp', encoding='utf-8').read().strip() raw = re.sub(r'^updateCenter\.post\(\s*', '', raw) raw = re.sub(r'\)\s*;?\s*$', '', raw) raw = raw.replace('https://updates.jenkins.io/download/plugins', 'https://mirrors.tuna.tsinghua.edu.cn/jenkins/plugins') open('/tmp/uc.json', 'w', encoding='utf-8').write(raw) print('UC_JSON_BYTES=', len(raw)) PY# 3. 把元数据 POST 回 Jenkins 的 update centercurl-s-XPOST-u"$JUSER:$JPASS"\-H'Content-Type: application/json'\--data-binary @/tmp/uc.json\"${JURL}updateCenter/byId/default/postBack"# 4. 现在可以按名字装插件了java-jarjenkins-cli.jar-s$JURL-auth$AUTHinstall-plugin\gitworkflow-aggregator pipeline-stage-view credentials-binding\ssh-credentials ssh-agent timestamper ws-cleanup junit\build-timeout gitlab-plugin matrix-auth-deploy三种改法的对比:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
改hudson.model.UpdateCenter.xml | 把<url>换成镜像的 update-center.json | 配置持久化 | 需重启,且首次仍要在线拉取才生效 |
sed 替换updates/default.json | 直接改磁盘上的缓存文件 | 不需要外网 | Jenkins 内存里可能已加载旧数据,时机难把握 |
| postBack 注入 | 用 API 把 JSON 灌进运行中的 Jenkins | 立即生效、可脚本化、不需重启 | JSONP 外壳要自己剥 |
经验:内网/弱网环境下部署 Jenkins,"插件怎么装"往往比"Jenkins 怎么装"更耗时。最稳的兜底是离线 hpi 包:在能上网的机器上把 hpi 及其依赖全下好,打包传进去丢到
$JENKINS_HOME/plugins/再重启。
七、失败 #9:nohup &起的后台任务,SSH 一断就没了
现象
用 paramiko 的exec_command启动长任务:
client.exec_command("nohup bash /tmp/12-gitlab-resume.sh > /tmp/x.log 2>&1 &")命令立刻返回,但过一会儿去查:
ps-ef|grep12-gitlab-resume# 没有进程cat/tmp/x.log# 只有开头几行退出码取到-1(通道异常关闭)。
根因
SSH 的 exec 通道和交互式 shell 不同:通道关闭时,该会话的进程组会收到SIGHUP。nohup理论上能屏蔽SIGHUP,但通过 exec 通道启动时,进程并没有真正脱离会话(没有 setsid、stdin/stdout 仍绑在通道上),通道一关就被连带清理。paramiko 这类库执行完命令立刻关通道,问题被放大。
解法:交给 systemd 托管
systemd-run--unit=gl-resume--collectbash/tmp/12-gitlab-resume.sh好处是全方位的:
| 维度 | nohup & | systemd-run --unit |
|---|---|---|
| 是否脱离会话 | 不可靠 | 彻底脱离,由 PID 1 接管 |
| 状态可查 | ps猜 | systemctl status gl-resume |
| 日志 | 自己重定向 | journalctl -u gl-resume -f,自动轮转 |
| 退出码 | 拿不到 | systemctl show gl-resume -p ExecMainStatus |
| 资源限制 | 无 | 可加-p MemoryMax=-p CPUQuota= |
| 清理 | 残留 | --collect退出后自动回收 unit |
配套的查询命令:
systemctl status gl-resume --no-pager systemctl show gl-resume-pActiveState-pSubState-pExecMainStatus journalctl-ugl-resume --no-pager-n50经验:云上远程运维,凡是超过 60 秒的任务,一律
systemd-run托管。这个习惯在后面救了我一次——SSH 完全断掉之后,服务器上的 GitLab 安装任务仍在继续跑。
八、失败 #10 / #11:出口 IP 一变,全军覆没
这是压垮窗口期的最后一根稻草,也是最值得写的一条。
现象
搭建进行到一半,突然所有rx.py调用超时。逐项探测:
$curl-s-o/dev/null-w"%{http_code}\n"--max-time8http://124.70.29.36:8080/login 000 $curl-s-o/dev/null-w"%{http_code}\n"--max-time8https://www.baidu.com200$curl-s-o/dev/null-w"%{http_code}\n"--max-time10https://gitcode.com200公网正常,但 4 台机器全挂。四台机器同时故障的概率极低,所以问题几乎一定在链路或访问控制,而不在服务器本身。
排查
第一步,看是不是端口级问题——用 bash 内建的/dev/tcp快速探 22 端口(比 telnet/nc 更通用,不用装东西):
forhin124.70.84.208124.70.29.36119.3.248.27124.70.110.32;dotimeout4bash-c"echo > /dev/tcp/$h/22"2>/dev/null\&&echo"$h:22 OK"||echo"$h:22 FAIL"done# 124.70.84.208:22 FAIL# 124.70.29.36:22 FAIL# 119.3.248.27:22 FAIL# 124.70.110.32:22 FAIL第二步,ICMP 也不通:
124.70.29.36 的 Ping 统计信息: 数据包: 已发送 = 3,已接收 = 0,丢失 = 3 (100% 丢失)第三步,看路径断在哪:
通过最多 12 个跃点跟踪到 124.70.29.36 的路由 1 <1 毫秒 <1 毫秒 <1 毫秒 192.168.3.1 2 3 ms 11 ms 7 ms 113.90.145.65 3 3 ms 6 ms 8 ms 202.105.155.189 4 * * * 请求超时。 5 * * * 请求超时。 6 * * * 请求超时。 7 37 ms 37 ms 37 ms 106.38.244.114 8 * * * 请求超时。 ... 12 * * * 请求超时。第 7 跳(37ms,已经到了北方骨干)之后全部静默,没有任何 ICMP unreachable 回包。
第四步,查本机出口 IP:
$curl-shttps://myip.ipip.net 当前 IP:113.90.145.122 来自于:中国 广东 深圳 电信和最初建立连接时的出口 IP不一致——宽带重新拨号/NAT 池切换导致公网 IP 变了。
根因判断
把四条证据串起来:
- 四台机器同时失联 → 不是单机故障;
- 公网其它站点正常 → 不是本地断网;
- 路径能进骨干网但在云厂商边界前静默消失、无 ICMP 拒绝回包→ 典型的防火墙 DROP(丢弃)而非 REJECT(拒绝);
- 本机出口 IP 发生了变更。
结论:安全组入方向规则按来源 IP 白名单放行,我的新出口 IP 不在白名单里,所有包被静默丢弃。
这里有个很实用的判断法则:
- 端口不通但立刻返回
Connection refused→ 服务没起,网络是通的;- 端口不通且卡到超时、traceroute 静默中断 → 大概率是防火墙/安全组 DROP;
- 只有个别端口不通 → 安全组端口规则;所有端口 + ICMP 全不通 → 来源 IP 被整体挡掉。
应对
已经发生时能做的:
- 立刻确认服务器侧任务不受影响——因为长任务用了
systemd-run托管,SSH 断了它照跑,这就是第七节那个习惯的回报; - 启动带退避的自动重连探测,避免人工反复试:
foriin$(seq1120);doforhin124.70.29.36124.70.84.208;doiftimeout4bash-c"echo > /dev/tcp/$h/22"2>/dev/null;thenecho"SSH_RECOVERED host=$hattempt=$i";exit0fidoneprintf'.';sleep8doneecho"probe-end (not recovered)"- 把不依赖服务器的工作切到前台继续做(本次是:整理脚本、写文档、推代码仓库),不让窗口期空转。
下次如何避免:
| 措施 | 说明 |
|---|---|
| 安全组按网段而非单 IP 放行 | 家宽/办公网出口通常在一个 /24 里,放113.90.145.0/24比放单 IP 稳得多 |
| 22 端口走堡垒机/VPN | 把白名单固定成堡垒机的固定 IP,本地 IP 怎么变都不影响 |
| 准备带外通道 | 云厂商的 VNC / 远程登录(走控制台,不经安全组)是最后的救命稻草 |
| 关键任务服务器侧自治 | 用 systemd/cron 托管,让任务不依赖控制端在线 |
| 提前把制品和源码放到公网可达的仓库 | 服务器可以自己git clone,不依赖"本地 → 服务器"的文件传输通道 |
最后一条尤其重要。本次我把演示应用和全部脚本推到了 GitCode,50-gitlab-seed.sh里 GitLab 的代码种子是这样取的:
gitclone--depth1https://gitcode.com/cpyaxjq/continuous-delivery-in-action.git srcSRC=/tmp/seedsrc/src/cd-demo-app这样只要服务器能上网,即使我本地传不上去文件,服务器也能自己把代码拉全。把"控制端"从关键路径上移除,是远程运维一个很值钱的设计原则。
九、11 个坑的速查表
| # | 现象 | 根因 | 解法 |
|---|---|---|---|
| 1 | apt-get update报NODATA | 镜像站 InRelease 非有效签名数据 | 放弃 apt,deb 直装 |
| 2 | Unable to locate package gitlab-ce | 仓库被拒 → 索引未建立 | 同上 |
| 3 | dpkg报lzma error: unexpected end of input | 1.3G 包下载被截断,curl 未报错 | 续传 + 字节比对 + SHA256 |
| 4 | Jenkins 源Packages 404 | 镜像 Debian 目录未同步 | 直接下jenkins_2.568.1_all.deb |
| 5 | Java 17 is older than minimum required (Java 21) | Ubuntu 24.04 默认 JDK 17 | 装 JDK21 +update-alternatives+service override |
| 6 | CLI 403Jenkins URL is not configured | 全局 URL 为空 | init.groovy.d里JenkinsLocationConfiguration.setUrl() |
| 7 | CLI 403Unexpected request origin | CLI 地址与全局 URL 不一致 | CLI 必须用完全一致的 URL |
| 8 | No update center data is retrieved | 拉不到 update-center.json | 剥 JSONP + 换镜像 URL +postBack注入 |
| 9 | nohup &起的任务 SSH 断即死 | exec 通道关闭触发 SIGHUP,进程未脱离会话 | systemd-run --unit=x --collect |
| 10 | 4 台机器同时全端口不通 | 出口 IP 变更,安全组白名单静默 DROP | 按网段放行 / 堡垒机 / 带外通道 |
| 11 | 内联 shell 命令多层引号转义失败 | Python → SSH → bash 三层转义 | 改为 SFTP 上传.sh再bash执行 |
关于 #11 补充一句:一开始我用client.exec_command(f"bash -c '{cmd}'")这种内联方式下发命令,只要命令里带引号、$、反引号就必然出问题,排查成本极高。改成"先把脚本 SFTP 上去,再执行文件"之后,转义问题一次性归零,而且脚本还留在服务器上可以复查、可以重跑。这是本次最省事的一个改动。
defrun_script(host,local_path,timeout=600):"""上传脚本再执行,彻底规避多层引号转义"""remote=f"/tmp/{os.path.basename(local_path)}"sftp=client.open_sftp()sftp.put(local_path,remote)sftp.close()returnrun(host,f"bash{remote}",timeout=timeout)十、完成度盘点:诚实地说清楚做到了哪一步
技术文章最忌讳的是把"设计好了"说成"跑通了"。这里逐项对账:
| 组件 / 能力 | 状态 | 说明 |
|---|---|---|
| 4 节点基础环境(主机名、hosts、时区、内核参数、工具) | ✅ 已验证 | 00-base.sh四机并行执行成功 |
| 节点间内网互通 | ✅ 已验证 | 同 VPC,内网 IP 互 ping/ssh 通 |
| ed25519 部署密钥生成与分发 | ✅ 已验证 | n2 → n1/n3/n4 免密登录验证通过 |
| n3 测试环境(JRE17 / Nginx / appuser / 目录规范 / systemd) | ✅ 已验证 | 30-target.sh执行成功 |
| n4 生产环境 + 蓝绿双实例骨架(8081/8082 / upstream / active_color) | ✅ 已验证 | 31-prod-bluegreen.sh执行成功 |
| Jenkins 安装并启动 | ✅ 已验证 | curl :8080/login返回 200 |
| Jenkins 插件安装 | ⚠️ 中断 | postBack 方案已跑通元数据注入,插件下载阶段遇上断网 |
| GitLab 安装 | ⚠️ 中断 | deb 续传任务由systemd-run托管中,断网后无法验证结果 |
| 演示应用工程(Spring Boot + 单测 + build-info) | ✅ 已完成 | 代码完整,见仓库cd-demo-app/ |
| Ansible 剧本(deploy / bluegreen / rollback) | ✅ 已完成 | 剧本完整,含 block/rescue 自动回滚 |
| Jenkinsfile 十阶段流水线 | ✅ 已完成 | 见仓库cd-demo-app/Jenkinsfile |
| GitLab 项目/分支/Webhook 自动化种子脚本 | ✅ 已编写 | 50-gitlab-seed.sh,未获得执行窗口 |
| Jenkins Job/凭据自动化脚本 | ✅ 已编写 | 51-jenkins-setup.sh,未获得执行窗口 |
| 端到端流水线跑通 | ❌ 未完成 | 断网 + 机器回收,窗口期用尽 |
没跑通就是没跑通。但这次失败暴露出来的 11 个坑,恰恰是任何一篇"顺利成功"的教程给不了的东西——因为顺利的时候你根本遇不到它们。
如果你拿这套脚本去自己的环境里跑,上面每一个坑我都已经在脚本里做了防御:续传校验、幂等重跑、systemd 托管、URL 一致性、update-center 注入。你大概率会比我顺利。
十一、把这次经历抽象成方法论
最后留三条我认为最有普适价值的结论。
第一,把失败当成默认路径来设计流程。
不是"如果失败了怎么办",而是"失败之后如何低成本重来"。这次能在 2 小时里返工三次方案还保住大部分成果,靠的就是:所有操作脚本化、所有脚本幂等、所有长任务托管、所有下载校验。这四条本身就是持续交付的核心思想——可重复、可验证、可回滚——只不过这次它们用在了搭平台上,而不是发版上。
第二,控制端是最脆弱的一环,要主动削弱对它的依赖。
本地网络、本地 IP、本地 SSH 会话,任何一个出问题都会让你从"在干活"变成"完全失联"。把任务下沉到服务器侧自治(systemd)、把物料放到双方都能访问的第三方(代码仓库),能显著提高整体韧性。
第三,遇到"莫名其妙"的问题,先分清是网络层、权限层还是应用层。
本次 11 个坑里,有 4 个第一反应会以为是应用配置错了(GPG NODATA、dpkg lzma、CLI 403、插件装不上),实际根因分别在镜像同步、传输完整性、URL 一致性、外网可达性。养成"先看报错发生在哪一层"的习惯,比记住任何具体解法都重要。
附:本文涉及的全部脚本
所有脚本、Ansible 剧本、Jenkinsfile 与演示应用源码已开源:
https://gitcode.com/cpyaxjq/continuous-delivery-in-action
ops/rx.py paramiko 并行远程执行器(含 run_script 防转义方案) ops/00-base.sh 四节点幂等初始化 ops/11-gitlab-deb.sh GitLab deb 直装 + gitlab.rb 轻量化调优 ops/12-gitlab-resume.sh 大文件下载三板斧(续传/字节比对/SHA256) ops/21-jenkins-deb.sh Jenkins deb 直装 + init.groovy.d 安全初始化 ops/22-jenkins-java21.sh Java 21 适配(含 service override) ops/26-jenkins-uc.sh update-center postBack 注入 ops/30-target.sh 目标环境标准化 ops/31-prod-bluegreen.sh 蓝绿双实例骨架 ops/40-sshkey.sh 部署密钥生成分发 ops/50-gitlab-seed.sh GitLab 项目/分支保护/Webhook/DeployKey/代码种子 ops/51-jenkins-setup.sh Jenkins 凭据/Job/首次构建 ops/52-e2e-evidence.sh 端到端实操证据采集(含蓝绿零中断验证)欢迎在自己的环境里复现,也欢迎把你踩到的新坑提 Issue 补充进来。