TinderのKubernetesへの移行

作成者:エンジニアリングマネージャークリスオブライエン|エンジニアリングマネージャークリストーマス| Jinyong Lee、シニアソフトウェアエンジニア| 編集者:Cooper Jackson、ソフトウェアエンジニア

なぜ

約2年前、TinderはプラットフォームをKubernetesに移行することを決定しました。 Kubernetesを使用することで、Tinder Engineeringを不変のデプロイを通じてコン​​テナ化とロータッチ操作に向かわせることができました。 アプリケーションのビルド、デプロイメント、インフラストラクチャはコードとして定義されます。

また、規模と安定性の課題に対処することも検討していました。 スケーリングが重要になると、新しいEC2インスタンスがオンラインになるのを数分待つことがよくありました。 数分ではなく数秒でトラフィックをスケジュールおよび提供するコンテナーのアイデアは、私たちにとって魅力的でした。

簡単ではありませんでした。 2019年前半の移行中に、Kubernetesクラスター内でクリティカルマスに達し、トラフィック量、クラスターサイズ、DNSが原因でさまざまな課題に直面し始めました。 200のサービスを移行し、Kubernetesクラスタを合計1,000ノード、15,000ポッド、48,000の実行中のコンテナで実行するという興味深い課題を解決しました。

どうやって

2018年1月から、移行作業のさまざまな段階に取り組みました。 まず、すべてのサービスをコンテナ化し、それらを一連のKubernetesがホストするステージング環境にデプロイしました。 10月から、すべてのレガシーサービスを体系的にKubernetesに移行し始めました。 翌年の3月までに移行が完了し、TinderプラットフォームはKubernetesでのみ実行されるようになりました。

Kubernetesのイメージの構築

Kubernetesクラスターで実行されているマイクロサービス用の30を超えるソースコードリポジトリがあります。 これらのリポジトリのコードは、同じ言語の複数のランタイム環境を備えたさまざまな言語(Node.js、Java、Scala、Goなど)で記述されています。

ビルドシステムは、各マイクロサービスの完全にカスタマイズ可能な「ビルドコンテキスト」で動作するように設計されています。これは通常、Dockerfileと一連のシェルコマンドで構成されます。 それらのコンテンツは完全にカスタマイズ可能ですが、これらのビルドコンテキストはすべて、標準化された形式に従って記述されています。 ビルドコンテキストの標準化により、単一のビルドシステムですべてのマイクロサービスを処理できます。

図1–1 Builderコンテナによる標準化されたビルドプロセス

ランタイム環境間の最大の一貫性を実現するために、同じビルドプロセスが開発およびテスト段階で使用されています。 これは、プラットフォーム全体で一貫したビルド環境を保証する方法を考案する必要があるときに、独特の課題を課しました。 その結果、すべてのビルドプロセスは特別な「ビルダー」コンテナ内で実行されます。

Builderコンテナの実装には、多数の高度なDockerテクニックが必要でした。 このビルダーコンテナーは、Tinderプライベートリポジトリへのアクセスに必要なローカルユーザーIDとシークレット(SSHキー、AWS認証情報など)を継承します。 ソースコードを含むローカルディレクトリをマウントして、ビルドアーティファクトを保存する自然な方法を備えています。 この方法では、ビルダーコンテナーとホストマシンの間でビルドされたアーティファクトをコピーする必要がないため、パフォーマンスが向上します。 保存されたビルドアーティファクトは、追加の設定なしで次回再利用されます。

特定のサービスでは、コンパイル時の環境とランタイム環境を一致させるために、ビルダー内に別のコンテナーを作成する必要がありました(たとえば、Node.js bcryptライブラリをインストールすると、プラットフォーム固有のバイナリアーティファクトが生成されます)。 コンパイル時の要件はサービスによって異なり、最終的なDockerfileはその場で作成されます。

Kubernetesクラスターのアーキテクチャと移行

クラスターのサイジング

