适当的HTTP关闭
#go #http

到今天为止,我继续遇到代码,这些代码在GO中优雅地关闭HTTP服务器方面存在问题。这就是为什么我决定写一篇文章。

背景

我们应该首先谈论优雅的HTTP关闭,何时可能很重要。如果您已经有水平缩放的微服务和滚动更新的经验,则可以跳过此部分。

当您部署了HTTP应用程序的多个实例时,您想使用较新版本更新这些实例时,总的来说,您想以避免停机时间或失败的HTTP请求进行此操作。

最常见的做法是进行滚动更新。简而言之,您可以使用该应用程序的新版本启动新实例,然后将入口配置为在路由HTTP请求时包含此新实例。接下来,您关闭一个旧实例。但是,在将其关闭之前,您首先需要配置入口以停止将HTTP请求路由到该特定实例。您可以重复此过程,直到所有旧实例被新实例替换为

侧面注意:滚动更新的实际算法可能与我上面解释的内容有所不同,具体取决于您使用的平台,但总体想法是相同的。

如果您使用的是某种类型的PAA或IAAS基础架构(例如云铸造厂或Kubernetes),则整个过程有些自动化或至少易于实现。

但是,要考虑一个重要方面。虽然您可能已经配置了新的请求以不再路由到打算关闭的特定实例,但您可能仍在进行活动连接。如果要关闭实例,则连接的客户端将遇到connection reset错误或类似。如果这些调用来自浏览器(例如,端点是为了服务前端请求,而不是微服务到微服务请求),并且没有一些重试机制,则会为客户提供差的用户体验每当您执行更新时。

通常,当平台关闭您的实例时,它会发送SIGTERMSIGINT信号,以告知您的应用程序是时候关闭了,并且可以确保所有连接在退出之前已完成处理。这是优雅地关闭您的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,它开始聆听信号,每当收到SIGINTSIGTERM时,我们都会关闭服务器。

虽然更接近真相,但此代码仍然无法实现优美的关闭,因为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.消息。

工作优雅的关闭

对上述问题有一个非常简单的解决方案。您只需交换ShutdownListenAndServe 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之类的东西。


注意:当我逐渐迁移此处时,本文已从另一个平台移植。对不起,如果您以前看过。