启动、检查与关闭服务

现在能完成业务操作了,但服务还需要回答:什么时候可以接流量?启动失败会留下什么?停止时数据库先关,还是请求先结束?

本章内容

从一次正常启动开始

先停止前面自己启动的订单服务,然后在原工程目录运行。保持相同数据库路径,便于停止后验证旧订单仍存在。

终端 1 · 本章只需要 HTTP
go run ./cmd/orders -db lesson.db
关键启动日志 · 前面还会带日志时间
listeners ready

这条日志不是 main 一进入就打印的。main 必须先打开存储、完成 app.New,再由 app.Run 启动并等到挂载端点就绪。在另一个终端检查两个探测路径,Windows 用 curl.exe:

终端 2 · 两种不同的检查
curl -i http://127.0.0.1:8080/livez
curl -i http://127.0.0.1:8080/readyz
正常情况下的响应
GET /livez  → 200,{"status":"alive"}
GET /readyz → 200,{"status":"ready"}

两者现在都是 200,不代表它们做了相同的检查。下面先追启动过程,再区分它们。

Start、WaitReady 和 Wait 各等什么

internal/app/app.go节选 · L66
func Run(parent context.Context, runtime *server.Server) error {
	ctx, stop := context.WithCancel(parent)
	defer stop()
	handle, err := runtime.Start(ctx)
	if err != nil {
		return err
	}
	readyCtx, cancelReady := context.WithTimeout(ctx, 5*time.Second)
	err = handle.WaitReady(readyCtx)
	cancelReady()
	if err != nil {
		stop()
		waitErr := handle.Wait() // Join shutdown before the caller closes the DB.
		if server.IsContextTermination(waitErr) {
			waitErr = nil
		}
		return errors.Join(err, waitErr)
	}

Start 开始本轮运行,返回管理这轮运行的 handle。它不是“所有监听都已成功”的同义词,所以随后用 WaitReady 等待启动结果,并为等待设置 5 秒上限。

如果等不到就绪,先 stop 取消本轮运行,再 Wait 等它停止,最后把启动与停止错误汇总返回。这里不能只 return err,把尚在退出的端点留给 main 去关闭数据库。

internal/app/app.go节选 · L84
	log.Print("listeners ready")
	err = handle.Wait()
	if parent.Err() != nil && server.IsContextTermination(err) {
		return nil
	}
	return err
}

启动成功才打印 listeners ready;接下来 Wait 等整个运行结束。主动取消导致的正常终止被转换为 nil;其他错误继续交给 main 记录并以失败状态退出。wait 的对象不同,不能把 WaitReady 与 Wait 交换。

就绪日志不等于数据库永远健康

internal/httpapi/api.go节选 · L23
	endpoint.Get("/livez", func(c *httpx.Context) error {
		return c.JSON(http.StatusOK, map[string]string{"status": "alive"})
	})
	endpoint.Get("/readyz", func(c *httpx.Context) error {
		ctx, cancel := context.WithTimeout(c.Context(), time.Second)
		defer cancel()
		if err := ready(ctx); err != nil {
			return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "not_ready"})
		}
		return c.JSON(http.StatusOK, map[string]string{"status": "ready"})
	})

/livez 不访问 Store,只说明请求能到达进程并获得响应。/readyz 每次请求都会调用 main 传入的 Ping,并给这次检查 1 秒预算;存储检查失败就返回 503。WaitReady 则只协调本轮受管理端点的启动结果,不替代这个持续检查。

不用破坏数据库文件也能观察这一区别。测试为 Register 传入一个永远返回 offline 的检查函数,同时使用会失败的 Store:

internal/httpapi/api_test.go节选 · L107
func TestInternalErrorIsNotExposed(t *testing.T) {
	e := httpx.New(httpx.Config{})
	httpapi.Register(e, order.New(failedStore{}), func(context.Context) error { return errors.New("offline") })
	w := request(e, "POST", "/orders", `{"sku":"tea","quantity":1}`)
	if w.Code != 500 || strings.Contains(w.Body.String(), "private") {
		t.Fatalf("unsafe response: %d %s", w.Code, w.Body)
	}
	if w := request(e, "GET", "/readyz", ""); w.Code != 503 {
		t.Fatalf("readiness = %d", w.Code)
	}
	if w := request(e, "GET", "/livez", ""); w.Code != 200 {
		t.Fatalf("liveness = %d", w.Code)
	}
}
验证依赖失败时的对外行为
go test ./internal/httpapi -run '^TestInternalErrorIsNotExposed$' -v -count=1

预期 PASS:创建返回 500 且不泄露内部错误;/readyz 为 503,/livez 仍为 200。这里是受控注入的失败,不是声称已经模拟了真实磁盘损坏。真实部署应选择“接收业务所必需”的依赖做 readiness,并由部署层限制探测接口的暴露范围。

让第二个进程撞上已占用的端口

