ZOZOMATにおけるEKSやgRPCを用いたシステム構成と課題解決

ZOZOテクノロジーズSRE部の市橋です。普段は主にAWSを用いて複数プロダクトのシステム構築、運用に携わっています。今回は2020年2月にリリースされたZOZOMATについて、システム構成と開発時に直面した課題、その課題を解決するために工夫した点について紹介します。

ZOZOMATではEKSやgRPCを新規に採用しており、これによって仕様の変更に強くなる、通信のオーバーヘッドを削減できるなど様々なメリットを享受できました。しかし導入時に一筋縄ではいかないことがあったため、今回苦戦した点についてご紹介できればと思います。

ZOZOMATとは

お客様の足を3Dで計測するために開発された計測用マットです。ZOZOMATでの計測情報をもとに、靴の推奨サイズを参照するなどのサービスをご利用いただくことが可能です。ご興味のある方はこちらをご確認ください。

ZOZOMATのシステム構成

システムの全体構成は以下のようになります。今回は構成図内のユーザートラフィックを処理するNLB、EKS周りが話の中心となります。この後説明していきます。

ZOZOMATシステムはAWS上に構築しています。システム監視にはDatadogを利用しており、Slackにインテグレーションして通知を行っています。

クライアントはネイティブアプリケーション(ZOZOTOWNアプリ)、ZOZOTOWNサーバーの2つで、上記の構成図内の上部に位置するものが該当します。通信方式は前者がHTTP/2(gRPC)、後者がHTTP/1.1(REST)となっています。

それぞれの役割をまとめると以下のようになります。

ZOZOTOWNアプリでは計測した時の足の画像データを元に各部位の計測値と3Dモデリングのデータを生成し、ZOZOMATシステムのデータベースに保存します。ZOZOTOWNサーバーからは靴の推奨サイズを参照する際にリクエストされ、保存されている計測情報を元に推奨サイズを計算し、ユーザーに結果を表示します。

前述の通り、アプリケーション実行基盤としてAWSのフルマネージド型のKubernetesサービスであるEKSを採用しました。EKSのワーカーノードはワーカーノードタイプと起動タイプの組み合わせから選択できます。

ワーカーノードタイプ 起動タイプ 特徴
セルフマネージド型 EC2 EC2インスタンスを自前で作成、管理する必要がある。
AutoScalingグループを自前で設定、管理する必要がある。
EC2インスタンスをEKSクラスターに参加させるために満たさなければならない要件が多い。
マネージド型 EC2 EC2インスタンスを自前で管理する必要がある。
EC2インスタンスをEKSクラスターに参加させるための設定が省略できる。
AutoScalingグループを設定することなく、水平スケーリングが可能。
マネージド型 Fargate EC2インスタンスの管理が不要。
EC2がプロビジョニングされないため柔軟なキャパシティ管理が可能。
NLBは2020年5月時点で未サポート。

今回は管理コストを削減することを目的として、マネージド型ワーカーノード、EC2起動タイプを選択しました。インスタンス管理が不要になるメリットを享受したくFargateの利用も検討したのですが、NLBに対応していない点で今回のシステム要件を満たすことができないことから採用を見送りました。NLBが必要な理由については後述します。

EKS内のpodに着目すると以下のようになります。

前述の通り、ZOZOMATシステムではgRPCとREST、両方のリクエストを受け付けられる必要があります。そのため、EnvoyのgRPC-JSON transcoder機能により、REST形式のAPIリクエストをgRPCに変換する処理を行っています。

アプリケーションはユーザーリクエストを受け付け、計測結果の登録やS3の署名付きURLの発行等を担うものと、計測後に表示される足形診断や靴のサイズ推奨値を計算する機械学習系のものに大別されます。前者はScala、後者はPythonで書かれています。上記のアプリケーションはそれぞれリソース、スケール要件が異なるため、それぞれ別のノードグループに配置しています。metrics-serverやexternal-dnsなどAPI処理以外の用途のpodについても、他のpodと比べて必要リソースや可用性レベルが下がるため別のノードグループに配置しました。

直面した課題

ここまではZOZOMATのシステム構成について説明しました。ここからはこの構成で開発を進める中で直面した課題についてみていきます。

1. 秘密情報の取り扱いについて

まず、秘密情報の取り扱いについてです。Kubernetesで秘密情報を扱う場合は、Secretリソースを利用します。公式からの抜粋となりますが、利用方法は以下のようになります。

