コンテナで動くGoのバイナリを安全に葬りたい

今年の11月からクラウドサインで主にバックエンドをやっている@enkdsnです。

この記事は弁護士ドットコム Advent Calendar 2020、13日目の記事です。 昨日は同じチームの@michimaniさんでした。これを読んではてなブログから移行したい。

qiita.com

はじめに

 クラウドサインでは現在、サービス基盤のコンテナ化とECSへの移行を進めています。 そんな中、ECSのタスク更新時に起きていることを確認すると、シグナルハンドリングやったほうがいいなという気付きがありました。

Amazon Elastic Container Service 開発者ガイド サービスの更新 より引用

更新中にサービススケジューラがタスクを置き換えるとき、サービスはまずロードバランサーからタスクを削除し (使用されている場合)、接続のドレインが完了するのを待ちます。その後、タスクで実行されているコンテナに docker stop と同等のコマンドが発行されます。この結果、SIGTERM 信号と 30 秒のタイムアウトが発生し、その後に SIGKILL が送信され、コンテナが強制的に停止されます。コンテナが SIGTERM 信号を正常に処理し、その受信時から 30 秒以内に終了する場合、SIGKILL 信号は送信されません。サービススケジューラは、最小ヘルス率と最大ヘルス率の設定で定義されているとおりに、タスクを開始および停止します。

  • ポイント
    • docker stop と同等のコマンドが発行されるので、SIGTERMが発行される。*1
    • SIGTERM信号の後、30秒しか猶予がない!怖い!
    • 30秒たったらSIGKILL。無力。*2

 これはECSで動かす場合に限らず、コンテナの上でアプリケーションを動かす場合は等しく意識すべきかと思います。ですので今回は、docker stopした際にGoのバイナリがいろいろ終了処理をやったあと正常に終了できるように、SIGTERMを拾って終了処理ができるよう実装していきたいと思います。

前提:30秒の猶予でやりたいこと

 実装に入る前に、まず終了処理で何をするべきかという部分について前提を置きます。 コンテナが途中で落ちた場合、処理中のデータのロスト、データ不整合の発生が懸念されます。これを防ぐために、Graceful Shutdownという仕組みがあります。HTTPサーバであれば、新規のリクエストの受付は止めて、すでに受け付けているリクエストはレスポンスを返しきり、そのあとにサーバを終了するといった具合です。これが終了処理が適切にされないままだと、ユーザのリクエスト情報が途中でロストして、データ不整合を起こす可能性があります。

 ですから、終了処理においては「処理中のデータをロストしないようにする」という目的を達成する必要があります。では、それを達成するために具体的に何をするべきでしょうか。仮にGoのバイナリを動かすようなシチュエーションの場合、以下のような終了処理が考えられるかと思います。

contextのキャンセルとトランザクションの掃除

 SIGTERMを受け取った段階で動いているgoroutineを止めに行きます(contextを伝搬させているもの)。データ不整合などを防ぐためにcontextがキャンセルされたらロールバックを行うような実装をしている場合は、contextのキャンセル通知→キャンセル処理が完了するまで待機とすることで、サービス層の一連の処理を安全に止めることができます。*3

 ここで、さらに具体的なケースを考えます。ある処理がトランザクションの内部にいる際にSIGTERMが発行された場合を考えましょう。contextが伝搬された処理のうち、例えば、sqlパッケージのQueryContext()は、内部でgrabConn()というメソッドを呼んでいます。

