[GKE] Deploymentのreplicasを0にしてGoアプリにOSシグナル SIGTERM を通知


お題

表題の通り。
実際の動きとしてそうなることを確認したかっただけ。

前提

  • GCP環境は用意済み。
  • GCPローカル設定済み。(gcloudコマンドが使用できる状態になっている。)
  • kubectlコマンドが使用できる状態になっている。
  • GKEクラスタ作成済み。

開発環境

# OS - Linux(Ubuntu)

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"

# gcloud

$ gcloud version
Google Cloud SDK 312.0.0

# kubectl

$ kubectl version
Client Version: version.Info{Major:"1", Minor:"17", GitVersion:"v1.17.9", GitCommit:"4fb7ed12476d57b8437ada90b4f93b17ffaeed99", GitTreeState:"clean", BuildDate:"2020-07-15T16:18:16Z", GoVersion:"go1.13.9", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"17+", GitVersion:"v1.17.12-gke.2502", GitCommit:"974eff7a63e05b7eb05c9aded92fae8a3ce14521", GitTreeState:"clean", BuildDate:"2020-10-19T17:01:32Z", GoVersion:"go1.13.15b4", Compiler:"gc", Platform:"linux/amd64"}

# バックエンド

# 言語 - Golang

$ go version
go version go1.15.2 linux/amd64

実践

ソース一式

ソース

Golang

適当にWebサーバを立てておいて、OSシグナル(SIGTERM)を受信したらログ(GOT_NOTIFY)を吐く。
deferでもログを仕込んでおいて、OSシグナル受信時に、deferのログは出ないことも確認する。

main.go
package main

import (
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    fmt.Println("APP_START")
    defer fmt.Println("DEFER")

    // OSシグナル(SIGTERM)の受信を待ち受ける Goroutine
    go func() {
        fmt.Println("BEFORE_NOTIFY")
        q := make(chan os.Signal, 1)
        signal.Notify(q, syscall.SIGTERM)
        <-q
        fmt.Println("GOT_NOTIFY")

        os.Exit(-1)
    }()

    // 適当にHTTPサーバーを立ち上げておく
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if _, err := fmt.Fprint(w, "Hello"); err != nil {
            fmt.Printf("HANDLE_ERROR_OCCURRED: %+v", err)
        }
    })
    if err := http.ListenAndServe(":8080", nil); err != nil {
        fmt.Printf("SERVE_ERROR_OCCURRED: %+v", err)
    }

    fmt.Println("APP_END")
}

Dockerfile

何の変哲もないマルチステージビルドなDockerfile。

FROM golang:1.15 as builder
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -mod=readonly -v -o server

FROM gcr.io/distroless/base
COPY --from=builder /app/server /server
CMD ["/server"]

Cloud Build設定

DockerイメージはContainer Registryを使う。

cloudbuild.yaml
steps:
  - name: 'gcr.io/cloud-builders/docker'
    args: [ 'build', '-t', 'gcr.io/$PROJECT_ID/golang-app-try01', '.' ]
images:
  - 'gcr.io/$PROJECT_ID/golang-app-try01'

上記を使ってビルドする用のシェルは下記。

build.sh
#!/usr/bin/env bash
set -euox pipefail
SCRIPT_DIR=$(dirname "$0")
cd "${SCRIPT_DIR}"

gcloud builds submit --config cloudbuild.yaml .

デプロイ設定

Container RegistryからDockerイメージを取得する。
Podは3つ。
コンテナポートは8080(別に今回は使わないけど)。

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: golang-app-try01
spec:
  replicas: 3
  selector:
    matchLabels:
      app: golang-app-try01
  template:
    metadata:
      labels:
        app: golang-app-try01
    spec:
      containers:
        - name: golang-app-try01
          image: gcr.io/MY_GCP_PROJECT_ID/golang-app-try01
          ports:
            - containerPort: 8080

上記を使ってデプロイするシェルは下記。
自分が使っているGCPプロジェクトのIDが必要で、それ自体はローカル環境でgcloudコマンドから拾えるのだけど、
k8sのYamlに直接書かずにGCPプロジェクトIDを指定する方法(※ConfigMapやSecret経由ならできるのかもだけど、出来れば手軽に)を調べるのが面倒だったので、sed で書き換え。

deploy.sh
#!/usr/bin/env bash
set -euox pipefail
SCRIPT_DIR=$(dirname "$0")
cd "${SCRIPT_DIR}"

project=$(gcloud config get-value project)
if [[ -z "${project}" ]]; then
  echo -n "need project"
  exit 1
fi
echo "${project}"

sed -i -e "s/MY_GCP_PROJECT_ID/${project}/" deployment.yaml

kubectl apply -f deployment.yaml

sed -i -e "s/${project}/MY_GCP_PROJECT_ID/" deployment.yaml

Pod数を書き換えるためのシェル

replica_n.sh
#!/usr/bin/env bash
set -euox pipefail
SCRIPT_DIR=$(dirname "$0")
cd "${SCRIPT_DIR}"

num=${1:-}

if [ -z "${num}" ]; then
  echo -n "input replicas number: "
  read num
fi

kubectl scale deployment golang-app-try01 --replicas="${num}"

動作確認

アプリのビルド(Dockerイメージを作成してContainer Registryに格納)

$ ./build.sh 
++ dirname ./build.sh
+ SCRIPT_DIR=.
+ echo .
.
+ cd .
+ gcloud builds submit --config cloudbuild.yaml .
Creating temporary tarball archive of 6 file(s) totalling 1.7 KiB before compression.
 ・
 ・
 ・
DONE
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

ID                                    CREATE_TIME                DURATION  SOURCE                                                                                   IMAGES                                        STATUS
6452c516-cfbf-4497-b536-378023cbc34d  2020-11-03T19:29:14+00:00  29S       gs://XXXXXXXX_cloudbuild/source/1604431752.38075-ccb069fbb0d0413382dc79d42e5c618a.tgz  gcr.io/XXXXXXXX/golang-app-try01 (+1 more)  SUCCESS

GKEにデプロイ

$ ./deploy.sh 
++ dirname ./deploy.sh
+ SCRIPT_DIR=.
+ echo .
.
+ cd .
 ・
 ・
 ・
+ kubectl apply -f deployment.yaml
deployment.apps/golang-app-try01 created
 ・
 ・
 ・

Podが3つ。

$ kubectl get deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
golang-app-try01   3/3     3            3           4m19s

この時点でコンテナログを見ると、3つのPodそれぞれで、アプリ起動時とOSシグナル待受開始のログが出ていることがわかる。

Pod数を0に変更

$ ./replica_n.sh 0
++ dirname ./replica_n.sh
+ SCRIPT_DIR=.
+ echo .
.
+ cd .
+ num=0
+ '[' -z 0 ']'
+ kubectl scale deployment golang-app-try01 --replicas=0
deployment.apps/golang-app-try01 scaled

OSシグナル受信時のログ(GOT_NOTIFY)がそれぞれのPodのログとして出た。
deferで仕込んでいた方のログ(DEFER)は出ない。

まとめ

GKEに載せるなら、アプリ停止時に確実に処理させたい内容は defer でなく、OSシグナル(SIGTERM)受信用の Goroutine を別途立てて対応。