Chapter 17

gRPCコンテナにヘルスチェックを実行する

さき(H.Saki)
さき(H.Saki)
2022.06.19に更新

この章について

クラウド上にgRPCサーバーを載せたところで、今度はそのコンテナが正しく動いているのかというところが気になるかと思います。
特にコンテナオーケストレーションツールを使っている場合、「ヘルスチェックに失敗したコンテナは自動で終了させて、新しいコンテナを立ち上げ直す」という修復機能を持っていることが多いので、それを有効活用したいという要望もあるでしょう。
この章では、gRPCサーバーに対するヘルスチェックはどのようにやればいいのかを説明します。

ヘルスチェックプロトコル

通常のHTTPサーバーへのヘルスチェックは、例えば/healthといったチェック用のパスにHTTPリクエストを飛ばして行われます。
これと同じように、gRPCの場合ではヘルスチェック用の通信もgRPCで行われます。

どのようなメソッド・どのようなメッセージ型を使ってヘルスチェックをするべきなのかは、「GRPC Health Checking Protocol」にて規定されています。

syntax = "proto3";

package grpc.health.v1;

message HealthCheckRequest {
  string service = 1;
}

message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
    SERVICE_UNKNOWN = 3;  // Used only by the Watch method.
  }
  ServingStatus status = 1;
}

service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);

  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}

Protocol Bufferにて規定されたヘルスチェックの仕様を要約すると以下のようになります。

  • ヘルスチェック用のリクエストには、サービス名serviceを含んだメッセージ型HealthCheckRequestを使う
  • ヘルスチェックのレスポンスには、ステータスstatusを含んだメッセージ型HealthCheckResponseが使われる
  • サービスのステータスには、UNKNOWNSERVINGNOT_SERVINGSERVICE_UNKNOWNの4種類がある
  • HealthCheckRequest型とHealthCheckResponse型を使ってヘルスチェックをするメソッドは、Unary用のCheckとStream用のWatchがある

gRPCサーバーにヘルスチェックサービスを実装する

それでは、gRPCサーバーの中にGRPC Health Checking Protocolで規定された内容を実装していきましょう。

使用するパッケージ

grpc_health_v1パッケージ

上で紹介したprotoファイルの内容をGoのコードの中で使いたいならば、本来はptorocコマンド経由でコードを自動生成させるという一手間が必要です。
しかし、その自動生成されたコードがgrpc_health_v1パッケージとして既に公開されているので、そちらを使えばOKです。

https://pkg.go.dev/google.golang.org/grpc@v1.47.0/health/grpc_health_v1

このgrpc_health_v1パッケージには、以下のようなコンポーネントが定義されています。

healthパッケージ

grpc_health_v1パッケージは、ヘルスチェックプロトコルをインターフェースとして提供しています。
それはつまり、「チェックに使うためのCheckメソッド・Watchメソッドを持つためのサーバーの実態を、自分で定義して実装しなくてはいけない」ということです。

しかし、これも準備がいいことに「grpc_health_v1パッケージで定義されたインターフェースに合うような、ヘルスチェック用のサーバー具体型」もhealthパッケージにて提供してくれています。

https://pkg.go.dev/google.golang.org/grpc/health

ヘルスチェックサービスの導入

それでは以上2つのパッケージを用いて、実際にヘルスチェックを実装してみましょう。

cmd/server/main.go
import (
+	"google.golang.org/grpc/health"
+	healthpb "google.golang.org/grpc/health/grpc_health_v1"
)

func main() {
	// (略)

	s := grpc.NewServer(
		// (略)
	)
	hellopb.RegisterGreetingServiceServer(s, NewMyServer())

+	healthSrv := health.NewServer()
+	healthpb.RegisterHealthServer(s, healthSrv)
+	healthSrv.SetServingStatus("mygrpc", healthpb.HealthCheckResponse_SERVING)

	reflection.Register(s)

	go func() {
		log.Printf("start gRPC server port: %v", port)
		s.Serve(listener)
	}()

	// (略)
}

