《Go 语言编程入门》17.3 部署与优雅重启

把 TaskAPI 真正部署到服务器:写 systemd 单元托管进程、用软链与健康门控的脚本切换版本、把第 12 章的优雅关闭接到 SIGTERM 上,并实测「发信号后排空在途请求再退出」的完整时序,最后讨论多实例滚动与套接字传递两种零停机思路。

本节把 TaskAPI 推进到「线上运行」:用 systemd 托管进程、用脚本完成健康门控的版本切换,并把第 12 章的优雅关闭与 SIGTERM、探针顺序拼成一条不丢请求的重启链路。
适用版本:Go 1.27(实测 go1.27.0)。

17.3 部署与优雅重启

镜像和二进制都有了,最后一步是让它「稳稳地跑在服务器上,并且能安全地换版本」。本节涉及三件事:进程托管(systemd)、版本切换(部署脚本)、以及最容易被忽略但最关键的——重启时不丢在途请求。

实测声明:本节的优雅关闭时序在本机以真实进程实测(发 SIGTERM 后确认在途请求完成再退出)。systemd 单元与 scp/ssh 部署脚本依赖 Linux 服务器,本机(macOS)无法执行,脚本仅做了 bash -n 语法校验,未在真实服务器上运行。

17.3.1 为什么不用 nohup

开发时常用 nohup ./taskapi & 把进程丢到后台。它的问题:开机不自启、崩了不拉起、没有统一日志、没法规范地发信号。生产环境要用进程管理器,Linux 上首选 systemd。

17.3.2 systemd 单元

一个能上生产的单元文件:

[Unit]
Description=TaskAPI service
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=taskapi
Group=taskapi
ExecStart=/opt/taskapi/bin/taskapi
Environment=PORT=8080
Environment=LOG_LEVEL=info
Restart=on-failure
RestartSec=2
TimeoutStopSec=15
KillSignal=SIGTERM
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

逐条说关键项:

  • User=taskapi:以专用非特权用户运行,和容器里 USER app 同理。
  • Restart=on-failure + RestartSec=2:进程非零退出时 2 秒后拉起;正常退出(exit 0)不重启。
  • KillSignal=SIGTERM:停止服务时先发 SIGTERM,正是 Go 的 signal.NotifyContext 监听的信号。
  • TimeoutStopSec=15:给 15 秒排空时间,超时才 SIGKILL 强杀。这个值必须 大于 程序里的 Shutdown 超时(本节示例用的是 10 秒),否则排空还没结束就被杀。
  • ProtectSystem=strict / ProtectHome / PrivateTmp:只读挂载系统目录、隔离 home 与 /tmp,缩小被攻破后的影响面。

装好单元后:systemctl daemon-reload && systemctl enable --now taskapi。

17.3.3 优雅关闭的时序

第 12 章写过优雅关闭的代码骨架,这里把它和部署场景串起来。程序主逻辑:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

go func() {
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatalf("listen: %v", err)
	}
}()

<-ctx.Done()                    // 收到 SIGTERM
log.Println("signal received, draining...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Fatalf("forced shutdown: %v", err)
}
fmt.Println("drained, exiting 0")

http.Server.Shutdown 做了三件事:停止接受新连接、等待在途请求完成、直到超时或全部结束。它不会主动断开正在处理的请求——这正是「不丢请求」的关键。

17.3.4 实测:SIGTERM 后排空在途请求

写一个带 /slow(睡 3 秒)的服务,发请求的同时发 SIGTERM,观察慢请求是否还能拿到完整响应:

./restartdemo &                 # 启动,监听 127.0.0.1:6062
curl -s http://127.0.0.1:6062/slow > slow.out &   # 发起一个 3 秒的请求
sleep 0.3
kill -TERM $!                   # 请求进行中,发 SIGTERM

实测结果:

$ curl -s http://127.0.0.1:6062/healthz
ok
--- SIGTERM sent ---
slow response: slow done
--- server log ---
2026/10/10 07:36:49 listening on :6062
2026/10/10 07:36:49 signal received, draining...
2026/10/10 07:36:49 drained, exiting 0

关键点:SIGTERM 是在慢请求还在处理时发出的,但 slow response 依然完整返回了 slow done,然后进程才打印 drained, exiting 0 并退出。如果换成直接 os.Exit 或没有 Shutdown,这个请求会被截断,客户端拿到连接重置。

17.3.5 与探针配合的顺序

单进程的优雅关闭已经对了,但在负载均衡后面还有一个顺序问题:如果先排空、后摘流量,排空期间 LB 还在往这个实例发新请求,新请求会被拒。正确顺序是:

  1. 收到 SIGTERM。
  2. 立即把 readiness 置为 false(第 16.3 节的 /readyz 返回 503)。
  3. 等一个「LB 感知窗口」(通常几秒),确保 LB 已停止发新流量。
  4. 再调用 srv.Shutdown 排空在途请求。
<-ctx.Done()
ready.Store(false)                 // 1. 先摘流量
time.Sleep(3 * time.Second)        // 2. 等 LB 收敛
_ = srv.Shutdown(shutdownCtx)      // 3. 再排空

那个 time.Sleep 看起来「不优雅」,但它是必要的:LB 的健康检查有轮询间隔,你摘了流量,LB 要过几秒才知道。这 3 秒就是它的收敛时间。

17.3.6 部署脚本:健康门控切换

版本切换的本质是「把新二进制放上去、切软链、重启、确认健康」。脚本:

#!/usr/bin/env bash
set -euo pipefail

APP=taskapi
HOST="${1:?usage: deploy.sh <host>}"
VERSION="${2:-$(git rev-parse --short HEAD 2>/dev/null || echo dev)}"
BIN="dist/${APP}_linux_amd64"

