Skip to content

28|优雅退出与信号

go-gateway 有两个 HTTP 服务在运行::8080 的代理层和 :8081 的管理后台。进程收到停止信号(如 systemd 的 systemctl stop 或 Kubernetes 的 kubectl delete pod)时,直接退出会导致正在处理的请求被中断,客户端收到连接重置错误。优雅退出的目标是:停止接收新请求,等待正在处理的请求完成,然后才退出进程。

一、信号处理

操作系统通过信号通知进程。Go 的 os/signal 包可以监听信号:

go
package main

import (
	"fmt"
	"os"
	"os/signal"
	"syscall"
)

func main() {
	done := make(chan os.Signal, 1)
	signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)

	fmt.Println("waiting for signal...")
	<-done
	fmt.Println("signal received, shutting down")
}
信号触发场景
SIGINT按 Ctrl+C
SIGTERMkill 命令、systemd stop、K8s 删除 Pod
SIGKILLkill -9,无法捕获,直接终止

SIGKILL 不能被捕获或忽略,进程没有机会做清理。优雅退出依赖 SIGTERM,所以部署配置里要用正常的停止方式,不要用 kill -9

二、http.Server.Shutdown

Go 1.8 引入的 Shutdown 方法实现了优雅关闭:

go
package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	srv := &http.Server{Addr: ":8080"}

	// 在 goroutine 里启动服务
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			fmt.Fprintln(os.Stderr, "server error:", err)
		}
	}()

	// 等待停止信号
	done := make(chan os.Signal, 1)
	signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
	<-done

	// 优雅关闭
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		fmt.Fprintln(os.Stderr, "shutdown error:", err)
	}
	fmt.Println("server stopped")
}

Shutdown 的工作流程:

  1. 关闭 Listener,停止接收新连接
  2. 等待所有正在处理的请求完成
  3. 如果超时时间到了还有请求没完成,强制关闭
  4. 返回 http.ErrServerClosed

三、双服务优雅退出

go-gateway 有代理层和管理后台两个服务,需要同时优雅关闭:

go
func main() {
	proxySrv := &http.Server{Addr: ":8080", Handler: proxyHandler}
	adminSrv := &http.Server{Addr: ":8081", Handler: adminHandler}

	go proxySrv.ListenAndServe()
	go adminSrv.ListenAndServe()

	done := make(chan os.Signal, 1)
	signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
	<-done

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	// 同时关闭两个服务
	go proxySrv.Shutdown(ctx)
	go adminSrv.Shutdown(ctx)

	<-ctx.Done()
	fmt.Println("shutdown complete")
}

超时时间要合理。太短(如 1 秒),长请求没完成就被强制关闭;太长(如 5 分钟),Pod 删除或滚动更新时卡住。通常设 10-30 秒,和 K8s 的 terminationGracePeriodSeconds 对齐。

四、context 传递

Shutdown 的 context 不仅控制超时,还可以传递给 handler,让 handler 知道自己该尽快结束:

go
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	select {
	case <-time.After(5 * time.Second):
		w.Write([]byte("done"))
	case <-ctx.Done():
		// 收到关闭信号,提前返回
		w.WriteHeader(http.StatusServiceUnavailable)
		w.Write([]byte("server shutting down"))
	}
}

r.Context()Shutdown 被调用时会触发 Done(),handler 可以监听这个信号,提前释放资源或返回错误。

五、资源清理

除了关闭 HTTP 服务,还要清理其他资源:

go
func shutdown() {
	// 1. 关闭 HTTP 服务
	proxySrv.Shutdown(ctx)
	adminSrv.Shutdown(ctx)

	// 2. 关闭数据库连接
	db.Close()

	// 3. 关闭 Redis 连接
	redisClient.Close()

	// 4. 刷新日志缓冲
	logger.Sync()
}

资源清理顺序很重要:先停止接收流量,再关闭下游连接,最后释放本地资源。顺序反了可能导致正在处理的请求失败。

六、常见错误

不处理 Shutdown 错误

go
srv.Shutdown(ctx) // 忽略了返回值

Shutdown 返回错误说明超时时间内没关闭完,应该记录日志或触发告警。

用 kill -9 停止

bash
kill -9 <pid>

SIGKILL 不给进程任何清理机会,直接终止。测试优雅退出时要用 kill(默认发 SIGTERM)或 Ctrl+C

主 goroutine 先退出

go
go srv.ListenAndServe()
// 主 goroutine 直接退出,进程结束

如果主 goroutine 在 ListenAndServe 启动后就退出,进程会直接结束。需要阻塞主 goroutine,等待信号或某个 channel。