ここで行っているのは以下の3ステップです。

  1. healthパッケージ内で用意されている、ヘルスチェック用のサービスを変数healthSevに代入
  2. 1で用意したヘルスチェックサービスを、grpc_health_v1パッケージのRegisterHealthServer関数を使ってサーバーに登録
  3. ヘルスチェック用のサービスに、「mygrpcサービスはSERVINGステータスである」ということを登録

挙動を確認してみよう

それでは、ヘルスチェック用のサービスがどのような挙動を示すのか、gRPCurlを用いて簡単に確認してみましょう。

SetServingStatusメソッドで登録したサービス名のステータスを確認

ヘルスチェック用のメソッドgrpc.health.v1.Health.Checkに、サービス名mygrpcのステータスがどうなっているのかを確認するリクエストを送ってみます。

$ grpcurl -plaintext -d '{"service": "mygrpc"}' localhost:8080 grpc.health.v1.Health.Check
{
  "status": "SERVING"
}
$ grpcurl -plaintext -d '{"service": "mygrpc"}' localhost:8080 grpc.health.v1.Health.Watch
{
  "status": "SERVING"
}

すると、SetServingStatusメソッドで指定した通りSERVINGステータスが返ってきました。

サービス名を指定せずにステータス確認を行った場合

今度はサービス名を指定せず、ただヘルスチェック用のメソッドにリクエストを送ってみます。

$ grpcurl -plaintext localhost:8080 grpc.health.v1.Health.Check
{
  "status": "SERVING"
}

すると、SERVINGステータスが返ってきました。

内部的にはこれは「サービス名が""(空白)のステータス」を問い合わせているのと同じで、そしてhealthパッケージで生成されるヘルスチェックサービスの初期値が以下のように定義されていることからこのような挙動になっています。

// health.NewServer()で得られるヘルスチェックサービスの初期値
func NewServer() *Server {
	return &Server{
		// サービス名""のステータスはSERVING
		statusMap: map[string]healthpb.HealthCheckResponse_ServingStatus{"": healthpb.HealthCheckResponse_SERVING},
		// (略)
	}
}

もちろん、この初期値はSetServingStatusメソッドを使うことで自由に書き換えることができます。

cmd/server/main.go
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(s, healthSrv)
healthSrv.SetServingStatus("mygrpc", healthpb.HealthCheckResponse_SERVING)
+healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING)
$ grpcurl -plaintext localhost:8080 grpc.health.v1.Health.Check
{
  "status": "NOT_SERVING"
}

SetServingStatusメソッドで登録していないサービス名のステータスを確認

SetServingStatusメソッドでステータスを登録していないサービスの状態を確認しようとすると、NotFoundというエラーコードが返ってきます。

$ grpcurl -plaintext -d '{"service": "unknown-service"}' localhost:8080 grpc.health.v1.Health.Check 
ERROR:
  Code: NotFound
  Message: unknown service

ヘルスチェックを実行する

gRPCサーバー側にヘルスチェックへの応答体制が整ったところで、今度はヘルスチェックのリクエストを送信する仕組みを作っていきましょう。
ここでは以下2種類の方法を紹介します。

  • ALBによるヘルスチェック
  • ECSタスク定義に組み込まれたヘルスチェック

ALBからヘルスチェック

ALBには、「トラフィックを転送しているターゲットグループのコンテナにヘルスチェックを行い、もしこれにてUnhealthyになった場合にはトラフィック転送先から外す」といった制御をする機能があります。
参考:AWS公式Doc: ターゲットグループのヘルスチェック

そのヘルスチェックをgRPCで行うための設定は以下のようになります。

# ALBターゲットグループ
resource "aws_lb_target_group" "myecs" {
  name = join("-", [var.base_name, "tg"])

  protocol         = "HTTP"
  protocol_version = "GRPC"
  port             = 8080

  vpc_id      = data.aws_vpc.myecs.id
  target_type = "ip"

+  health_check {
+    enabled             = true
+    healthy_threshold   = 5
+    unhealthy_threshold = 2
+    timeout             = 5
+    interval            = 30
+    matcher             = "0"
+
+    path = "/grpc.health.v1.Health/Check"
+    port = "traffic-port"
+  }

  lifecycle {
    create_before_destroy = true
  }
}

