Amazon EKSでAWS App Meshを利用してgRPCサーバーを運用する

こんにちは、スタディサプリENGLISH SREグループの横山です。

以前の記事1) スタディサプリENGLISHの基盤をECSからEKSに移行しましたにあるように、先日ついにスタディサプリENGLISHのインフラ基盤をECSからEKSに移行することができました。
本記事では、このEKS移行に伴いAWS App Mesh(以下、AppMesh)の導入をしたので、これに関連した話を書いていきたいと思います。

なぜAppMesh導入を検討し始めたか

インフラ基盤がECSだった頃、gRPCの負荷分散にEnvoyとAmazon ECS Service Discoveryを利用していました。この構成については以下の記事に詳細があります。

この構成にした当初は、デプロイ時にECS Serviceのローリングアップデートのみを利用していました。
しかし、Service Discoveryで登録しているIPのレコードがRoute53から消えるタイミングとコンテナの終了のタイミングを調整するのが難しく、STOP済みのコンテナのIPへリクエストがいってしまうという問題がありました。
そこで、この問題に対処するために、ECS Seviceを同種のServiceで2つ用意しService毎入れ替えるBlue/Greenデプロイを行う、それを自前のデプロイツールで制御、Jenkinsのシェルでsleepを入れる等、少々複雑なことを行っていました。
このSTOP済みのコンテナのIPへリクエストがいってしまう問題は、デプロイ時だけでなくコンテナが落ちてしまったときにも発生する問題であり、この場合はデプロイ時と異なりBlue/Greenデプロイ等の仕組みを入れることができなかったため、完全に解決するのが難しい問題でした。

また、スタディサプリENGLISHの拡大と共にServiceが増えていく中で、このECS時代の構成だと、Seviceを増やす度にEnvoyのECS Serviceを増やす必要があるためEnvoyの設定ファイルを個別に管理する量が増えていく、デプロイツールやJenkinsfileの修正を都度修正していく必要がある、等の手間が増えてきたという課題も出てきました。
そこで、自前の仕組みを減らしAWSやKubernetes(以下、k8s)の仕組みに可能な限り全て乗せたいというモチベーションが出てきました。

私たちはAppMeshに関しては下記記事のように以前から注目しており、将来的にサービスメッシュを本格的に導入し、サービス全体の通信の可視化や安定化のためのトラフィック制御を強化していきたいという考えがありました。

Envoy管理という課題に対しては、AppMeshを利用することで容易になるのではないかという案があり、
完全に解決できていなかったリクエストが落ちてしまう問題や、デプロイ周りの複雑さに対しても、k8sのPodの設定での制御やAppMeshのトラフィック制御等で対処できないかという案がありました。
このような背景があり、今回EKS移行と共にAppMeshを本番導入していくことを決定しました。

AppMeshがどういう機能を持ちどのような概念があるかについての詳細は、上記の記事を参照していただければと思います。
現在はAppMeshはGAになっており、Virtual gateways 2)Virtual gateways等新しい機能もリリースされていますが、私たちは現在のところ上記記事で挙げている機能のみ利用しています。

AppMeshをEKS(k8s)上で管理する

AppMeshを導入する上で、まずはAppMesh自体の設定をどう行うか調査をしていきました。

AWS App Mesh Controller For K8s(以下、App Mesh Controller)というものがあり、これを利用することでk8sのCRD3)カスタムリソースとしてAWS上のAppMeshリソースを全て管理することができ、私たちのEKS移行の目的の1つであるインフラ基盤の全てをなるべくEKS(k8s)の世界で完結させたいという点に非常にマッチしていたため、採用を決めました。4)App Mesh Controllerは検証段階ではGAになっていなかったのですが、現在はGAになっています。https://aws.amazon.com/jp/about-aws/whats-new/2020/06/aws-app-mesh-controller-for-kubernetes-is-now-generally-available/
また、App Mesh ControllerのEKSクラスタへのインストールには、helm chartがAWSのリポジトリで用意されているためこれを利用しました。5)aws/eks-chart

以下では、実際にAppMeshの設定をするManifestの一部を紹介したいと思います。まずはMeshの定義です。

apiVersion: appmesh.k8s.aws/v1beta2
kind: Mesh
metadata:
  name: sample-mesh
spec:
  namespaceSelector:
    matchLabels:
      mesh: sample-mesh
