複雑なKubernetes Manifestに立ち向かうためのHelm移行と運用の工夫

この記事は10X 新春ブログリレー 2026の1月23日分の記事になります。

SREチームのid:horimislimeです。今回はチームでこれまでに取り組んできたことの1つとして、弊社Stailerの機能提供に使っているKubernetesのmanifest改善について紹介します。

背景

10XではGoogle CloudのGKE上でアプリケーションを稼働させています。

業務向けサービスという性質上、特にCronJobのworkloadが多くなっています。また耐障害性の観点から、これらは契約先のパートナー企業様ごとに独立したworkloadとして稼働しています(実際に動くロジックは共通)。

現在Stailerは事業規模・小売業種の異なる13社様にサービス提供をしています(参考: Company Deck)。我々は「プラットフォームを提供する」というミッションの下で多くの機能を共通化していますが、業務ロジックという複雑性によりどうしても特定パートナー専用の実装・workloadなども存在します。契約先が増えるごとにworkload数だけでなく、イレギュラーパターンも増えるという構造です。

またmanifestはdevelopment・productionなど複数環境の差分も表現が必要です。つまり弊社の場合だと 機能に必要なworkload数 x 契約先パートナー数 x 実行環境 の掛け合わせでパターンが増えるという複雑さがありました。

Kubernetes Manifestに関して課題をまとめると次のようになりました。

  • 機能提供に必要なworkloadも多い上に、それがパートナー新規契約のたびに増えていく
  • 元々は生のmanifest YAMLを使用していて、多くのworkloadは実装が重複していて冗長
  • 実行環境やパートナー間の差異をkustomizeで表現していたが、管理が複雑化

そこで将来的なスケールに耐えうるよう次の施策を実施しました。

  • 生のYAMLでのworkload記述をやめ、Helm Chartを利用した管理体制に移行
  • Helm templateとvaluesの記述方法を工夫し、動作環境ごとの差異をシンプルに記述
  • helmfileを利用して複雑かつ巨大なworkloadセット管理を効率化

ここからは各施策の詳細と、実施により得られたメリットについてご紹介します。

Helm Chartとvaluesの整備

Helmやその周辺の詳細はネットの情報も豊富なので省きつつ、現在のStailerがどのような構成になっているかの概要を説明していきます。

まず元々生のYAMLで実装されていたmanifestを見直し、アプリケーションの種別ごとにChartを作成し独立したGitHub Repositoryで管理するようにしました。

ファイル構成と要点は次のようになります。

  • リクエストを受け付けるRPCサーバ、非同期処理Worker、CronJobなどアプリケーション種別ごとにChartを用意し、Docker imageと起動コマンドをvaluesで渡すように
  • 各ChartではKindごとのtemplateファイルを用意し、それ以外は基本的に同じ構成
  • SWEがvaluesを記述する際ミスに気づきやすいように Schema File を用意
./charts
├── cron
│   ├── Chart.yaml
│   ├── templates # KindごとにYAMLを用意
│   │   ├── configmap.yaml
│   │   ├── cronjob.yaml
│   │   └── externalsecret.yaml
│   ├── values.schema.json
│   └── values.yaml
├── foo-backend
│   ├── ...
│   └── values.yaml
├── foo-frontend
│   ├── ...
│   └── values.yaml
...
└── worker
    ├── Chart.yaml
    ├── templates
    │   ├── configmap.yaml
    │   ├── deployment.yaml
    │   ├── hpa.yaml
    │   └── service.yaml
    ├── values.schema.json
    └── values.yaml

Chartを修正する際はPRごとにGitHub Actionsで開発版パッケージをGitHub Packagesにアップし、参照してテストできるようにしています。

参照側でversionが固定されてない場合も意図せず使用されないよう -pre を使用

各workloadを表現するvaluesは次のような形式を取りました。

一見何の変哲もないvaluesですが、工夫点としては environmentOverride というキーを用意して異なる実行環境で適用したいパラメータを1つのYAMLで表現できるようにしています。

name: cron-job-foo
namespace: stailer-bar
schedule: "0 22 * * *"
severity: SEV-3
ownedBy: team-X
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 1200
args:
  - runCronJobFoo
resources:
  requests:
    cpu: 100m
    memory: 100Mi
  limits:
    cpu: 100m
    memory: 100Mi
environmentOverride:
  production:
    resources: # 本番環境のみ指定の値を変更したい、といった場合
      requests:
        cpu: 1
        memory: 500Mi
      limits:
        cpu: 1
        memory: 500Mi

バッチ処理は開発環境と本番で捌く量が異なるので、それぞれでリソースリクエストを調整するといったケースが多くあります。以前はこうした差分の解消をkustomizeで実現していましたが、実行環境の差異・パートナー固有事情(共通workload以外に専用実装の有無)という両方を吸収する必要があり、それらの為にoverlayを多数書く必要があるなど見通しづらさがありました。

Helm templateは次のように実装し、環境固有値が定義されている場合はそれをoverrideしてtemplateに適用しています。

# 実行環境に対応するresourceOverrideがある場合は、resourceにoverride値をマージ(定義されているkeyの値だけ上書き)
{{- $resource := merge (index (.Values.environmentOverride | default dict) .Values.environment) .Values }}

apiVersion: batch/v1
kind: CronJob
metadata:
  name: {{ .Release.Name }}
spec:
  {{- if $resource.suspend }}
  suspend: {{ $resource.suspend }}
  {{- end }}
  schedule: {{ $resource.schedule | quote }}
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
...
---

以上でworkload間での冗長な実装を取り払いSWEが記述する量を最小限にし、実行環境ごとの設定を1つのworkload定義(values)に集約しシンプル化できました。