$ echo -n '1f2d1e2e67df' | base64
MWYyZDFlMmU2N2Rm
apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  username: YWRtaW4=
  password: MWYyZDFlMmU2N2Rm

秘密情報として管理したい値をbase64エンコードしてマニフェストファイルに設定し、kubectl applyコマンドにより適用することで利用可能になります。しかし、この例は秘密情報をbase64エンコードしているだけでデコードも容易なため、このマニフェストファイルをGitHub等で構成管理してしまうと安全とは言えません。

この問題に対して、大きく2つの方法で対応しました。

1つは、initContainersを利用する方法です。この方法ではSecretを利用しません。initContainersは本来稼働させたいコンテナが起動する前に起動し、事前処理を行わせることができます。役割を全うしたらinitContainersはTerminateするため、余分なリソースを必要とすることなく運用できます。

以下は証明書情報をSecretsManagerから取得するときの例になります。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    run: nginx
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      run: nginx
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
    type: RollingUpdate
  template:
    metadata:
      labels:
        run: nginx
    spec:
      initContainers:
      - name: set-cert
        image: infrastructureascode/aws-cli
        command: ["sh", "-c"]
        args:
        - |
          aws secretsmanager get-secret-value --secret-id ssl/certificate --region ap-northeast-1 --query "SecretString" --output text > /tmp/server.pem
          aws secretsmanager get-secret-value --secret-id ssl/privatekey --region ap-northeast-1 --query "SecretString" --output text > /tmp/server.key
        volumeMounts:
        - mountPath: /tmp/
          name: cert
      containers:
      - image: nginx
        ports:
          - name: https
            containerPort: 443
        name: nginx
          volumeMounts:
            - mountPath: /tmp/
              name: cert
      volumes:
        - name: cert
          emptyDir: {}

まず、initContainersからawscliを使ってSecretsManagerから証明書情報を取得し、マウントしたvolumeに保存します。その後、稼働させたいコンテナから証明書情報が保存されたvolumeをマウントすることで、証明書を利用することが可能になります。

もう1つの方法としては、GoDaddy社製のkubernetes-external-secretsというツールを使用する方法です。このツールを利用するとSecretsManager、またはSystemsManagerから値を取得し、その値をKubernetesのSecretリソースに格納できます。インストール方法は公式ページの記載の通り、以下のコマンドを実行してマニフェストファイルを取得し、kubectl applyによって適用することで利用可能になります。

$ git clone https://github.com/godaddy/kubernetes-external-secrets  
$ helm template -f charts/kubernetes-external-secrets/values.yaml --output-dir ./output_dir ./charts/kubernetes-external-secrets/  

インストールが完了したら、任意の値をSecretsManagerから取り込むマニフェストを作成することで利用可能となります。以下にSecretsManagerからDBの接続情報を取得し、環境変数に設定する例を示します。前提としてSecretsManagerには以下のような形で格納されていることとします。

シークレット名 シークレット値(Key) シークレット値(Value)
db/connect_info username hogehoge
password fugafuga

マニフェストのサンプルとしては以下のようになります。

apiVersion: kubernetes-client.io/v1
kind: ExternalSecret
metadata:
  name: external-secret-db-info
spec:
  backendType: secretsManager
  data:
    - key: db/connect_info
      property: username
      name: db-username
    - key: db/connect_info
      property: password
      name: db-password

kubernetes-external-secretはkindに ExternalSecret を指定することで利用できます。今回はSecretsManagerを利用するので、backendTypeに secretsManager を指定します。data の設定項目の意味はそれぞれ以下のようになります。

設定項目 説明
key SectetsManagerから取得したいシークレット名を指定
property SectetsManagerから取得したいシークレット値のKeyを指定
name KubernetesのSecretとして設定する名前を指定

次にSecretを利用する側のマニフェストを以下に示します。ポイントとしては、secretKeyRef のnameにはexternal-secretsで設定した .metadata.name(external-secret-db-info)、keyには .spec.data.name(db-username、またはdb-password)を指定します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: api-sample
          imagePullPolicy: IfNotPresent
          env:
            - name: DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: external-secret-db-info
                  key: db-username
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: external-secret-db-info
                  key: db-password

これらの方法を使うことで、安全に秘密情報を管理することが可能になります。

2. NLBがALPNに対応していない件について