Amazon EC2インスタンスでの自動クラスタープロビジョニングにはkube-awsを使用することにしました。 初期には、すべてを1つの一般的なノードプールで実行していました。 リソースをより有効に活用するために、ワークロードをさまざまなサイズとタイプのインスタンスに分離する必要性をすばやく特定しました。 推論では、スレッド化されたポッドの数を減らして実行すると、単一スレッドのポッドを多数共存させるよりも、予測可能なパフォーマンス結果が得られます。

私たちは解決しました:

  • 監視用のm5.4xlarge(Prometheus)
  • Node.jsワークロードのc5.4xlarge(シングルスレッドワークロード)
  • JavaおよびGo用のc5.2xlarge(マルチスレッドワークロード)
  • コントロールプレーン用のc5.4xlarge(3ノード)

マイグレーション

従来のインフラストラクチャからKubernetesに移行するための準備手順の1つは、既存のサービス間通信を、特定のVirtual Private Cloud(VPC)サブネットで作成された新しいElastic Load Balancer(ELB)を指すように変更することでした。 このサブネットはKubernetes VPCにピアリングされました。 これにより、サービスの依存関係の特定の順序に関係なく、モジュールを細かく移行できました。

これらのエンドポイントは、新しい各ELBを指すCNAMEを持つ加重DNSレコードセットを使用して作成されました。 カットオーバーするために、重みが0の新しいKubernetesサービスELBを指す新しいレコードを追加しました。次に、レコードのTime To Live(TTL)を0に設定しました。次に、古い重みと新しい重みをゆっくり調整して最終的には、新しいサーバーで100%になります。 カットオーバーが完了した後、TTLはより妥当な値に設定されました。

Javaモジュールは低いDNS TTLを尊重しましたが、Nodeアプリケーションはそうしませんでした。 エンジニアの1人が接続プールコードの一部を書き直して、60秒ごとにプールを更新するマネージャーにコードをラップしました。 これは、パフォーマンスに大きな影響を与えることなく、非常にうまく機能しました。

学習

ネットワークファブリックの制限

2019年1月8日の早朝に、Tinderのプラットフォームで永続的な障害が発生しました。 その朝早くにプラットフォームのレイテンシの無関係な増加に対応して、ポッドとノードの数がクラスターでスケーリングされました。 これにより、すべてのノードでARPキャッシュが使い果たされました。

ARPキャッシュに関連するLinuxの値は3つあります。

クレジット

gc_thresh3はハードキャップです。 「ネイバーテーブルオーバーフロー」ログエントリを取得している場合、これは、ARPキャッシュの同期ガベージコレクション(GC)の後でも、ネイバーエントリを格納するための十分な領域がないことを示しています。 この場合、カーネルはパケットを完全にドロップします。

私たちは、KubernetesのネットワークファブリックとしてFlannelを使用しています。 パケットはVXLAN経由で転送されます。 VXLANは、レイヤー3ネットワーク上のレイヤー2オーバーレイ方式です。 MACアドレスインユーザーデータグラムプロトコル(MAC-in-UDP)カプセル化を使用して、レイヤー2ネットワークセグメントを拡張する手段を提供します。 物理データセンターネットワーク上の転送プロトコルは、IPとUDPです。

図2-1フランネル図(クレジット)

図2–2 VXLANパケット(クレジット)

各Kubernetesワーカーノードは、大きな/ 9ブロックから独自の/ 24の仮想アドレス空間を割り当てます。 これにより、ノードごとに、1つのルートテーブルエントリ、1つのARPテーブルエントリ(flannel.1インターフェイス上)、および1つの転送データベース(FDB)エントリが作成されます。 これらは、ワーカーノードが最初に起動したとき、または各新しいノードが検出されたときに追加されます。

さらに、ノードからポッド(またはポッドからポッド)の通信は、最終的にeth0インターフェースを介して流れます(上のフランネル図に示されています)。 これにより、対応する各ノードの送信元と宛先のARPテーブルにエントリが追加されます。

私たちの環境では、このタイプの通信は非常に一般的です。 Kubernetesサービスオブジェクトの場合、ELBが作成され、KubernetesがすべてのノードをELBに登録します。 ELBはポッドを認識せず、選択されたノードはパケットの最終宛先ではない可能性があります。 これは、ノードがELBからパケットを受信すると、サービスのiptablesルールを評価し、別のノードのポッドをランダムに選択するためです。