保持终端 1 正常运行。在终端 2 执行下面命令;使用内存避免同时打开同一个练习库。这条命令预期失败,不要关闭第一个进程来“修复”它。

终端 2 · 故意申请同一个 8080
go run ./cmd/orders -memory
应观察到的行为 · 错误文字随操作系统不同
第二个进程:报告 bind / address already in use / 地址占用,退出失败
第二个进程:没有 listeners ready
第一个进程:仍能响应 /readyz

失败不是订单业务导致的。问题发生在监听端口阶段,因此应该检查启动地址和正在运行的进程,而不是给 Create 加重试或重新建表。

不依赖你的 8080 状态,运行自动化版本
go test ./internal/app -run '^TestRunRejectsOccupiedPort$' -v -count=1

预期 PASS。internal/app/app_test.go 的这个测试先占住一个临时本机端口,再让 app.Run 使用它,要求返回启动错误,且 runtime 已进入 Stopped。测试会自行释放临时端口,不会杀掉你的服务。

按一次 Ctrl+C,追到数据库关闭

回到终端 1 按 Ctrl+C。正常结束时应看到 service stopped,命令行重新可输入。源码中的顺序是:

  1. main 的信号 goroutine 调用 cancelcmd/orders/main.go

    把操作系统信号转成普通 context 取消。

  2. Server 停止受管理端点,app.Run 等到结束internal/app/app.go

    在配置的预算内进行停止;Wait 返回后,外层 run 才能返回。

  3. run 的 defer 关闭 SQLitecmd/orders/main.go

    不会在端点还正常处理请求时,就因为组装函数返回而提前关闭数据库。

  4. main 打印 service stopped

    若 run 返回真实错误,则记录错误并退出失败,不打印这条成功停止日志。

停止后再请求 /readyz,应连接失败,而不是期待一个已经退出的进程返回 503。再次用相同的 -db 路径启动,上一章记录的 ID 应仍可查询。这个实验验证正常停止和重开,不证明强制终止或断电时的行为。

把限制对应到实际处理的位置

超时不是一个总开关。一次请求经过不同阶段,各阶段的配置保护不同资源。以下都是 internal/app/app.go 和 handler 中的教学取值:

位置本例设置它没有承诺什么
HTTP 请求体4 KiB不是整个进程的内存上限
HTTP 读 / 写5 秒 / 5 秒不是业务一定能在 5 秒内完成
业务调用 context2 秒不会强杀任意 goroutine;下层必须配合取消
TCP 连接与单条数据体64 个连接 / 4 KiB不是整个服务的吞吐或可用性保证
TCP 单会话出站队列16 条 / 64 KiB入队成功不等于对端业务确认
Router MaxInflight256约束消息路由任务,不是 HTTP 并发上限
停止预算5 秒不保证所有外部副作用可靠完成

例如 JSON 能正确解码但数量为 0,应该修改请求而不是调大超时;请求体超限,则先判断是否符合接口契约。请先定位失败阶段,再决定是否调整配置。这些数值都不是生产推荐容量。

runtime.Use(middleware.Recover()) 也有边界:它为受管理的 handler 调用提供 panic 恢复,不会补偿已经提交的事务或发出的邮件,也不会自动接住任意自建 goroutine 的 panic。

修复冲突,让两个进程各自运行

让使用 lesson.db 的第一个进程继续在 8080 运行;若刚才已停止,先按本章开头的命令重新启动。在另一个终端启动第二个进程,这次给它一个空闲的 8081 端口,并使用独立内存:

第二个进程 · 若 8081 已占用,换一个空闲端口
go run ./cmd/orders -memory -http 127.0.0.1:8081

现在它也应打印 listeners ready。再开终端,请求 http://127.0.0.1:8081/readyz,应为 200;用第一个进程保存过的订单 ID 查询 8081,应为 404,而同一 ID 在 8080 仍为 200。

为什么两个进程都就绪,数据却不同

修改 -http 解决了监听冲突,并没有让第二个进程自动读取第一个进程的数据库。它选择了 -memory,拥有自己的空 Store;/readyz 为 200 只表示它的检查成功,不表示某笔订单存在。这个差别同时用到了第 4 章的组装、第 5 章的存储选择和本章的生命周期。

练习结束后,在各自终端 Ctrl+C 停止这两个由你启动的进程。不要停止来源不明的进程,也不需要删除数据库。程序可启动、可探测、可正常退出,只是运行基础;上线还需要鉴权、TLS、创建幂等键、迁移与备份、日志指标、容量与故障验证,以及部署回滚方案。

提交自己的改动前,检查这个教学应用
go test ./... -count=1
go test -race ./... -count=1
go vet ./...

这些检查覆盖代码中表达的行为,不完成生产认证。KPro 仍是 pre-1.0,本例固定 v0.2.1;升级时保持依赖、CLI 和文档版本一致,并重新验证应用。下一章把这些检查用在一次真正的需求变更上。

项目教程

构建一个订单服务

启动、检查与关闭服务

本页目录