ここで重要なのは、以下2つの項目です。

ヘルスチェックのパス

gRPCのヘルスチェックは、grpc.health.v1.HealthサービスのCheckメソッドで動いています。
そしてgrpc.health.v1.HealthサービスのCheckメソッドの呼び出しというのは、HTTP/2の通信としてはパス/grpc.health.v1.Health/Checkへのリクエストという形で表されます。
ALBのヘルスチェック設定では、HTTP通信のパスでリクエスト先を指定する必要があるため、path属性には文字列/grpc.health.v1.Health/Checkを指定しています。

health_check {
  path = "/grpc.health.v1.Health/Check"
}

ヘルスチェックの成功条件

matcherフィールドには、「何番のgRPCステータスが返ってきたらヘルスチェック成功とするか」を定義します。

health_check {
  matcher = "0"
}

ここでは、ステータスコード0番(OK)が返ってくればHealthy判定、例えば12番(Unimplemented)や5番(NotFound)が返ってくるとUnhealthy判定となる設定にしています。

タスクコンテナのヘルスチェック

ALBでは、タスクのHealthy判定にステータスコードまでしか使うことができません。
つまり、先ほどの例ですと「ステータスコードは0番だけど、レスポンスの中身に含まれているステータスはNOT_SERVING」だったというパターンはHealthy判定されてしまいます。

ステータスの中身まで見てHealthy判定を行いたいのならば別の方法が必要で、その一つとして考えられるのは「grpc-health-probeコマンドを使ったヘルスチェックを、タスクコンテナに設定する」というものです。

grpc-health-probeコマンド

grpc-health-probeコマンドは、「GRPC Health Checking Protocolに従ったチェックを行い、もしもSERVING以外のステータスが得られた場合には非0のステータスコードで終了する」というものです。

// 使用イメージ
$ grpc_health_probe -addr=localhost:8080 -service=mygrpc
healthy: SERVING

$ grpc_health_probe -addr=localhost:8080
service unhealthy (responded with "NOT_SERVING")

$ grpc_health_probe -addr=localhost:8080 -service=unknown-service
error: health rpc failed: rpc error: code = NotFound desc = unknown service

ECSタスクコンテナのヘルスチェック

ECSタスクには、「タスク内部で定期的に指定コマンドを実行し、それが非0ステータスコードで終了した場合にUnhealthy判定としタスクコンテナを終了させる」という機能があります。
参考:AWS公式Doc: タスク定義パラメータ - ヘルスチェック

そのため「grpc_health_probeコマンドが異常終了したらUnhealthyにする」という設定をここで施すことによって、レスポンスの中身に含まれているステータスの内容を踏まえたチェックを実現することが可能です。

実装

まずは、gRPCサーバーコンテナ内からgrpc_health_probeコマンドを使えるようにDockerfileを書き換えます。

Dockerfile
# build用のコンテナ
FROM golang:1.18-alpine AS build

+RUN GRPC_HEALTH_PROBE_VERSION=v0.3.1 && \
+    wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \
+    chmod +x /bin/grpc_health_probe

RUN go mod download \
	&& CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server

# server用のコンテナ
FROM alpine:3.15.4

COPY --from=build ${ROOT}/server ${ROOT}

+COPY --from=build /bin/grpc_health_probe /bin/grpc_health_probe

EXPOSE 8080
CMD ["./server"]

そして、ECSタスク定義の中で「grpc_health_probeコマンドが異常終了したらUnhealthy判定」になるように設定を追加します。

# タスク定義
resource "aws_ecs_task_definition" "myecs" {
  container_definitions = jsonencode([
    {
      name      = "gRPC-server"
      // (中略)
+      healthCheck = {
+        command = ["CMD-SHELL", "/bin/grpc_health_probe -addr=:8080 || exit 1"]
+      }
    }
  ])
}