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