Go 语言中正确的 HTTP 关闭方式
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
直到今天,我仍然会遇到用 Go 语言编写的代码在优雅地关闭 HTTP 服务器方面存在问题。这就是我决定写这篇文章的原因。
背景
我们首先应该讨论一下优雅地关闭 HTTP 服务以及何时需要这样做。如果您已经有水平扩展微服务和滚动更新方面的经验,则可以跳过本节。
当您部署了多个 HTTP 应用程序实例,并且想要用更新的版本更新这些实例时,通常您希望以一种避免停机或 HTTP 请求失败的方式来执行此操作。
最常见的做法是滚动更新。简单来说,就是启动一个运行新版本应用程序的新实例,并配置入口路由,使其在路由 HTTP 请求时包含这个新实例。接下来,关闭一个旧实例。但在关闭旧实例之前,需要先配置入口路由,停止向该实例路由 HTTP 请求。重复此过程,直到所有旧实例都被新实例替换。
附注:根据您使用的平台,滚动更新的实际算法可能与我上面解释的略有不同,但总体思路是相同的。
如果您使用某种类型的 PaaS 或 IaaS 基础设施(例如 Cloud Foundry 或 Kubernetes),则整个过程在某种程度上是自动化的,或者至少很容易实现。
然而,有一个重要方面需要考虑。即使您已配置新请求不再路由到您计划关闭的特定实例,仍可能有正在进行的连接。如果您关闭该实例,已连接的客户端会收到connection reset错误或其他类似信息。如果这些调用源自浏览器(例如,这些端点旨在处理前端请求,而不是微服务之间的请求),并且没有相应的重试机制,那么每次执行更新时,都会导致客户体验不佳。
通常情况下,当平台关闭你的实例时,它会发送一个SIGTERM信号SIGINT通知你的应用程序该关闭了。你的应用程序需要确保所有连接在退出前都已完成处理。而优雅地关闭HTTP服务器正是在这里发挥作用。它能确保连接被正确释放。
问题实现
既然我们已经了解了优雅关闭的重要性,那么让我们来探讨一些最常见的 Go HTTP 服务器实现,以及它们在正确关闭 HTTP 方面失败的原因。
“Hello World”方法
这可能是你开始学习 Go 和 HTTP 时最常遇到的代码。
package main
import "net/http"
func main() {
http.Handle("/", http.FileServer(http.Dir("./public")))
http.ListenAndServe(":8080", nil)
}
说实话,对于一个简单的“Hello World”程序来说,这样做可能没问题。问题在于,它会给新手开发者造成很多错误的认知,而这些认知很难纠正。
第一个问题是它http.ListenAndServe会返回一个错误。所以我们需要处理它。接下来是以下代码。
package main
import (
"log"
"net/http"
)
func main() {
http.Handle("/", http.FileServer(http.Dir("./public")))
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("HTTP server error: %v", err)
}
}
这样好些了吗?其实不然。事实证明,即使http.ListenAndServe它正常返回(注意这是一个阻塞调用),实际上也会返回一个http.ErrServerClosed错误。
附注:我个人认为这是 Go 团队的一个失误。我不明白为什么返回 nil不是更好、更符合 Go 惯例的做法。如果有人知道答案,请留言。
因此,我们进行了另一次迭代,并解决了上述问题。
package main
import (
"errors"
"log"
"net/http"
)
func main() {
http.Handle("/", http.FileServer(http.Dir("./public")))
if err := http.ListenAndServe(":8080", nil); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server error: %v", err)
}
}
我们甚至还特意使用了更复杂的方法,errors.Is通过函数来比较误差。这应该足够了吧。
很遗憾,事实并非如此。
正如我在上一节中提到的,当应用程序停止运行时,它会收到一个SIGINT(CTRL+C在某些平台上)SIGTERM信号。
如果你查看signal包的文档,你会发现 Go 程序在接收到这两个信号之一时,默认行为是退出。这意味着程序会突然停止,它不会从调用中返回并执行错误检查,就好像被调用了 `exit`http.ListenAndServe一样。os.Exit
让我们在上面的代码中添加以下日志语句,看看会发生什么。
package main
import (
"errors"
"log"
"net/http"
)
func main() {
log.Println("Starting...")
http.Handle("/", http.FileServer(http.Dir("./public")))
if err := http.ListenAndServe(":8080", nil); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server error: %v", err)
}
log.Println("Stopped.")
}
如果在终端运行这段代码CTRL+C,我们会得到以下输出。
$ go build -o experiment .; ./experiment
2022/01/14 00:19:51 Starting...
^C
$ echo $?
130
附注:我使用 go build而不是 ,go run因为go run总是返回等于 的退出代码1,即使应用程序编写正确。
如您所见,我们并未收到Stopped.日志语句。相反,signal: interrupt程序打印了内容,检查程序的退出代码可知,程序以130非零退出代码退出。
信号处理方法
经过一番研究,我们意识到为了避免 Go 应用突然退出,我们需要处理传入的信号。很快,我们就使用了signal包。我们还发现需要创建一个专用的http.Server实例,因为没有办法让程序http.ListenAndServe解除阻塞。最终,我们得到了以下代码。
package main
import (
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
)
func main() {
server := &http.Server{
Addr: ":8080",
}
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
if err := server.Close(); err != nil {
log.Fatalf("HTTP close error: %v", err)
}
}()
http.Handle("/", http.FileServer(http.Dir("./public")))
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server error: %v", err)
}
}
我们所做的就是生成一个 goroutine,它开始监听信号,每当收到 aSIGINT或SIGTERMb 时,我们就关闭服务器。
虽然这离真相更近了一步,但这段代码仍然无法实现优雅的关闭,因为Close会立即终止所有活动连接,而不会等待它们被处理。
我们查阅了一些资料,并将我们的实现方式改为使用带有超时功能的Shutdown(通过使用超时上下文)。
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
server := &http.Server{
Addr: ":8080",
}
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownRelease()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Fatalf("HTTP shutdown error: %v", err)
}
}()
http.Handle("/", http.FileServer(http.Dir("./public")))
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server error: %v", err)
}
}
我们运行程序,发现不再出现任何信号退出错误,一切Shutdown正常,可以继续进行下去了。我们部署了应用程序,对出色的工作成果感到满意。
然而,一段时间后,当我们把这种模式应用到许多微服务应用中后,我们开始connection reset在日志仪表板中发现错误。我们开始排查问题,最终发现我们的应用根本没有正常关闭。
那么到底出了什么问题呢?我们Shutdown毕竟是在使用,不是吗?
问题在于很多开发者并没有认真阅读文档,最终落入了这个陷阱。对于那些了解我所指的人来说,上面的代码可能看起来很愚蠢,不太可能发生,但我在实践中确实见过好几次。有时,这种错误并不容易发现,因为其中可能使用了自定义框架,或者代码被拆分成了多个函数(甚至可能跨越多个文件;通常还涉及通道和上下文)。
如果我们仔细阅读关机相关的文档,会发现以下警告:
当调用 Shutdown 时,Serve、ListenAndServe 和 ListenAndServeTLS 会立即返回 ErrServerClosed。请确保程序不要退出,而是等待 Shutdown 返回结果。
在上面的代码中,我们是Shutdown从 goroutine 内部调用的。这会立即解除函数server.ListenAndServe内部的调用阻塞main,使其在主 goroutine 中运行。
第二个问题是,Go 语言对于主函数有一条非常特殊的规则,这条规则经常被遗忘,甚至被一些资深的 Go 开发者忽略——如果主函数返回,程序就会立即终止。所有其他 goroutine 都会被终止,甚至不会执行任何defer语句。
因此,一旦server.ListenAndServe阻塞解除,程序就立即终止,server.Shutdown调用根本没有机会释放连接和资源。我们可以通过添加一些日志语句轻松验证这一点。
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
server := &http.Server{
Addr: ":8080",
}
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownRelease()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Fatalf("HTTP shutdown error: %v", err)
}
log.Println("Graceful shutdown complete.")
}()
http.Handle("/", http.FileServer(http.Dir("./public")))
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server error: %v", err)
}
log.Println("Stopped serving new connections.")
}
我们得到以下输出:
$ go run main.go
^C
2022/01/13 23:44:54 Stopped serving new connections.
我们根本看不到这Graceful shutdown complete.条信息。
优雅地关机
上述问题有一个非常简单的解决方案。只需交换 `and` 和 ` Shutdownor`的位置即可ListenAndServe calls,前者可以从主函数中调用,后者可以从 goroutine 中调用。
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
server := &http.Server{
Addr: ":8080",
}
http.Handle("/", http.FileServer(http.Dir("./public")))
go func() {
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server error: %v", err)
}
log.Println("Stopped serving new connections.")
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownRelease()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Fatalf("HTTP shutdown error: %v", err)
}
log.Println("Graceful shutdown complete.")
}
如果我们运行这个程序然后停止它,我们可以看到以下输出。
$ go run main.go
^C
2022/01/14 20:49:25 Stopped serving new connections.
2022/01/14 20:49:29 Graceful shutdown complete.
最终,我们达到了想要的结果。
上面的例子还可以进一步改进。您可以考虑server.Close在调用的错误分支中添加一个调用server.Shutdown。这样,如果优雅关闭操作无法在指定的超时时间内完成,您仍然可以强制服务器关闭。
概括
本文的主要要点如下:
-
务必阅读你所使用的 Go 方法的文档。文档中通常会包含对重要特殊情况或意外结果的提示。
-
该
httpServer.ListenAndServe方法被调用时会立即解除阻塞httpServer.Shutdown。 -
确保在真正准备退出之前,不要从主函数返回。你可以按照上述示例的方式组织代码,或者使用同步原语(例如等待组、通道)。
结语
希望以上示例对您有所帮助,并能帮助您避免一些常见的陷阱。很抱歉文章篇幅较长,但我希望这篇文章能对 Go 语言的初学者和高级开发者都有所帮助。
需要说明的是,在更复杂的应用中,可能无法Shutdown在主 goroutine 中阻塞调用,因为您可能需要并发运行和停止多个 HTTP 服务器。通常情况下,您会使用某种框架来启动和停止并发子进程(暂且这么称呼)。即便如此,也请确保Shutdown在退出主函数之前所有调用都已解除阻塞。您可以利用WaitGroup同步机制或其他同步机制来优化这一过程。
使用 Kubernetes 时需要注意的一点是,即使代码编写良好,在部署过程中仍然可能遇到连接问题。这是因为 Kubernetes 需要时间来调整其入口路由,并阻止新连接到达正在停止的实例(本例中为 Pod/容器)。在这种情况下,connection reset您可能会看到bad gateway错误信息,而不是直接显示错误,具体取决于您的入口实现。
为了解决这个问题,你可以在容器上使用preStop钩子,以确保在从入口分离(或者更确切地说,请求分离)到收到SIGTERM信号之间有一定的延迟时间。
我还应该指出,在微服务和云计算领域,人们普遍认为云应用程序应该具有故障恢复能力,因此它们的实现方式应该能够应对应用程序的突然关闭。
虽然我同意这一点,并且强烈建议您采用重试失败请求、熔断器等策略,但请记住,所有这些机制都会导致错误日志、可能无法立即清理的连接、额外的处理开销、需要时间来弄清楚发生了什么并开始向工作实例重新发送消息的消息队列等等。
因此,我个人认为应用程序应该尽可能优雅地停止(正确释放所有资源并断开与服务的连接),但也应该具备处理故障的机制。毕竟,应用程序崩溃、运行它们的虚拟机消失、网络出现故障等等情况时有发生。如果您真的想测试您的环境并确保其具有弹性,您可以考虑使用Chaos Monkey之类的工具。
注:本文是从其他平台移植过来的,我正在逐步迁移到这里。如果您之前已经看过这篇文章,敬请谅解。
文章来源:https://dev.to/mokiat/proper-http-shutdown-in-go-3fji