Skip to main content

Graceful Shutdown

We are conducting AI Engineer Bootcamp, a 3-month program to help you become an AI Engineer. If you are interested, please check here. The registration for the next batch will be closed soon.

Problem

Pod/VM shutdown adalah proses yang tidak bisa dihindari. Penyebabnya macam-macam, misal:

  • Dikill oleh engineer (Ctrl+C)
  • Scale down (due to Autoscaling)
  • Rolling update
  • System maintenance
  • Preemptible VM
  • Resource yang tidak cukup
  • Machine crash/hardware failure

Failure adalah hal yang tidak bisa dihindari, tapi kita bisa mengurangi dampaknya. Salah satu cara adalah dengan melakukan graceful shutdown.

Scenario

Client

package main

import (
"fmt"
"log"
"net/http"
)

func main() {

sender := "bob"
receiver := "alice"

url := fmt.Sprintf("http://localhost:8080/?from=%s&to=%s", sender, receiver)

resp, err := http.Get(url)
if err != nil {
log.Fatalln(err)
}
log.Println(resp.Status)
}

Server

package main

import (
"log"
"net/http"
"os"
"time"
)

var logger *log.Logger

func sendMoney(w http.ResponseWriter, r *http.Request) {
// ?from=alice&to=bob
sender := r.URL.Query().Get("from")
receiver := r.URL.Query().Get("to")

logger.Printf("Received sending money request from %s to %s\n", sender, receiver)

logger.Printf("Deducting money from %s\n", sender)

// sleep for 30 seconds, assume long processing here
time.Sleep(30 * time.Second)

logger.Printf("Adding money to %s\n", receiver)
}

func main() {
logger = log.New(os.Stdout, "server: ", log.LstdFlags)
http.HandleFunc("/", sendMoney)
logger.Println("Starting server on port 8080")
http.ListenAndServe(":8080", nil)
}

30 seconds timeout di situ adalah simulasi dari proses yang memakan waktu lama. Misal asumsi proses transfer uang perlu menembak third party API yang bisa memakan waktu lama.

Apa masalah dari code di atas? Masalah muncul jika server dimatikan ketika sleep sedang berjalan. "Adding money" tidak akan pernah terjadi, walaupun "Deducting money" sudah terjadi. Uang akan hilang!

Graceful shutdown adalah salah satu (walau belum complete) cara untuk mengatasi masalah di atas.

Graceful Shutdown

Ide dari Graceful Shutdown adalah memberikan waktu kepada server untuk menyelesaikan proses yang sedang berjalan dengan baik-baik (graceful) sebelum server benar-benar dimatikan (shutdown)

Caranya sebenarnya relatif simple, program bisa menangkap signal dari OS (SIGINT, SIGTERM) kemudian mematikan server baik-baik

package main

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

var logger *log.Logger

func sendMoney(w http.ResponseWriter, r *http.Request) {
// ?from=alice&to=bob
sender := r.URL.Query().Get("from")
receiver := r.URL.Query().Get("to")

logger.Printf("Received sending money request from %s to %s\n", sender, receiver)

logger.Printf("Deducting money from %s\n", sender)

// sleep for 30 seconds, assume long processing here
time.Sleep(30 * time.Second)

logger.Printf("Adding money to %s\n", receiver)
}

func main() {
// ...
logger = log.New(os.Stdout, "server: ", log.LstdFlags)
http.HandleFunc("/", sendMoney)
logger.Println("Starting server on port 8080")

server := &http.Server{Addr: ":8080"}

// Create a channel to listen for OS signals
stop := make(chan os.Signal, 1)

// Catch SIGINT (Ctrl+C) and SIGTERM (Kubernetes pod shutdown etc.)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

go func() {
// Start the server
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Printf("Error starting server: %v\n", err)
os.Exit(1)
}
}()

// Wait for the OS signal to stop the server
<-stop

logger.Println("OS signal received")

// Create a context with a timeout of 40 seconds
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()

logger.Println("Shutting down server...")

// Shutdown the server gracefully
if err := server.Shutdown(ctx); err != nil {
logger.Printf("Error shutting down server: %v\n", err)
os.Exit(1)
}

logger.Println("Server gracefully stopped")
}
  • <-stop akan blocking hingga menerima signal SIGINT dan SIGTERM.
  • server.Shutdown akan membuat server tidak menerima request baru, request baru yang datang akan mendapatkan response Connection Refused
  • Nah, di sini kita memberikan timeout 40 detik untuk menyelesaikan proses yang sedang berjalan.
  • Jika prosesnya belum selesai setelah 40 detik tsb, server baru akan dimatikan paksa. (kita tidak perlu matikan proses anyway)

Deterministic: WaitGroup

Apa yang bisa diimprove dari cara di atas?

Kita berasumsi dengan waktu. Kenapa 40 detik? Kenapa tidak 30 detik? Kenapa tidak 50 detik? Kenapa tidak 1 menit?

Tadi kita pilih 40 detik karena heuristic saja. sleep terjadi selama 30 detik, ok lah kalau kita beri waktu 40 detik. sleep pasti sudah selesai

