Azure Container Appsを試す前に知っておきたいこと - 定期実行 & graceful shutdown編

Posted on Nov 16, 2021

何の話か

Azure Container Appsを試すにあたり、知っておくときっと得をするエントリの第2弾です。プレビュー初期ということもあり、公式ドキュメントが充実するまでの、つなぎ的な企画です。

前回はIngress編、今回は定期実行とgraceful shutdownについて、です。

Container Appsはプレビュー中ということもあり、これから説明する内容は変更される可能性があります。公式ドキュメントを正としてください。また、選定や設計するにあたって足りない情報があれば、どしどし要求しましょう。公式ドキュメント(英語)の各ページ下部にGitHub Issuesへのリンクがあります。

背景

システムを作る際、それが主役かどうかはさておき、定期的に実行したいジョブやタスクがひとつふたつあるのではないでしょうか。代表例は、日次の締め処理、監視目的のポーリングなどです。

Azure Container Appsは従量課金のマネージドサービスです。できる限りサービスの提供する機能を利用して楽をしたい、また、ジョブが動いていない時間帯は止めてコストを抑えたい、というニーズは、きっとあるでしょう。定期実行のうまいやり方は、Container as a Serviceの先輩であるAzure Container Instances、コンテナが使えるFunctions as a ServiceのAzure Functionsを使うユーザーから、これまで多く質問をいただいてきたテーマでもあります。

選択肢

アプリへの定期実行ロジック組み込みや外部からの起動を含めると、選択肢は多くあります。その中で、Container Appsの特徴を活かす、「らしい」やり方は、以下の2つです。

考慮点

レプリカ数をゼロにするのであれば、そのタイミングでアプリを安全に停止したいことでしょう。仮にCronに指定する想定実行時間を、余裕をもって長めにとるとしても、データの整合性など確認した上で停止したいものです。万が一のオーバーランなど、あるかもしれませんし。いわゆるgraceful shutdownを考慮すべきです。

Azure FunctionsなどApp Serviceシリーズでコンテナを使う場合、graceful shutdownの実装は課題でした。いっぽう、Container AppsはベースとしているKubernetesと同じように、シグナルハンドラの実装で対応できます。

Azure Container Apps プレビューでのアプリケーション ライフサイクル管理

  • コンテナアプリのスケールイン時
  • コンテナアプリが削除された時
  • リビジョンが非アクティブ化された時

上記タイミングでコンテナにシグナル(SIGTERM)が送られます。

なお、graceful shutdownの実装は、スケールアウト/インやアプリケーションの更新時の安全性向上にも寄与します。定期実行ジョブに限らず、他の用途でも検討をおすすめします。

Dapr Cronバインディング 実装例

では、シンプルな検証用アプリケーション(Go)を作り、実装と挙動を確認しましょう。

package main

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

	"net/http"
	"os"

	"github.com/dapr/go-sdk/service/common"
	daprd "github.com/dapr/go-sdk/service/http"
	flag "github.com/spf13/pflag"
)

var (
	logger  = log.New(os.Stdout, "", 0)
	address string
	wait    int
)

func main() {
	// SIGINTとSIGTERMを登録
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	logger.Printf("Starting...")

	flag.StringVarP(&address, "address", "a", ":8080", "set ip:port of this app")
	flag.IntVarP(&wait, "wait", "w", 0, "set wait seconds after getting signal for testing")
	flag.Parse()

	s := daprd.NewService(address)

	// cronハンドラを登録
	if err := s.AddBindingInvocationHandler("/cron", cronHandler); err != nil {
		logger.Fatalf("Error adding binding handler: %v", err)
	}

	// シグナルハンドラ
	go func() {
		<-ctx.Done()
		logger.Printf("Got signal. shutting down...")
		// シグナル受信からSIGKILLまでの猶予時間を検証するための待ち
		for i := 0; i < wait; i++ {
			time.Sleep(1 * time.Second)
			logger.Printf("Waited for %d seconds...", i+1)
		}
		s.Stop()
	}()

	if err := s.Start(); err != nil && err != http.ErrServerClosed {
		logger.Fatalf("Error starting service: %v", err)
	}

	// シグナルハンドラでdaprdサービスを正常終了(Stop)できれば、到達
	logger.Printf("Exit.")
}