停止時には、クラスターには合計605のノードがありました。 上記の理由により、デフォルトのgc_thresh3値を超えるにはこれで十分です。 これが発生すると、パケットがドロップされるだけでなく、仮想アドレススペースのフランネル/ 24全体がARPテーブルから失われます。 ノードからポッドへの通信とDNSルックアップが失敗します。 (この記事の後半で詳しく説明するように、DNSはクラスター内でホストされます。)

解決するには、gc_thresh1、gc_thresh2、およびgc_thresh3の値を上げ、不足しているネットワークを再登録するためにフランネルを再起動する必要があります。

大規模なDNSを予期せず実行

移行に対応するために、サービスのトラフィックシェーピングとレガシーからKubernetesへの増分カットオーバーを容易にするためにDNSを大幅に活用しました。 関連するRoute53 RecordSetに比較的低いTTL値を設定しました。 EC2インスタンスでレガシーインフラストラクチャを実行したとき、リゾルバー構成はAmazonのDNSを指していました。 私たちはこれを当然のことと考えており、サービスとAmazonのサービス(DynamoDBなど)の比較的低いTTLのコストはほとんど気付かれませんでした。

ますます多くのサービスをKubernetesにオンボーディングしていくと、毎秒250,000リクエストに応答するDNSサービスを実行していることがわかりました。 アプリケーション内で断続的で影響の大きいDNSルックアップタイムアウトが発生していました。 これは、徹底的なチューニング作業と、かつては最大で1,000ポッドで120コアを消費するCoreDNSデプロイメントへのDNSプロバイダーの切り替えにもかかわらず発生しました。

他の考えられる原因と解決策を調査したところ、Linuxパケットフィルタリングフレームワークnetfilterに影響を与える競合状態を説明する記事が見つかりました。 表示されていたDNSタイムアウトと、フランネルインターフェイスのinsert_failedカウンターの増加が、記事の調査結果と一致しています。

この問題は、送信元および宛先ネットワークアドレス変換(SNATおよびDNAT)およびその後のconntrackテーブルへの挿入中に発生します。 内部で議論され、コミュニティによって提案された1つの回避策は、DNSをワーカーノード自体に移動することでした。 この場合:

  • トラフィックはノード上にローカルに留まっているため、SNATは必要ありません。 eth0インターフェイスを介して送信する必要はありません。
  • 宛先IPはノードに対してローカルであり、iptablesルールごとにランダムに選択されたポッドではないため、DNATは必要ありません。

私たちはこのアプローチを進めることにしました。 CoreDNSはDaemonSetとしてKubernetesにデプロイされ、kubelet — cluster-dnsコマンドフラグを設定することにより、ノードのローカルDNSサーバーを各ポッドのresolv.confに挿入しました。 この回避策はDNSタイムアウトに効果的でした。

ただし、ドロップされたパケットとFlannelインターフェースのinsert_failedカウンターのインクリメントが引き続き表示されます。 DNSトラフィックのSNATやDNATのみを回避したため、これは上記の回避策の後でも持続します。 競合状態は、他のタイプのトラフィックでも発生します。 幸い、ほとんどのパケットはTCPであり、この状態が発生すると、パケットは正常に再送信されます。 すべてのタイプのトラフィックに対する長期的な修正については、現在検討中です。

Envoyを使用してより良い負荷分散を実現する

バックエンドサービスをKubernetesに移行するにつれて、ポッド間で負荷のバランスが崩れるようになりました。 HTTPキープアライブが原因で、ELB接続が各ローリングデプロイの最初の準備完了ポッドにスタックしているため、ほとんどのトラフィックが使用可能なポッドのごく一部を通過していることがわかりました。 私たちが試みた最初の緩和策の1つは、最悪の犯罪者のための新しい展開で100%MaxSurgeを使用することでした。 これはわずかに効果的であり、一部の大規模な展開では長期的には持続できませんでした。

