Kubernetesをちょっと理解したあなたに贈るGKEの実践ノウハウ4選

はじめに

こんにちは。EC基盤グループの池田(@progrhyme)です。

モノタロウでは昨年、商品のレコメンデーションに用いるシステムを内製化するという取り組みを行いました。
私もこのプロジェクトに参加し、主にGoogle Kubernetes Engine(以下、GKE)上でのアプリケーションの構築・設定やCI/CD設定、監視設定などを行っていました。

私自身、本番運用するGKEのプロダクトを本格的に触るのは、本件が初めての経験でした。
そこで、本記事では、この案件のシステム構築・運用を通して得られたGKEのノウハウをまとめます。

商品推薦システム: RecSys について

上で述べた商品レコメンデーションの内製化プロジェクトと、このプロジェクトで作られたシステムは、社内で「RecSys」と呼ばれています。
ここでは、前提としてRecSysの概要を示します。

現在では、旧システムからRecSysへの移行が完了しており、 monotaro.comサイトの各所でRecSysによるレコメンドが行われています。
一例としては、下のキャプチャのように、商品ページの下部にカルーセルで表示されるものが挙げられます。

f:id:progrhyme:20210324183332p:plain
図: RecSysによる商品レコメンデーション

また、RecSysの関連システムと、データ処理の流れを示した図が下となります。

f:id:progrhyme:20210324183402p:plain
図: RecSys、及び関連システムの通信の流れ

RecSys APIによって、ユーザにレコメンドされた商品が表示されるまでのフローを簡単に記すと、以下のようになります。

  1. Webページのロード後に、ブラウザからRecSys APIへ非同期でリクエスト送信。現在表示している商品の情報をパラメータとして付加する
  2. RecSys APIはデータ取得をBackend APIに委譲し、得られた商品リストの情報を結合・整理してクライアントにレスポンスを送出

GKEを採用した理由

上の図のように、RecSys API(及びBackend API)はGKE上に構築しています。

実は、私が本プロジェクトに参画する前にはGoogle App Engineで開発が進んでいた時期もあったのですが、以下のような理由でGKEを採用することが決まりました。

  • Docker / Kubernetesベースのシステムで構築しておくことで、アプリケーションのポータビリティを保つことができる
  • キャパシティを柔軟にコントロールしたかった。RecSys APIはリクエスト量が多く、後述するようにBotアクセスによってリクエストが急増・急落することもあった
  • モノタロウの他のシステムでもGAE→GKEの移行が進んでおり、社内で原則としてGKEを採用する潮流ができていた

それでは、以降では本システムの構築・運用を通して得られたGKEのノウハウを紹介します。

GKEノウハウの紹介

RegionalクラスタでZone, NodeごとにPodが分散されるようにAffinityを設定

本案件の本番環境では、GKEクラスタを「Regional」(=複数Zone)として構成し、1つのZoneがダウンしてもサービスが継続できるように設計しました。当然、Node Poolも複数のZoneに配置するように設定しました。

ただし、実際にAPIアプリケーションが動作するPodがどのZoneに配置されるかについては、別途設定が必要です。何も設定しなければ、1つのZoneに集中して配置されることも十分にあり得ます。

このような場合に、KubernetesのAffinity*1という仕組みを使って、Podが配置されるNodeの条件をコントロールすることができます。

具体的には、Podのマニフェストで下のように記述しています:

apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: recsys
  name: recsys
  labels:
    app: recsys
spec:
  template:
    metadata:
      labels:
        app: recsys
    spec:
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 99
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app
                      operator: In
                      values:
                        - recsys
                topologyKey: failure-domain.beta.kubernetes.io/zone
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app
                      operator: In
                      values:
                        - recsys
                topologyKey: kubernetes.io/hostname

このAffinityの設定によって、Podの配置方法について次の指定をしています:

  • 「app: recsys」というラベルを持つPodについて、
    • 対象のNodeに既に同じラベルを持つPodが稼働している場合、スキップする(そのNodeにはPodを配置しない)
    • 対象のZoneで既に同じラベルを持つPodが稼働している場合、スキップする

重みパラメータである「weight」には1から100までの値を指定できます。「topologyKey: kubernetes.io/hostname」の方の値をより大きくしているので、「同じNodeに配置しない」というルールの方が優先度が高いということになります。

このやり方について、当時調べていた時には同じような設定例は見つけられませんでしたが、ドキュメントを読みながら試行錯誤して、上の設定にたどり着きました。

また、実際にスケールイン・アウト時のポッドの配置を確認し、意図通りの挙動をしていることを確認しました。

