見出し画像

プロダクトのBackendをServerless化した話

ナレッジワークでソフトウェアエンジニアをしている @yudoufu です。

先日、ナレッジワークのプロダクトBackendはGKEからCloud Runへの移行を終え、サブシステムを含むプロダクト全体がServerless化されました。

今回は、ナレッジワークのプロダクト本体のAPI BackendをGKEからCloud Runに移植した話を紹介します。

初期のナレッジワークのシステム構成

ナレッジワークでは立ち上げ当初より、サービス本体とも言えるAPIバックエンドをGKE(Standard)環境で構築・運用されていました。

開発最初期には当然、PMFを目指すためにプロダクトには様々な試行錯誤的な機能追加が行われることになり、またシステムのワークロードなども含めて今後の運用形態に不透明な部分が多くあります。

そのため、システムの機能面・性能面の両面で拡張に対する柔軟性が高く、かつIaC運用との相性もそこそこ良いKubernetes/GKEが採用されていたようでした。
選定時には自分はJoinしていませんでしたが、多方面で不確実性の高い中でのバランスを取った選択だったと思います。

サービスの現状に合った形を求めて

サービスの形が固まって利用者がすこしずつ増えたりチームが出来上がってくると、徐々に不確実だった部分が明確化されてきます。

まず、利用者が増えてきたことで、トラフィックパターンからサービス利用が極端に平日日中に集中していることが明確になってきました。
深夜・土日には無風状態となる時間も多く、意外と多くのサーバコストが無駄になっていることがわかりました。

そのため、不要な時間帯におけるコストのロスを極小化しやすいGKE Autopilot or Cloud Runへの移行案が浮上し始めます。

一方、内部構成としては、この時点でシステムの中に複数のサブシステムが出来上がっていました。

  • Officeドキュメント等をPDFに変換し、サムネイルを生成するcontent-processingシステム

  • メールの送信を流量制御するemail-senderシステム

  • 外部IdPサービスとSCIMによってアカウント連携を行うscim-authシステム

  • etc..

これらは、責任が分解しやすい & APIバックエンドの本体とはトラフィックの特徴が異なるため、GKE上に配置せずCloud Run & Cloud Functionsを軸としたServerless構成で作られました。
APIバックエンド本体も当面は(モジュラー)モノリス *1 で作り続けていくという判断もあり、GKE内でサービスが増えていく可能性も薄くなっていました。

そういった、Serverless構成が増えてきている状況とGKEの活用度の低さから、システム全体で仕組みを統一して運用負担を下げる意味でGKE AutopilotではなくCloud Runを選定することにしました。*2

*1 初期はチーム議論の結果モノリスを選択していましたが、現在はモジュラーモノリス化しています。ナレッジワークではわりと高頻度でリファクタリングが行われており、その一環の中でアーキテクチャもフェーズに合わせて変更されています。
*2 もちろんこれ以外にも、すべてGCPの世界で完結できる・Audit logからGKE内部の通信のような余計な情報が外せる・といったメリットもCloud Run選定に影響しました。

要件が異なる環境への移行の不確実さ

GKE上で動いていたPodをCloud Runに移行する際、その特性の変化に合わせていくつかの点で改修・変更を求められることになります。

  • 内部で非同期処理(goroutine)していたものの同期・もしくは外部化(Task化) *3

  • プライベートアクセスへの対応・Sidecarとして動いていたシステムの分離

  • Kubernetes上に保持していたSecretの移設

    • 外部から参照できず、k8sクラスタ自体も撤去予定のため

  • etc

これらの対応にはインフラとアプリケーション両面の修正が必要であり、不確実要素も多くて期間も長い、リスクのある移行になります。

そのため、移行計画を立てるにあたり

  • サービスを極力止めずにスムーズに移行できる

  • 何かあればすぐにもとの構成に戻れる

  • 既存のアプリケーション開発の進行を阻害しない

といった基本方針を定めて、これを叶える仕組みを検討しました。

*3 Cloud Run では通常、リクエストの処理中にのみ CPU が割り当てられます。レスポンス返却後の非同期処理はCPU 割り当てがなくなるため、リクエスト内で処理するか別のシステムに処理を移譲することが求められます。(別の手として、always-on CPU allocatedを使う選択肢もあります)

安全に、歩みを止めずにシステムを移行する

上記のような要件を叶えるためには、まず新旧のシステムを並行稼働しつつ切り替えていく計画を立てました。

その上でサービスの切替ポイントとして、Load Balancerによるheaderベースのトラフィックルーティングと、アプリケーションの環境に合わせた挙動変更の2箇所のフラグを設定することにしました。

フラグを2箇所に分離しておくことでアプリケーション改修の責任とインフラ構築の責任を分離し、かつ他の機能改修と競合せずにそれぞれ独立のスケジュールで作業できるようにする目的です。

(下記の図は、当時のDesignDocから持ってきたOverviewの転載です)

このように並行稼動しておくことで、常に既存のGKE環境を生かしたまま新環境の構築・アプリケーション対応をすすめられるようにしました。

GKE上のサービスをCloud Runへ移行する

