用 docker-compose 優雅關閉服務

logo

大家應該遇過如果服務還有工作還沒處理完,服務要進行更新,需要等到全部工作處理完成才可以將服務的停止,而當服務收到關閉通知信號時,第一要先停止接受 Job 任務,接著等待 Worker 將手上 Job 處理完畢後,才停止服務,接著更新再上線。而這狀況怎麼透過 docker-compose 來處理停止服務,這就是本篇的重點。文章內會用 Go 語言當教學範例,如何接受 Docker 傳來的 Signal 訊號,接受訊號後該如何處理,及如何設定 docker-compose 的 YAML 檔案確保所有的工作都可以正常執行完畢。

之前已經有寫過幾篇關於 Graceful Shutdown 教學文章,大家有興趣可以先閱讀底下教學連結資訊,而本篇最主要是紀錄在如何用 docker 指令優雅關閉容器服務,尤其是關閉服務前,可以讓原本服務內的工作可以正常做完,才正式關閉。在本文開始前,先將 dockerdocker-compose 版本資訊貼出來,避免有資訊的落差,畢竟 docker-compose 在不同版本之間有不同的設定方式。

影片視頻

之後會把影片視頻放到底下 Udemy 課程內

環境資訊

目前跑在 Mac M1 晶片系統,底下是 docker 版本:

 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
$ docker version
Client:
 Cloud integration: 1.0.17
 Version:           20.10.7
 API version:       1.41
 Go version:        go1.16.4
 Git commit:        f0df350
 Built:             Wed Jun  2 11:56:23 2021
 OS/Arch:           darwin/arm64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.7
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       b0f5bc3
  Built:            Wed Jun  2 11:55:36 2021
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.4.6
  GitCommit:        d71fcd7d8303cbf684402823e425e9dd2e99285d
 runc:
  Version:          1.0.0-rc95
  GitCommit:        b9ee9c6314599f1b4a7f497e1f1f856fe433d3b7
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

接著是 docker-compose 版本資訊

1
2
3
4
5
$ docker-compose version
docker-compose version 1.29.2, build 5becea4c
docker-py version: 5.0.0
CPython version: 3.9.0
OpenSSL version: OpenSSL 1.1.1h  22 Sep 2020

程式碼範例

先準備好實際應用範例,我們可以跑一個服務專門執行一些需要時間很久的工作,當我們需要更新服務時,就需要讓服務不再接受任何工作,等待的是把原本還在跑的工作做完,才停止服務。底下是 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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package main

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

func withContextFunc(ctx context.Context, f func()) context.Context {
  ctx, cancel := context.WithCancel(ctx)
  go func() {
    c := make(chan os.Signal)
    // register for interupt (Ctrl+C) and SIGTERM (docker)
    signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL)
    defer signal.Stop(c)

    select {
    case <-ctx.Done():
    case <-c:
      f()
      cancel()
    }
  }()

  return ctx
}

func main() {
  jobChan := make(chan int, 100)
  stopped := make(chan struct{})
  finished := make(chan struct{})
  wg := &sync.WaitGroup{}
  ctx := withContextFunc(
    context.Background(),
    func() {
      log.Println("stop the server")
      close(stopped)
      wg.Wait()
      close(finished)
    },
  )

  // create 4 workers to process job
  for i := 0; i < 4; i++ {
    go func(i int) {
      log.Printf("start worker: %02d", i)
      for {
        select {
        case <-finished:
          log.Printf("stop worker: %02d", i)
          return
        default:
          select {
          case job := <-jobChan:
            time.Sleep(time.Duration(job*100) * time.Millisecond)
            log.Printf("worker: %02d, process job: %02d", i, job)
            wg.Done()
          default:
            log.Printf("worker: %02d, no job", i)
            time.Sleep(1 * time.Second)
          }
        }
      }
    }(i + 1)
  }

  // send job
  go func() {
    for i := 0; i < 50; i++ {
      wg.Add(1)
      select {
      case jobChan <- i:
        time.Sleep(100 * time.Millisecond)
        log.Printf("send the job: %02d\n", i)
      case <-stopped:
        wg.Done()
        log.Println("stoped send the job")
        return
      }
    }
    return
  }()

  select {
  case <-ctx.Done():
    time.Sleep(1 * time.Second)
    log.Println("server down")
  }
}

上面例子可以看到,先建立四個 worker 用來接受工作執行內容,另外最後的 Goroutine 用來產生工作。另外給兩個 channel 用來停止 worker 及停止產生 Job。當程式正在進行時,直接按下 ctrl + c 會觸發關閉 stopped,這時候就會停止送 Job 進去 jobChan 內,而等到四個 worker 把剩下的工作都執行結束後,就會關閉 finished,這時會把四個 worker 正式停止。接著來看看,用 docker-compose 指令來停止服務。