---
apiVersion: v1
kind: Namespace
metadata:
  name: ns1
  labels:
    mesh: sample-mesh
    appmesh.k8s.aws/sidecarInjectorWebhook: enabled

上記のようにMeshの設定ができ、meshラベルをNamespaceに付与することで、該当のNamespace内で作成したVirtualNodeはラベルで付与したmeshに自動的に登録されるようになります。
appmesh.k8s.aws/sidecarInjectorWebhook ラベルは、Pod起動時にSidecarコンテナとしてEnvoyをInjectさせるかどうかの制御で、これをenableにしておくとそのNamespace内で起動したPod全てにEnvoyがInjectされるようになります。
また、全てのPodにEnvoyをInjectさせたくない場合は、いくつか方法はありますが、例えば appmesh.k8s.aws/sidecarInjectorWebhook: enabled にしておき、InjectさせたくないPodに下記のannotationを付与するという方法があります。

annotations:
  appmesh.k8s.aws/sidecarInjectorWebhook: disabled

次に、EnvoyをInjectさせるService側に設定するVirtualNode等は下記のようなManifestになります。

元々、APIサーバーからgRPCサーバーへのリクエストを負荷分散させるためにEnvoyを入れていた箇所です。6)ECSの時の構成図
EKS+AppMeshに移行時には下記のような構成になっています。

apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualNode
metadata:
  name: api-server
spec:
  podSelector:
    matchLabels:
      app: api-server
  listeners:
    - portMapping:
        port: xxxx
        protocol: http
      timeout:
        http:
          perRequest:
            value: 60
            unit: s
          idle:
            value: 60
            unit: s
  backends:
    - virtualService:
        virtualServiceRef: 
          name: grpc-server
  serviceDiscovery:
    dns:
      hostname: api-server
---
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualNode
metadata:
  name: grpc-server
spec:
  podSelector:
    matchLabels:
      app: grpc-server
  listeners:
    - portMapping:
        port: xxxxx
        protocol: grpc
      healthCheck:
        port: xxxxx
        protocol: grpc
        healthyThreshold: 2
        unhealthyThreshold: 3
        timeoutMillis: 2000
        intervalMillis: 5000
      timeout:
        grpc:
          perRequest:
            value: 60
            unit: s
          idle:
            value: 60
            unit: s
  serviceDiscovery:
    awsCloudMap:
      namespaceName: grpc-server.local
      serviceName: grpc-server
---
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualService
metadata:
  name: grpc-server
spec:
  awsName: grpc-server.service.local
  provider:
    virtualRouter:
      virtualRouterRef:
        name: grpc-server-router
---
apiVersion: appmesh.k8s.aws/v1beta2
kind: VirtualRouter
metadata:
  name: grpc-server-router
spec:
  listeners:
    - portMapping:
        port: xxxxx
        protocol: grpc
  routes:
    - name: grpc-server-route
      grpcRoute:
        action:
          weightedTargets:
            - virtualNodeRef: 
                name: grpc-server
              weight: 1
        retryPolicy:
          maxRetries: 5
          perRetryTimeout:
            unit: ms
            value: 2000
          grpcRetryEvents:
            - cancelled
            - unavailable
          httpRetryEvents:
            - stream-error
            - gateway-error
          tcpRetryEvents:
            - connection-error

上記のManifestは全てAppMeshのCRDで、APIサーバーやgRPCサーバー側のManifestにはSidecarでEnvoyを入れる設定を書く必要がありません
App Mesh Controllerの機能で、Pod起動時にSidecarでEnvoyをInjectしてくれます。これによってAppMeshの設定と各ServiceのManifestは個別にできるため、アプリケーションとインフラの設定を分離した状態で管理することができるという利点があります。

また補足ですが、gRPCサーバー側のService Discoveryには下記のようにCloudMapを利用しています。

  serviceDiscovery:
    awsCloudMap:
      namespaceName: grpc-server.local
      serviceName: grpc-server

これは、Service DiscoveryにDNSを指定するとLogical DNSが使われgRPCの負荷分散に使えないためです。7)Virtual nodes

このようにApp Mesh Controllerを使うと、AppMeshに必要な設定を全てk8sのManifestで管理することができます。
開発環境を増やす場合は、私たちはManifest管理にkustomizeを使っているため、overlayで環境毎の差分を追記するだけでAppMeshの設定も楽に増やせます。
また、Serviceを増やす場合でも他のServiceとほぼ同様のManifestで良いです。デプロイ周りの設定も自前のツール等は用意せず、最終的にk8s(とデプロイに利用しているArgoCD8)EKSでのArgoCDを使ったGitOps CD)の機能のみ利用で本番導入できたため非常に簡潔になりました。