経緯と基本的な方針についてご紹介したところで、GKEからCloud Runへの移行計画が具体的にどのようなものだったか紹介していきます。

設計当時のDesignDocの図を用いて紹介していきますが、それと共に考慮事項や現実としてどうであったかのポイントなどについても触れていきます。

従来のGKE構成

元々ナレッジワークで利用していたGKEではさほど複雑なことはせず、以下のようなシンプルな構成でした。

構成の特徴としては、アプリケーションのAPI管理にEndpointsを利用しており、各PodにSidecarとしてESPが同居している点があります。
API自体はgRPCで開発しており、それをESPがHTTP<->gRPC変換を行ってHTTP APIとして提供しています。

Load BalancerもIngressとしてGKEの世界で管理されており、k8s用のyaml(や一部helmfile)で定義されていました。
当然これらからGCPリソースも参照しており、それらのGCPリソースやそのrole管理用のservice accountなどはterraformで管理されていました。

つまり、ナレッジワークのインフラリソースは大きく分けて、以下のような2種類の管理に別れていたことになります。

  • GKEの世界のリソース: k8sのyaml

  • GCPの世界のリソース: terraform

最終的に目指すゴールの意味するところは、これらをできうる限りterraform管理に移すということでもありました。

Step1: Load BalancerをGKE管理からGCP管理へ

アプリケーションを改修・テストしていくにあたって、できるだけ早く実際と同じリクエストが受けられる環境を作りたかったため、最初にLoad Balancerの構築から着手することにしました。

既存のAPI全体がGKEの世界にあるので、これをCloud Runと共存させていくために、Load BalancerをGCPの世界に配置し、そこからGKEへroutingするような構成変更を行います。

GKEのIngressが内部的にNetwork Endpoint Group(NEG)を作成しているため、これを参照して新しく作成するLoad Balancerに接続します。

下図はGCPのLoad Balancerのアーキテクチャを示したものですが、ここでのVIP〜 Backend Service までを自作し、NEG以下をGKEのIngressが作成したものを流用するようにします。