graceful shutdownするコンテナでもpreStopが必要

KubernetesでPodが終了するとき、以下のような流れになります*2:

  1. Pod終了のリクエストがKubernetesのAPI Serverに到達する
  2. サービスのエンドポイントリストからPodが削除される
  3. (2と同時に)Podのシャットダウンプロセスが開始する
    1. PodにpreStopフックが設定されていれば実行される
    2. Pod内のプロセスにTERMシグナルが送信される

私は当初、Pod側のアプリケーションがgraceful shutdown(処理中のリクエストが終了してから、サービス影響を与えずに停止する)に対応していれば、preStopフックを設定する必要はないと認識していたのですが、上の2と3は必ずしもこの順番で行われるというわけではないようです。なお、RecSys APIのアプリケーションはgunicornで動いており、graceful shutdownに対応しています。

これは、Kubernetes: 詳解 Pods の終了 - Qiitaで述べられているように、Kubernetesの仕様のようです*3

記事から一部を引用します。

ここで注意しなければいけないことは、Pod preStop フックの実行と Service エンドポイントから Pod IP アドレスの削除は並列して実行されているということです。これはどちらが先に実行されるか分からないことを意味します。

これによって、Podがスケールインするとき、Podがエンドポイントリストから削除される前にPodのシャットダウンプロセスが始まることが起こり、preStopフックを設定していなかったためにただちにTERMシグナルが送信され、APIリクエストがエラーになってしまうことが起こりました。

これを回避するため、次のように単にsleepするだけのpreStopフックを追加しました。

  spec:
    template:
      metadata:
        labels:
          app: recsys
      spec:
        containers:
        - name: recsys
          image: asia.gcr.io/my-project/recsys:v1
          ports:
            - containerPort: 8080
+         lifecycle:
+           preStop:
+             exec:
+               command: ["sh", "-c", "sleep 15"]

この設定を入れたことで、上述のスケールイン時のエラーは解消しました。

コンテナネイティブの負荷分散を利用

コンテナネイティブの負荷分散は、GKEで一昨年の9月に一般公開となった機能*4で、構築当時は比較的新しい機能でした。

こちらを利用することで、公式ドキュメント(コンテナ ネイティブの負荷分散 | Kubernetes Engine ドキュメント | Google Cloud)にあるように、負荷分散時の余分なネットワークホップを省くことができ、パフォーマンスの向上を図ることができます。

厳密な比較検証を行ったわけではありませんが、パフォーマンスが上がること自体は確認できました。

現在では、GKEクラスタ 1.17.6-gke.7 以降で、いくつかの条件*5を満たす場合、コンテナネイティブの負荷分散がデフォルトで使われます。

そうでない場合にコンテナネイティブの負荷分散を使いたければ、Serviceのマニフェストに以下のようなアノテーションを明示的に付加する必要があります:

kind: Service
...
  annotations:
    cloud.google.com/neg: '{"ingress": true}'
...

負荷試験によるHPAのパラメータ調整

RecSysではシステム構築当初からautoscaling/v1のHorizontalPodAutoscaler(以下、HPA)を利用しています。 これによって、CPU使用率に従ってPod数を増減させることが可能です。

本番環境では、Botアクセスにより不定期にアクセスが急増することがわかっていました。 状況は当時と今で大差なく、極端なときには1分間でリクエスト数が5倍に跳ね上がることもあります。

このようにアクセスが急増する場合に、当初の設定ではスケールアウトが追いつかずレイテンシが悪化することがあったため、対応として基準値であるtargetCPUUtilizationPercentageの値をかなり低めに設定しました。
設定値は、実際に負荷試験環境で本番に近いリクエストの急増を再現し、APIの性能に問題が出ないように調整しました。

これにより、常に余剰のコンピューティングリソースを抱えることにはなりますが、上記の問題は解決しました。

現在ではautoscaling/v2beta2のHPAが利用可能になっています。

こちらを使うことで、スケーリングの基準値にCPU以外の指標を用いたり、スケールイン時にPodを減らす速度を調整する、といったことも可能になるため、近い内に試したいと考えています。

まとめ

本稿では、商品推薦システムの内製化案件でのシステム構築・運用を通して得られたGKEのノウハウを紹介しました。 GKEやKubernetesをお使いの方の参考になれば幸いです。

モノタロウではGCPやGKEを活用したクラウドインフラを支えるエンジニアを募集しています。
新しめの技術であっても合理性があれば採用される環境なので、技術的好奇心が高い人や、技術を駆使してサービスの改善やビジネスの課題解決をしたい人は大歓迎です。

興味がある方は、ぜひ下のバナーからご応募ください。