func (tx *Tx) grabConn(ctx context.Context) (*driverConn, releaseConn, error) {
    select {
    default:
    case <-ctx.Done():
        return nil, nil, ctx.Err()
    }

 ここでは伝搬されてきたcontextがキャンセルされていたら空のコネクションを返し、ctx.Err()を返しています。これで、クエリ発行前にコンテキストのキャンセルがあった場合は、コネクションの獲得を途中でキャンセルできます。 また、トランザクションの内部に入ってコミット手前という状況も考えます。

func (tx *Tx) Commit() error {
    // Check context first to avoid transaction leak.
    // If put it behind tx.done CompareAndSwap statement, we can't ensure
    // the consistency between tx.done and the real COMMIT operation.
    select {
    default:
    case <-tx.ctx.Done():
        if atomic.LoadInt32(&tx.done) == 1 {
            return ErrTxDone
        }
        return tx.ctx.Err()
    }

 こちらもgrabConnと同様に、contextがキャンセルされていたらエラーを返すようにしています。終了処理としてコンテキストのキャンセルを適切に行うことで、トランザクションの掃除ができます。

DB接続の切断

 一通りcontextのキャンセル処理がなされるとDBのコネクションの解放がされているはずです*4トランザクションが一通り処理され、さらに新しいクエリも受け付けない状態にします。

サーバーのGraceful Shutdown

 サーバーのGraceful Shutdownを行い、リクエストとレスポンスの掃除をします。Go1.8からhttpパッケージにGraceful Shutdownの機能が入りましたので、リクエストの終了からコンテキストの掃除まで標準パッケージでできます。Shutdownを起動すれば、Listnerを閉じ、指定したインターバルだけcontextの処理の終了を待つので、上述したcontextのキャンセルよりも綺麗です。

golang.org


 終了処理の例はこれだけでは無いと思いますが、Web界隈でよくあるバッチやサーバーにおける終了処理はこれらが該当するでしょう。

実装

 30秒の猶予で何をしたいか整理したので、ここからはシグナルを受け取る実装をしていきます。ただし、終了処理の内部については詳細まで実装していないので悪しからず。

実装1:signal受け取りの簡単な例

 まず簡単なサンプルコードからです。雰囲気を手元で確認する場合は割り込みの方が確認しやすいいので、SIGINTを受け取ります。SIGINTを使う実装はCLIツールを割り込みされても正しく終了する時に使えます。(この段階ではWaitGroupが機能してないですが、後の実装でWaitする実装が入るので前もって入れています)

package main

import (
    "fmt"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

func main() {

    // 必ず、バッファ付きチャネルを使用する。
    sigChan := make(chan os.Signal, 1)
    defer close(sigChan)
    signal.Notify(sigChan, syscall.SIGINT)

    wg := sync.WaitGroup{}
    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            time.Sleep(1 * time.Second)
            fmt.Println("app running")
        }
    }()

    s := <-sigChan // sigChanからsignalを受け取る
    switch s {
    case syscall.SIGINT:
        fmt.Println("!!! SIGINT detected !!!!")
        OnAppStop()
        return
    default:
        fmt.Println("unexpected signal")
    }
}

// OnAppStop は、アプリの終了前にやるべき処理(DBConnectionの解放とか)を行う
func OnAppStop() {
    fmt.Println("=== 終了処理をいろいろやる ===")
    fmt.Println("successful termination")
}

メインゴルーチンはアプリケーションをゴルーチンで起動した後、signalの受け取りを待ちます。ctrl+cで割り込みがあった際はSIGINTを検出して終了処理(OnAppStop)を起動します。 注意点ですが、今回のようにシグナルハンドラとしてsignalを受け付けるチャネルはバッファ1のバッファ付きチャネルにしてください。*5

Package signal will not block sending to c: the caller must ensure that c has sufficient buffer space to keep up with the expected signal rate. For a channel used for notification of just one signal value, a buffer of size 1 is sufficient.

Notify()のドキュメンテーションコメントにも書いてありますね。

実際に動かし、ctrl+cで割り込みをします。 結果は下記のようになるはずです。

app running
app running
app running
app running
^C!!! SIGINT detected !!!!
=== 終了処理をいろいろやる ===
successful termination

 ちゃんと終了処理が動いてますね。シグナルを受け取り終了処理が完了するとメインゴルーチンが終了するので、アプリケーションを動かしていたサブ的なゴルーチンも消えます。

実装2:Goのバイナリを実行しているコンテナを止める

 つぎに、先ほどのコードを、docker stopされた際に終了処理が行えるようにします。docker stopを打ったあとは、対象のコンテナで起動しているPID=1のプロセスをSIGTERMで止めにきます。検出するシステムコールをSIGINTからSIGTERMに変えました。(この段階ではWaitGroupが機能してないですが、後の実装でWaitする実装が入るので前もって入れています)

package main

import (
    "fmt"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

func main() {
    sigChan := make(chan os.Signal, 1)
    defer close(sigChan)

    signal.Notify(sigChan, syscall.SIGTERM) 

    wg := sync.WaitGroup{}
    wg.Add(1)

    go func() {
        defer wg.Done()
        for {
            time.Sleep(1 * time.Second)
            fmt.Println("app running")
        }
    }()

    s := <-sigChan
    switch s {
    case syscall.SIGTERM:
        fmt.Println("!!! SIGTERM detected !!!!")
        OnAppStop()
        return
    default:
        fmt.Println("unexpected signal")
    }
}

func OnAppStop() {
    fmt.Println("=== 終了処理をいろいろやる ===")
    fmt.Println("successful termination")
}

コンテナで動かします。

FROM golang:latest

WORKDIR /go/src/sig_test
COPY main.go .

RUN go install -v

CMD ["sig_test"]

コンテナを実行。

$ docker build -t sig_test_docker .
$ docker run -d --rm --name sig_test sig_test_docker

動いているかログを確認。大丈夫そうです。

$ docker logs -f sig_test
2020-12-02T04:24:53.306288800Z app running
2020-12-02T04:24:54.306528100Z app running
2020-12-02T04:24:55.307003500Z app running

ここで、docker stopします

docker stop sig_test

するとログには

app running
app running
!!! SIGTERM detected !!!!
=== 終了処理をいろいろやる ===
successful termination

のように表示され、終了処理が正しく動いていることがわかります。

実装3:contextを伝搬させたタスクの処理

さらに拡張させ、contextを伝搬させたタスクを安全に終了させるようにします。 タスクをgoroutineで起動させます。

package main

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

func main() {
    sigChan := make(chan os.Signal, 1)
    defer close(sigChan)

    signal.Notify(sigChan, syscall.SIGTERM)

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

    wg := sync.WaitGroup{}
    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := Task(ctx); err != nil {
            fmt.Printf("%+v\n", err)
            cancel()
        }
    }()

    s := <-sigChan
    switch s {
    case syscall.SIGTERM:
        fmt.Println("!!! SIGTERM detected !!!!")
        cancel()    // 伝搬させたcontextにキャンセルを伝える
        wg.Wait()   // キャンセルを待つ
        OnAppStop() // 終了処理の実行
    default:
        fmt.Println("unexpected signal")
    }
}

func Task(ctx context.Context) error {
    for i := 1; i <= 10; i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(1 * time.Second):
            fmt.Printf("%d sec..\n", i)
        }
    }
    return nil
}

