部署上线:编译、进程托管、网关与灰度

本章目标:把 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 白名单与实例分批推进并盯住在线连接数;因为长连接的连接一旦建立就固定了后端,所以「灰度生效靠新连接、回滚优先改权重」,而不是靠批量踢连接。

笔记加载中…