helmfileを使った各社ごとのworkload管理

次にパートナーごとのworkloadセットを管理する方法について説明します。これに関してはhelmfileを採用しました。

helmfileと各workloadを定義するvaluesは次のようなディレクトリで管理しています。

  • common-resources 下にKindごとのサブディレクトリを切り、その下に全社共通workloadのvaluesを配置。helmfile-base.yamlでそれらを集約
  • パートナー単位でディレクトリを切り、その下に各社固有workloadのvaluesを定義
  • 各社ディレクトリ下のhelmfileからhelmfile-baseを参照して共通workloadを利用
./helm/stailer # stailerのcluster
├── common-resources # パートナー共通リソース
│   └── cron 
│       ├── common-job-1.yaml # 共通workloadを定義するvalues
│       ├── common-job-2.yaml
|       ├── ...
│       └── helmfile-base.yaml # valuesを参照
├── partnerA
│   └── cron
│       ├── custom-job-1.yaml # 会社固有のcronjobを定義
│       ├── custom-job-2.yaml
│       └── helmfile.yaml # 固有jobおよび共通job(helmfile-base)を参照
├── partnerB
│   └── cron
│       ├── custom-job-1.yaml
│       ├── helmfile.yaml
...

共通workloadセットを定義する helmfile-base.yaml はそれ単体での利用は想定しておらず、次のようにtemplateで共通workloadのvaluesたちを列挙しています。ここに記述しているEnvironmentについては後述します。

templates:
  common-job-1:
    chart: "{{ .Environment.Values.chart_url }}"
    version: "{{ .Environment.Values.chart_version }}"
    namespace: "{{ .Environment.Values.namespace }}"
    values:
      - ../../common-resources/cron/common-job-1.yaml
      - partnerType: {{ .Environment.Values.partnerType }}
  common-job-2:
...

このhelmfile-baseはパートナー単位のディレクトリ下に用意した次のようなhelmfileから参照します。

  • 各社固有の環境変数などはvaluesではなく helmfile.yaml のenvironmentで定義し、パートナー内で共通化
  • bases で共通workloadを読み込み重複記述を抑制。helmfileに定義したenvironmentがhelmfile-base内にも一括適用される
  • 共通workloadのvaluesにカスタマイズを加えたい場合、helmfile上で指定
  • パートナー専用workloadを作る場合は各ディレクトリ下にvaluesを書きhelmfileで参照
environments:
  default:
    values:
      - partnerType: *** # 各パートナー固有の識別子。環境変数でコンテナに注入するもの
      - namespace: partnerA # GKE namespace
      - chart_url: oci://[ghcr.io/10xinc/chart/cron](http://ghcr.io/10xinc/chart/cron)
      - chart_version: "0.7.0"
---
bases:
  - ../../common-resources/cron/helmfile-base.yaml

releases:
  # common-resourcesで定義されているworkloadを指定する。使用しないものは記述しない
  - name: common-job-1
    inherit:
      - template: common-job-1
    values:
      - resources:
          requests:
            cpu: "1.2" # 共通workloadだが、特定パートナーにおいてはカスタマイズを加えたい等のケース
            memory: 100Mi
          limits:
            cpu: "1.2"
            memory: 100Mi
      - environmentOverride:
          development:
            suspend: true # 各社固有の事情で共通workloadを止めておきたい、等の制御も記述する
...
  # パートナー固有のworkload定義。同じディレクトリ下のvaluesを指定する
  - name: custom-job-1
    chart: "{{ .Environment.Values.chart_url }}"
    version: "{{ .Environment.Values.chart_version }}"
    namespace: "{{ .Environment.Values.namespace }}"
    values:
      - ./custom-job-1.yaml
      - siteType: "{{ .Environment.Values.partnerType }}"

パートナーごとのworkloadの違いをkustomizeで表現していた時と比較して、各パートナー向けにデプロイされるworkload一覧の定義が1箇所に集約され、一目で分かりやすくなりました。

その他の効果

ここまでの取り組みの前後で比較してみると、ファイル数・総行数ともに半分以上を削減できました。

# plainなYAMLで管理していた時代
find ./kubernetes \( -name "*.yaml" -o -name "*.yml" \) | tee >(wc -l) | xargs cat | wc -l                       
     462
   18678

# Helmチャートでの管理時代
find ./helm \( -name "*.yaml" -o -name "*.yml" \) | tee >(wc -l) | xargs cat | wc -l
     201
    7806

副次的効果として、デプロイをhelmfile単位(= 各パートナーの各Kind単位)で行うようになりCI周辺の体験も改善されました。デプロイはGitHub Actionsのmatrixビルドで並列化でき、helm-diff pluginを使ってPRマージ前の差分検証も実装がシンプルになりました。

現在は開発・本番環境合わせて1,500以上のhelm releaseが稼働し、日々Stailerの開発を支えています。

まとめ

弊社10Xが提供するStailerにおけるmanifest管理改善の取り組みについて紹介しました。

機能追加以外にパートナー契約拡大という軸でも管理が複雑化する事情に対し、Chartの導入(workload定義簡略化)・templateおよびvalues設計の工夫(動作環境の差分をシンプルに定義)・helmfileの導入(パートナーごとのworkloadセット・差異を宣言的に記述)という3本立てでアプローチしました。

今回は特殊だったり難解なアプローチは採用せず、むしろ一般的なプラクティスを組み合わせ、複雑で悩ましい問題をどう分解・各レイヤーで吸収するかを工夫するところにフォーカスしました。まだまだ下地ができた段階に過ぎず、ここから改善したい所がたくさんある状態です。

最後になりますが、こうした取り組みに興味が湧いた方は採用情報もぜひ覗いてみてください。