ZOZOMATシステムではgRPCを利用して通信しています。gRPCはHTTP/2を利用した通信となるため、ロードバランサーがHTTP/2に対応している、もしくはL4(TCP)ロードバランサーである必要があります。Application Load Balancerは、フロントエンド(リスナー)はHTTP/2に対応しているものの、バックエンド(ターゲット)はHTTP/1.1にしか対応していません。そのため、ロードバランサーの背後で稼働するgRPCアプリケーションにリクエストを転送できません。AWSで利用できるHTTP/2の通信に対応したロードバランサーはNetwork Load Balancer(以下、NLB)、Classic Load Balancer(以下、CLB)となります。CLBはEC2-Classicネットワーク内に構築されたシステムの場合に使う必要があるのですが、それ以外の場合は性能上の理由から採用する理由はないため、実質NLB一択となります。

しかし、NLBでTLS終端しようとした際、ALPN(Application-Layer Protocol Negotiation)に未対応である点が問題となりました。ALPNはTLSの拡張で、同じTCPまたはUDPポートで複数のアプリケーションプロトコルがサポートされている場合にTLSのコネクション内で使用されるプロトコルをネゴシエートするものです。HTTP/2でTLSを利用する場合はこのALPNが前提となっており、gRPCクライアントとNLB間で通信に失敗するという事象が発生しました。

この問題に対して、NLBのリスナーをTCPモードに設定し、NLB配下に置かれているEnvoyにTLS終端の役割を担わせることで対応しました。この方法を取る場合は、ACMが利用できないためSSL/TLS証明書を自前で購入、管理する必要があるという注意点があります。

まとめると以下のようになります。

本章の見出し、文中にNLBがALPNに対応していないと書いていますが、本記事の執筆中に対応したようです。ただし、現時点ではHTTP/2通信のTLS終端はできないようです。着実に対応が進んでいることは感じ取れるので、NLBでHTTP/2通信のTLS終端に対応する日を心待ちにしたいと思います。

Network Load Balancer now supports TLS ALPN Policies

3. 特定APIエンドポイントへのアクセス元IPアドレス制限について

次にアクセス元のIP制限についてみていきます。システム構成を見て頂くと分かる通り、ZOZOMATシステムではネイティブアプリケーションからの通信以外に、ZOZOTOWNサーバーとのAPI連携を行っています。ZOZOTOWNサーバーから実行されるAPIについてはアクセス元のIPアドレスを特定できるため、制限をかける必要があります。

よくある構成の例として、ALBを利用する場合はALBのセキュリティグループにアクセス元のIPアドレスのみ許可する設定を行うことで制限をかけることが可能です。しかし、今回はNLBを利用するためセキュリティグループを設定できません。今回の構成において、アクセス元のIP制限がかけられるネットワーク内のノードと、APIエンドポイント単位の制限の可否についてまとめると下記のようになります。

構成ノード APIエンドポイント単位の制限可否 特徴
AWS Network ACL × ステートレスなため、戻りのトラフィックも考慮する必要がある。
通常、AWSを利用する上ではあまり意識しないNTP等もルールを追加する必要がある。
ワーカーノード セキュリティグループ × -
Kubernetes NetworkPolicy × -
Envoy HTTP filters Lua拡張を利用することでAPIエンドポイント毎のIP制限を設定することが可能。

上記からAPIエンドポイント毎にIP制限をかけることができるEnvoyのHTTP Filtersの機構を利用しました。これを実現するには、EKSがクライアントのIPアドレスを取得できるようにする必要があります。まず、EnvoyのServiceリソースを記載しているマニフェストに externalTrafficPolicy: Local の設定をします。これを設定することでクライアントのIPアドレスをx-forwarded-forヘッダから取得することが可能になります。

マニフェストの例を以下に示します。

apiVersion: v1
kind: Service
metadata:
  name: envoy
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
    service.beta.kubernetes.io/aws-load-balancer-internal: "false"
spec:
  type: LoadBalancer
  selector:
    app: envoy
  ports:
    - name: https
      protocol: TCP
      port: 443
      targetPort: 443
  externalTrafficPolicy: Local

なお、この設定値はデフォルトだと cluster が設定されています。この設定の場合、ワーカーノードにリクエストが到達した後に別のワーカーノードにもリクエストを転送することが可能になり、podの負荷を均等に保つことができます。しかしこれを実現するために各ワーカーノード上で稼働するkube-proxyが送信元、送信先IPアドレスを書き換える必要があり、その結果クライアントのIPアドレスを取得できなくなります。