使用 docker-compose 指令

要將服務重啟,可以先透過 docker-compose stop 來關閉服務,如果服務內沒有去處理 Signal 的話,此服務就會被直接停止,那正在跑的 Job 就會直接被砍掉,這行為顯然不是大家想要的,所以在寫程式務必處理 Signal 信號,而當執行 docker-compose stop 後,docker 會發送 SIGTERM 信號到容器內 (容器內 root process PID 為 1),服務接到此信號後,可以做後續的處理,但是你會發現 10 秒後,docker 又會送出另一個訊號 SIGKILL 去強制關閉服務,要解決這問題其實很簡單,通常會大概知道每個工作需要花費多少時間,而四個 worker 跑全部跑滿工作,最後會花費多少時間,這時候可以加上 -t 來決定幾秒後發送 SIGKILL 信號。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ docker-compose stop -h
Stop running containers without removing them.

They can be started again with `docker-compose start`.

Usage: stop [options] [--] [SERVICE...]

Options:
  -t, --timeout TIMEOUT      Specify a shutdown timeout in seconds.
                             (default: 10)

像是執行底下

1
docker-compose stop -t 600 app

docker-compose 設定

由於 docker-compose stop 預設會優先發送 SIGTERM 信號,如果想用其他信號來取代的話,可以直接在 docker-compose.yml 增加 stop_signal 來決定新的信號,除了此之外,也可以設定 stop_grace_period 來決定多少時間後 docker 才發送 SIGKILL,預設會是 10 秒,都可以透過上述方式調整

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
version: "3.9"

services:
  app:
    image: app:0.0.1
    build:
      context: .
      dockerfile: Dockerfile
    restart: always
    stop_signal: SIGINT
    stop_grace_period: 30s
    logging:
      options:
        max-size: "100k"
        max-file: "3"

詳細說明可以參考 stop_signalstop_grace_period。完成這兩項設定後,也可以正常使用 docker-compose up -d 來重新啟動容器服務。

docker 信號處理

從上面可以知道,每個服務都需要處理 docker 傳來的信號,而在寫 dockerfile 該注意哪些事情?底下拿 Go 語言搭配 Dockerfile 來當例子:

1
2
3
4
5
6
7
8
FROM golang:1.16-alpine

COPY main.go /app/
COPY go.mod /app/

WORKDIR "/app"

CMD ["go", "run", "main.go"]

接著編譯在執行

1
2
docker-compose build
docker-compose up app

啟動完成後,你會發現當下 stop 指令後,容器內完全收不到此信號,這時候直接進到容器裡面查看,透過 ps 指令

1
2
3
4
5
6
/app # ps
PID   USER     TIME  COMMAND
    1 root      0:00 go run main.go
   68 root      0:03 /tmp/go-build4218998070/b001/exe/main
   78 root      0:00 /bin/sh
   84 root      0:00 ps

會發現送 SIGTERM 信號到 PID 1,而真正執行的 Process ID 為 68,所以一直會收不到信號的原因就在這邊。這裡解決方式也很簡單,就是不要透過 go run 方式來執行,而是要先 build 成執行檔後在使用。

1
2
3
4
5
6
7
8
9
FROM golang:1.16-alpine

COPY main.go /app/
COPY go.mod /app/
RUN go build -o /app/main /app/main.go

WORKDIR "/app"

CMD ["/app/main"]

另外如果在 CMDENTRYPOINT 請用 ["program", "arg1", "arg2"] 方式,而不是 program arg1 arg2,後者對於 docker 來說會再包一層 bash 在前面,但是 bash 基本上沒有處理 Signal 訊號,這樣也會造成無法正常關閉服務。

如果想要從 bash 處理 Signal 訊號,可以參考此篇文章『Trapping signals in Docker containers』。請看看底下官網 docker-compose 範例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
simple:
  image: busybox:1.31.0-uclibc
  command:
    - sh
    - '-c'
    - |
        trap 'exit 0' SIGINT
        trap 'exit 1' SIGTERM
        while true; do :; done        
  stop_signal: SIGINT

後記心得

除了 Docker 信號處理之外,還需要透過 docker-compose up --scale 方式來完成服務擴展。如果服務是需要處理大量工作,工作時間長久,就需要透過此方式來更新服務,不然工作突然被中斷,要怎麼恢復工作又是另一個議題需要解決。

參考文章


See also