Namun pada kasus real, kita tidak bisa asumsikan waktu. Kita tidak tahu berapa lama proses akan selesai. Bagaimana kalau API call memakan lebih dari 40 detik? Server keburu dimatikan, proses tidak selesai, uang hilang.

Ada cara yang lebih deterministic, yaitu dengan menggunakan sync.WaitGroup

wg.Wait() akan menunggu hingga semua wg.Done() dipanggil. Dengan cara ini, jika ada 100 goroutine sendMoney yang sedang berjalan, kita akan menunggu hingga semua goroutine tersebut selesai.

package main

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

var wg sync.WaitGroup
var logger *log.Logger

func sendMoney(w http.ResponseWriter, r *http.Request) {
wg.Add(1)
defer wg.Done()

// ...
// ?from=alice&to=bob
sender := r.URL.Query().Get("from")
receiver := r.URL.Query().Get("to")

logger.Printf("Received sending money request from %s to %s\n", sender, receiver)

logger.Printf("Deducting money from %s\n", sender)

// sleep for 30 seconds, assume long processing here
time.Sleep(30 * time.Second)

logger.Printf("Adding money to %s\n", receiver)
}

func main() {
// ...
logger = log.New(os.Stdout, "server: ", log.LstdFlags)
http.HandleFunc("/", sendMoney)
logger.Println("Starting server on port 8080")

server := &http.Server{Addr: ":8080"}

// Create a channel to listen for OS signals
stop := make(chan os.Signal, 1)

// Catch SIGINT (Ctrl+C) and SIGTERM (Kubernetes pod shutdown etc.)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

go func() {
// Start the server
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Printf("Error starting server: %v\n", err)
os.Exit(1)
}
}()

// Wait for the OS signal to stop the server
<-stop
logger.Println("OS signal received")

// Create a context with a timeout of 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()

logger.Println("Shutting down server...")
// Shutdown the server gracefully
if err := server.Shutdown(ctx); err != nil {
logger.Printf("Error shutting down server: %v\n", err)
os.Exit(1)
}

// Wait for all pending requests to finish
wg.Wait()

logger.Println("Server gracefully stopped")
}

Cancel

Hmm, masih ada masalah. Bagaimana kalau proses sendMoney ternyata memakan waktu cepat? Kenapa kita bahkan perlu menunggu 40 detik? Bisa kah kita terminate earlier?

Kita bisa buat shutdown handling yang lebih baik dengan menggunakan context.Context dan context.WithCancel. Idenya adalah, jika server menerima signal shutdown, kita akan cancel context yang sedang berjalan. Dengan cara ini, proses yang sedang berjalan akan mendapatkan sinyal untuk berhenti.

Karena proses terminated earlier (tidak dibiarkan selesai), maka perlu ada handling untuk menangani kondisi ini. (misal, rollback transaksi yang sudah terlanjur dilakukan)

package main

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

var wg sync.WaitGroup
var logger *log.Logger

func sendMoney(ctx context.Context, w http.ResponseWriter, r *http.Request) {
wg.Add(1)
defer wg.Done()

// ?from=alice&to=bob
sender := r.URL.Query().Get("from")
receiver := r.URL.Query().Get("to")

logger.Printf("Received sending money request from %s to %s\n", sender, receiver)

logger.Printf("Deducting money from %s\n", sender)

select {
case <-ctx.Done():
// Handle early termination
logger.Printf("Refund money to %s\n", sender)
return
case <-time.After(30 * time.Second):
// sleep for 30 seconds, assume long processing here
}

logger.Printf("Adding money to %s\n", receiver)
}

func main() {
// ...
logger = log.New(os.Stdout, "server: ", log.LstdFlags)
logger.Println("Starting server on port 8080")

server := &http.Server{Addr: ":8080"}

// Create a channel to listen for OS signals
stop := make(chan os.Signal, 1)

// Catch SIGINT (Ctrl+C) and SIGTERM (Kubernetes pod shutdown etc.)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)


ctx, cancel := context.WithCancel(context.Background())
defer cancel()

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
sendMoney(ctx, w, r)
})
// ...

go func() {
// Start the server
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Printf("Error starting server: %v\n", err)
os.Exit(1)
}
}()


// Wait for the OS signal to stop the server
<-stop
logger.Println("OS signal received")

logger.Println("Shutting down server...")

// Cancel the context to signal the sendMoney function to stop
cancel()

// Create a shutdown context with timeout
// Hard deadline to force shutdown after 30 seconds
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()

// Shutdown the server gracefully
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Printf("Error shutting down server: %v\n", err)
os.Exit(1)
}

// Wait for all pending requests to finish
wg.Wait()

logger.Println("Server gracefully stopped")
}

Next Problems

  • Bagaimana jika ada proses yang tidak bisa di-cancel? Misal proses yang memanggil third party API yang tidak support cancel
  • Bagaimana jika shutdown terjadi ketika proses rollback?
  • Bagaimana handle kondisi yang tidak bisa dihandle oleh graceful shutdown?