func OnAppStop() {
    fmt.Println("=== 終了処理をいろいろやる ===")
    fmt.Println("successful termination")
}

コードをすべて反映できている訳ではないですが、概ね下記のようなシーケンスを想定しています。

f:id:hagityann224:20201210093404p:plain

コンテキスト付きでタスクを起動し、メインゴルーチンはシグナルの受け取りを待ちます。SIGTERMをsigChanで受け取ったらコンテキストのキャンセルと終了処理を行った後、メインゴルーチン を終了します。

このコードから生成したバイナリをコンテナを実行している途中でdocker stopすると下記のようになります。

16 sec..
17 sec..
18 sec..
19 sec..
20 sec..
21 sec..
22 sec..
!!! SIGTERM detected !!!!
context canceled
=== 終了処理をいろいろやる ===
successful termination

 SIGTERMを検出したあとにcontext canceledが出力され、そのあとに終了処理を行っています。contextのキャンセルをwg.Wait()で待たせてあるので、終了処理はコンテキストのキャンセルが完了したあとに起動するようになっているためです。これで、コンテキスト内の終了処理→コンテキスト整理後の終了処理を順次行えます。

おわりに

 今回は、Goでシグナルハンドリングをしながら終了処理を行う簡単なサンプルについて記事を書きました。ここでのサンプルコード以外にもさまざまな実装方法があるかと思いますが、シグナルを待つ→シグナルを受け取る→後処理の流れは変わらないと思います。みなさま是非、コンテナを安全に葬れるようなコーディングを心がけましょう。明日は @ubonsa さんです。お楽しみに!

余談

Q SIGKILLは?

A SIGKILLはハンドリングできません。ので、docker killされたら終了処理も出来ずにコンテナが死にます。

Q SIGTERMとSIGINT両方拾いたいんだが

A func signal.Notify(c chan<- os.Signal, sig ...os.Signal)では、拾いたいシステムコールを追加できます。したがって、signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)とすれば、二つのシステムコールのどちらかを拾うことができます。

Q: このサンプルコードだとsignal受け取らない限りメインゴルーチン終了しないよね?

A 終了しません。sigChanで待ち続けます。ですので、下記コードのようにsignalを受け取るgoroutineを別で走らせておくのがいいと思います。waitSigみたいなファンクションを切って呼びたい。

func main() {

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

    wg := sync.WaitGroup{}
    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := Task(ctx); err != nil {
            fmt.Printf("%+v\n", err)
            cancel()
        }
    }()

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGTERM)

    go func() {
        defer close(sigChan)
   
        s := <-sigChan
        switch s {
        case syscall.SIGTERM:
            fmt.Println("!!! SIGTERM detected !!!!")
            cancel()    // 伝搬させたcontextにキャンセルを伝える
            wg.Wait()   // キャンセルを待つ
            OnStopApp() // 終了処理の実行
        default:
            fmt.Println("unexpected signal")
        }
    }()
 
    wg.Wait()
    fmt.Println("done!")
}

雑なシーケンス f:id:hagityann224:20201210090141p:plain

参考文献

*1:ECSだけでなくkubernetesでも、podの終了処理時に同等のことをやっています。https://qiita.com/superbrothers/items/3ac78daba3560ea406b2

*2:一応、stopTimeoutというパラメータでMAX120秒までコンテナの寿命を伸ばせます。 タスク定義パラメータ - Amazon Elastic Container Service

*3:ECSのタスク更新・SIGTERM発行→SIGKILL発行まで30秒の猶予がありますから、大抵の処理は終了しているはずです。とはいったものの、外部のAPIのコールなどネットワーク遅延による大幅な遅延もなきにしもあらずですから、終了処理を書いて安全にアプリのコンテナを止めることには十分意義があると思います。仮にマイクロサービスで動いているのであれば、トランザクションのコーディネーターに対して未完了であることを伝達する必要がある盤面もあるでしょう。

*4:正確には、tx.Commit()、もしくは、tx.Rollback()がコールされると、sql.DBインスタンスから借りていたコネクションをfreeConに返す処理をしています。この辺の挙動については以下の記事が詳しいです。

please-sleep.cou929.nu

*5:

budougumi0617.github.io