等待服务器排空

Go 语言Beginner
立即练习

介绍

下载台需要关闭,却不能截断正在接收实地笔记的人的传输。你将用 Go 普通 HTTP 的关闭协调替代立即关闭连接,并保留由原进程提供的一份真实完整下载。

本单元独立启动,提供准备好的处理器和同样的两份文档。你需要掌握上一单元的精确 PID 与已接受请求观察,或具有同等经验,以及处理器、错误、goroutine 和 channel。下文会解释信号投递、有限 context、错误匹配和 main 的等待职责。本单元讨论普通 HTTP,不涉及 WebSocket、分布式流量切换或生产零停机保证。预计约 60 分钟;这是作者估计。

让 main 等待关闭完成

本步骤中,你将实现有限的关闭协调,同时保留准备好的 HTTP 处理器。

从 Downloads 开始,进入独立工作区:

cd /home/labex/project/download-desk
cat lifecycle.go

只允许修改 lifecycle.go。保持 main.go 中的处理器、两份文档、go.mod、准备好的观察器及观察记录不变。当前函数捕获 SIGTERM,却调用 Close,因而中断活动连接。仅有信号处理并不能让服务优雅关闭。

http.Server 管理监听器和活动 HTTP 连接。ListenAndServe 在接受工作期间阻塞。Shutdown 先关闭监听器和已有空闲连接,然后等待普通活动请求变为空闲。一旦开始关闭,ListenAndServe 就立即返回 http.ErrServerClosed,即使已接受的处理器仍在运行。如果 main 此时返回,所有 goroutine 都会消失,传输便被截断。因此主路径必须等待 Shutdown 本身返回。

context 携带取消或截止时间预算。context.Background() 提供起始 context。context.WithTimeout(..., 45*time.Second) 创建有限的 45 秒关闭预算和 cancel 函数;defer cancel() 会在此函数返回时释放相关资源,即使工作更早完成也一样。取消并不是完成的证明。公开上限是停止请求后的 60 秒:如果一个已接受的响应无法在所选预算内完成,应报告错误,并在这一上限内以非零状态退出。参考实现使用 45 秒;也允许不超过 60 秒的等效有限预算。检查包含一个在 60 秒内无法完成的有限长响应,因此仅用 context.Background() 进行关闭是不够的。在该隔离超时试验中,部分传输和明确报告的服务器非零退出,才是正确的失败结果,而非成功排空。如果预算到期,Shutdown 返回错误;我们的函数返回该错误,main 以非零状态退出。不能把这种情况称为成功排空,也不能静默重启服务器。

signal.Notify(stopped, syscall.SIGTERM) 要求 Go 将该信号发送到 channel。此 channel 的缓冲区为 1,因为信号投递不能等待接收者;本场景排队一个停止请求就足够了。signal.Stop(stopped) 在函数结束时取消此 channel 的注册,它与停止 HTTP 服务器不同。同样,带缓冲的结果 channel 让服务 goroutine 能在 main 执行关闭期间报告它唯一的结果。

第一个 select 等待服务错误或停止信号,避免绑定/启动错误永远被等待信号所掩盖。成功关闭后,errors.Is(err, http.ErrServerClosed) 识别预期的服务结果。其他服务错误会被返回,不能当成成功。main 调用 runServer,因此让该函数在关闭完成后才返回,才能保持进程存活。

替换这一个文件。带引号的 here-document 分隔符使 shell 按字面写入 Go 源码,直到最后单独一行的 GO;> 只替换 lifecycle.go:

cat > lifecycle.go <<'GO'
package main

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

func runServer(server *http.Server) error {
 stopped := make(chan os.Signal, 1)
 signal.Notify(stopped, syscall.SIGTERM)
 defer signal.Stop(stopped)
 result := make(chan error, 1)
 go func() { result <- server.ListenAndServe() }()
 select {
 case err := <-result:
  return err
 case <-stopped:
 }
 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
 defer cancel()
 if err := server.Shutdown(ctx); err != nil {
  return err
 }
 err := <-result
 if !errors.Is(err, http.ErrServerClosed) {
  return err
 }
 return nil
}
GO

用 gofmt -w 格式化源码;-w 写回格式化后的文件。使用明确的可执行程序路径构建当前包,避免新终端依赖隐藏的 PATH 设置:

/usr/local/go/bin/gofmt -w lifecycle.go
/usr/local/go/bin/go build -o desk .

