📝

SaaSプロダクトのKubernetesマニフェスト管理をcdk8s化して運用性・保守性を大幅改善しました

こんにちは、エンジニアの齊藤です。

私が携わっているプロダクトでは、複数の旅行会社向けにSaaSサービスを提供しており、Kubernetes(k8s)でWebアプリケーションやバッチジョブを運用しています。嬉しいことに導入してくださるお客様が増えてきたのですが、各顧客に要求されるリソース量や細かな設定値が異なっているため、顧客数の増加に伴い運用負荷が高まってきていたという側面がありました。

具体的には、k8sマニフェストの管理が問題となっていました。k8sを用いてアプリをデプロイするためにはk8sマニフェストを用意する必要がありますが、我々のプロダクトでは細かな設定値の異なる複数の顧客に対して、1つのマニフェストを共有して使用するという運用をしていました。顧客ごとの要件の違いや、運用環境(検証環境・本番環境)による設定値の違いはMakoテンプレートを用いて外部から変数を渡すことで動的に生成するようにしていました(Makoテンプレートを採用した話はこちら)。

k8s/apps/myApp.yml(従来のMakoテンプレート例)
metadata:
  name: my-app-${dataversion}
  namespace: ${namespace}
spec:
  replicas: ${replicas}
  template:
    spec:
      containers:
      - name: my-app
        resources:
          requests:
% if (stage == "prod"):
            cpu: "40m"
% else:
            cpu: "20m"
% endif
% if (namespace == "顧客A"):
            memory: "4Gi"
% elif (namespace == "顧客B" and stage == "prod"):
            memory: "4Gi"
% else:
            memory: "512Mi"
% endif
        env:
          - name: CUSTOMER_ENV
% if (namespace == "顧客C" and stage == "prod"):
            value: "1"
% else:
            value: "0"
% endif

顧客数が少ないうちはどうにかなっていたのですが、数が増えるにつれてマニフェスト内の条件分岐も複雑化していき、また設定変更時に他の顧客への影響を心配しながら作業する状況が続いていたため、TypeScriptベースのcdk8s(読み方:シーディーケイツ、Cloud Development Kit for Kubernetes)を導入し、型安全で保守性の高いマニフェスト管理に移行することにしました。本記事では、その取り組みで得られた成果や苦労した点について紹介します。

cdk8s化とは

cdk8s(Cloud Development Kit for Kubernetes)について簡単に説明しますと、TypeScriptやPythonといったプログラミング言語でKubernetesマニフェストを定義できるツールです。我々のチームでは、「従来のKubernetesマニフェスト(=YAMLファイル)を直接編集する方法ではなく、TypeScriptベースのcdk8sを用いたプログラムコードでリソースを定義し、最終的にKubernetesマニフェストを自動生成する方法へと移行する」この一連の移行プロジェクトを「cdk8s化」と呼んで進めてまいりました(実際にはcdk8s+を用いており、より抽象化されたコンストラクトを使用して簡潔に記述できるようにしています)。

なぜcdk8s(cdk8s+)を選んだのか

Kubernetesマニフェスト管理には、HelmやKustomizeといった非常に有名なツールも存在しますが、我々の要件を検討した結果、cdk8s(cdk8s+)が最適な選択だと判断しました。

まず、社内での技術スタックとの親和性が要因の1つとして挙げられます。弊社では既にAWS CDKを使用したインフラ管理の実績があり、TypeScriptでの型安全なIaC化やCDKライクな記述方法に慣れ親しんでいました。また、検討をした時期にちょうどcdk8s+がリリースされ、さらに簡潔に記述できる環境が整ったことも大きな後押しとなりました。

Helmは、チャートによるひとまとまりの管理方式のため我々の運用スタイルに適さないと判断しました。我々のプロダクトでは、DeploymentやServiceを個別に細かく更新していく必要があることが多く、Helmのチャート単位での管理では柔軟性に欠けると感じました。また、Go templateの記法が社内で浸透しておらず、学習コストの観点からも採用は困難でした。

Kustomizeは、我々が管理すべきアプリケーションや設定数が既に相当な数増えていることからも、Kustomizeでは管理が複雑になりすぎるという懸念がありました。顧客数の増加とともに設定パターンが増えていくことが考えられる状況においては、より抽象化された仕組みが必要だと考えました。

以上の検討を踏まえて、CDK8s+の採用に至りました。

従来の課題:Makoテンプレートの複雑化