私たちが使用した別の緩和策は、重要なサービスのリソース要求を人工的に膨らませて、同じ場所に配置されたポッドが他の重いポッドと並んでより多くのヘッドルームを持つようにすることでした。 また、リソースの浪費のため、長期的にはこれは妥当ではなく、ノードアプリケーションはシングルスレッドであり、事実上1コアで制限されていました。 唯一の明確な解決策は、より優れた負荷分散を利用することでした。

私たちは内部でEnvoyを評価しようとしていました。 これにより、非常に限られた方法で展開し、すぐにメリットを得る機会が与えられました。 Envoyは、大規模なサービス指向アーキテクチャー向けに設計された、オープンソースの高性能レイヤー7プロキシーです。 自動再試行、回路遮断、グローバルレート制限などの高度な負荷分散技術を実装できます。

私たちが思いついた構成は、ローカルコンテナーポートにぶつかる1つのルートとクラスターを持つ各ポッドの横にEnvoyサイドカーを配置することでした。 カスケードの可能性を最小限に抑え、小さな爆発半径を維持するために、各サービスの各アベイラビリティーゾーン(AZ)に1つのデプロイメントであるフロントプロキシエンボイポッドの艦隊を利用しました。 これらは、特定のサービスの各AZ内のポッドのリストを単に返す、エンジニアの1人がまとめた小さなサービスディスカバリメカニズムにヒットしました。

次に、front-Envoysサービスは、このサービス検出メカニズムを1つの上流クラスターとルートで利用しました。 適切なタイムアウトを構成し、すべての回路ブレーカー設定をブーストしてから、一時的な障害と円滑な展開を支援するために最小限の再試行構成を採用しました。 これらの各フロントエンボイサービスの前には、TCP ELBを配置しました。 メインのフロントプロキシレイヤーからのキープアライブが特定のEnvoyポッドに固定されていたとしても、負荷をより適切に処理でき、バックエンドへのleast_requestを介して分散するように構成されていました。

デプロイメントでは、アプリケーションとサイドカーポッドの両方でpreStopフックを利用しました。 このフックはサイドカーヘルスチェックに失敗した管理エンドポイントと小さなスリープで、機内接続が完了してドレインできるようにするための時間を提供します。

迅速に移動できた理由の1つは、通常のPrometheusセットアップと簡単に統合できた豊富なメトリックスによるものでした。 これにより、構成設定を繰り返してトラフィックをカットしたときに何が起こっているのかを正確に確認できました。

結果は即時かつ明白でした。 最も不均衡なサービスから始め、現時点では、クラスター内で最も重要な12のサービスの前で実行されています。 今年は、より高度なサービス検出、回路遮断、異常値検出、レート制限、およびトレースを備えたフルサービスメッシュへの移行を計画しています。

図3–1 envoyへのカットオーバー中の1つのサービスのCPU収束

最終結果

これらの学習と追加の調査を通じて、大規模なKubernetesクラスターの設計、デプロイ、運用の方法に精通している強力な社内インフラストラクチャチームを開発しました。 Tinderのエンジニアリング組織全体は、アプリケーションをコンテナ化してKubernetesにデプロイする方法に関する知識と経験を持っています。

従来のインフラストラクチャでは、追加のスケールが必要な場合、新しいEC2インスタンスがオンラインになるのを数分待つことがよくありました。 コンテナーは、数分ではなく数秒以内にトラフィックをスケジュールして提供します。 単一のEC2インスタンスで複数のコンテナーをスケジュールすると、水平方向の密度も向上します。 その結果、2019年にはEC2で前年度と比較して大幅なコスト削減が見込まれます。

2年近くかかりましたが、2019年3月に移行を完了しました。Tinderプラットフォームは、200のサービス、1,000のノード、15,000のポッド、および48,000の実行中のコンテナーで構成されるKubernetesクラスターでのみ実行されます。 インフラストラクチャは、もはや私たちの運用チームのために予約されたタスクではありません。 代わりに、組織全体のエンジニアがこの責任を分担し、すべてのコードとしてアプリケーションを構築および展開する方法を制御します。