本节把 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 还在往这个实例发新请求,新请求会被拒。正确顺序是:
- 收到
SIGTERM。 - 立即把 readiness 置为 false(第 16.3 节的
/readyz返回 503)。 - 等一个「LB 感知窗口」(通常几秒),确保 LB 已停止发新流量。
- 再调用
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 需求梳理与架构设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。