func cronHandler(ctx context.Context, in *common.BindingEvent) (out []byte, err error) {
	logger.Printf("Got cron event.")
	return nil, nil
}

では、このアプリをコンテナアプリとしてデプロイします。

なお余談ですが、現状、Container AppsのデプロイはAzure CLIではなくARMテンプレートをおすすめします。CLIのcontainerapp拡張は、開発がサービスに追いついていない印象です。Terraformなどサードパーティーツールのサポート状況は適宜確認してください。

ポイントだけ抜き出したARMテンプレートを以下に説明します。全体はGistを参照してください。

...
"containers": [
    {
        "image": "torumakabe/dapr-cron-handler:1.0.1",
        "name": "dapr-cron-handler",
        "resources": {
            "cpu": 0.25,
            "memory": "0.5Gi"
        },
        "command": [
            "/dapr-cron-handler",
            "-w",
            "3"
        ]
    }
],
"scale": {
    "minReplicas": 1,
    "maxReplicas": 1
},
"dapr": {
    "enabled": true,
    "appPort": 8080,
    "appId": "dapr-cron-handler",
    "components": [
        {
            "name": "cron",
            "type": "bindings.cron",
            "version": "v1",
            "metadata": [
                {
                    "name": "schedule",
                    "value": "0 0 * * * *"
                }
            ]
        }
    ]
}
...

おおよそ、定義は読めばわかりますね。Dapr Cronバインディングのcronフォーマットは秒指定できる6フィールド型です。@dailyなどのマクロも使えます。この検証では、毎時0分0秒に実行するよう指定しています。

なお、SIGTERMのシグナルハンドリングに3秒かかるよう設定しました。

では、デプロイ後に数時間放置し、コンテナアプリを削除した後、ログを見てみましょう。

11/15/2021, 11:19:49.486 PM	Starting...
11/15/2021, 11:19:49.827 PM	2021/11/15 23:19:33 http: superfluous response.WriteHeader call from github.com/dapr/go-sdk/service/http.(*Server).registerBaseHandler.func3 (topic.go:60)
11/16/2021, 12:00:01.483 AM	Got cron event.
11/16/2021, 1:00:01.335 AM	Got cron event.
11/16/2021, 1:30:11.799 AM	Got signal. shutting down...
11/16/2021, 1:30:12.240 AM	Waited for 1 seconds...
11/16/2021, 1:30:13.488 AM	Waited for 2 seconds...
11/16/2021, 1:30:14.263 AM	Waited for 3 seconds...
11/16/2021, 1:30:14.263 AM	Exit.

ログ http: superfluous response.WriteHeader call[...] は、Dapr Go SDKからの不要なメッセージなので気にしないでください。SDKで修正中です。動作には問題ありませんので、以降省略します。

1秒ほどのオーバーヘッドはありますが、毎時0分0秒にDapr Cronバインディングから呼び出されている(Got cron event)ことがわかります。また、コンテナアプリの削除時にSIGTERMをハンドリングし、3秒待った後に正常終了(Exit)できています。

KEDA Cronスケーラを組み合わせる

では次に、KEDA Cronスケーラを組み合わせた場合も見てみましょう。Daprの定義は同じです。これもポイントのみ抜き出し、全体はGistに置いておきます。