(by https://cloud.google.com/kubernetes-engine/docs/how-to/standalone-neg#standalone_negs )

参照部分の擬似的なterraformの実装としては、以下のような状態です。

これによってGCPの世界からGKE内部のServiceにhealth check & 分散できるようになります。
あとはDNSレコードを新しいLoad Balancerに向け直せば、新しいLoad Balancerを経由してこれまでと同様のPodへの分散を実現できます。

data "google_compute_network_endpoint_group" "gke_neg" {
  name = var.gke_neg
  zone = var.zone
}

resource "google_compute_backend_service" "gke" {
  name = "${var.prefix}-backend-gke"

  protocol      = "HTTP"
  enable_cdn    = false
  health_checks = [google_compute_health_check.gke.self_link]

  backend {
    group = data.google_compute_network_endpoint_group.gke_neg.self_link
  }
}

Step2: Cloud Runを用いた新構成を並列展開する

次にCloud Run構成にした新環境を構築していきました。

ナレッジワークの構成では、先述の通りAPIアプリケーションの手前にESPが配置されています。
GKEではSidecarとなっていましたが、Cloud Runでは1 containerしか配置できないため、Cloud Runを2つに分けて2段のCloud Runで構成する形になります。

このため、新構成では一段目のESP Cloud RunがLoad Balancerからのトラフィックを受けつつ、そのBackendとしてAPIアプリケーションのCloud Runにリクエストが送られます。

Cloud RunをLoad BalancerのBackendとする場合には、直接Cloud Runのendpontを接続するのではなく、Serverless NEGを作成してそれをBackend Serviceにする必要があります。

terraformでの実装としては以下のようになります。

resource "google_cloud_run_service" "esp" {
  location = var.region
  name     = "${var.prefix}-endpoint"

  metadata {
    annotations = {
      "run.googleapis.com/ingress" = "internal-and-cloud-load-balancing"
    }
  }
}

resource "google_cloud_run_service_iam_member" "esp" {
  location = var.region
  service  = google_cloud_run_service.esp.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

resource "google_compute_region_network_endpoint_group" "esp" {
  region                = var.region
  name                  = "${var.prefix}-endopint-neg"
  network_endpoint_type = "SERVERLESS"
  cloud_run {
    service = google_cloud_run_service.esp.name
  }
}

resource "google_compute_backend_service" "default" {
  name    = "${var.prefix}-backend-default"

  # Timeout sec is not configurable for serverless NEG.
  # timeout_sec                     = 30
  enable_cdn                      = false
  health_checks                   = null

  backend {
    group =  google_compute_region_network_endpoint_group.esp.self_link
  }
}

重要な点として、Load BalancerからCloud Runへの接続はIAM role等で判定が行えないため、呼び出し(invoker)権限はallUsersに与えつつ、接続元(ingress)設定として `internal-and-cloud-load-balancing` として制限しています。
こうすることでCloud Runが保有するendpointへの接続が内部通信だけであることを保証しています。*4

こうして作成したBackend Serviceと、GKE側のBackend SerivceをHTTP Headerによってルーティングするようにします。

resource "google_compute_url_map" "default" {
  name            = "${var.prefix}-url-map"
  default_service = google_compute_backend_service.gke.id

  host_rule {
    hosts        = ["*"]
    path_matcher = "allpaths"
  }

  path_matcher {
    name            = "allpaths"
    default_service = google_compute_backend_service.gke.id

    route_rules {
      priority = 1
      service  = google_compute_backend_service.default.id
      match_rules {
        prefix_match = "/"
        ignore_case  = true
        header_matches {
          header_name = "KWORK-USE-NEWPF"
          exact_match = "1"
        }
      }
    }
  }
}

これで、Header によってそれぞれの環境を使い分けることができるようになりました。*5

アプリケーション自体は、それぞれの環境にCloud Buildで同一アプリケーションをdeployした上でENVとして環境用のフラグを渡すことで切り替えるようにしていました。

これらの実装によって、既存システムと並行稼動させつつ、新環境特有の実装の対応・テストを気軽に行えるようになりました。

フラグはご契約のあるテナントごとに切り替えられる仕組みになっているので、お客様の環境に影響が出ないよう自社のデバッグ用テナントから切り替えを行うなどして、細かい問題を潰しながら切り替え対応を進めていきました。

*4 ちなみに ESP Cloud Run -> gRPC Cloud Run の間はIAMのroleで限定できるのでそちらを使うのがおすすめです。
*5 なお余談ですが、GCPのLoad BalancerはWebコンソールからだとルーティング機能を部分的にしか利用できないため、header base routingを利用しようとすると必然的にterraformでの実装を迫られます。これはplayground的な実験をするときには、やや手間でした。割とサクサク切り替えテストしたい機能なので、早く実装されてほしいですね。

Step3: 完全な切り替えとGKEの撤去

最終的には、Load BalancerのDefault Backendを新環境に切り替えを完了させ、残ったGKE resourceの撤去を完了させるところでゴールとなります。


Default Backend切り替えの際、自分が採用した方式は以下のようにURL Mapの向き先をフラグ切り替えしてデフォルト自体を切り替える方法でした。

locals {
  default_backend_service = var.enable_default_new_pf ? google_compute_backend_service.default.id : google_compute_backend_service.gke.id
  flagged_backend_service = var.enable_default_new_pf ? google_compute_backend_service.gke.id : google_compute_backend_service.default.id
}

resource "google_compute_url_map" "default" {
  name            = "${var.prefix}-url-map"
  default_service = local.default_backend_service

  host_rule {
    hosts        = ["*"]
    path_matcher = "allpaths"
  }

  path_matcher {
    name            = "allpaths"
    default_service = local.default_backend_service

    route_rules {
      priority = 1
      service  = local.flagged_backend_service
      match_rules {
        prefix_match = "/"
        ignore_case  = true
        header_matches {
          header_name = "KWORK-USE-NEWPF"
          exact_match = var.enable_default_new_pf ? "0" : "1"
        }
      }
    }
  }
}

切り替えの作業自体はメンテ時間を設けた中で実施していたので問題なかったのですが、上記の方式は同一resourceを使える代わりに実は数分程度のサービス断となってしまう対応でした。

より安全に実施するには、ここではもう1台Default Backendを切り替え済みのLoad Balancerを用意してDNS切り替えを再度実施するのが良い対応だろうと思います。

ともあれ、ここでの切り替えが完了すればあとは順次リソースを削除すれば完了です。
上記のURL Mapから以下のように各種ruleを削除するような対応を実施しましたが、これについてはトラフィックが途切れることなくサービス継続しながらの切り替えができました。

resource "google_compute_url_map" "default" {
  name            = "${var.prefix}-url-map"
  default_service = google_compute_backend_service.default.id
}

なお、削除の際に一気にclusterを消して終了でも良いのですが、個人的には関連リソースを確実に消し切るために構成要素の上位レイヤーから順に消していくことをおすすめします。

devなどでそれらを丁寧に行っておくことで、思わぬ暗黙の依存によってリソースが消えたり、逆に残ってしまうという状態を防ぐことができます。

おわりに

今回はナレッジワークのインフラでGKEを廃止してCloud Runに移行した流れを紹介しました。
これによって、ナレッジワークの現在のインフラは一部特殊なものを除きServerlessなシステムとなりました。

ただ、もちろんこれは現在の状況に応じて選択した構成であって、その最適解は様々な状況の変化によって変わってくると考えています。

例えば今よりも人数が増えてプロダクトチームが複数になってくると、コンウェイの法則に従って複数サービス化・マイクロサービス化が進むことになる可能性があります。
そうなってきた場合にはまた、GKE環境を改めて選択したりAnthosを採用してService Meshの恩恵を受けよう、と考えるかも知れません。

重要なことは、アプリケーションに限らずインフラにおいても、そのように常に状況に合わせたより良い形にリファクタリングし続けられる状況を保つことではないかなと思います。

もし、今回の話や考え方などに興味を持ってもらえたら、ぜひカジュアルにお話しましょう。ご応募TwitterのDM、下記カジュアル面談フォームなどからお気軽にご応募ください!


みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!