Knative Servingを用いて多数の開発環境APIを低コストで構築する

ogp

はじめに

こんにちは、技術本部ML・データ部MLOpsブロックの鹿山(@Ash_Kayamin)です。先日、20個の開発環境APIを用意し、各APIをリクエストに応じて動的に起動できる仕組みをKnative Servingを用いて構築しました。

この記事ではKnative Servingを利用した背景と、利用方法、はまりどころ、利用によって得られたコスト削減効果についてご紹介します。なお、今回はKubernetesクラスタのバージョンとの互換性の都合でKnativev1.3.1を利用しました。2022/9現在の最新バージョンはv1.7.1になりますのでご注意ください。

目次

課題:20個の異なる開発環境APIを低コストで提供したい

ZOZOTOWNには20個の開発環境が存在し、それぞれが独立して開発できるように、20環境分の独立したAPIを提供する必要がありました。MLOpsブロックがGKEクラスタ上で提供するAPIも例外ではありません。しかしながら、単純に20環境を常に起動しておくとノードの費用が嵩んでしまいます。

解決策:Knative Servingを用いて、リクエストに応じて動的にAPIサーバーを起動する仕組みを導入する

今回の開発対象は開発環境のAPIであり、高いサービスレベルは求められていません。初回リクエストの処理に時間がかかっても問題なく、リクエスト数も少ないです。また、APIサーバーはコンテナ化されており、GKEクラスタ上で動いています。これらの前提から、コンテナ化されたAPIサーバーをリクエストに応じて動的に起動する仕組みを導入して、リクエストがない時のAPIサーバー費用を削減することを検討しました。

Google Cloud上でコンテナ化したAPIサーバーを動的に起動し、APIを提供する方法には、大きく分けてCloud RunCloud Run For AnthosGKEクラスタ上で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からのサポートを受けられる
  • デメリット
    • 有料かつクラスタ設定の変更が必要な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)
  • デメリット
    • 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 ServingKnativeを構成するコンポーネントの1つです。KnativeはKubernetes上でのServerless、 Event drivenなアプリケーションの構築をサポートするKubernetesオペレーターです。KnativeはServingEventingの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を用いて実現した環境を図解したものです。

goal.png

Knative Servingの主要なコンポーネント

Knative Servingには代表的なカスタムリソースとしてService・Route・Configuration・Revision・Ingressが存在します。それぞれの役割を以下に示します。Serviceリソースを作成すると、その他のリソースはKnative Servingのカスタムコントローラーによって自動的に生成されます。Serviceリソースを通じて各種機能を利用するのが基本ですが、個別にConfiguration、 Route等を定義して挙動を制御することも可能です。

knative-components.png

  • 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にはIstioContourKourierの3つの選択肢があります。どれを選択した場合でもEnvoyを用いてルーティングを実現することには変わりはありません。Routeから生成されたKnative/Ingressの情報を元にNetwork layerがEnvoyコンテナを起動・設定・更新することで、ルーティングを実現します。

今回、Network layerにはKourierを選択しました。IstioやContourを利用する場合は、それらのカスタムリソース・カスタムコントローラーをKubernetesクラスタにインストールする必要があります。加えて、Knative/IngressからIstioやContourのIngressを生成するカスタムコントローラー(net-istionet-contour)を動かす必要があります。

他方、KourierはKnative Servingのために開発されたIngress実装であり、カスタムリソースの定義は一切必要なく、カスタムコントローラーを動かすだけで良いです。Kourierのカスタムコントローラーnet-kourierはKnative/Ingressから直接Envoyの設定を生成し、Envoyコンテナに設定を反映することでルーティングを実現します。Network layerにIstio、Contourを利用する場合と比較して、Kourierを用いる構成は非常にシンプルであり、必要十分な機能を備えていたことがこの選択をした理由です。

Kourierの主要なコンポーネント

Kourierの主要なコンポーネントとその挙動を以下図に示します。

kourier-components.png

カスタムコントローラーである、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ファイルを用いてインストールする手順を参考に導入しました。

最終的に構築されるのは以下のシステムになります。

whole-system.png

  • Namespace: knative-serving
    • Knative ServingのカスタムコントローラーPod: controllerや、リクエストをキューイングしたり、リクエスト対象となるPodを起動・スケールさせるためのPod: activatorPod: autoscalerといった、Knative Serving関連のリソースが作成されます。またKourierのカスタムコントローラーPod: net-kourier-controllerやConfigMap等もここに作成されます。
  • Namespace: kourier-system
    • クラスタ外部からのリクエストを受け付けるためのロードバランサーをGKE Ingressを用いて作成するためのIngress: kourier-internal-ingressや、リクエストをホスト等の情報に基づき、あらかじめ設定したルールに基づいてルーティングを行うEnvoyコンテナを起動するPod: 3scale-kourier-gateway等を作成します。
  • Namespace: recommendation-module
    • APIコンテナを起動する設定を記載したKnative/Service: dev1~dev20等を作成します。

今回の説明に必要となる主要なリソースだけ記載しており、実際にはこの図に記載した以外のリソースも数多く作成される点にご注意ください。

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に作成する必要がある

kourier-envoy-namespace-problem

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のカスタムコントローラーのDeployment
    • Service: net-kourier-controller: net-kourier-controllerへルーティングするClusterIP
  • Namespace: kourier-system
    • Deployment: 3scale-kourier-gateway: Knative ServingでKourierを利用する場合に、リクエストのルーティングを担うEnvoyコンテナのDeployment
    • ConfigMap: 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_clustreraddressの値にはnet-kourier-controller.knative-servingが指定されています。したがってEnvoyコンテナはNamespace: knative-servingService: net-kourier-controllerで名前解決される先からEnvoyの設定を取得しようとします。

Kourierのマニフェストで作成されるリソースの作成先NamespaceをKustomizeで一律にkourier-systemとしてしまうと、Namespace: knative-servingにはDeployment: net-kourier-controllerService: net-kourier-controllerが作成されません。結果、Envoyコンテナでの設定取得先の名前解決に失敗してしまいます。そのため、何かしらのKnative/Serviceを作成しても、Knative/Serviceから作成されるPodへのルーティング設定はEnvoyコンテナに反映されず、リクエストの適切なルーティングができなくなっていました。

最終的にはデフォルトのKourierのマニフェスト通り、Namespace: knative-servingDeployment: net-kourier-controllerService: 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: kouriertype: Loadbalancerであり、作成されるTCP/UDPロードバランサーでは各ノードで起動するkube-proxyに対してヘルスチェックを行います。

一方、GKE Ingressを利用してHTTP(S)ロードバランサーを作成する場合は、type: NodeportなServiceで接続する先のPodに対してヘルスチェックを行うため、Podにヘルスチェックのエンドポイントを用意する必要があります。そのため、KourierでGKE Ingressを利用するためにはService: kouriertype: 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-moduleKnative/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.selectorspec.strategy等はKnative/Servingでは定義されていないので適宜除去する必要があります。PodSpecのいくつかの項目についてはConfigMap: config-featuresで明示的に利用を許可する必要があります8。既存のDeploymentマニフェストでは、起動するノードを指定するためにnodeAffinitytolerationを、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[]全体がパッチで定義している値で置き換えられてしまいました。

./images/kustomize-behavior.png

この問題に対しては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.yamlopenapiブロックを追加してスキーマを指定
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では一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

corp.zozo.com hrmos.co

カテゴリー