上一篇作者有提到『什麼是 graceful shutdown?』,本篇透過 docker-compose 方式來驗證 Go 語言的 graceful shutdown 是否可以正常運作。除了驗證之外,單機版 Docker 本身就可以設定 scale 容器數量,那這時候又該如何搭配 graceful shutdown 來實現 rolling update 呢?相信大家對於 rolling update 並不陌生,現在的 kubernetes 已經有實現這個功能,用簡單的指令就可以達到此需求,但是對於沒有在用 k8s 架構的開發者,可能網站也不大,那該如何透過單機本的 docker 來實現呢?底下先來看看為什麼會出現這樣的需求。
假設您有一個 App 服務,需要在單機版上面透過 docker-compose 同時啟動兩個容器,可以透過底下指令一次完成:
docker-compose up -d --scale app=2
其中 app
就是在 YAML 裡面的服務名稱。這時候可以看到背景就跑了兩個容器,接著要升級 App 服務,您會發現在下一次上述指令,可以看到 docker 會先把兩個容器先停止,但是容器被停止前會透過 graceful shutdown 確認背景的服務或工作需要完成結束,才可以正確停止容器並且移除,最後再啟動新的 App 容器。這時候你會發現 App 服務被終止了幾分鐘時間完全無法運作。底下來介紹該如何解決此問題,以及驗證 graceful shutdown 是否可以正常運作
教學影片
如果對於課程內容有興趣,可以參考底下課程。
graceful shutdown 範例
先簡單寫個 Go 範例:
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 秒後才能拿到回應
curl -v -H Host:app.docker.localhost http://127.0.0.1:8088
準備 docker 環境
準備 dockerfile
# 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。
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 都有被正式啟動
docker-compose up -d --scale app=2
接下來先修改原本的 Go 範例,在編譯一次把 Image 先產生好。另外開兩個 console 頁面直接下
curl -v -H Host:app.docker.localhost http://127.0.0.1:8088
會發現 curl 會等待 15 秒才能拿到回應,這時候直接下
docker-compose up -d --scale app=2
就可以看到
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
從上面可以看到,當執行了
docker-compose up -d --scale app=2
docker 會把目前的容器都全部停止,假設這時候都有重要的工作需要繼續執行,但是 graceful shutdown 已經將連接埠停止,造成使用者已經無法連線,這問題該如何解決呢?其實不難,只需要修正幾個指令就可以做到。由於 docker-compose up -d
會先將所有容器先停止,造成無法連線,這時候需要使用 --no-recreate
flag 來避免這問題
docker-compose up -d --scale app=3 --no-recreate
將數量 + 1 的意思就是先啟動一個新的容器用來接受新的連線,接著將舊的容器移除:
docker stop -t 30 \
$(docker ps --format "table {{.ID}} {{.Names}} {{.CreatedAt}}" | \
grep app | \
sort -k2 | \
awk -F " " '{print $1}' | head -2)
其中 -t 30
一定要設定,預設會是 10 秒相當短,也就是 10 秒容器沒結束就自動 kill 了,後面的 head -2
代表移除舊的容器,原本是開兩台,就需要停止兩台。接著將已經停止的容器砍掉:
docker container prune -f
現在正在執行的容器只剩下一台,故還需要透過 scale 將不足的容器補上:
docker-compose up -d --scale app=2 --no-recreate
完成上述步驟後,就可以確保服務不會中斷。如果有更好的解法歡迎大家提供。