语法错误会指出文件和行号。对照完整代码块,必要时只替换 lifecycle.go;不要重置 VM。检查还会在隔离的临时夹具中构建你的原样函数,变化真实下载时长和完成顺序。它既检查正常成功,也检查长响应的失败分支,因此一次检查约需一分钟。它不会向 现场下载台发信号,也不会修改源码。

保全下载并关闭监听器

本步骤中,你将保全一次已接受的真实下载,同时拒绝新连接,并等待原服务器。

切换到 Server,进入工作区并启动一次新的观察尝试。此终端会一直被占用,直到原进程结束:

cd /home/labex/project/download-desk
python3 desk.py serve drained

等待真实的就绪消息,再切换到 Downloads。开始下面的有限慢速传输前,先读完整个步骤,包括预期的探针失败和恢复方法。 然后按顺序执行各代码块,不要在传输窗口中学习新概念。像上一单元一样,在后台启动新的有限慢速传输。每两次 32 字节写入之间的 300ms 提供正常阅读时间,并非无限等待信号。

python3 desk.py download drained completed north-pier.txt 300 & download_job=$!
python3 desk.py accepted drained completed

在停止任何进程之前,观察器读取真实已接受的请求。获取并检查原构建进程的身份,然后从此终端发送 SIGTERM:

server_pid=$(python3 desk.py pid drained)
python3 desk.py identity drained
kill -TERM "$server_pid"

在已接受的下载仍进行时,测试一个新连接。准备好的探针最多在两秒内进行有界的真实连接尝试,保留每一次原生结果,直到出现拒绝。这允许 Go 先处理已投递的信号,再观察到首次连接失败;探针不假定 kill 会同步关闭监听器。下一条命令预期返回 7,并显示连接被拒绝的消息:

python3 desk.py probe drained

这是拒绝建立新连接,不是 HTTP 403,也不表示已接受的传输损坏。原下载应继续。等待其后台作业,并将保存的实际字节与原件比较:

wait "$download_job"
cmp documents/north-pier.txt downloads/completed.txt
cat downloads/completed.txt

预期 curl 原生返回 0,cmp 没有输出,并包含完整的最后一章。切换到 Server:它现在应报告 Original server native exit: 0。服务器可能在客户端进程读完或刷新文件之前完成发送;关闭不等待客户端应用层确认。这些是彼此独立的观察,不要求两条终端消息按特定顺序出现。

返回 Downloads。保留 lifecycle.go、完整下载和原生观察记录。检查读取这一 现场结果,并在隔离的随机端口上,用已有空闲连接及不同顺序的已接受请求测试同样的源码。直接 Close、在 Shutdown 仍运行时从 main 返回、让监听器一直开放到工作完成,或猜测一个时间睡眠,都不符合契约。

如果在 accepted 或停止动作之前下载已经完成,保留文件,在仍运行的尝试中选择新标签。如果服务器已经退出,在 Server 显式启动一个新尝试名称,并在新慢速输出及 identity、accepted、probe 命令中使用该名称。仅在进程退出后出现的拒绝探针,不能证明监听器提前关闭;用新名称重做活动工作流观察。不要覆盖先前成功的文件,也不要重置观察历史。

上一尝试退出后,若需完整重试,在 Server 执行:

python3 desk.py serve drained-retry

看到就绪消息后使用 Downloads。先读完所有代码块。新名称保留了先前文件和观察记录:

python3 desk.py download drained-retry completed-retry north-pier.txt 300 & download_job=$!
python3 desk.py accepted drained-retry completed-retry
server_pid=$(python3 desk.py pid drained-retry)
python3 desk.py identity drained-retry
kill -TERM "$server_pid"

下面的探针预期返回 7:

python3 desk.py probe drained-retry
wait "$download_job"
cmp documents/north-pier.txt downloads/completed-retry.txt

预期 curl 返回 0、完整字节相等,以及 Server 中原进程退出状态为 0。如果先前服务器仍在运行,先读完说明,再在该尝试中启动新的慢速输出;download 和 accepted 都使用新标签,identity 和 probe 则保留该尝试名称。如果先前探针已经存在,保留它,显式停止自己原来的尝试并等待其退出,再执行上述完整的新尝试重试。

真实启动错误会出现在服务器已有 stderr 中,并返回非零;纠正已介绍的源码/构建错误,或等待自己的先前尝试退出,再使用 8080。不要杀死同名的平台进程。关闭截止时间错误表示有限预算未能确认完成;应如实读取,不要用替代进程或复制早先文档来冒充结果。

总结

你协调了监听器、已接受的 HTTP 处理器、有限关闭 context 和 main。原进程停止接受连接,同时完成已有工作。精确的最终字节与原生结果,让这一成果可以被实际观察。