従来のMakoテンプレートを用いた運用には、主に以下の3つの課題がありました。

  • エラーの発見が困難: 文字列ベースの変数置換のためtypoや構文エラーに気づきにくい
  • 可読性の低下: % if文による条件分岐が複雑に入り組み、設定の全体像を把握しにくい
  • 変更リスクの増大: ある顧客用の設定値を変更するためにマニフェストを修正する時に、既存の分岐ロジックへの影響が予測しにくい

cdk8s化による改善効果

従来の課題で挙げた3つの問題点に対して、cdk8s化によってどのような改善が得られたかを説明します。

1. 型安全性による品質向上

従来の課題: 文字列ベースの変数置換でtypoや構文エラーに気づきにくい

cdk8s化のメリットの1つとして、TypeScriptの型システムによる品質向上が挙げられます。従来のMakoテンプレートでは文字列ベースの変数置換をしていたため、設定値の型が正しいかどうかは実際にマニフェストを適用してみるまで分からないという状況でしたが、TypeScriptによる型チェックがあるため、設定値の型不一致や必須項目の欠如をコンパイル時に発見できるようになりました。例えば、CPU要求量として文字列を設定すべきところに数値を設定してしまった場合、従来であれば実際にPodを起動するまで気づけませんでしたが、現在では開発段階で即座にエラーとして検出されます。

また、IDEの自動補完機能により、設定可能な項目や値の候補が表示されるため、設定漏れや誤った設定値の入力を防ぐことができるようになりました。型定義を変更する際も、影響箇所が自動で検出されるため、安全にリファクタリングを行えます。

2. 構造化による可読性向上

従来の課題: % if文による条件分岐が複雑に入り組み、設定の全体像を把握しにくい

顧客別設定を構造化されたTypeScriptオブジェクトで管理することで、可読性の大幅な改善を実現できました。

従来のMakoテンプレートでは、複数の% if文による条件分岐が複雑に入り組んでおり、どの顧客にどの設定が適用されるかを把握するのが困難でした。これがcdk8s化により、生成されるマニフェストが実際に適用させるほぼ完全な状態で記述されるようになったため、人間の目で見ても理解しやすい状態になったと言えます。また、各顧客の設定は独立したファイルに分離しているため、設定差分を明確に可視化できるようにもなっております。例えば、顧客Aの設定ファイルを見れば、その顧客固有の設定値が一目で分かるようになっています。

k8s/manifest/顧客A.prod/myApp.yml(顧客用マニフェスト例)
metadata:
  name: my-app-${dataversion}
  namespace: ${namespace}
spec:
  replicas: ${replicas}
  template:
    spec:
      containers:
      - name: my-app
        resources:
          requests:
            cpu: "40m"
            memory: "4Gi"
        env:
          - name: CUSTOMER_ENV
            value: "0"

3. 影響範囲の分離による安全性向上

従来の課題: ある顧客用の設定値を変更するためにマニフェストを修正する時に、既存の分岐ロジックへの影響が予測しにくい

設定定義とマニフェスト生成ロジックの明確な分離により、変更時の影響範囲を大幅に限定できました。従来のMakoテンプレートでは、顧客固有の設定値とマニフェストの構造が密結合していたため、一つの変更が他の顧客に予期しない影響を与えるリスクがありました。

cdk8s化後は、各顧客の設定ファイルが完全に独立しているため、顧客Aの設定変更が顧客Bに影響することは構造的にありえません。また、設定ファイルで純粋にどの顧客がどのようなリソースを必要とするかを記述すれば良いので、設定変更の際に考慮すべき要素が明確になり、作業の複雑さが大幅に軽減されました。

実装内容

以下では、cdk8s化で採用した具体的な設計について説明します。

アーキテクチャ設計

cdk8s化により、以下の構成に再設計しました。

k8s/cdk8s/
├── configs/          # 顧客・環境別設定ファイル
│   ├── config.顧客A.prod.ts
│   ├── config.顧客A.stg.ts
│   ├── config.顧客B.prod.ts
│   ├── config.顧客B.stg.ts
│   └── types.ts      # 設定の型定義
├── src/
│   ├── index.ts      # メイン生成ロジック
│   ├── resources/    # Kubernetesリソース定義
│   └── definition/   # 定数・型定義
└── manifest/         # 生成されたマニフェスト出力先
    ├── 顧客A.prod/
    ├── 顧客A.stg/
    ├── 顧客B.prod/
    └── 顧客B.stg/

設定ファイルの型定義

顧客設定の型安全性を確保するための型定義を作成しました。細かな部分は省略しておりますが、各社ごとのmetadataと各アプリの設定値の調整に使用します。顧客固有の要件(コンテナ起動時の特定の処理、特定の環境変数の追加等)も、この設定ファイルで制御できるように設計しています。

