部署上线:编译、进程托管、网关与灰度
本章目标:把 im-go 的网关(gateway)与逻辑层(logic)两个进程编译成静态二进制,用 supervisor 托管、Nginx 反代(包括 WebSocket 升级透传),最后用「按用户白名单 + 按网关实例分批」两种灰度方式把新版本放出去——并且随时能切回来。
1. 交叉编译:一条命令产出可部署制品
VERSION=$(git describe --tags --always 2>/dev/null || echo dev) # 版本号进二进制,排查第一步
OUT=dist/im-go-$VERSION && mkdir -p $OUT
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o $OUT/gateway ./cmd/gateway
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION" -o $OUT/logic ./cmd/logic
sha256sum $OUT/gateway $OUT/logic > $OUT/SHA256SUMS
# 先上传到新目录,再切软链——正在跑的进程完全不受影响
rsync -avz $OUT/ prod:/opt/im-go/releases/$VERSION/
ssh prod "ln -sfn /opt/im-go/releases/$VERSION /opt/im-go/current"
四个参数各有理由:CGO_ENABLED=0 产静态链接(static linking)二进制,不依赖目标机 glibc;GOARCH=amd64 在 ARM 服务器上换 arm64;-trimpath 去掉构建机绝对路径(泄漏内部目录结构,也影响可复现构建);-s -w 去符号表让体积更小,pprof 仍可正常使用。在 cmd/gateway/main.go 里声明 var version = "dev" 并在启动日志打印,线上排查第一件事永远是确认跑的哪个包。
2. 进程托管:supervisor 两个 program
# /etc/supervisor/conf.d/im-go.conf
[program:im-gateway]
command=/opt/im-go/current/gateway -conf /opt/im-go/configs/config.yaml
user=deploy
autostart=true
autorestart=true
stopwaitsecs=30
stopsignal=TERM
environment=IM_GATEWAY_JWT_SECRET="%(ENV_IM_JWT_SECRET)s",IM_ENV="prod",TZ="Asia/Shanghai"
stdout_logfile=/var/log/im-go/gateway.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=5
redirect_stderr=true
[program:im-logic] # 逻辑层同构,只改 command 与日志路径
command=/opt/im-go/current/logic -conf /opt/im-go/configs/config.yaml
user=deploy
environment=IM_ENV="prod",IM_MYSQL_DSN="%(ENV_IM_MYSQL_DSN)s",TZ="Asia/Shanghai"
stdout_logfile=/var/log/im-go/logic.log
密钥不写进配置片段(它会进 Git),而是由 systemd 注入到 supervisord 的环境里:systemctl edit supervisor 写 Environment=IM_JWT_SECRET=...,supervisor 用 %(ENV_...)s 展开。网关的重启代价和逻辑层完全不同:逻辑层是无状态(stateless)的,重启只影响正在处理的请求;网关重启等于把所有在线连接一次性掐断,所以 stopwaitsecs 给 30 秒(第 01 章的优雅退出超时是 10 秒),并且改完配置固定走 reread + update;要让网关分批重启,就用 numprocs 拆成多个 program(im-gateway-0、im-gateway-1)逐个 supervisorctl restart,或干脆逐台机器滚动。
3. Nginx 反代:WebSocket 与 HTTP 分开写
map $http_upgrade $connection_upgrade {
default upgrade; # 带 Upgrade 的握手请求 → upgrade
'' close; # 普通请求 → close,不误伤 keepalive
}
upstream im_gateway { server 10.0.0.11:8080; server 10.0.0.12:8080; }
server {
listen 443 ssl;
server_name im.example.com;
location /ws {
proxy_pass http://im_gateway;
proxy_http_version 1.1; # 升级依赖 1.1,默认 1.0 会直接失败
proxy_set_header Upgrade $http_upgrade; # 透传 Upgrade: websocket
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 网关日志要记真实客户端 IP
proxy_read_timeout 3600s; # 远大于心跳周期:空闲连接交给服务端心跳判定
proxy_send_timeout 3600s;
proxy_buffering off; # 长连接不做响应缓冲
}
location /api/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection ""; # HTTP 要清空,才能复用 upstream 长连接
proxy_read_timeout 30s;
}
}
两条 location 的差别必须记牢:WebSocket 的 Connection 是 upgrade,普通 HTTP 的是空串,混用就会出现「HTTP 复用不了长连接」或「WebSocket 握手不是 101」。反代超时给到小时级是故意让服务端心跳(30 秒)先把死连接回收掉,而不是等 Nginx 掐断——这样客户端收到的是明确的重连指令,而不是莫名其妙的断开。另外 worker_connections 与 limit_conn 要按第 13 章实测的单机连接数配置,否则压测能挂住、上线挂不住。
4. 灰度:按用户白名单与按实例分批
| 灰度方式 | 判定位置 | 生效时机 | 适用 |
|---|---|---|---|
| 按用户白名单 | 网关鉴权后选逻辑层目标 | 新建连接立即生效 | 内部账号先验证、定向邀请用户 |
| 按网关实例分批 | Nginx upstream / 摘实例 | 仅新连接落到新实例 | 网关版本灰度,兼容新旧协议 |
| 按逻辑层实例分批 | gRPC 目标地址 / 服务发现 | 立即 | 无状态,风险最低,可整批放开 |
func (g *Gateway) logicTarget(uid int64) string { // 鉴权通过后决定这条连接用哪个逻辑层
if g.gray.InWhitelist(uid) { // Redis Set gray:uid,本地缓存 30 秒热更新
return g.cfg.Logic.GRPCAddrV2 // 新版本逻辑层
}
return g.cfg.Logic.GRPCAddr
}
长连接灰度有一个绕不过去的特点:判定只发生在建连那一刻,连接建立后后端就固定了。存量连接不会自己迁移到新实例,想让白名单用户立刻用上新版本,只能由服务端下发重连指令或按比例断开让其重连——这在小流量时完全够用,千万别在高峰期做「批量踢连接重连」这种动作。放量节奏按 1 台 → 25% → 50% → 100% 走,每档至少观察 15 分钟,盯四个指标:在线连接数(是否异常下跌)、重连率、消息投递 P99、错误率。回滚优先用「新实例权重归零」,其次才是切软链加重启进程。
5. 上线验证与回滚
ssh prod '/opt/im-go/current/gateway -v' # 确认线上运行的版本号
ssh prod 'sudo supervisorctl status' # 期望 im-gateway/im-logic 均 RUNNING
ssh prod 'curl -s -o /dev/null -w "gateway=%{http_code}\n" http://127.0.0.1:8080/healthz; curl -s -o /dev/null -w "logic=%{http_code}\n" http://127.0.0.1:9101/healthz' # 业务口与监控口
TOKEN=$(curl -s -X POST https://im.example.com/api/v1/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"alice123","device_id":"deploy-check","platform":"web"}' | jq -r .access_token)
curl -i -N --max-time 3 "https://im.example.com/ws?token=$TOKEN&device_id=deploy-check" \
-H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" # 期望 101
ssh prod "ln -sfn /opt/im-go/releases/$LAST /opt/im-go/current && sudo supervisorctl restart im-gateway im-logic"
回滚比发布更需要提前演练:$LAST 上一版本目录必须还在、验证过能启动;切软链后必须重启进程(二进制是被 exec 进内存的,软链变化不会自动生效)。
常见坑
| 坑 | 现象 | 做法 |
|---|---|---|
用 go build 不指定 GOOS/GOARCH | 传上去报「无法执行二进制文件」 | 交叉编译固定写全环境变量,CI 里统一 |
Nginx 漏传 Upgrade/Connection | 握手一直失败或 60 秒后必断 | map 生成 Connection,两行 header 都写上 |
WebSocket 用了 HTTP 那套 Connection "" | 101 变成 400/502 | 两条 location 分开写,别硬凑一条 |
| 网关整批重启 | 在线数瞬间归零,全量重连打爆鉴权 | 分批重启,配合灰度与客户端退避重连 |
stopwaitsecs 太小 | 优雅退出被打断,连接未通知即消失 | ≥ 优雅退出超时(30 秒起步) |
| 灰度只看错误率不看连接数 | 大面积掉线被当成「网络抖动」 | 在线数下降超过 5% 立即回滚 |
小结:部署这条链路的四个要点是——交叉编译出静态二进制并注入版本号、supervisor 托管两个进程且网关按批次重启、Nginx 把 WebSocket 与 HTTP 分成两条 location 写(Connection 取值相反)、灰度按 uid 白名单与实例分批推进并盯住在线连接数;因为长连接的连接一旦建立就固定了后端,所以「灰度生效靠新连接、回滚优先改权重」,而不是靠批量踢连接。