デプロイ時にリクエストが落ちる問題への対処

AppMesh検証時点で、予想通りデプロイ時にリクエストが落ちるという既存の構成と同様の問題がありました。
しかし、ECS時代のように自前でデプロイ周りの仕組みを用意する必要はなく、AppMesh周りの設定とk8sのPodの制御で解決できたので紹介していきます。

App Mesh best practicesに沿ったretryの追加、DeploymentのmaxSergeの設定

App Mesh best practicesというものがAWSのドキュメントにあり、これに沿って設定を修正しました。
retryの設定については、VirtualNodeのManifestに入れるだけで設定できます。
下記は前章で紹介したVirtualNodeのManifestのretry設定部分の抜粋です。

  routes:
    - name: grpc-server-route
      grpcRoute:
        action:
          weightedTargets:
            - virtualNodeRef: 
                name: grpc-server
              weight: 1
        retryPolicy:
          maxRetries: 5
          perRetryTimeout:
            unit: ms
            value: 2000
          grpcRetryEvents:
            - cancelled
            - unavailable
          httpRetryEvents:
            - stream-error
            - gateway-error
          tcpRetryEvents:
            - connection-error

CloudMap経由で作るレコードのTTLを0に

ECS時代でも同様のことがありましたが、STOPしたコンテナのIPへリクエストが行ってしまうためリクエストが落ちるという事象が起きていました。少しでもSTOPしたコンテナのIPへリクエストが行く可能性を減らすため、CloudMap経由で作るレコードのTTLを0に設定しました。
このレコードはVirtualNodeの設定経由でCloudMapで設定されるもので、TTLの値をApp Mesh Controllerから変更できます。9)https://github.com/aws/eks-charts/blob/master/stable/appmesh-controller/values.yaml#L71
検証時はTTLを0に設定できないbugがありましたが、これについては私たちの方でPRを投げ、今は修正が反映されているので0に設定できるようになっています。10)https://github.com/aws/eks-charts/pull/269

PodのpreStopでsleepを入れて調整

上記までの試みで軽減されてはいましたが、まだリクエストが落ちる問題が残っていました。
k8sのServiceからPodが切り離されるタイミングとCloudMapからPodのIPが削除されるのが完全に同期できないことが原因であると思われるので、sleepを挟んで少し待つことで対処しています。

spec:
  containers:
  - image: image
    name: name
    lifecycle:
      preStop:
        exec:
          command: [ "sleep", "60"]

以上、これらの方法で、本番導入しても問題はない程度にリクエストが落ちる問題の対処をすることができました。
retryやpreStopのsleepは、デプロイ時だけでなくコンテナの異常終了時も同じ挙動になるため、あらゆる場面でのPodの入れ替わり時において、リクエストが落ちてしまう問題に対応することができるようにもなっています。

今回の本番導入の時点では、AppMeshのVirtual Routerを利用したトラフィックの切り替え等の活用までは至りませんでしたが、さらなる安定化のため今後検証を進めていく予定です。

gRPCの負荷分散はどうなったか

元々、ECS時代にEnvoyを入れた目的はgRPCの負荷分散でした。
AppMesh導入後もgRPCの負荷分散が以前と同様にできているかの検証も行ったので、簡単に比較していきます。
1つのServiceのコンテナ毎のCPU使用率のグラフです。

上図の通り、AppMesh移行後も問題なくgRPCの負荷分散ができていることが確認できています。

まとめ

今回はEKS移行と共にAppMeshを本番導入し、gRPCサーバの負荷分散等、既存の要件を維持しつつEKS移行前にあった課題を解決できた話を紹介させていただきました。
まだAppMeshを、Envoyの管理をApp Mesh Controllerで楽にする、retry等を使用したリクエストが落ちてしまう問題へ対処等にしか活用できていないため、これらの点に絞った話にはなってしまいましたが、今回AppMeshを導入したことでサービスの通信可視化等、スタディサプリENGLISHのインフラ基盤の安定性向上のために様々なものを取り入れていく土台ができたと思っています。

AppMeshのロードマップ11)aws-app-mesh-roadmapを見ると今後も様々な機能のリリースが予定されているようなので、役立ちそうなものを検証していき積極的に取り入れていきたいと考えています。