...
"containers": [
    {
        "image": "torumakabe/dapr-cron-handler:1.0.1",
        "name": "dapr-cron-handler",
        "resources": {
            "cpu": 0.25,
            "memory": "0.5Gi"
        },
        "command": [
            "/dapr-cron-handler",
            "-w",
            "60"
        ]
    }
],
"scale": {
    "minReplicas": 0,
    "maxReplicas": 1,
    "rules": [
        {
            "name": "cron",
            "custom": {
                "type": "cron",
                "metadata": {
                    "timezone": "UTC",
                    "start": "50 * * * *",
                    "end": "10 * * * *",
                    "desiredReplicas": "1"
                }
            }
        }
    ]
},
...

scaleのminReplicasを0にし、ルールに合致しない時間にはレプリカ数をゼロにします。そして、0分0秒のDapr Cronバインディングからの呼び出しに間に合うよう、余裕をもって毎時50分にレプリカ数をdesiredReplicasで指定した1にスケールします。そして、アプリケーションの実行は10分以内に終わる、という想定で、毎時10分にスケールインします。なお、KEDA Cronスケーラのcronフォーマットは5フィールドです。

また、SIGTERM受信後の待ち時間を長めに、60秒としました。SIGTERMからSIGKILLまでの猶予時間がどれだけあるかを確認したいからです。Kubernetesの既定値は30秒ですが、さて同じでしょうか。

こちらは、デプロイ後に1時間ほど放置し、ログを見てみましょう。

11/15/2021, 11:21:08.265 PM	Starting...
11/15/2021, 11:25:35.590 PM	Got signal. shutting down...
11/15/2021, 11:25:36.360 PM	Waited for 1 seconds...
11/15/2021, 11:25:37.558 PM	Waited for 2 seconds...
11/15/2021, 11:25:38.388 PM	Waited for 3 seconds...
...
11/15/2021, 11:26:03.892 PM	Waited for 28 seconds...
11/15/2021, 11:26:04.421 PM	Waited for 29 seconds...
11/15/2021, 11:26:05.385 PM	Waited for 30 seconds...
11/15/2021, 11:50:08.577 PM	Starting...
11/16/2021, 12:00:01.774 AM	Got cron event.
11/16/2021, 12:14:35.524 AM	Got signal. shutting down...
11/16/2021, 12:14:36.448 AM	Waited for 1 seconds...
11/16/2021, 12:14:37.289 AM	Waited for 2 seconds...
11/16/2021, 12:14:38.733 AM	Waited for 3 seconds...
...
11/16/2021, 12:15:03.972 AM	Waited for 28 seconds...
11/16/2021, 12:15:05.609 AM	Waited for 29 seconds...
11/16/2021, 12:15:05.829 AM	Waited for 30 seconds...
11/16/2021, 12:50:08.335 AM	Starting...

まず、11:21の開始は、デプロイ時にコンテナが起動したことを意味します。定義したminReplicasは0ですが、コンテナアプリの作成(アクティベーション)時には、最低1つのレプリカが起動します。そして起動後に、ルールに合わせてスケールアウト/インが実施されます。11:25にKEDA Cronスケーラがルールに合わない時間帯であると判断し、レプリカを削除、ゼロにしました。

ところで、SIGTERMを受け取った後に60秒待つように設定しましたが、ログは30秒で止まっています。そして、Exitも出力されていません。これは、SIGTERMを受け取った後、30秒後にSIGKILLされたことを意味します。30秒で支度しな、です。Kubernetesの既定値と同じですね。この仕様は念のため、公式ドキュメントへの記載を要求しています。

そして11:50ごろ、ルールに合わせてコンテナが起動しています。そして12:00にDapr Cronバインディングから呼び出され、12:14にはSIGTERMを受け取っています。

このように、KEDA Cronスケーラは定義した時間にどんぴしゃりで開始、終了されません。これは主に、KEDAのルール評価間隔(pollingInterval)と、スケールをゼロにするまでの猶予時間(cooldownPeriod)の影響です。よって、余裕をもって設定しましょう。なお、Container Appsにおけるこれらの設定値についても、公式ドキュメントへの記載を要求しています。