- バックエンド / リーダー候補
- PdM
- Webエンジニア(シニア)
- Other occupations (19)
- Development
- Business
バックグラウンドワーカーが落ちたら気付けるように、K8sレイヤでチェックを入れた話
Photo by Jefferson Santos on Unsplash
はじめに
こんにちは、WantedlyのDX (Developer Experience) チームに副業で参加していた金築です。今回は業務の中で長期間取り組んでいた、K8sのworker Podのhealth checkの改善活動に関して、改善の理由や、それに対する具体的な改善策をご紹介します。
今回の改善で行ったこと
- K8s上で動かしているSidekiq, Google Cloud PubSub Subscriber の worker Podの中の処理にhealth checkをすることができるインターフェースを用意し、それをreadinessProbe、livenessProbeを使用してバックグラウンドワーカーが起きていたら気付けるようにした
今回の改善の対象のworker Pod
- Sidekiq worker (Ruby)
- PubSub の Subscriber worker (Ruby, Golang)
改善の理由
K8sではliveness, readiness, startup Probeが用意されており、今回health checkで使用したreadinessProbe、livenessProbeを使うと、それぞれ以下のことが行えます。
- readinessProbe
- そのPodがリクエストを受けられるようになっているかを確認する。health checkに失敗したPodはServiceからのルーティングの対象から外され、リクエストが流れないようにできる
- livenessProbe
- health checkに失敗したPodを自動で再起動することができる
この2つのProbeを使用することで、Podがunhealthyの時にリクエストを到達しないようにして、かつ、そのPodを再起動することができます。
既存のWebサーバーでは、それぞれのアプリケーションでhealth checkエンドポイントを用意し、K8sのこれらのProbeを使ってhealth checkをしていましたが、K8sが既定でサポートしているHTTP, TCP, gRPCによるhealth checkが使用できない、今回のSidekiqやSubscriberのworkerでは、どんなインターフェースで healthy であると宣言するかという定義から行う必要があることから、これまでProbeではhealth checkが入っていませんでした。
そのため、Podの起動時にCrashLoopBackoffになったworkerは外形監視で気づくことができましたが、何らかの不具合があってPodはRunningになっているものの、その中のworker processが正常に動かない状態になっているような場合を検知することができませんでした。
そこで、今回の改善ではデプロイの時に不具合のあるPodがhealthyとみなされてrolloutされてしまうことを防いだり、何らかの原因で処理ができなくなったPodをlivenessProbeで自動で再起動して、処理を継続できるようにしました。
具体的な改善策
readinessProbeの例
readinessProbe:
exec:
command:
- sh
- -c
- test "$(cat worker_heartbeat 2>/dev/null || echo 0)"
-gt "$(($(date +%s) - 60))"
initialDelaySeconds: 30
今回の改善で考えていた選択肢
今回の改善では、K8sのProbeでチェックする際の、各worker Podがheartbeatを書き込んで、そのhealth checkをK8sのProbeでチェックしに行くためのインターフェースを用意するにあたり、RedisなどのインメモリDBにheartbeat書き込む方法と、ローカルのファイルシステムにheartbeatを書き込む方法の2つの選択肢を検討しました。その時に、それぞれ以下のようなメリット・デメリットがあると思いました。
- RedisなどのインメモリDBにheartbeat書き込む方法
- health checkの流れ
- Key: workerからRedisにheartbeat_{pod_name}_{container_name}のような形でheartbeatを書きこむ
- そのheartbeatをworker側からチェックしに行くスクリプトを用意し、K8sのProbeでそのスクリプトを実行することでhealth checkをする
- メリット
- SidekiqのworkerはすでにRedisにheartbeatを書き込んでいるため、Sidekiqのworkerはheartbeatを書き込む処理を作るのが不要
- デメリット
- Sidekiqを使用しないPubSubのworkerが、heartbeatのためだけにRedisへの依存が増える
- workerは正常に動いているが、Redisとの接続ができなくなった時に、heartbeatが書き込めなくなり、health checkに失敗する
- ローカルのファイルシステムにheartbeatを書き込む
- health checkの流れ
- ローカルのファイルシステムのファイルにunixtimeの現在時刻をhealth checkとして書き込む
- その時刻をKubernetesのProbeでチェックする
- メリット
- コンテナの中だけでheartbeatを書き込む機構が完結するため、外部への依存が増えない
- ローカルのファイルシステムに書き込むため、書き込みの処理でネットワーク遅延が出ない
- デメリット
- 既にRedisにheartbeatを書き込んでいるSidekiqのworkerも、追加でローカルのファイルシステムにheartbeatを書き込む処理を加える必要がある
主にこの2つの選択肢から、今回はhealth checkの処理をシンプルに保つため、コンテナ内でheartbeatの処理を閉じることができる、ローカルのファイルシステムにheartbeatを書き込む方法を採用しました。
Sidekiq worker (Ruby)
sidekiqのbeatというライフサイクルイベントを使用することで、sidekiqのプロセスが動いていれば10秒に一度、自前で定義した処理を動かすことができます。今回はこのbeatを使って、プロセスのhealth checkをしました。また、sidekiqでは、そのプロセスが生きている場合に、5秒毎にRedisにheartbeatを自動的に書き込むようになっており、beatで発火した処理でこの情報を確認しに行くことで、sidekiqのプロセスの死活確認をするようにしました。
(以下のコードは一部変更しています)
heartbeat_checker.rb
module SelfDefinedModule::Sidekiq
class HeartbeatChecker
HEARTBEAT_FILE = 'worker_heartbeat'
# sidekiq liveness info as heartbeat is written to Redis every 10 seconds from sidekiq to redis with ${host}-${pid}-${identify_hash} format
def write_heartbeat
return unless process_alive?
begin
FileUtils.mkdir_p(File.dirname(HEARTBEAT_FILE))
File.open(HEARTBEAT_FILE, "w") do |f|
f.write(Time.now.to_i)
end
rescue Errno::ENOENT, Errno::EACCES => error
Servicex.logger.warn("Failed to write heartbeat: #{error.message}")
end
end
private
def process_alive?
# sidekiq requires redis connection for its process and it can not work well without Redis
# Errno::ECONNREFUSED, Redis::CannotConnectError will be caused when the process can not establish connection to Redis
# rescue such exceptions here to return process_alive? as false
# https://github.com/mperham/sidekiq#requirements
process_set = Sidekiq::ProcessSet.new
hostname = Socket.gethostname
process_set.any? { |process| process['hostname'] == hostname }
end
end
end
sidekiq.rb
Sidekiq.configure_server do |config|
config.on(:beat) do
SelfDefinedModule::Sidekiq::HeartbeatChecker.new.write_heartbeat
end
end
Subscriber worker (Ruby)
wantedlyではGoogle Cloud PubSubをgcpcという外部公開しているRuby clientを介して使用しています。gcpcに対して変更を入れたPRはこちらでご覧いただけます。gcpcに入れた具体的なhealth checkの内容は以下です。
- Sidekiq同様に、beatというライフサイクルイベントを使用者側に提供し、使用者側はそのbeatに、発火させたい処理を渡す
Gcpc::Config.instance.on(:beat) do
p "Hi"
end
- Gcpc内部では、Subscriber::Engineを起動する時に、health checkを行うワーカーを別スレッドで起動して、そのスレッド内でプロセスが動いているか否かを確認する(詳細はPRをご参照ください)
Subscriber worker (Golang)
Rubyと同様に、health checkのために、Subscriberの中で別のスレッドを立てて、その中で定期的にhealth checkをローカルのファイルシステムに書き込むようにしています。
heartbeat/run.go
func (c Config) Run(ctx context.Context) {
logx.Info("Start writing heartbeat")
interval := c.Interval
if interval == 0 {
interval = defaultInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
dir := c.Dir
if dir == "" {
dir = defaultDir
}
fileName := c.FileName
if fileName == "" {
fileName = defaultFileName
}
for {
select {
case <-ctx.Done():
logger.Info("Finish writing heartbeat")
return
case <-ticker.C:
err := write(dir, fileName)
if err != nil {
logger.Error("Failed to write worker heartbeat", "error", err)
}
}
}
}
engine.go
go heartbeat.Default().Run(ctxWithCancel)
if err := e.Engine.Start(ctx); err != nil {
return fail.Wrap(err)
}
まとめ
Worker Podのhealth checkはHTTPリクエストを受けるサーバーと比べて、それぞれの処理の中で行っている処理や提供されているインターフェースが様々で、一様にhealth checkをすることが難しいと言うイメージがありました。しかし、それぞれの処理の中でやっていることは違えど、ローカルのファイルシステムに書き込むアプローチを取ることで、KubernetesのProbeで行う処理は共通化することができるため、認知コストが上がるのを抑え、メンテナンス性を保ったまま、health checkができることが学びとなりました。