Let's Go Further خاموش شدن تدریجی › اجرای خاموش شدن
قبلی · فهرست مطالب · بعدی
فصل ۱۱.۳.

اجرای خاموش شدن

دریافت سیگنال‌ها خوب است، اما تا زمانی که کاری با آن‌ها انجام ندهیم چندان مفید نیست! در این فصل، برنامه خود را به‌گونه‌ای به‌روزرسانی می‌کنیم که سیگنال‌های SIGINT و SIGTERM که دریافت می‌کنیم، خاموش شدن تدریجی API ما را آغاز کنند.

به‌طور خاص، پس از دریافت یکی از این سیگنال‌ها، متد Shutdown() را روی سرور HTTP خود فراخوانی می‌کنیم. مستندات رسمی این متد را این‌گونه توصیف می‌کند:

Shutdown سرور را بدون قطع اتصالات فعال، به‌صورت تدریجی خاموش می‌کند. Shutdown ابتدا تمام listener‌های باز را می‌بندد، سپس تمام اتصالات بیکار را می‌بندد، و در نهایت بدون محدودیت زمانی منتظر می‌ماند تا اتصالات به حالت بیکار بازگردند و سپس خاموش شوند.

الگوی پیاده‌سازی این قابلیت در عمل را با کلمات نمی‌توان به‌خوبی توضیح داد، پس بیایید مستقیماً وارد کد شویم و جزئیات را در حین کار بررسی کنیم.

فایل: cmd/api/server.go
package main

import (
    "context" // New import
    "errors"  // New import
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func (app *application) serve() error {
    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", app.config.port),
        Handler:      app.routes(),
        IdleTimeout:  time.Minute,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        ErrorLog:     slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
    }

    // Create a shutdownError channel. We will use this to receive any errors returned
    // by the graceful Shutdown() function.
    shutdownError := make(chan error)

    go func() {
        // Intercept the signals, as before.
        quit := make(chan os.Signal, 1)
        signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
        s := <-quit

        // Update the log entry to read "shutting down server" instead of "caught signal".
        app.logger.Info("shutting down server", "signal", s.String())

        // Create a context with a 30-second timeout.
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()

        // Call Shutdown() on our server, passing in the context we just made.
        // Shutdown() will return nil if the graceful shutdown was successful, or an
        // error (which may happen because of a problem closing the listeners, or 
        // because the shutdown didn't complete before the 30-second context deadline is
        // hit). We relay this return value to the shutdownError channel.
        shutdownError <- srv.Shutdown(ctx)
    }()

    app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)

    // Calling Shutdown() on our server will cause ListenAndServe() to immediately 
    // return a http.ErrServerClosed error. So if we see this error, it is actually a
    // good thing and an indication that the graceful shutdown has started. So we check 
    // specifically for this, only returning the error if it is NOT http.ErrServerClosed. 
    err := srv.ListenAndServe()
    if !errors.Is(err, http.ErrServerClosed) {
        return err
    }

    // Otherwise, we wait to receive the return value from Shutdown() on the  
    // shutdownError channel. If the return value is an error, we know that there was a
    // problem with the graceful shutdown and we return the error.
    err = <-shutdownError
    if err != nil {
        return err
    }

    // At this point we know that the graceful shutdown completed successfully and we 
    // log a "stopped server" message.
    app.logger.Info("stopped server", "addr", srv.Addr)

    return nil
}

در نگاه اول این کد ممکن است کمی پیچیده به نظر برسد، اما در سطح بالا کاری که انجام می‌دهد را می‌توان بسیار ساده خلاصه کرد: وقتی سیگنال SIGINT یا SIGTERM دریافت می‌کنیم، به سرور خود دستور می‌دهیم که درخواست‌های HTTP جدید را نپذیرد و به درخواست‌های در حال پردازش یک «مهلت زمانی» ۳۰ ثانیه‌ای می‌دهیم تا قبل از خاتمه برنامه کار خود را به پایان برسانند.

مهم است بدانید که متد Shutdown() منتظر تکمیل وظایف پس‌زمینه نمی‌ماند و اتصالات طولانی‌مدت مانند WebSockets را نیز نمی‌بندد. در عوض، باید منطق خودتان را برای هماهنگ‌سازی خاموش شدن تدریجی این موارد پیاده‌سازی کنید. در ادامه کتاب به برخی تکنیک‌ها برای انجام این کار نگاه خواهیم انداخت.

اما با این اوصاف، این قابلیت اکنون باید به‌خوبی در برنامه ما کار کند.

برای کمک به نمایش قابلیت خاموش شدن تدریجی، می‌توانید یک تأخیر ۴ ثانیه‌ای به متد healthcheckHandler اضافه کنید، به این صورت:

فایل: cmd/api/healthcheck.go
package main

import (
    "net/http"
    "time" // New import
)

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    env := envelope{
        "status": "available",
        "system_info": map[string]string{
            "environment": app.config.env,
            "version":     version,
        },
    }

    // Add a 4 second delay.
    time.Sleep(4 * time.Second)

    err := app.writeJSON(w, http.StatusOK, env, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

سپس API را راه‌اندازی کنید و در یک پنجره ترمینال دیگر یک درخواست به endpoint healthcheck ارسال کنید و بلافاصله سیگنال SIGTERM را ارسال کنید.

$ curl localhost:4000/v1/healthcheck & pkill -SIGTERM api

در لاگ‌های سرور، باید بلافاصله پس از سیگنال SIGTERM یک پیام "shutting down server" مشابه این مشاهده کنید:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.722+02:00 level=INFO msg="shutting down server" signal=terminated

سپس پس از یک تأخیر ۴ ثانیه‌ای برای تکمیل درخواست در حال پردازش، healthcheckHandler ما باید پاسخ JSON را به‌صورت عادی برگرداند و باید ببینید که API ما یک پیام نهایی "stopped server" قبل از خروج صحیح ثبت کرده است:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.722+02:00 level=INFO msg="shutting down server" signal=terminated
time=2023-09-10T10:59:18.722+02:00 level=INFO msg="stopped server" addr=:4000

تأخیر ۴ ثانیه‌ای بین پیام‌های "shutting down server" و "stopped server" در زمان‌های بالا را مشاهده کردید؟

پس اکنون این قابلیت به‌خوبی کار می‌کند. هر زمان که بخواهیم برنامه خود را به‌صورت تدریجی خاموش کنیم، می‌توانیم با ارسال سیگنال SIGINT (Ctrl+C) یا SIGTERM این کار را انجام دهیم. تا زمانی که هیچ درخواست در حال پردازشی بیش از ۳۰ ثانیه طول نکشد، handler‌های ما زمان کافی برای تکمیل کار خود خواهند داشت و مشتریان ما پاسخ HTTP مناسبی دریافت خواهند کرد. و اگر هر زمان بخواهیم فوراً بدون خاموش شدن تدریجی خارج شویم، همچنان می‌توانیم با ارسال سیگنال SIGQUIT (Ctrl+\) یا SIGKILL این کار را انجام دهیم.

در نهایت، اگر در حال پیگیری هستید، لطفاً healthcheckHandler را به حالت اولیه برگردانید تا تأخیر ۴ ثانیه‌ای حذف شود. به این صورت:

فایل: cmd/api/healthcheck.go
package main

import (
    "net/http"
)

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    env := envelope{
        "status": "available",
        "system_info": map[string]string{
            "environment": app.config.env,
            "version":     version,
        },
    }

    err := app.writeJSON(w, http.StatusOK, env, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}