続いてEnvoyの設定をみていきます。以下のものがEnvoyのConfigMapになります。

apiVersion: v1
kind: ConfigMap
metadata:
  name: envoy-conf
data:
  envoy.yaml: |
    static_resources:
      listeners:
      - address:
          socket_address:
            address: 0.0.0.0
            port_value: 443
        filter_chains:
        - filters:
          - name: envoy.http_connection_manager
            config:
              access_log:
              - name: envoy.file_access_log
                config:
                  path: "/dev/stdout"
              codec_type: AUTO
              stat_prefix: ingress_http
              use_remote_address: true
              route_config:
                name: local_route
                virtual_hosts:
                - name: http
                  domains:
                  - "*"
                  routes:
                  - name: api
                    match:
                      prefix: "/"
                    route:
                      cluster: api
                      timeout: 60s
                      retry_policy:
                        retry_on: "connect-failure"
                        num_retries: 3
              http_filters:
              - name: envoy.lua
                typed_config:
                  "@type": type.googleapis.com/envoy.config.filter.http.lua.v2.Lua
                  inline_code: |
                    function envoy_on_request(request_handle)
                      local request_path = request_handle:headers():get(":path")
                      if string.match(request_path, "/hogehoge/%w") then
                        local ip_whitelist = os.getenv('IP_WHITELIST')
                        local source_ip = string.gsub(request_handle:headers():get("x-forwarded-for"), "%.", "%%.")

                        if not(string.match(ip_whitelist, source_ip)) then
                          request_handle:respond({[":status"] = "404"})
                        end
                      end
                    end
              - name: envoy.router
                config: {}
          tls_context:
            common_tls_context:
              alpn_protocols:
              - "h2,http/1.1"
              tls_certificates:
              - certificate_chain:
                  filename: "/tmp/server.pem"
                private_key:
                  filename: "/tmp/server.key"
      clusters:
      - name: api
        connect_timeout: 5s
        type: STRICT_DNS
        dns_lookup_family: V4_ONLY
        lb_policy: ROUND_ROBIN
        drain_connections_on_host_removal: true
        http2_protocol_options: {}
        load_assignment:
          cluster_name: api
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: api-service.default.svc.cluster.local
                    port_value: 8080
        health_checks:
          timeout: 2s
          interval: 3s
          unhealthy_threshold: 2
          healthy_threshold: 2
          grpc_health_check: {}
    admin:
      access_log_path: "/dev/stdout"
      address:
        socket_address:
          address: 127.0.0.1
          port_value: 8090

Envoy側でもクライアントのIPアドレスを扱うために use_remote_address: true を設定する必要があります。今回は特定エンドポイントに対してIP制限をかける必要があるのですが、Envoyに備わっている機能だけでは実現できないため、Luaを使って機能拡張する必要があります。以下がLua拡張の抜粋部分になります。

http_filters:
- name: envoy.lua
  typed_config:
    "@type": type.googleapis.com/envoy.config.filter.http.lua.v2.Lua
    inline_code: |
      function envoy_on_request(request_handle)
        local request_path = request_handle:headers():get(":path")
        if string.match(request_path, "/hogehoge/%w") then
          local ip_whitelist = os.getenv('IP_WHITELIST')
          local source_ip = string.gsub(request_handle:headers():get("x-forwarded-for"), "%.", "%%.")
          if not(string.match(ip_whitelist, source_ip)) then
            request_handle:respond({[":status"] = "404"})
          end
        end
      end

ファンクションに envoy_on_request を指定することでリクエストを受け付けた際に任意の処理を実行できます。レスポンス時に処理させたい場合は envoy_on_response を指定します。処理の内容としてはヘッダからリクエストパスを取得し、任意のパスであればIPアドレスの検査を行います。IPアドレスがホワイトリストに含まれていなければEnvoyが404を返し、バックエンドへの転送は行われなくなります。上記の設定を行うことでエンドポイントごとのアクセス元のIPアドレス制限を設定できます。

まとめ

今回はZOZOMATシステムの構成と開発時に苦労した点を紹介しました。これからEKSの導入、またはAWS上でgRPC通信を考えている方に何か1つでも参考になる点があれば幸いです。リリースはできたもののまだまだ改善点はあるので、1つずつ改善してより良いサービスに成長させていきたいです。

ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

tech.zozo.com

カテゴリー