はじめに
こんにちは、技術本部ML・データ部MLOpsブロックの鹿山(@Ash_Kayamin)です。先日、20個の開発環境APIを用意し、各APIをリクエストに応じて動的に起動できる仕組みをKnative Servingを用いて構築しました。
この記事ではKnative Servingを利用した背景と、利用方法、はまりどころ、利用によって得られたコスト削減効果についてご紹介します。なお、今回はKubernetesクラスタのバージョンとの互換性の都合でKnativev1.3.1
を利用しました。2022/9現在の最新バージョンはv1.7.1
になりますのでご注意ください。
目次
- はじめに
- 目次
- 課題:20個の異なる開発環境APIを低コストで提供したい
- 解決策:Knative Servingを用いて、リクエストに応じて動的にAPIサーバーを起動する仕組みを導入する
- Knative Servingとは
- Knative Servingを用いて実際にAPIを動かす
- どれくらい費用を削減できているのか?
- 今後の展望/終わりに
課題:20個の異なる開発環境APIを低コストで提供したい
ZOZOTOWNには20個の開発環境が存在し、それぞれが独立して開発できるように、20環境分の独立したAPIを提供する必要がありました。MLOpsブロックがGKEクラスタ上で提供するAPIも例外ではありません。しかしながら、単純に20環境を常に起動しておくとノードの費用が嵩んでしまいます。
解決策:Knative Servingを用いて、リクエストに応じて動的にAPIサーバーを起動する仕組みを導入する
今回の開発対象は開発環境のAPIであり、高いサービスレベルは求められていません。初回リクエストの処理に時間がかかっても問題なく、リクエスト数も少ないです。また、APIサーバーはコンテナ化されており、GKEクラスタ上で動いています。これらの前提から、コンテナ化されたAPIサーバーをリクエストに応じて動的に起動する仕組みを導入して、リクエストがない時のAPIサーバー費用を削減することを検討しました。
Google Cloud上でコンテナ化したAPIサーバーを動的に起動し、APIを提供する方法には、大きく分けてCloud Run
、Cloud Run For Anthos
、GKEクラスタ上でKnative Servingを動かす
の3通りがあります。次に、それぞれの概要・メリット・デメリットについてご説明します。
Google Cloud上でAPIコンテナを動的に起動する方法の比較
Cloud Run
Google Cloudが提供するマネージドなコンテナサービスです。後述するKnative Servingをベースに作られています。用意したエンドポイントへのリクエストに応じて事前に定義したDocker ImageからAPIサーバーを起動してリクエストを処理し、レスポンスを返すことができます。
- メリット
- Docker Imageを用意し、起動するAPIの定義・アクセス設定等を行えば即座に利用できる
- HTTPリクエスト以外にもさまざまなイベントをトリガーにコンテナを起動できる
- ゾーン障害への冗長性がデフォルトで備わっている
- コンテナ、Knative Serving関連のログ、メトリクスをCloud Logging、Cloud Monitoringで簡単に取得できる
- TerraformでCloud Runのインスタンスを定義・デプロイできる
- デメリット
- 既存のKubernetesマニフェストからTerraform定義を生成・更新しなくてはいけない
- 既存のGKEクラスタとはネットワーク構成が異なり、Shared VPCとのIngress、Egress通信を可能にするための構成が追加で必要となり複雑
Cloud Run For Anthos
Google Cloudが提供するマネージドなKnativeサービスです。Anthosを利用しているGKEクラスタ上にマネージド、かつサポート付きのKnativeを構築し、Knativeが提供する機能を全て利用可能です1。
- メリット
- GKEクラスタ上で他のAPIと同じように扱える
- Kubernetesマニフェストを用いて管理できる
- 同じコマンドラインツールを用いて、確認・操作ができる
- 既存のAPIと同じネットワーク・権限を利用できる
- Knative周りの挙動・エラーについてGoogle Cloudからのサポートを受けられる
- GKEクラスタ上で他のAPIと同じように扱える
- デメリット
- 有料かつクラスタ設定の変更が必要なAnthosの利用が必須
- Anthos Service Mesh(マネージドなIstio)の導入が必要
Knative Serving
Kubernetesオペレーターの一種であるKnativeの一部分であるKnative ServingをGKEクラスタ上で動かします。Knative Servingの機能を用いて、用意したエンドポイントへのリクエストに応じて事前に作成したDocker ImageからAPIサーバーを起動してリクエストを処理し、レスポンスを返します。
- メリット
- GKEクラスタ上で他のAPIと同じように扱える
- Kubernetesマニフェストを用いて管理できる
- 同じコマンドラインツールを用いて、確認・操作ができる
- 既存のAPIと同じネットワーク・権限を利用できる
- Knative ServiceのNetwork layerを自由に選択できる(Istio、Contour、Kourier)
- GKEクラスタ上で他のAPIと同じように扱える
- デメリット
- Knative Servingのインストール・運用・バージョンアップを自前で行わなくてはいけない
- Knative Servingのメトリクスを取得するためには、追加のセットアップをしてCloud Monitoringにメトリクスを送る等する必要がある
今回は次の理由からGKEクラスタ上でKnative Servingを動かす方針としました。
- 既存APIと同じGKEクラスタ上で動かすことによって、ネットワークや権限周りの構成、Kubernetesマニフェストを共通化して認知負荷を下げたい
- => Knative Serving,Cloud Run for Anthosの優先度が上がる
- 有料かつクラスタ設定の変更が必要なAnthosや、導入・運用コストが高いIstioを利用したい強い理由がない
- => Cloud Run for Anthosの優先度は下がる
- 提供する開発環境APIでは運用初期からの高いサービスレベルは求められていない
- => Cloud Run、Cloud Run for Anthosの高いサービスレベルは不要で、Knative Serving自前運用のリスクは許容できる
Knative Servingとは
Knavite ServingはKnativeを構成するコンポーネントの1つです。KnativeはKubernetes上でのServerless、 Event drivenなアプリケーションの構築をサポートするKubernetesオペレーターです。KnativeはServing、Eventingの2つのコンポーネントから構成されます。
ServingはKuberentes上でのServerless Containerの実現をサポートします。Serverless Containerとは何らかのイベント(HTTPリクエスト等)に応じてコンテナ化されたアプリケーションを0台の状態から起動、必要に応じて複数台にスケールし、処理を行う環境のことを指します。
ServingはServerless Container実現に必要なネットワーク周りの設定や処理、0台からのコンテナのオートスケール、コンテナのバージョン管理周りの処理を自動化してくれます。これによってユーザーは簡単にKubernetes上にServerless Containerを利用したサービスを実現できます。
EventingはKubernetes上でのHTTPリクエストを用いたイベントのPub/Subの実現をサポートします。Serving、Eventingの具体的な活用例については公式に構成図付きのサンプルが用意されているのでそちらをご覧ください。
今回、HTTPリクエストを受けて、リクエストのホスト名に応じたAPIサーバーを0台の状態から起動、または複数台にスケールし、処理を行ってレスポンスを返す仕組みを実現するために、Knative Servingを利用しました。
以下の図は、Knative Servingを用いて実現した環境を図解したものです。
Knative Servingの主要なコンポーネント
Knative Servingには代表的なカスタムリソースとしてService・Route・Configuration・Revision・Ingressが存在します。それぞれの役割を以下に示します。Serviceリソースを作成すると、その他のリソースはKnative Servingのカスタムコントローラーによって自動的に生成されます。Serviceリソースを通じて各種機能を利用するのが基本ですが、個別にConfiguration、 Route等を定義して挙動を制御することも可能です。
- Service
- Servingでコンテナを起動し、コンテナへのルーティングを実現するために必要な要素を抽象化したカスタムリソース(以降Knative/Serviceと呼びます)
- Knative/Serviceが作成されると、KnativeのカスタムコントローラーがKnative/Serviceで定義された情報に従って、Configuration・Revision・Route・Ingressを作成します
- Configuration
- 最新のRevisionの定義を保持します
- Revision
- ある時点のConfigurationを記録するスナップショットであり、Configurationが新規作成・更新される場合にConfigurationで定義された情報から生成されます
- KnativeのカスタムコントローラーはRevisionで定義された情報に従って、Deployment等を作成します
- Route
- リクエストをどのRevisionから生成されるPodにルーティングするのかを管理します
- Routeから、後述するNetwork layerの設定を抽象化したカスタムリソースIngressが生成されます
- Network layerには複数の実装の選択肢(Istio、Contour、Kourier)があり、どの実装で利用するIngressを生成するかをCofigMapで指定します
- Network layerでは生成されたIngressリソースをもとに、ルーティングを設定します
- Ingress
- Kubernetes標準のIngressリソースをKnative用に拡張したカスタムリソース(以降Knative/Ingressと呼びます)
- Network layerから参照され、Knative Servingでのリクエストのルーティングを実現するための情報を提供します
Red Hatさんのブログ記事、あらためてKnative入門!(Knative Servingやや発展編)で図付きのわかりやすい解説があるのでぜひこちらもご参照ください。
Network layer
Knative Servingで、受けたリクエストのコンテナまでのルーティングを実現するのがNetwork layerになります。Network layerにはIstio、Contour、Kourierの3つの選択肢があります。どれを選択した場合でもEnvoyを用いてルーティングを実現することには変わりはありません。Routeから生成されたKnative/Ingressの情報を元にNetwork layerがEnvoyコンテナを起動・設定・更新することで、ルーティングを実現します。
今回、Network layerにはKourierを選択しました。IstioやContourを利用する場合は、それらのカスタムリソース・カスタムコントローラーをKubernetesクラスタにインストールする必要があります。加えて、Knative/IngressからIstioやContourのIngressを生成するカスタムコントローラー(net-istio、net-contour)を動かす必要があります。
他方、KourierはKnative Servingのために開発されたIngress実装であり、カスタムリソースの定義は一切必要なく、カスタムコントローラーを動かすだけで良いです。Kourierのカスタムコントローラーnet-kourierはKnative/Ingressから直接Envoyの設定を生成し、Envoyコンテナに設定を反映することでルーティングを実現します。Network layerにIstio、Contourを利用する場合と比較して、Kourierを用いる構成は非常にシンプルであり、必要十分な機能を備えていたことがこの選択をした理由です。
Kourierの主要なコンポーネント
Kourierの主要なコンポーネントとその挙動を以下図に示します。
カスタムコントローラーである、Pod: net-kourier-controller
がKnative/Ingressに定義されたリクエストのルーティング情報を読み取り、そのルーティングを実現するためのEnvoyの設定を生成・保持・更新します。Pod: net-kourier-controller
が保持するEnvoyの設定は、Pod: 3scale-kourier-gateway
で起動するEnvoyコンテナからEnvoyのxDS APIを用いて随時読み取られる2ことで、Envoyコンテナでのルーティングが設定・変更されます。
ブログ記事、Kourier: A lightweight Knative Serving ingressに図付きのわかりやすい解説があるのでこちらもぜひご参照ください。
Knative Servingを用いて実際にAPIを動かす
ここからはGKEクラスタにKnative Servingを導入し、APIを動かすための具体的な設定と3つの手順についてご説明します。公式のYAMLファイルを用いてインストールする手順を参考に導入しました。
最終的に構築されるのは以下のシステムになります。
Namespace: knative-serving
- Knative Servingのカスタムコントローラー
Pod: controller
や、リクエストをキューイングしたり、リクエスト対象となるPodを起動・スケールさせるためのPod: activator
・Pod: autoscaler
といった、Knative Serving関連のリソースが作成されます。またKourierのカスタムコントローラーPod: net-kourier-controller
やConfigMap等もここに作成されます。
- Knative Servingのカスタムコントローラー
Namespace: kourier-system
- クラスタ外部からのリクエストを受け付けるためのロードバランサーをGKE Ingressを用いて作成するための
Ingress: kourier-internal-ingress
や、リクエストをホスト等の情報に基づき、あらかじめ設定したルールに基づいてルーティングを行うEnvoyコンテナを起動するPod: 3scale-kourier-gateway
等を作成します。
- クラスタ外部からのリクエストを受け付けるためのロードバランサーをGKE Ingressを用いて作成するための
Namespace: recommendation-module
- APIコンテナを起動する設定を記載した
Knative/Service: dev1~dev20
等を作成します。
- APIコンテナを起動する設定を記載した
今回の説明に必要となる主要なリソースだけ記載しており、実際にはこの図に記載した以外のリソースも数多く作成される点にご注意ください。
Cloud DNSで名前解決を行い、内部ロードバランサーに到達したリクエストは、Pod: 3scale-kourier-gateway
でホスト情報に基づきルーティングされます。ルーティングされたリクエストはPod: activator
でキューイングされ、必要に応じてPod: autoscaler
によってリクエスト対象となるPodが起動、またはオートスケールしたのちに対象となるPodに到達します。リクエスト対象のPodが起動しているか、同時に処理しているリクエストの数は設定されている同時処理数上限を上回っているか等によってルーティングの経路は変わります。より詳細は図付きの公式ドキュメントをご参照ください。
手順1. Knative Serving、KourierをGKEクラスタへインストールする
MLOpsブロックではKustomizeを用いてKubernetesマニフェストを管理しています。今回は以下のようなディレクトリ構成でKnative Serving、Kourierを導入しました。
./ ├── base │ └── knative-serving │ ├── knative-serving │ │ ├── configmap.yaml │ │ ├── deployment.yaml │ │ └── kustomization.yaml │ ├── kourier │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── kustomization.yaml │ │ └── service.yaml │ └── kustomization.yaml └── dev └── knative-serving └── kustomization.yaml
ファイルの内容は以下になります。
base/knative-serving/knative-serving/kustomization.yaml
- Knative公式のマニフェストファイルを取得して、一部ConfigMapにパッチを当てて適用します
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - https://github.com/knative/serving/releases/download/knative-v1.3.2/serving-core.yaml patchesStrategicMerge: - configmap.yaml - deployment.yaml
base/knative-serving/knative-serving/configmap.yaml
- いくつかのConfigMapにパッチを当てることでKnative Servingの設定を変更します
config-features
ではKnative/ServiceマニフェストのPod Specの特定の項目の利用を明示的に許可しています
apiVersion: v1 kind: ConfigMap metadata: name: config-network Namespace: knative-serving data: ingress.class: kourier.ingress.networking.knative.dev # IngressにはKourierを利用することを設定 autocreate-cluster-domain-claims: "true" # 各Namespace でのサブドメインの自動生成、割り当てを許可 --- apiVersion: v1 kind: ConfigMap metadata: name: config-domain Namespace: knative-serving data: example.zozo.com: | # 特定のドメインへのリクエストをどのKnative/Serviceにマッピングするかを指定 selector: run: zozo-module-recommendations-api --- apiVersion: v1 kind: ConfigMap metadata: name: config-features Namespace: knative-serving data: kubernetes.podspec-affinity: "Allowed" # Knative/ServiceマニフェストのPod SpecでのAffinityの指定を許可 kubernetes.podspec-tolerations: "Allowed" kubernetes.podspec-fieldref: "Allowed"
base/knative-serving/kourier/kustomization.yaml
- Knative公式のマニフェストファイルを取得して、一部Serviceにパッチを当てて適用します
- GKE Ingressで内部Load Balancer(以下LBと記述)を作成しルーティングできるようにするため、Ingressマニフェストの追加・Serviceへのパッチ当てをしています
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - https://github.com/knative/net-kourier/releases/download/knative-v1.3.0/kourier.yaml - ./ingress.yaml patchesStrategicMerge: - ./service.yaml - ./deployment.yaml
base/knative-serving/kourier/service.yaml
- GKE Ingressで内部LBを作成しルーティングできるようにするため、デフォルトでは
type: Loadbalancer
となっているところをtype: Nodeport
に変更しています - 既存の接続先ポートに加えて、ヘルスチェックのため9000番ポートへの接続も追加しています
apiVersion: v1 kind: Service metadata: name: kourier Namespace: kourier-system annotations: cloud.google.com/neg: '{"ingress": true}' cloud.google.com/backend-config: '{"default": "kourier-backend-config"}' spec: type: NodePort # GKE Ingressを利用するため、LoadBalancerからNodePortに変更 ports: # LBからのヘルスチェックを行うため、kourier-gateway (envoy container) pod がデフォルトでlistenしているポートへのルーティングを設定 - name: http-port9000 port: 9000 protocol: TCP targetPort: 9000
base/knative-serving/kourier/ingress.yaml
- GKE Ingressを用いて内部LBを作成するためのマニフェスト
- LBのヘルスチェック先として、Nodeportで追加した9000番ポートの/readyを指定しています
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: kourier-internal-ingress Namespace: kourier-system annotations: kubernetes.io/ingress.regional-static-ip-name: "kourier" # 必要なIPアドレスは事前に割り当て kubernetes.io/ingress.class: "gce-internal" spec: defaultBackend: service: name: kourier port: number: 80 --- apiVersion: cloud.google.com/v1 kind: BackendConfig metadata: name: kourier-backend-config Namespace: kourier-system spec: healthCheck: # ヘルスチェック先のパスとポートを明示的に指定 type: HTTP requestPath: /ready port: 9000
導入で躓いた点
Knative Serving、KourierをGKEクラスタへインストールする際にいくつかつまづいた点があるのでご紹介します。
Kustomizeを利用する場合はserving-crds.yamlを個別にapplyする必要はない
Knative ServingをYAMLからインストールする公式の手順では、serving-crds.yaml
をapplyしたのちに、serving-core.yaml
をapplyしています。ですが、Kustomizeを用いてapplyする場合はserving-crds.yaml
のapplyは不要です。Custom Resource Difinition(以下CRD)はserving-crds.yaml
,serving-core.yaml
両方に定義されています。2つをまとめてapplyしようとするとKustomizeが重複を検出してエラーになります。
issueのコメントにあるように、CRDが作成されていない段階でCustom Resource(以下CR)のマニフェストを処理しようとしてエラーになることを回避するために、公式ドキュメントでは順にapplyする手順が示されています。しかしながら、Kustomizeを用いる場合は順にapplyする必要はないため、serving-core.yaml
をapplyするだけで問題ありません。
KourierのカスタムコントローラーはNamespace: knative-serving
に作成する必要がある
Knative Servingを動かすクラスタはマルチテナントクラスタになっており、通信の制御・権限の分割するためにリソースを作成するNamespaceを分けています。そのため、Kustomizeでリソースを作成する際にはNamespaceを明示的に指定しています。その一環で、Kourierのリソースを作成するNamespaceをKustomizeで明示的にkourier-system
と指定していたところ、リクエストをルーティングできない問題が発生しました。
Knative ServingのマニフェストをapplyするとNamespace: knative-serving
が作成され、Knative Serving関連のリソースはこのNamespaceに作成されます。そしてKourierのマニフェストをapplyするとNamespace: kourier-system
が作成され、以下Kourier関連のリソースはこの2つのNamespaceに作成されます。(今回のエラーに関連するもののみ明示しています)
Namespace: knative-serving
Deployment: net-kourier-controller
: Knative/IngressからEnvoy設定ファイルを生成したりするKourierのカスタムコントローラーのDeploymentService: net-kourier-controller
:net-kourier-controller
へルーティングするClusterIP
Namespace: kourier-system
Deployment: 3scale-kourier-gateway
: Knative ServingでKourierを利用する場合に、リクエストのルーティングを担うEnvoyコンテナのDeploymentConfigMap: kourier-bootstrap
:3scale-kourier-gateway
で起動するEnvoyコンテナの初期設定を含むConfigMap
Kourierはkourier-system
にEnvoyコンテナを起動するDeployment: 3scale-kourier-gateway
を作成します。合わせて作成されるConfigMap: kourier-bootstrap
にEnvoyの初期設定が定義されており、こちらがEnvoyコンテナ起動時にマウントされて利用されます。Envoyには外部から動的に設定を読み込んで反映する仕組みがあります。Knative Servingでのルーティングの設定変更にはxDS API(KourierではそのうちのgRPCを利用)を用いて外部から設定を取得する仕組みが用いられています。この設定取得先はConfigMap: kourier-bootstrap
で指定されています。ConfigMap: kourier-bootstrap
に記載の、Envoyの設定を取得する先dynamic_resources
として指定されているxds_clustrer
のaddress
の値にはnet-kourier-controller.knative-serving
が指定されています。したがってEnvoyコンテナはNamespace: knative-serving
のService: net-kourier-controller
で名前解決される先からEnvoyの設定を取得しようとします。
Kourierのマニフェストで作成されるリソースの作成先NamespaceをKustomizeで一律にkourier-system
としてしまうと、Namespace: knative-serving
にはDeployment: net-kourier-controller
、Service: net-kourier-controller
が作成されません。結果、Envoyコンテナでの設定取得先の名前解決に失敗してしまいます。そのため、何かしらのKnative/Serviceを作成しても、Knative/Serviceから作成されるPodへのルーティング設定はEnvoyコンテナに反映されず、リクエストの適切なルーティングができなくなっていました。
最終的にはデフォルトのKourierのマニフェスト通り、Namespace: knative-serving
にDeployment: net-kourier-controller
、Service: net-kourier-controller
を作成してエラーを解消しました。
Envoyコンテナのエラーログ抜粋 - gRPC周りの部分でエラーが発生していることが分かるのでこのエラーを起点に調査をしました。
[2022-07-14 07:19:22.380][1][warning][config] [bazel-out/k8-opt/bin/source/common/config/_virtual_includes/grpc_stream_lib/common/config/grpc_stream.h:63] Unable to establish new stream [2022-07-14 07:19:34.345][1][warning][config] [bazel-out/k8-opt/bin/source/common/config/_virtual_includes/grpc_stream_lib/common/config/grpc_stream.h:101] StreamAggregatedResources gRPC config stream closed: 14, no healthy upstream
ConfigMap: kourier-bootstrap
のマニフェスト関連部分抜粋
apiVersion: v1 kind: ConfigMap metadata: name: kourier-bootstrap Namespace: kourier-system ~~~ data: envoy-bootstrap.yaml: | dynamic_resources: ads_config: transport_api_version: V3 api_type: GRPC rate_limit_settings: {} grpc_services: - envoy_grpc: {cluster_name: xds_cluster} cds_config: resource_api_version: V3 ads: {} lds_config: resource_api_version: V3 ads: {} ~~~ clusters: ~~~ - name: xds_cluster connect_timeout: 1s type: strict_dns load_assignment: cluster_name: xds_cluster endpoints: lb_endpoints: endpoint: address: socket_address: address: "net-kourier-controller.knative-serving" # 設定取得先のドメイン(${Service名}.${Namespace名})が指定されている port_value: 18000 http2_protocol_options: {} type: STRICT_DNS ~~~
LBからのヘルスチェックに成功させるために適切なポート、 パスを設定する必要がある
Kourierの公式YAMLで作成されるService: kourier
はtype: Loadbalancer
であり、作成されるTCP/UDPロードバランサーでは各ノードで起動するkube-proxyに対してヘルスチェックを行います。
一方、GKE Ingressを利用してHTTP(S)ロードバランサーを作成する場合は、type: Nodeport
なServiceで接続する先のPodに対してヘルスチェックを行うため、Podにヘルスチェックのエンドポイントを用意する必要があります。そのため、KourierでGKE Ingressを利用するためにはService: kourier
をtype: Nodeport
に単純に変更するだけでは駄目で、Service接続先のEnvoyコンテナにヘルスチェックエンドポイントを用意する必要がありました。
EnvoyにはヘルスチェックのエンドポイントとしてGET /readyが存在し、Deployment: 3scale-kourier-gateway
のreadinessProbeではこちらを利用しています。このエンドポイントへのリクエストにはヘッダーHost: internalkourier
を付与する必要があります。しかしながら2022/9現在、BackendConfigを用いたGKE Ingressのヘルスチェック定義では、ヘルスチェックのHTTPリクエストにカスタムヘッダーを付与できません。GCLBに設定できるヘルスチェックの設定項目ではカスタムヘッダーを指定できますが、BackendConfigからは設定できません。そのため、別途カスタムヘッダーが不要なヘルスチェック用のエンドポイントをEnvoyコンテナに設定する必要があります。
Envoyコンテナの初期設定として利用されるConfigMap: kourier-bootstrap
を調べると、9000番ポートでGETメソッドに対して/ready
エンドポイントが公開されていました。そこで今回はService: kourier
にパッチを当ててtype: Nodeport
とした上で、9000番ポートを開けて、Backendconfigにコンテナの9000番ポートへのヘルスチェックを設定しました。
ConfigMap: kourier-bootstrapのマニフェスト関連部分抜粋
apiVersion: v1 kind: ConfigMap metadata: name: kourier-bootstrap namespace: kourier-system ~~~ data: envoy-bootstrap.yaml: | ~~~ static_resources: listeners: - name: stats_listener address: socket_address: address: 0.0.0.0 port_value: 9000 filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: stats_server http_filters: - name: envoy.filters.http.router route_config: virtual_hosts: - name: admin_interface domains: - "*" routes: # /ready を含む一部のadmin_interfaceを公開している - match: safe_regex: google_re2: {} regex: '/(certs|stats(/prometheus)?|server_info|clusters|listeners|ready)?' headers: - name: ':method' exact_match: GET route: cluster: service_stats clusters: - name: service_stats connect_timeout: 0.250s type: static load_assignment: cluster_name: service_stats endpoints: lb_endpoints: endpoint: address: pipe: path: /tmp/envoy.admin ~~~ http2_protocol_options: {} type: STRICT_DNS admin: access_log_path: "/dev/stdout" address: pipe: path: /tmp/envoy.admin
手順2. カスタムドメインを設定し、適切にルーティングされることを確認する
Knative Servingでは、Knative/Serviceで定義されるPodへ繋がるFQDNは以下のように定められます。
${Routeのname}.${Knative/Routeを作成したNamespace}.${ConfigMap: config-domainで定義されたドメイン}
例えば以下のようにNamespace: recommendation-module
にKnative/Service: dev1
を作成するとRoute: dev1
が自動で作成されます。合わせてConfigMap: config-domain
でドメインを指定することで、最終的に
dev1.recommendation-module.example.zozo.com
というドメインがKnative/Service: dev1
に割り当てられます。
Knative/Service: dev1
apiVersion: serving.knative.dev/v1 kind: Service metadata: name: dev1 namespace: recommendation-module labels: run: zozo-module-recommendations-api spec: template: ~~~
Knative/Service: dev1
の作成によってRoute: dev1
が作成される
❯ kubectl get ksvc --namespace=recommendation-module NAME URL LATESTCREATED LATESTREADY READY REASON dev1 http://dev1.recommendation-module.example.zozo.com dev1-00002 dev1-00002 True ❯ kubectl get route --namespace=recommendation-module NAME URL READY REASON dev1 http://dev1.recommendation-module.example.zozo.com True
ConfigMap: config-domain
apiVersion: v1 kind: ConfigMap metadata: name: config-domain namespace: knative-serving data: # ラベル run: zozo-module-recommendations-apiを持つRouteにドメインexample.zozo.comへのリクエストを紐づける example.zozo.com: | selector: run: zozo-module-recommendations-api
基本は上記設定に従い、ホストヘッダーベースのルーティングをEnvoyコンテナで行います。Knative/Serviceマニフェストのspec.traffic.tag
に値を設定することでFQDNのホスト部分をルーティング対象とするRevision毎に作り分けたり3、同じFQDNを利用しつつもリクエストにKnative-Serving-Tag
ヘッダーを付与することでリクエスト先を分ける4ことも可能です。
ここまででKnative Servingで利用するFQDNが定まりました。次はそのFQDNで、Deployment: 3scale-kourier-gateway
のPod(Envoyコンテナ)をバックエンドに持つLBへリクエストが名前解決されるようにする必要があります。
今回はGKE Ingressを利用しているので、GKE Ingressで作成される内部LBのIPアドレスへ名前解決されるように、CloudDNSにワイルドカードAレコードを設定しました。具体的には*.recommendation-module.example.zozo.com.
に対するAレコードを作成し、Namespace: recommendation-module
以下に作成されるKnative/Serviceへのリクエストは全て内部LBのIPアドレスへ名前解決されるようにしました。こうすることで、作成したFQDNに対するリクエストはEnvoyコンテナを経由し、Envoyコンテナで各Knative/Serviceから生成されるPodへとホストヘッダーベースのルーティングが行われます。
この状態でリクエストを行うと以下のようにPodが起動しレスポンスが返されます。
リクエストはPod: activator
でキューイングされます。キューイング時に、リクエスト先のPodが起動していない・起動しているPod数xPod毎の並列リクエスト処理数設定
がリクエストに対して不足している場合はPod: autoscaler
がDeploymentのReplicasを更新することでルーティング先のPodを必要な台数起動します。
Podが起動したら、キューイングされていたリクエストがPodに送られます。オートスケーリングの挙動(どれくらいのリクエストが来たらPod数を増やす・どれくらいの間リクエストが来なければPod数を減らすか等)はKnative/Serviceで細かく設定できます5。Podを起動するのに十分なリソースを持ったノードがない場合は、GKEに設定しているノードプールのオートスケール機能でノードが追加されてからPodがスケジュールされて起動するため、ノード起動を待つ分だけレスポンスタイムは長くなります。
curlのログ
$ curl -v "http://dev1.recommendation-module.example.zozo .com/api/v1/zozo/home-modules/?app=pc&mall=shoes&sex=all&member_id=30&ga_client_id=deviceId 30" * Trying 10.96.97.135:80... * Connected to dev1.recommendation-module.example.zozo.com (10.96.97.135) port 80 (#0) > GET /api/v1/zozo/home-modules/?app=pc&mall=shoes&sex=all&member_id=30&ga_client_id=deviceId30 HTTP/1.1 > Host: dev1.recommendation-module.example.zozo.com > User-Agent: curl/7.78.0-DEV > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/1.1 200 OK < content-length: 14865 < content-type: text/plain;charset=UTF-8 < date: Wed, 10 Aug 2022 04:28:59 GMT < x-envoy-upstream-service-time: 26199 < server: envoy < via: 1.1 google < ~~~
Deployment・Revision・Podの変化
# リクエスト前 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 0 0 ❯ kubectl get deploy --namespace=recommendation-module NAME READY UP-TO-DATE AVAILABLE AGE dev1-00002-deployment 0/0 0 0 5d21h # リクエスト直後 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 0 1 ❯ kubectl get deploy --namespace=recommendation-module NAME READY UP-TO-DATE AVAILABLE AGE dev1-00002-deployment 0/1 1 0 5d21h ❯ kubectl get pod --namespace=recommendation-module NAME READY STATUS RESTARTS AGE dev1-00002-deployment-65f8d57f4c-c8dns 1/2 Running 0 19s # レスポンスを返した直後 ❯ kubectl get revision --namespace=recommendation-module NAME CONFIG NAME K8S SERVICE NAME GENERATION READY REASON ACTUAL REPLICAS DESIRED REPLICAS dev1-00002 dev1 2 True 1 1 ❯ kubectl get pod --namespace=recommendation-module NAME READY STATUS RESTARTS AGE dev1-00002-deployment-65f8d57f4c-z6r66 2/2 Running 0 73s
Envoyコンテナのログ
[2022-08-10T04:28:33.808Z] "GET /api/v1/zozo/home-modules/?app=pc&mall=shoes&sex=all&member_id=30&ga_client_id=deviceId30 HTTP/1.1" 200 - 0 14865 26201 26199 "10.96.66.8,10.96.97.135" "curl/7.78.0-DEV" "d10aca2a-c215-418e-84eb-12c569dc2754" "dev1.recommendation-module.example.zozo.com" "10.96.68.3:8012"
Activatorコンテナのログ
# リクエストを受けてスケールアウトさせる {"severity":"INFO","timestamp":"2022-08-10T04:28:55.574545201Z","logger":"activator","caller":"net/throttler.go:318","message":"Set capacity to 2147483647 (backends: 1, index: 0/1)","commit":"ac29233","knative.dev/controller":"activator","knative.dev/pod":"activator-6c496d64d8-kskdk","knative.dev/key":"recommendation-module/dev1-00002"} # リクエストが来なくなったのでスケールインさせる {"severity":"INFO","timestamp":"2022-08-10T04:30:03.613571203Z","logger":"activator","caller":"net/throttler.go:318","message":"Set capacity to 0 (backends: 0, index: 0/1)","commit":"ac29233","knative.dev/controller":"activator","knative.dev/pod":"activator-6c496d64d8-kskdk","knative.dev/key":"recommendation-module/dev1-00002"}
手順3. Knative/Serviceマニフェストを既存のDeploymentマニフェストから生成する
Knative/Serviceマニフェストは既存の開発環境APIを定義するDeploymentマニフェストからスクリプトで生成するようにしました。該当Deploymentマニフェストに変更が加えられた際には、スクリプトを実行することでKnative/Serviceのマニフェストも更新し、変更を反映します。Deploymentに変更が入ったにも関わらず、生成しているKnative/Serviceのマニフェストに変更が反映されていない場合はCIでエラーになるようにし、反映忘れを防ぐようにしました。
Knative/Serviceマニフェストの生成方法ですが、基本的には、Deploymentのspec.template.spec
(=PodSpec)の値をそのままKnative/Servingのspec.template.spec
の値とするだけでよいです6,7。Deploymentで定義されている、spec.selector
やspec.strategy
等はKnative/Servingでは定義されていないので適宜除去する必要があります。PodSpecのいくつかの項目についてはConfigMap: config-features
で明示的に利用を許可する必要があります8。既存のDeploymentマニフェストでは、起動するノードを指定するためにnodeAffinity
・toleration
を、Podが起動したノードのIPアドレスをKubernetes Downward API経由で取得するためにfieldRef
の利用しているのでこれらの利用を許可しました。
apiVersion: v1 kind: ConfigMap metadata: name: config-features namespace: knative-serving data: kubernetes.podspec-affinity: "Allowed" kubernetes.podspec-tolerations: "Allowed" kubernetes.podspec-fieldref: "Allowed"
スクリプトで生成したKnative/Serviceマニフェストknative-service-generated.yaml
を元に以下のようなディレクトリ構成でdev1〜20環境を構成しました(関連するファイルのみ記載しています)。
./ ├── base │ ├── zozo-module-recommendations-api │ │ └── deployment.yaml │ └── zozo-module-recommendations-api-knative │ ├── kustomization.yaml │ ├── custom-schema.json # 既存のKuberenetesクラスタのOpenAPIスキーマに、Knative/ServiceのOpenAPIスキーマを追記したjsonファイル │ └── knative-service-generated.yaml # zozo-module-recommendations-api/deployment.yaml からスクリプトで生成する └── dev └── zozo-module-recommendations-api-knative ├── kustomization.yaml # dev1~20 の kustomization.yaml を参照する ├── dev1 │ ├── knative-service.yaml │ └── kustomization.yaml # base の kustomizatio.yaml を参照する ├── dev2 │ ├── knative-service.yaml │ └── kustomization.yaml ~~~ └── dev20 ├── knative-service.yaml └── kustomization.yaml
base/zozo-module-recommendations-api-knative/kustomization.yaml
- Knative/ServiceのOpenAPIスキーマ情報を追加で読み込み、patchStrategicMergeの挙動をカスタマイズしています(後述)
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: recommendation-module resources: - knative-service-generated.yaml # OpenAPIのスキーマJSONを指定することで、カスタムリソースであるKnative/ServiceのpatchStrategicMerge方法を指定する # ref. https://github.com/kubernetes-sigs/kustomize/blob/master/examples/customOpenAPIschema.md openapi: path: custom-schema.json
dev/zozo-module-recommendations-api-knative/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: recommendation-module resources: - ./dev1 - ./dev2 ~~~ - ./dev20
dev/zozo-module-recommendations-api-knative/dev1/kustomization.yaml
- JSON Patchでリソース名を環境毎に書きかえます
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: recommendation-module resources: - ../../../base/zozo-module-recommendations-api-knative patchesStrategicMerge: - knative-service.yaml # リソース名を変更するために、patchesJsonを利用する patchesJson6902: - target: group: serving.knative.dev version: v1 kind: 'Service' name: 'zozo-module-recommendations-api' patch: |- - op: replace path: "/metadata/name" value: dev1
dev/zozo-module-recommendations-api-knative/dev1/knative-service.yaml
- 各環境毎に、image・環境変数にパッチを当てています。必要に応じて変更します。
apiVersion: serving.knative.dev/v1 kind: Service metadata: name: zozo-module-recommendations-api spec: template: spec: containers: - name: zozo-module-recommendations-api image: gcr.io/example:dev env: - name: GCS_BUCKET value: example-bucket ~~~ - name: ENABLE_SWAGGER value: "true"
複数環境の構築で躓いた点
カスタムリソースに対するKustomizeのpatchStrategicMergeの挙動は自ら定義する必要がある
従来、baseのマニフェストで環境共通の値を定義し、環境間で異なる一部の値(環境変数など)のみpatchStrategicMergeでパッチを当てて定義していました。Kubernetesネイティブなリソース(PodやDeployment等)に対するpatchStrategicMergeの挙動はKustomize内で定義されています。例えばPodのspec.containers[]
は配列で値が定義されますが、nameが一致した配列要素のvalueのみを置換する挙動が定義されているため、patchStrategicMergeを用いて配列の値の追加・一部置き換えができます。
他方、カスタムリソースに対するpatchStragtegicMergeの挙動はKustomizeにはデフォルトでは定義されていません。カスタムリソースに定義されているフィールドの値の置換方法は自ら定義しKustomizeに設定する必要があります。挙動が定義されていない場合、JSON Patchの挙動となり、合致するフィールドの値をそのまま置き換えてしまいます。Knative/Serviceのマニフェスト内でspec.template.spec.containers[]
の中身を環境毎に一部置き換えようとするとbase側で定義しているspec.template.spec.containers[]
全体がパッチで定義している値で置き換えられてしまいました。
この問題に対してはKustomize側で対応方法が用意されています。KustomizeにカスタムリソースのAPIについてのOpenAPIのスキーマJSONファイルを渡すことで、mergeの挙動を指定できます9。 今回、具体的には以下3つのステップで理想とする挙動を実現しました。
(1)Knative ServingのCRDをapply済みのKubernetesクラスタから既存のOpenAPIスキーマJSONを取得する
$ kustomize openapi fetch > custom-schema.json
(2)Knative/Service関連部分のスキーマを編集する
取得したデフォルトのOpenAPIスキーマJSONではRevisionSpec以下のフィールドについての定義が省略されていること、RevisionSpecはいくつかの独自のフィールドとPodSpecのインライン展開で構成されていることから以下のように考えました。
- RevisionSpecの部分について明示的にスキーマを定義することでpatchStrategicMergeの挙動をカスタマイズする
- 既存のPodSpecに対する挙動を実現できれば良いので、PodSpecのスキーマをそのまま挿入すればよい
- ※ Kubebuilderのinlineアノテーションに相当する機能はOpenAPIにはないので、PodSpecのスキーマを参照するのではなく、コピー&ペーストで挿入しています
custom-schema.json
修正前
~~~ "dev.knative.serving.v1.Service": { ~~~ "properties": { ~~~ "spec": { ~~~ "properties": { "template": { "description": "Template holds the latest specification for the Revision to be stamped out.", "properties": { "metadata": { "x-kubernetes-preserve-unknown-fields": true }, "spec": { # RevisionSpec以下のスキーマが省略されている "description": "RevisionSpec holds the desired state of the Revision (from the client).", "required": [ "containers" ], "x-kubernetes-preserve-unknown-fields": true } }, "type": "object" }, "traffic": { ~~~
custom-schema.json
修正後
~~~ "dev.knative.serving.v1.Service": { ~~~ "properties": { ~~~ "spec": { ~~~ "properties": { "template": { "description": "Template holds the latest specification for the Revision to be stamped out.", "properties": { "metadata": { "x-kubernetes-preserve-unknown-fields": true }, "spec": { # RevisionSpec以下のスキーマを明示的に定義する "containerConcurrency": { "format": "int64", "type": "integer" }, "timeoutSeconds": { "format": "int64", "type": "integer" }, "responseStartTimeoutSeconds": { "format": "int64", "type": "integer" }, "idleTimeoutSeconds": { "format": "int64", "type": "integer" }, # 以下はPodSpec のスキーマからコピペしたもの "activeDeadlineSeconds": { ~~~ "containers": { "description": "List of containers belonging to the pod. Containers cannot currently be added or removed. There must be at least one container in a Pod. Cannot be updated.", "items": { "$ref": "#/definitions/io.k8s.api.core.v1.Container" }, "type": "array", "x-kubernetes-patch-merge-key": "name", # 何をキーにマージをするかを指定 "x-kubernetes-patch-strategy": "merge" # patchを当てる際の挙動を指定 }, "required": [ "containers" ], "type": "object" } }, "type": "object" }, "traffic": { ~~~
(3)(2)で作成したスキーマをKustomizeで用いる
base/zozo-module-recommendations-api-knative/kustomization.yaml
にopenapi
ブロックを追加してスキーマを指定
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: recommendation-module resources: - knative-service-generated.yaml # OpenAPIのスキーマJSONを指定することで、カスタムリソースであるKnative/ServiceのpatchStrategicMerge方法を指定する # ref. https://github.com/kubernetes-sigs/kustomize/blob/master/examples/customOpenAPIschema.md openapi: path: custom-schema.json
どれくらい費用を削減できているのか?
今回の用途でKnative Serving関連のPodを起動するにあたっては、e2-small($12.23/月)
ノードが4台あれば十分でした。
APIで利用するノードはn1-standard4($97/月)
であり、リクエストが来た時のみ起動します。追加で用意したdev1〜20環境へは、開発案件がある時にのみリクエストが来るため、大きく見積もっても全環境合計で1ヶ月に1インスタンス分の料金になります。シンプルに20環境分のAPIを立ち上げる場合と比べると約92%(≒100-(12.23(e2-small)*4+97(n1-standard4))/(97(n1-standard4)*20(環境数))*100
)、年間換算で$21,417
の費用削減になりました。
もちろん、あまり利用されない大量の開発環境APIを常に起動しておくのは現実的ではないので、実際にはここまでの費用削減にはなりません。また、依頼ベースで開発環境APIを起動・停止するといった手間のかかる運用作業がないのは嬉しいポイントです。
今後の展望/終わりに
本記事では複数のAPIを集約するマルチテナントGKEクラスタ上に、Knative Servingを用いて、既存のAPIをServerless Containerとして手軽に提供する環境を構築する方法をご紹介しました。今後はこの方法を用いて他のAPIも必要に応じて20個の開発環境APIを用意し、ZOZOTOWNの開発をより高速に進められる環境を整えていきます。
ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!
- Cloud Run は理解した。Cloud Run for Anthos って何?↩
- Envoy公式Doc: Configuration Reference↩
- Knative公式Doc: Traffic management↩
- Knative公式Doc: Tag Header Based Routing↩
- Knative公式Doc: Autoscaling↩
- Knative公式Doc: Converting a Kubernetes Deployment to a Knative Service↩
- serving.knative.dev/v1 Service↩
- Knative公式Doc: Feature and extension flags↩
- Kustomize公式Doc: Using a Custom OpenAPI schema↩