概要
この記事は、ヘルスチェック実装で依存先の負荷を減らすためにキャッシュを検討してみた話と、Goで実装する場合は https://github.com/alexliesenfeld/health が便利そうなので実装してみた、という記事です。
はじめに
マイクロサービスにおいてヘルスチェック機能を実装する際は、DB等の正常性確認に加えて依存先のサービスの正常性も含めて確認したくなります。
そのような実装を加えた際に問題になるものの一つとして、依存先のサービスへのリクエスト量が増える点があります。原因としては次のようなものがあると思います。
- KubernetesのReadiness Probeによって高頻度に実行される
- 自分自身も多くのサービスの依存先となっており、ヘルスチェックのリクエストを多く受ける
そのような理由により依存先へのリクエストが増えた際に問題になるのが次のような点です。
- ヘルスチェック起因のリクエストが負荷になる
- 依存先サービス側のログにヘルスチェック起因のものが増え調査しづらくなる
マイクロサービスにおいて負荷への対処はスケールをさせることです。ただ、スケールできないリソースがある、もしくはヘルスチェック自体もスケールしてしまうなどの場合には他の手段の検討が必要になるかと思います。
キャッシュをする
同じリクエストを送りすぎてしまう場合の対処方法としてはキャッシュがあるかと思います。
キャッシュをすることでリクエスト間隔を抑えることができ、依存先への負荷を抑えることができます。
ただし、デメリットもあります。キャッシュ時間(TTL)の間は、サービスの生存状態が反映されるまでのラグが発生する点です。この点は検証を行う際にTTL値のチューニングを行うことで許容できる範囲を検討する必要があります。また、キャッシュの仕組みを入れることでアプリケーションがステートフルになるので複雑性が増す点もあります。これらを踏まえたうえでメリットの方が大きい場合は導入を検討できるのではないかと思います。
実装においてはキャッシュなので単純なハッシュマップとしての機能だけでなく、TTLによって消える仕組みも入れる必要はあります。メジャーな言語であればキャッシュを行うためのライブラリはあると思うのでそれを利用しても良いですし、RedisのようにTTLの仕組みをもったKVSを利用するのも良いと思います。
Goで実装してみる
awesome-go でこのようなことを行うための良さそうなライブラリはないかと探してみたところ、ずばりhealth ライブラリを使えばシンプルに実現できそうです
!ヘルスチェック機能をこちらでラップしてしまえば、キャッシュの機能を追加でき、またそれ自体がHTTP Handlerとしても機能できるためHTTPのレスポンスの実装も不要そうです。
サンプルサービス
下記のようなHelloサービスと、依存先のw,o,r,l,dサービスを考えます。ちなみにhelloサービスにリクエストをすると依存先の各サービスからw,o,r,l,d
を1文字ずつ取得し、hello world
を返却します。コードはgo-healthz-sampleにありますのでご参照ください。
healthの利用方法は exapmle がわかりやすいですが、今回のキャッシュの用途であれば下記のようになります。生成されたCheckerによりWithCheckで指定したCheck関数が実行されますが、WithCacheDurationで指定した間隔でキャッシュされます。
return health.NewChecker(
health.WithCacheDuration(1*time.Second),
health.WithTimeout(10*time.Second),
health.WithCheck(health.Check{
Name: "ユニークな名前",
Check: func(ctx context.Context) error {
return fmt.Errorf("いつも失敗!")
},
}),
実際にCheck関数まで実装すると下記のようになります。各マイクロサービスがエラーレスポンスを返すかどうかをチェックしています。
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"github.com/alexliesenfeld/health"
"github.com/go-resty/resty/v2"
)
func initHealthCheck() health.Checker {
return health.NewChecker(
health.WithCacheDuration(10*time.Second),
health.WithTimeout(30*time.Second),
health.WithCheck(health.Check{
Name: "w",
Check: func(ctx context.Context) error {
return worldCheck(ctx, "4000")
},
}),
health.WithCheck(health.Check{
Name: "o",
Check: func(ctx context.Context) error {
return worldCheck(ctx, "4001")
},
}),
health.WithCheck(health.Check{
Name: "r",
Check: func(ctx context.Context) error {
return worldCheck(ctx, "4002")
},
}),
health.WithCheck(health.Check{
Name: "l",
Check: func(ctx context.Context) error {
return worldCheck(ctx, "4003")
},
}),
health.WithCheck(health.Check{
Name: "d",
Check: func(ctx context.Context) error {
return worldCheck(ctx, "4004")
},
}),
health.WithStatusListener(func(ctx context.Context, state health.CheckerState) {
log.Println(fmt.Sprintf("health status changed to %s", state.Status))
}),
)
}
func worldCheck(ctx context.Context, port string) error {
client := resty.New()
_, err := client.R().
SetContext(ctx). // Set Context for health check timout.
EnableTrace().
Get(fmt.Sprintf("http://localhost:%s", port))
if err != nil {
return err
}
return nil
}
そして、作成したCheckerをHTTPのHandlerとして生成しそれを利用しているHTTPのフレームワークに渡します。Ginであれば下記のようになります。これだけでヘルスチェック機能の実装が完了しました!
func main() {
checker := initHealthCheck()
healthHandler := health.NewHandler(checker)
api := gin.Default()
api.GET("/healthz", gin.WrapH(healthHandler))
api.Run(":3000")
}
では実際に動作確認をしてみます。まずは全サービスを起動します。
起動が成功しているのでレスポンスが取得できます。
> curl localhost:3000/
hello world
ではヘルスチェックのエンドポイントにリクエストしてみます。
下記のようにCheckerに渡したNameのキーごとにサービスの起動状態が見れるようになっています。
> curl localhost:3000/healthz | jq .
{
"status": "up",
"details": {
"d": {
"status": "up",
"timestamp": "2022-02-12T18:24:40.348828637Z"
},
"l": {
"status": "up",
"timestamp": "2022-02-12T18:24:40.348403495Z"
},
"o": {
"status": "up",
"timestamp": "2022-02-12T18:24:40.349729039Z"
},
"r": {
"status": "up",
"timestamp": "2022-02-12T18:24:40.350399064Z"
},
"w": {
"status": "up",
"timestamp": "2022-02-12T18:24:40.349504173Z"
}
}
}
キャッシュの動作を確認するために、Helloサービスにヘルスチェックのリクエストを大量に送ります。左がHelloサービス 、右が依存先のwサービスですが、どんなにリクエストしても依存先にはTTLとして指定した間隔(10s)未満でのリクエストが送られていないことがわかります。
では次に依存先のwサービスを落とし、ヘルスチェックをリクエストしてみます。
キャッシュが切れた後に下記のようなレスポンスが返るようになりました。
ダウンさせたwサービスだけがダウンしているように表示され、エラーメッセージも表示されています。ヘルスチェックAPIとしてはまさにこのようなレスポンスが欲しいので助かりますね!
ちなみにHTTPのステータスコードとしては成功時が200であったのに対して失敗時は503となりました。
> curl localhost:3000/healthz | jq .
{
"status": "down",
"details": {
"d": {
"status": "up",
"timestamp": "2022-02-12T18:33:46.754222566Z"
},
"l": {
"status": "up",
"timestamp": "2022-02-12T18:33:46.754938328Z"
},
"o": {
"status": "up",
"timestamp": "2022-02-12T18:33:46.754229827Z"
},
"r": {
"status": "up",
"timestamp": "2022-02-12T18:33:46.754673749Z"
},
"w": {
"status": "down",
"timestamp": "2022-02-12T18:33:46.754471368Z",
"error": "Get \"http://localhost:4000\": dial tcp 127.0.0.1:4000: connect: connection refused"
}
}
}
wサービスを再び起動してキャッシュのTTL時間経過後は再びStatusがupとなりました。
"w": {
"status": "up",
"timestamp": "2022-02-12T18:38:36.639985712Z"
}
まとめ
マイクロサービスにおけるヘルスチェックで依存先への負荷を軽減するための方法としてキャッシュを検討し、Goのサンプルとして実装しました。
Goの場合ライブラリを利用しシンプルに書けましたが、考え方としてはキャッシュのキーをサービスの名前、バリューをヘルスチェックの関数としTTLを設ければどの言語でも実装できそうです。
また、今回は依存するマイクロサービスへの負荷に関して検討しましたが、DBなどほかの依存リソースにおいても同様に利用できると思います。