echo ">> deploying ${APP} ${VERSION} to ${HOST}"
scp "$BIN" "${HOST}:/tmp/${APP}.new"
ssh "$HOST" bash -s -- "$VERSION" <<'REMOTE'
set -euo pipefail
APP=taskapi
VERSION="$1"
install -m 0755 "/tmp/${APP}.new" "/opt/${APP}/bin/${APP}.${VERSION}"
ln -sfn "/opt/${APP}/bin/${APP}.${VERSION}" "/opt/${APP}/bin/${APP}"
sudo systemctl restart "${APP}"
for i in $(seq 1 30); do
  if curl -fsS http://127.0.0.1:8080/healthz >/dev/null; then
    echo "healthy after ${i}s"; exit 0
  fi
  sleep 1
done
echo "health check failed" >&2
exit 1
REMOTE

要点:

  • 带版本号存二进制:taskapi.0.1.0,软链 taskapi 指向它,回滚只需重指软链。
  • 健康门控:重启后最多等 30 秒,curl /healthz 通了才算部署成功,否则脚本非零退出,CI 能感知失败。
  • set -euo pipefail:远端脚本也带上,任何一步失败立即中止。
  • 幂等:重复部署同一个版本不会出错。

17.3.7 零停机重启的两种思路

上面的脚本会有秒级不可用窗口:systemctl restart 期间进程不在,LB 上的健康检查可能短暂失败。要完全零停机,有两条路:

思路做法代价
多实例滚动起两个实例,逐个换,LB 只指向健康的需要 LB 与编排,最稳
套接字传递新进程继承旧进程的监听 fd,旧进程排空后退出代码复杂,需 SO_REUSEPORT 或 fd 传递

多实例滚动是绝大多数场景的正解:Kubernetes 的 RollingUpdate、systemd 管多个实例、或简单起两个端口配 nginx upstream 都能做到。它把「零停机」的责任从应用代码转移到了编排层——应用只需保证「收到 SIGTERM 能干净退出」,剩下的交给编排系统。

套接字传递(类似 nginx 的 reload)在 Go 里可以用 net.Listener 的 File() 拿到 fd,再通过环境变量传给子进程。大致流程是:旧进程监听 SIGUSR2,把 listener 的 fd 复制一份、以环境变量 LISTEN_FD=3 传给 os.StartProcess 启动的新进程,新进程用 net.FileListener 恢复监听,旧进程再排空退出。好处是内核连接队列连续、一个连接都不丢;代价是代码复杂、本地难调试,除非有硬性要求,否则不推荐。

对大多数团队来说,编排层滚动比应用层套接字传递更划算:它把复杂度放在运维侧一次解决,应用代码保持简单。这也是为什么 Kubernetes 时代很少再见到 Go 程序自己实现热重启。

17.3.8 看日志与回滚

systemd 会把进程的 stdout/stderr 收进 journal,用 journalctl 查:

journalctl -u taskapi -f            # 实时跟踪
journalctl -u taskapi --since "10 min ago"
journalctl -u taskapi -p err        # 只看 error 级别

因为 TaskAPI 的日志是第 16 章的结构化 JSON,一条一条 journalctl -o cat 出来可以直接喂给日志采集器。

回滚比部署更简单——旧版本二进制还在,把软链指回去、重启即可:

ssh "$HOST" 'ln -sfn /opt/taskapi/bin/taskapi.0.0.9 /opt/taskapi/bin/taskapi && sudo systemctl restart taskapi'

所以「带版本号存二进制」不只是整洁,它是可回滚的前提。永远不要用同一个文件名覆盖旧版本。

17.3.9 部署检查清单

上线前逐条过一遍:

  • 二进制是 GOOS=linux 且 CGO_ENABLED=0(file 确认静态链接)。
  • 版本号已通过 -ldflags -X 注入,启动日志里能看到。
  • systemd 的 TimeoutStopSec 大于程序 Shutdown 超时。
  • /healthz 只检查自身,/readyz 才查依赖。
  • SIGTERM 顺序:先摘 readiness,等 LB 收敛,再 Shutdown。
  • 观测端口(pprof/metrics)不暴露公网。
  • 回滚方案:旧版本二进制还在,软链可重指。

17.3.10 常见坑

  • TimeoutStopSec 小于程序超时:排空没完就被 SIGKILL,在途请求被截断。
  • KillSignal 默认是 SIGTERM,但若被改成 SIGKILL,程序根本没机会排空。
  • 忽略 SIGTERM 的旧代码:signal.NotifyContext 没接 SIGTERM,进程只能被强杀。
  • 重启后立刻判定成功:健康检查没等依赖连上,导致 LB 把流量发给没准备好的实例。
  • 没有回滚版本:新版本一挂,只能现场重新编译,故障时间被拉长。

小结

  • 生产用 systemd 托管,Restart=on-failure 自动拉起,User= 非特权运行。
  • 优雅关闭靠 signal.NotifyContext + srv.Shutdown:停止收新连接、排空在途请求、超时才强杀。
  • 本机实测确认:SIGTERM 后排空期间在途请求仍能拿到完整响应,进程随后 exit 0。
  • 负载均衡后面要「先摘 readiness、等收敛、再 Shutdown」。
  • 部署脚本用软链切版本 + 健康门控;零停机首选多实例滚动。

到这里,TaskAPI 从一行 go mod init 长成了一个能交叉编译、能打镜像、能优雅部署的服务。但前面每一章都是「局部推进」,你可能还没看清它的全貌。最后一章,我们从头复盘整个项目的需求与架构,补齐端到端,然后打包上线。

阅读导航:上一节:17.2 多阶段 Docker 镜像 · 下一节:18.1 需求梳理与架构设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练