[Go 教學] graceful shutdown 搭配 docker-compose 實現 rolling update

線上課程:『Go 語言實戰』目前特價 $2100 TWD,優惠代碼『202003』,也可以直接匯款(價格再減 100),如果想搭配另外兩門課程合購可以透過 FB 聯絡我

golang logo

上一篇作者有提到『什麼是 graceful shutdown?』,本篇透過 docker-compose 方式來驗證 Go 語言的 graceful shutdown 是否可以正常運作。除了驗證之外,單機版 Docker 本身就可以設定 scale 容器數量,那這時候又該如何搭配 graceful shutdown 來實現 rolling update 呢?相信大家對於 rolling update 並不陌生,現在的 kubernetes 已經有實現這個功能,用簡單的指令就可以達到此需求,但是對於沒有在用 k8s 架構的開發者,可能網站也不大,那該如何透過單機本的 docker 來實現呢?底下先來看看為什麼會出現這樣的需求。

假設您有一個 App 服務,需要在單機版上面透過 docker-compose 同時啟動兩個容器,可以透過底下指令一次完成:

1
docker-compose up -d --scale app=2

其中 app 就是在 YAML 裡面的服務名稱。這時候可以看到背景就跑了兩個容器,接著要升級 App 服務,您會發現在下一次上述指令,可以看到 docker 會先把兩個容器先停止,但是容器被停止前會透過 graceful shutdown 確認背景的服務或工作需要完成結束,才可以正確停止容器並且移除,最後再啟動新的 App 容器。這時候你會發現 App 服務被終止了幾分鐘時間完全無法運作。底下來介紹該如何解決此問題,以及驗證 graceful shutdown 是否可以正常運作

教學影片

如果對於課程內容有興趣,可以參考底下課程。

graceful shutdown 範例

先簡單寫個 Go 範例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package main

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

var (
    listenAddr string
)

func main() {
    flag.StringVar(&listenAddr, "listen-addr", ":8080", "server listen address")
    flag.Parse()

    logger := log.New(os.Stdout, "http: ", log.LstdFlags)

    router := http.NewServeMux() // here you could also go with third party packages to create a router
    // Register your routes
    router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(15 * time.Second)
        w.WriteHeader(http.StatusOK)
    })

    router.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(10 * time.Second)
        w.WriteHeader(http.StatusOK)
    })

    server := &http.Server{
        Addr:         listenAddr,
        Handler:      router,
        ErrorLog:     logger,
        ReadTimeout:  30 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  30 * time.Second,
    }

    done := make(chan bool, 1)
    quit := make(chan os.Signal, 1)

    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        <-quit
        logger.Println("Server is shutting down...")

        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()

        if err := server.Shutdown(ctx); err != nil {
            logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
        }
        close(done)
    }()

    logger.Println("Server is ready to handle requests at", listenAddr)
    if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        logger.Fatalf("Could not listen on %s: %v\n", listenAddr, err)
    }

    <-done
    logger.Println("Server stopped")
}

上面程式可以知道,直接打 / 就會等待 15 秒後才能拿到回應

1
curl -v -H Host:app.docker.localhost http://127.0.0.1:8088

準備 docker 環境

準備 dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# build stage
FROM golang:alpine AS build-env
ADD . /src
RUN cd /src && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app

# final stage
FROM centurylink/ca-certs
COPY --from=build-env /src/app /

EXPOSE 8080

ENTRYPOINT ["/app"]

準備 docker-compose.yml,使用 Traefik v2 版本來做 Load balancer。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
version: '3'

services:
  app:
    image: go-training/app
    restart: always
    logging:
      options:
        max-size: "100k"
        max-file: "3"
    labels:
      - "traefik.http.routers.app.rule=Host(`app.docker.localhost`)"

  reverse-proxy:
    # The official v2.0 Traefik docker image
    image: traefik:v2.0
    # Enables the web UI and tells Traefik to listen to docker
    command: --api.insecure=true --providers.docker
    ports:
      # The HTTP port

      - "8088:80"
      # The Web UI (enabled by --api.insecure=true)
      - "8080:8080"
    volumes:
      # So that Traefik can listen to the Docker events
      - /var/run/docker.sock:/var/run/docker.sock

可以看到 8088 port 會是入口,app.docker.localhost 會是 app 網域名稱。

驗證 graceful shutdown

啟動全部服務,App 及 Traefik 都有被正式啟動

1
docker-compose up -d --scale app=2

接下來先修改原本的 Go 範例,在編譯一次把 Image 先產生好。另外開兩個 console 頁面直接下

1
curl -v -H Host:app.docker.localhost http://127.0.0.1:8088

會發現 curl 會等待 15 秒才能拿到回應,這時候直接下

1
docker-compose up -d --scale app=2

就可以看到

1
2
3
4
app_2   | http: 2020/02/08 14:06:20 Server is shutting down... 
app_2   | http: 2020/02/08 14:06:20 Server stopped
app_1   | http: 2020/02/08 14:06:20 Server is shutting down...
app_1   | http: 2020/02/08 14:06:20 Server stopped

這代表 graceful shutdown 可以正常運作,確保 app 連線及後續處理的動作可以正常被執行。

用 docker-compose 執行 rolling update

從上面可以看到,當執行了

1
docker-compose up -d --scale app=2

docker 會把目前的容器都全部停止,假設這時候都有重要的工作需要繼續執行,但是 graceful shutdown 已經將連接埠停止,造成使用者已經無法連線,這問題該如何解決呢?其實不難,只需要修正幾個指令就可以做到。由於 docker-compose up -d 會先將所有容器先停止,造成無法連線,這時候需要使用 --no-recreate flag 來避免這問題

1
docker-compose up -d --scale app=3 --no-recreate

將數量 + 1 的意思就是先啟動一個新的容器用來接受新的連線,接著將舊的容器移除:

1
2
3
4
5
6
docker stop -t 30 \
  $(docker ps --format "table {{.ID}} {{.Names}} {{.CreatedAt}}" | \
  grep app | \
  awk -F  " " '{print $1 " " $3 "T" $4}' |\
  sort -k2 | \
  awk -F  "  " '{print $1}' | head -2)

其中 -t 30 一定要設定,預設會是 10 秒相當短,也就是 10 秒容器沒結束就自動 kill 了,後面的 head -2 代表移除舊的容器,原本是開兩台,就需要停止兩台。接著將已經停止的容器砍掉:

1
docker container prune -f

現在正在執行的容器只剩下一台,故還需要透過 scale 將不足的容器補上:

1
docker-compose up -d --scale app=2 --no-recreate

完成上述步驟後,就可以確保服務不會中斷。如果有更好的解法歡迎大家提供。


See also