configs/types.ts
export interface Cdk8sConfig {
  metadata: {
    namespace: string;
    coop: string;
    stage: "prod" | "stg";
  };
  apps: {
    [K in AppKind]?: AppConfig<K>;
  };
}

export interface AppConfig<T extends AppKind> {
  patchEnv?: EnvPatchConfig;
  patchResources?: ResourcePatchConfig;
}
configs/config.customer-a.prod.ts
import { cdk8sConfig } from "./types";

const config: Cdk8sConfig = {
  metadata: {
    namespace: "customer-a",
    coop: "travel-company-a",
    stage: "prod",
  },
  apps: {
    myApp: {
      patchResources: {
        webapp: {
          server: {
            requests: {
              cpu: 80,
              memory: 1024
            }
          }
        }
      },
    }
  },
};

export default config;

マニフェストを生成する仕組み

設定ファイルから実際のKubernetesマニフェストを作る部分はこのようになっており、各社configファイルを読み取ってそれに沿ったマニフェストを生成します。

src/index.ts
function generate(options: Options) {
  const config: Cdk8sConfig = 
    require(`../configs/config.${options.nsEnv}`).default;

  const deploymentApp = createApp(options.nsEnv, "deployment");
  const serviceApp = createApp(options.nsEnv, "service");

  for (const appKind of getUnionKeys(config.apps)) {
    // Deploymentリソースの生成
    const deployment = new Deployment(...);
    // Serviceリソースの生成
    new Service(...);
  }
}

移行時の課題

複数の顧客・環境の設定をMakoテンプレートからTypeScript設定に移行する作業は、複雑な課題で、主に以下3つの観点で苦労しました。

設定項目の完全移行

まず最初の障壁として、顧客固有の設定項目を漏れなく移行することが挙げられます。Makoテンプレートでは条件分岐が複雑に入り組んでおり、特定の条件下でのみ有効になる設定が数多く存在していました(例えば「顧客Aかつ本番環境の場合のみ特定の環境変数を設定する」といった条件)。これらの全体像を理解したうえで、今後の拡張性も考えたときに汎用的な作りにすることが求められていました。

コードの共通化と個別対応の両立

アプリや顧客ごとに異なる要件への対応も大きな課題でした。設定ファイルの分離により個別の要件は解決できる一方で、今後の保守性を見据えてTypeScriptのコード部分を極力共通化することが重要なポイントでした。共通のマニフェスト生成ロジックを作りつつ、顧客固有の差分は設定ファイルで吸収するという設計により、コードの重複を避けながらも柔軟性を確保できました。

移行前後の同一性保証

最も慎重を要したのは、移行前後でのマニフェスト出力結果の同一性を保証することでした。移行前のマニフェストと完全一致するマニフェストを生成したいのであれば確認は容易かもしれませんが、cdk8s化の過程でコンテナの処理をスクリプトに移行したり、一部の設定値をアプリケーション側に移したりといった改善も同時に行ったため、完全に同じマニフェストが生成されるわけではありませんでした(重要なのは文字列レベルでの完全一致ではなく、Kubernetesクラスタ上での動作が機能的に同等であることを保証することでした)。

そのため、単純なファイル比較だけでは不十分で、設定値の意味的な差分を理解したうえで本質的に重要な変更と改善による意図的な変更を区別する必要がありました。実際の作業では、差分検知ツールを活用して移行前後のマニフェストを詳細に比較し、本当に必要な差分と、改善による意図的な差分を明確に分類し、前者については必ず修正対応を行いました。

わずかな設定の見落としが本番環境の障害につながる可能性があるため、このような詳細な比較検証に加えて、検証環境でのテストも十分に実施する等、慎重に移行を進めました。

まとめ

SaaSプロダクトのKubernetesマニフェスト管理をcdk8s化することで、従来の3つの課題を解決できました。

  1. エラーの発見が困難: TypeScriptの型システムにより、設定ミスを開発段階で検出できるようになり、本番環境での障害リスクが削減された

  2. 可読性の低下: 複雑な条件分岐から顧客別独立ファイルへの移行により、設定の全体像を瞬時に把握できるようになった

  3. 変更リスクの増大: 顧客間の設定が完全に分離されたことで、1つの顧客の変更が他の顧客に影響するリスクが構造的に排除された

cdk8s化は単なる技術移行ではなく、SaaSサービスの運用性を根本的に改善する取り組みでした。特に顧客数が増加していく環境において、型安全で保守性の高いインフラ管理基盤の重要性を実感しました。

今後はこの基盤を活用してArgo Workflowsを導入し、アプリのリリースフローを改善していきたいと思っています。

この記事を書いた人

齊藤 亮将
2023年新卒入社

FORCIA Tech Blog

Discussion