この記事は kintone 生成 AI チームで連載中の kintone AI リレーブログ 2026 の 10 本目の記事です。 リレーブログでは生成 AI チームのメンバーが AI トピックに限らず、さまざまなことについて発信していきます。
こんにちは! kintone の生成 AI チームでソフトウェアエンジニアをやっている福田です。
私たちのチームでは cdk8s を使って Kubernetes マニフェストを管理しています。(cdk8s の詳細は別の記事で紹介していますので、あわせてご覧ください。) cdk8s を使うと TypeScript でマニフェストが書けるだけでなく、Helm チャートと統合したマニフェスト管理も簡単に行うことができて非常に便利なのですが、YAML のマニフェスト生成に時間がかかることがチーム内でも問題になっていました。 Kubernetes へのデプロイは、cdk8s で生成した YAML のマニフェストを apply するという方法で行っており、その生成に時間がかかってしまうと、開発のイテレーションが遅くなってしまいます。
今回はマニフェスト生成が遅いという問題に対して、その原因の特定と解決方法の実装について共有します。
何が遅かったのか
cdk8s は TypeScript のコードを実行してマニフェストを生成する仕組みになっているため、コードを実行する際のプロファイルを取ることで、どの部分で時間がかかっているか特定できると考えました。
Node.js には --prof オプションがあり、これを使うことで JavaScript のコードの実行時のプロファイルを取ることができます。
私たちのプロジェクトでは TypeScript を使っているので、tsc で JavaScript にトランスパイルしてから Node.js で実行してプロファイルを取ることにしました。
以下はプロファイリングの結果の抜粋です。
[Bottom up (heavy) profile]:
ticks parent name
9129 88.6% epoll_pwait@@GLIBC_2.6
5439 59.6% JS: ~spawnSync node:internal/child_process:1105:19
3741 68.8% JS: ~spawnSync node:child_process:857:19
3687 98.6% JS: ~renderTemplate /home/user/apps/node_modules/.pnpm/cdk8s@2.70.15_constructs@10.4.2/node_modules/cdk8s/lib/helm.js:58:24
3687 100.0% JS: ~Helm /home/user/apps/node_modules/.pnpm/cdk8s@2.70.15_constructs@10.4.2/node_modules/cdk8s/lib/helm.js:20:16
3687 100.0% JS: ~CertManagerChart /home/user/apps/lib/src/core/cert-manager/index.js:12:16
54 1.4% JS: ~execFileSync node:child_process:941:22
54 100.0% JS: ~loadurl /home/user/apps/node_modules/.pnpm/cdk8s@2.70.15_constructs@10.4.2/node_modules/cdk8s/lib/yaml.js:106:17
28 51.9% JS: ~load /home/user/apps/node_modules/.pnpm/cdk8s@2.70.15_constructs@10.4.2/node_modules/cdk8s/lib/yaml.js:68:16
26 48.1% JS: ^load /home/user/apps/node_modules/.pnpm/cdk8s@2.70.15_constructs@10.4.2/node_modules/cdk8s/lib/yaml.js:68:16
全体で見ても、JavaScript のコードを実行している時間はわずか 3.2% で、ほとんどの時間が Node.js の C++ 部分(の I/O wait)に使われていました。
cdk8s では Helm construct を使って Helm チャートをマニフェストに組み込むので、原因の特定のためにそのあたりのコードを詳しく見ていきます。
すると、このあたり で helm template を実行してマニフェストを生成していることがわかります。
この処理が全 Helm construct 内で呼び出されていたために、マニフェストの生成に時間がかかっていることがわかりました。
このままでは Helm を使えば使うほどマニフェスト生成が遅くなるため、今後さらに辛くなることも予想されました。
どう解決したか
Helm construct の実装はそこまで複雑なものではないこともあり、独自の Helm チャートを読み込むための construct を独自に作って、そこにキャッシュの仕組みを入れることにしました。
Helm は同じチャートと同じ値であれば同じマニフェストを生成するはずなので、その construct に与えられた props が同じなら helm template をスキップして、前回の結果をそのまま使い回します。
キャッシュの仕組みはシンプルで、/tmp/cdk8s-helm-cache/<hash>.yaml に結果を保存しておくだけです。
export class CachedHelm extends Include { constructor(scope: Construct, id: string, props: CachedHelmProps) { const hash = createHash('sha256') .update(stringify(props) ?? '') .digest('hex'); const cacheDir = path.join(os.tmpdir(), 'cdk8s-helm-cache'); fs.mkdirSync(cacheDir, { recursive: true }); const cachePath = path.join(cacheDir, `${hash}.yaml`); if (!fs.existsSync(cachePath)) { renderTemplate(props, cachePath); } super(scope, id, { url: cachePath }); } }
CI 環境ではキャッシュがないため、マニフェスト生成は遅くなりますが、クリーンな環境でマニフェストを生成することができます。 ローカル環境のような高速にイテレーションを回したい環境では、2 回目以降はキャッシュが効いて高速にマニフェストを生成することができます。
既存の Helm を使っていた箇所は CachedHelm に置き換えるだけなので、呼び出し側のコードはほぼ変わりません。
// 変更前 new Helm(this, 'cert-manager', { chart: 'oci://...', namespace: 'cert-manager', releaseName: 'cert-manager', // ... }); // 変更後 new CachedHelm(this, 'cert-manager', { chart: 'oci://...', namespace: 'cert-manager', releaseName: 'cert-manager', // ... });
実装の留意点
キャッシュキーには props を文字列化したもののハッシュを使っていますが、ここで普通に JSON.stringify を使うとオブジェクトのキー順序によってハッシュが変わってしまいます。
JavaScript のオブジェクトはキーの順序が保証されていないため、同じ内容なのにキャッシュがヒットしない可能性があります。
そこで、キー順序を正規化してくれる json-stable-stringify を使って文字列化するようにしています。(自分で JSON 以外のシリアライザを書いて解決してもいいかもしれません。)
import stringify from 'json-stable-stringify'; const hash = createHash('sha256') .update(stringify(props) ?? '') .digest('hex');
また、今回のコード例では省略しましたが、helm template の実行に失敗したとき、中途半端なキャッシュファイルが残ってしまうと次回以降に壊れたキャッシュを掴んでしまいます。
そのため、エラーハンドリングをしっかり行って、成功時にだけキャッシュファイルが残るようにしています。
結果
最適化の前後でマニフェストの生成にかかる時間を比較してみました。(いずれも JavaScript にトランスパイルした後の実行時間です)
| 条件 | 実行時間 |
|---|---|
| 最適化前 | 11.020s |
| 最適化後: キャッシュなし(初回) | 10.329s |
| 最適化後: キャッシュあり(2回目以降) | 0.961s |
初回はキャッシュがないのでほぼ変わりませんが、2 回目以降は大幅に短縮しました。
キャッシュがある状態でプロファイルを確認すると、IO wait がほとんどの処理時間を占めている状況は解消されています。
[Bottom up (heavy) profile]:
ticks parent name
353 26.5% UNKNOWN
100 28.3% JS: ^wrapSafe node:internal/modules/cjs/loader:1596:18
99 99.0% JS: ^<anonymous> node:internal/modules/cjs/loader:1656:37
99 100.0% JS: ^<anonymous> node:internal/modules/cjs/loader:1804:37
99 100.0% JS: ^<anonymous> node:internal/modules/cjs/loader:1432:33
99 100.0% JS: ^<anonymous> node:internal/modules/cjs/loader:1169:24
1 1.0% JS: ~<anonymous> /home/user/apps/node_modules/.pnpm/cdk8s@2.70.15_constructs@10.4.2/node_modules/cdk8s/lib/chart.js:1:1
1 100.0% JS: ~<anonymous> /home/user/apps/node_modules/.pnpm/cdk8s@2.70.15_constructs@10.4.2/node_modules/cdk8s/lib/api-object.js:1:1
1 100.0% JS: ~<anonymous> /home/user/apps/node_modules/.pnpm/cdk8s@2.70.15_constructs@10.4.2/node_modules/cdk8s/lib/index.js:1:1
1 100.0% JS: ~<anonymous> /home/user/apps/lib/src/main.js:1:1
なお、前述のようにプロファイルを取る際は Node.js で直接実行する都合上、TypeScript を JavaScript にトランスパイルした後に実行しています。
開発中には ts-node によって、TypeScript を JavaScript にトランスパイルされてから実行されるため、トランスパイルに時間を要すると全体の実行時間も長くなってしまいます。
そこで、今回の変更とあわせて ts-node から tsx への置き換えも行いました。
置き換え前後での実行時間は以下のようになりました。
| 条件 | 実行時間 |
|---|---|
| ts-node 使用(キャッシュあり) | 6.704s |
| tsx 使用 (キャッシュあり) | 1.828s |
| (参考)Helm キャッシュの最適化なし かつ ts-node 使用 | 16.558s |
まとめ
今回の最適化を入れることにより、理想的な条件ではマニフェストの生成にかかる時間を 16.558s から 1.828s に短縮することができました。 これにより、開発のイテレーションが大幅に高速化され、開発者のストレスも減ることが期待できます。
なお、キャッシュしても正しくマニフェストを生成できているかどうかは、最適化前後で生成されたマニフェストの diff を確認しました。 マニフェストに差分がなければ、Kubernetes クラスタに対する変更もないことが明確なため、マニフェストの生成が正しく行われていることがわかります。
We are hiring!
kintone 生成 AI チームでは、AI × プロダクト開発に情熱を持つエンジニアを募集しています!
私たちのチームでは、ただモノを作るだけでなく、今回のような「なんか辛い」を放置せず、ちゃんと向き合って改善していく文化が根付いています。 生成 AI を使って一緒に kintone をもっと便利にしませんか? ご応募お待ちしております!