到今天为止,我继续遇到代码,这些代码在GO中优雅地关闭HTTP服务器方面存在问题。这就是为什么我决定写一篇文章。
背景
我们应该首先谈论优雅的HTTP关闭,何时可能很重要。如果您已经有水平缩放的微服务和滚动更新的经验,则可以跳过此部分。
当您部署了HTTP应用程序的多个实例时,您想使用较新版本更新这些实例时,总的来说,您想以避免停机时间或失败的HTTP请求进行此操作。
最常见的做法是进行滚动更新。简而言之,您可以使用该应用程序的新版本启动新实例,然后将入口配置为在路由HTTP请求时包含此新实例。接下来,您关闭一个旧实例。但是,在将其关闭之前,您首先需要配置入口以停止将HTTP请求路由到该特定实例。您可以重复此过程,直到所有旧实例被新实例替换为
侧面注意:滚动更新的实际算法可能与我上面解释的内容有所不同,具体取决于您使用的平台,但总体想法是相同的。
如果您使用的是某种类型的PAA或IAAS基础架构(例如云铸造厂或Kubernetes),则整个过程有些自动化或至少易于实现。
但是,要考虑一个重要方面。虽然您可能已经配置了新的请求以不再路由到打算关闭的特定实例,但您可能仍在进行活动连接。如果要关闭实例,则连接的客户端将遇到connection reset
错误或类似。如果这些调用来自浏览器(例如,端点是为了服务前端请求,而不是微服务到微服务请求),并且没有一些重试机制,则会为客户提供差的用户体验每当您执行更新时。
通常,当平台关闭您的实例时,它会发送SIGTERM
或SIGINT
信号,以告知您的应用程序是时候关闭了,并且可以确保所有连接在退出之前已完成处理。这是优雅地关闭您的HTTP服务器来播放的地方。它确保连接被正确排干。
有问题的实现
现在我们了解了为什么优雅的关闭很重要,让我们探索一些最常见的GO HTTP服务器实现以及它们在正确的HTTP关闭时如何失败。
Hello World方法
这可能是您开始使用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 Team方面的错误。我没有理由为什么返回 nil
不会更好,更恰当地与对手保持一致。如果有人知道这个答案,请写评论。
所以我们进行了另一个迭代并从上面解决问题。
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
函数比较了错误。当然应该足够。
不幸的是,它不是T。
请参阅我在上一节中提到的,当应用程序停止时,它将发送给SIGINT
(在某些平台上的CTRL+C
)或SIGTERM
信号。
如果您检查signal软件包的文档,则会看到GO程序收到这两个信号之一时的默认行为是退出。这意味着该程序被突然停止。它永远无法从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
(non-Zero)退出代码退出。
信号处理方法
四处挖掘后,我们意识到,为了使我们的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,它开始聆听信号,每当收到SIGINT
或SIGTERM
时,我们都会关闭服务器。
虽然更接近真相,但此代码仍然无法实现优美的关闭,因为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的文档,我们会注意到以下警告:
当调用关闭时,请服用,收听和聆听和侦听服务,立即返回errservercled。确保该程序不会退出,而是等待关闭以返回。
在上面的代码中,我们从goroutine中调用Shutdown
。这立即在main
函数中取消阻止server.ListenAndServe
呼叫,在主goroutine上运行。
和第二个问题是,GO对主要功能有一个非常具体的规则,该规则常常被遗忘或更高的GO开发人员忽略,如果主要函数返回,则该程序即时终止。所有其他goroutines都被杀死,甚至没有任何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.
消息。
工作优雅的关闭
对上述问题有一个非常简单的解决方案。您只需交换Shutdown
和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.Shutdown
呼叫的错误分支中添加server.Close
呼叫。这样,如果优雅的关闭无法在指定的超时内完成,您仍然可以迫使服务器关闭。
概括
从这篇文章中夺走的主要要点如下:
-
始终阅读有关您正在使用的方法的GO文档。
通常有暗示的提示。
-
httpServer.ListenAndServe
方法在称为httpServer.Shutdown
时取消封锁。 -
请确保您永远不会从主功能返回,直到您实际上就可以退出为止。要么以这种方式构建代码(如上所述),要么使用同步原始词(例如等待组,频道)。
最后一句话
我希望上面的示例对您有用,这有助于您避免常见的陷阱。我对漫长的帖子感到抱歉,但我希望这对初学者和高级GO开发人员都有用。
我应该提到,在更复杂的应用程序中,可能无法在主goroutine上拥有Shutdown
呼叫块,因为您可能要运行多个HTTP服务器并同时停止。大多数情况下,您会使用某种类型的框架来启动和停止并发子过程(因为缺乏更好的术语)。即使这样,请确保在退出主函数之前,所有Shutdown
呼叫都没有阻止。使用WaitGroup
或其他同步机制来提高您的优势。
使用kubernetes时要记住的事情是,即使您的代码写得很好,您仍然可能在推出期间遇到连接问题。问题在于,Kubernetes需要时间调整其入口路由,并防止正在停止的新连接(在这种情况下为POD/容器)到达您的实例。在这种情况下,根据您的入口实现,您可能会观察到connection reset
错误或类似的错误。
要解决此问题,您可以在容器上使用preStop钩子,以确保从入口拆下(或更确切地说是要求脱离的)和接收SIGTERM
信号之间给出了多秒钟。
我还应该指出,在微服务和云世界中,有一个普遍的理解,即云应用程序应具有弹性,因此应以它们可以处理突然的应用程序关闭的方式实施。
虽然我同意这一点,并且强烈建议您采用诸如重试失败的请求,断路器等之类的策略,请记住,所有这些机制都会导致错误日志,可能不会立即清理的连接,额外的连接处理开销,消息队列需要时间来弄清楚发生了什么并开始向工作实例中rectheress邮件等。
因此,我个人的看法是,应用程序应该尝试尽可能优雅地停止(正确释放所有资源并与服务断开连接),但还应具有应对失败的机制。毕竟,碰巧的是,应用程序崩溃,运行的VM消失,网络开始行动,依此类推。如果您真的想测试环境并确保其弹性,则可以考虑使用Chaos Monkey之类的东西。
注意:当我逐渐迁移此处时,本文已从另一个平台移植。对不起,如果您以前看过。