<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>PLAID Engineer Blog - 株式会社プレイド</title>
        <link>https://tech.plaid.co.jp/</link>
        <description>株式会社プレイドのエンジニアブログです。プレイドエンジニアのユニークなパーソナリティを知ってもらうために執筆しています。</description>
        <lastBuildDate>Wed, 18 Feb 2026 03:05:29 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>ja</language>
        <image>
            <title>PLAID Engineer Blog - 株式会社プレイド</title>
            <url>https://tech.plaid.co.jp/favicon.ico</url>
            <link>https://tech.plaid.co.jp/</link>
        </image>
        <copyright>All rights reserved 2026, 株式会社プレイド</copyright>
        <item>
            <title><![CDATA[PR数4倍でも破綻しない、Claude Codeをチーム運用する仕組み]]></title>
            <link>https://tech.plaid.co.jp/claude-code-scalable-team-operation</link>
            <guid>https://tech.plaid.co.jp/claude-code-scalable-team-operation</guid>
            <pubDate>Wed, 18 Feb 2026 03:00:00 GMT</pubDate>
            <description><![CDATA[チーム開発でClaude Codeを安定して回すために整備してきた設定と運用を共有します。]]></description>
            <content:encoded><![CDATA[<p>はじめまして。Core PlatformでKARTEのJourney機能の開発に携わっている市川です。最近、GitHub元CEOのThomas Dohmke氏がXで次の投稿をして話題になりました。</p><blockquote><p>“The concept of understanding and reviewing code is a dying paradigm.”</p></blockquote><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/ashtom/status/2021255786966708280">https://x.com/ashtom/status/2021255786966708280</a></p><p>この主張にはポジショントークの側面もあると思います。ただ、現場で「コード中心」から「意図・制約・成果中心」へ重心が移りつつあるのは確かです。弊社ではClaude Codeがデファクトになりつつあり、Codex（OpenAI Codex CLI）も一部利用しています。この記事では、チーム開発でClaude Codeを安定して回すために整備してきた設定と運用を共有します。</p><h2>TL;DR</h2><ul><li><p>AGENTS.md / rules / docs で「何を知っているべきか」を設計する</p></li><li><p>Permission / Hooks で「やってはいけないこと」を機械的にブロックする</p></li><li><p>Skills / Subagents で「どうやるか」定型化、検証・レビューを隔離をする</p></li><li><p>設定はすべてリポジトリにcommitし、チーム全員が同じ環境で作業する</p></li></ul><h2>前提</h2><ul><li><p>モノレポで複数プロダクトを開発。Journeyは比較的新しい機能</p></li><li><p>言語はTypeScriptが多め。一部Go</p></li><li><p>複数リポジトリにまたがった開発が必要（インフラはTerraform/K8s manifest等で別リポジトリ）</p></li><li><p>1プロダクトを数名のエンジニアで担当し、要件定義〜運用まで持つ</p></li></ul><h2>現状の成果</h2><p>Journey Teamはエンジニア約5名のチームで、2025年9月と比べて現時点でチームのPR数は約4倍（約150PR/月 → 約600PR/月）になりました。2/17時点で450PR以上Merge済みです。</p><p>また、担当者が変わっても「Issue整理 → Plan → 実装 → Verify → PR作成」の流れを再現しやすくなっています。新しく入ったメンバーや社内のメンバーからは、「Journeyでclaudeを起動すると賢い気がする」「どのように取り入れるべきか教えてほしい」という声も上がっています。</p><hr /><h2>1. 課題と方針</h2><p>AIエージェントを導入すると実装速度はまず上がります。ただ、チーム運用に乗せると別の問題が出てきます。</p><ol><li><p><strong>レビュー量の爆発</strong> — PRの差分を全部人間が追う前提が重くなる</p></li><li><p><strong>運用の属人化</strong> — 「このプロンプトを知っている人」だけが早い</p></li><li><p><strong>判断のブラックボックス化</strong> — なぜその実装になったかが後から追えない</p></li></ol><p>この課題に対して、「誰がコードを書くか」ではなく「意図・制約・成果をどう共有するか」を設計の軸にしました。</p><p>エージェントのスループットが人間の注意力・レビュー可能量を大きく上回る環境では、「修正のコストは比較的低く、待つことのコストが相対的に高い」という前提を考慮しなければいけません。つまり、手戻りを恐れて止め続けるよりも、一定の安全策を維持したうえで前に進み、問題は速いフィードバックループで回収するほうが、合理的になりやすいということです。</p><h3>方針</h3><p>上記の前提から、速いフィードバックループを回すことを目指しました。レビュープロセスは今まで通りやっていますが、「何をレビューすべきか」「何を省略できるか」「なにを最低限検証するべきか」も見えてこない。まず回して、そこから削る、重要なものは自動化する方向で進めています。</p><p>それ以外に設計として意識しているのは以下です</p><ul><li><p><strong>gitリポジトリ内の情報を正（System of Record）とする</strong>。NotionやSlackも使うが、正しい情報はあくまでgitに入れる。coding agentが触りやすい状態を作ります。</p></li><li><p><strong>PRをできるだけ早くMerge/Closeする。</strong>人間のレビュー待ちをするよりも、早く動かして早く失敗を検知し、早く再発防止策を作ることにフォーカスしたほうが数年後のアウトプット量は増えると考えています。Journeyは比較的新しい機能だからできる部分も大いにあります。</p></li></ul><h3>作る順番</h3><p>自分の普段の業務を棚卸しし「代替しやすく効果が高い」部分から作っていきました。</p><p>Codexと業務の棚卸しをしていたとき、「『自分が仕事した気分になる』業務から代替する方向で考えろ」とアドバイスされました。Codexのどストレートなところ大好きです。</p><p><strong>「代替しやすく効果が高い」の基準</strong></p><ol><li><p>難易度が低い</p></li><li><p>時間がかかる</p></li></ol><p><strong>「代替をやめる」基準</strong></p><ul><li><p>判断理由を構造化して、チーム内で再現・共有できなくなったら、その作業の代替はやめる</p></li></ul><p>この「再現可能性」を崩さないことが、個人活用とチーム運用の分岐点だと思っています。</p><hr /><h2>2. <strong>全体像</strong></h2><p>Claude Codeの設定と人間用ドキュメントを、アプリケーションコードと一緒にcommitしています。</p><pre><code class="language-bash">project/
├── .claude/
│   ├── hooks/       # 自動実行フック
│   ├── plans/       # claudeのplanファイル（価値があるものだけcommit）
│   ├── rules/       # ファイル別ルール
│   ├── skills/      # スキル定義
│   ├── statusline.sh # statusline
│   └── settings.json # Project共有のPermissionやHooks、env設定
├── docs/            # 人間向け（ADR/オンボーディング/運用）
├── AGENTS.md        # プロジェクト説明（docs index含む）
└── apps/            # アプリケーションコード
</code></pre><p>これらの設定要素は、セッション中にそれぞれ異なるタイミングでContext Windowに読み込まれます。以下の図はその全体像です。</p><img data-asset-id="699410c6a3f8bba287e95a08" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=699410c6a3f8bba287e95a08" alt="" width="1265" height="1261" /><p><code>/context</code> コマンドで実際のコンテキスト使用量を確認できます。セッション開始直後で約36k/200kトークン（18%）を使用しています。</p><img data-asset-id="699410d1a3f8bba287e95a36" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=699410d1a3f8bba287e95a36" alt="" width="1124" height="570" /><h3>2-1. Permission / Hooks — 「やってはいけないこと」を機械的にブロックする</h3><p>AGENTS.mdやrulesにルールを書いても、Claudeが毎回従うとは限りません。機械的にチェックできるものはPermissionとHooks、Linterに落とし込んで、<strong>ルール違反を不可能にする</strong>方が確実です。</p><h4>Permission</h4><ul><li><p>何か：settings.jsonで定義するツール実行制約</p></li><li><p>誰が起動するか：Claude Code — 常時適用</p></li><li><p>いつ使うか：すべてのツール実行時に適用される。</p></li><li><p>使用例：</p><ul><li><p>readコマンドはallow</p></li><li><p><code>git push:*</code> はask（毎回確認）</p></li><li><p><code>rm -rf</code> はdeny</p></li><li><p>段階的に追加する（まずはread系から）</p></li></ul></li></ul><h4>Hooks</h4><ul><li><p>何か：ツール実行の前後に自動で走るスクリプト</p></li><li><p>誰が起動するか：Claude Code — ライフサイクルイベント（PreToolUse / PostToolUse / SessionStart等）</p></li><li><p>いつ使うか：「毎回確実に実行したいチェック」に使う。AGENTS.mdに書くと守られないルールも、Hooksなら確実</p></li><li><p>使用例：</p><ul><li><p>PostToolUse: Edit/Write後にPrettier/gofmtで自動format、typecheck</p></li><li><p>PreToolUse: 保護ブランチへの直接commitをブロック、commit/push前にsecretlintで機密検知</p></li><li><p>SessionStart: インストール済みのcli toolをClaudeにフィードバック</p></li></ul></li></ul><p>ポイントは、exitコードで「Claudeだけにフィードバック」か「Claudeとユーザー両方にフィードバック」かを制御できる点です。</p><table style="min-width: 100px;"><colgroup><col style="min-width: 25px;" /><col style="min-width: 25px;" /><col style="min-width: 25px;" /><col style="min-width: 25px;" /></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Exit Code</p></th><th colspan="1" rowspan="1"><p>stdout</p></th><th colspan="1" rowspan="1"><p>stderr</p></th><th colspan="1" rowspan="1"><p>用途</p></th></tr><tr><td colspan="1" rowspan="1"><p>0</p></td><td colspan="1" rowspan="1"><p>Claudeにフィードバック</p></td><td colspan="1" rowspan="1"><p>ユーザーに警告表示</p></td><td colspan="1" rowspan="1"><p>成功時の情報伝達</p></td></tr><tr><td colspan="1" rowspan="1"><p>2</p></td><td colspan="1" rowspan="1"><p>Claudeにフィードバック</p></td><td colspan="1" rowspan="1"><p>両方にエラー表示</p></td><td colspan="1" rowspan="1"><p>ツール実行をブロック</p></td></tr><tr><td colspan="1" rowspan="1"><p>その他</p></td><td colspan="1" rowspan="1"><p>Claudeにフィードバック</p></td><td colspan="1" rowspan="1"><p>両方にエラー表示</p></td><td colspan="1" rowspan="1"><p>hook自体の失敗</p></td></tr></tbody></table><p>Claudeに早期にフィードバックすることで、想定外の動作がほぼなくなります。特にsecretlintがあると「とりあえずPR作成まで任せる」運用ができて安心感が高い。</p><hr /><h3>2-2. AGENTS.md / rules / docs — 「何を知っているべきか」を設計する</h3><p>基本的に「常時読ませたい」→ AGENTS.md、「編集対象限定の制約」→ rules、「人間が理解するための詳細」→ docs という使い分けをしています。</p><h4>AGENTS.md</h4><ul><li><p>何か：Guidance — プロジェクト全体のコンテキスト</p></li><li><p>誰が起動するか：Claude Code — セッション開始時に自動読み込み</p></li><li><p>いつ使うか：プロジェクト全体に適用される規約・構成情報。「目次」として機能させ、詳細はdocs/に置く</p></li><li><p>使用例：</p><ul><li><p>プロジェクト構成（各ディレクトリの役割）</p></li><li><p>docs index（圧縮インデックス形式）</p></li><li><p>開発ルール（TDD、Git運用、planファイル扱い）</p></li><li><p>利用可能MCPと推奨ツール</p></li></ul></li><li><p>他ツール対応：Codex、Copilotも読める。CLAUDE.mdには <code>@AGENTS.md</code> のみ記載し、実態をAGENTS.mdに集約</p></li></ul><p>記述量はBoris氏の5.3Kトークンを目安（<a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/bcherny/status/2015254548106170716">https://x.com/bcherny/status/2015254548106170716</a>）</p><p>どの階層にも置けます。Claudeがある子ディレクトリのファイルを読む前に、子ディレクトリのAGENTS.mdを自動で読み込みます。子ディレクトリのAGENTS.mdは構成を固定して、他の制約があればrulesに書いています。</p><h4>rules</h4><ul><li><p>何か：Guidance — ファイルパターン別のコーディングルール</p></li><li><p>誰が起動するか：Claude Code — frontmatterのpath条件にマッチしたとき自動</p></li><li><p>いつ使うか：特定の言語やディレクトリに限定したルール。pathなしなら常時読み込み</p></li><li><p>使用例：</p><ul><li><p><code>**/*.ts</code> → TypeScript規約（any禁止、interface優先、不変性）</p></li><li><p><code>**/*.go</code> → Go規約（context第一引数、datetimeパッケージ必須）</p></li><li><p><code>apps/client/**</code> → フロントエンド固有（テスト戦略、Storybook運用）</p></li></ul></li><li><p>補足：subagentにも読ませたいものはAGENTS.md、そうでないものはpath条件なしrulesに書いています。</p></li></ul><h4>docs/</h4><ul><li><p>何か：Knowledge Base — 人間が読むドキュメント（Claudeもindex経由で参照可能）</p></li><li><p>誰が起動するか： Claude — AGENTS.mdの圧縮indexから必要な箇所を必要なときに参照</p></li><li><p>いつ使うか：設計背景、運用手順、ADRなど、AGENTS.mdに収まらない詳細情報</p></li><li><p>使用例：</p><ul><li><p><code>development/</code> — getting-started、開発手法</p></li><li><p><code>architecture/</code> — システム構成、DB設計、コントラクト</p></li><li><p><code>operations/</code> — DB操作、デプロイ、監視情報</p></li><li><p><code>adr/</code> — 意思決定ログ（Architecture Decision Records）</p></li></ul></li><li><p>運用ルール: コード変更と同時更新し、PR時に更新漏れを検知。</p></li></ul><hr /><h3>2-3. Skills / Subagents - 「どうやるか」定型化、検証・レビューを隔離をする</h3><h4>Skills</h4><ul><li><p>何か：Guidance, Instructions, Scripts — 定型ワークフローのパッケージ</p></li><li><p>誰が起動するか：人間が <code>/skill-name</code> で呼び出し。LLMがSkillのdescriptionから起動</p></li><li><p>いつ使うか：繰り返しの作業フローを再現可能にしたいとき。開発ワークフローの各フェーズに配置</p></li><li><p>使用例：</p><ul><li><p><code>/create-pr --wait</code> — PR作成・CI監視・CI失敗時の自動修正・レビューコメント自動対応</p></li><li><p><code>/verify</code> — 変更種別に応じた動作確認。影響範囲の特定方法を固定</p></li><li><p><code>/interviewing-issues</code> — 4段階インタビューでIssue仕様を明確化</p></li><li><p><code>/orchestrating-tdd</code> — TDD自動実行（Red→Green→Refactor）</p></li><li><p>/codex</p></li></ul></li></ul><p>基本的にはClaude Code公式やCodex公式のSkillを使い、ないものは <code>/skill-creator</code> で自作、<code>/improving-skills</code> で整えてから使用しています。</p><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/TomokiIchi/my-claude-skills/blob/main/skills/interviewing-issues/SKILL.md">https://github.com/TomokiIchi/my-claude-skills/blob/main/skills/interviewing-issues/SKILL.md</a></p><h4>Subagents</h4><ul><li><p>何か：隔離されたコンテキストで実行されるカスタムエージェント</p></li><li><p>誰が起動するか：Claudeが自動で起動（タスク内容に応じて判断）</p></li><li><p>いつ使うか：大量の中間出力が出る作業のとき。親コンテキストを汚したくないとき</p></li><li><p>使用例：</p><ul><li><p>db-reader — 全DB読み取りクエリを隔離。SubagentのHooksで読み取り専用に制限し、破壊的クエリのリスクなし</p></li><li><p>code-simplifier — 実装完了後のコード簡素化</p></li></ul></li></ul><p>Subagentは「コンテキストの分離」と「Memory機能」が本質です。レビューや検証など大量の中間出力が出る作業を親セッションから分離できます。Boris氏もverify-appやcode-simplifierといったSubagentを活用しているとXで発言していました。</p><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/bcherny/status/2007179832300581177">Boris Cherny on X</a></p><p><strong>SkillsとSubagentの使い分け:</strong></p><p>Skillsも <code>context: fork</code> でコンテキストを分離できます。実用上の違いは主に「誰が起動するか」と「memory機能の有無」です。</p><table style="min-width: 100px;"><colgroup><col style="min-width: 25px;" /><col style="min-width: 25px;" /><col style="min-width: 25px;" /><col style="min-width: 25px;" /></colgroup><tbody><tr><th colspan="1" rowspan="1"><p></p></th><th colspan="1" rowspan="1"><p>Skills</p></th><th colspan="1" rowspan="1"><p>Skills（context: fork）</p></th><th colspan="1" rowspan="1"><p>Subagent</p></th></tr><tr><td colspan="1" rowspan="1"><p>memory</p></td><td colspan="1" rowspan="1"><p>なし</p></td><td colspan="1" rowspan="1"><p>なし</p></td><td colspan="1" rowspan="1"><p>あり（セッションをまたいで知識をprojectレベルに蓄積）</p></td></tr><tr><td colspan="1" rowspan="1"><p>向いている用途</p></td><td colspan="1" rowspan="1"><p>定型ワークフロー（PR作成, TDD等）</p></td><td colspan="1" rowspan="1"><p>コンテキストを汚す作業（レビュー, 検証）</p></td><td colspan="1" rowspan="1"><p>コンテキストを汚す作業（レビュー, 検証）</p></td></tr><tr><td colspan="1" rowspan="1"><p>結果</p></td><td colspan="1" rowspan="1"><p>セッションに直接反映</p></td><td colspan="1" rowspan="1"><p>要約だけが親に返る</p></td><td colspan="1" rowspan="1"><p>要約だけが親に返る</p></td></tr></tbody></table><hr /><h2>3. 外部連携ツール（MCP/cli）・実例</h2><p>cliが存在するものはcliを、ない場合はMCPを導入しています。MCPのほうがコンテキスト消費が大きいため、cliで済むならcliを優先しています。</p><p>実際にSentryのエラートリアージ→修正の流れは以下のようになっています。</p><p>SentryのエラーはGitHub IssueにClaudeによってtriageされています。以下のフローをClaude Code上で完結できます:</p><ol><li><p>（手動）<code>/debug</code> ${GitHub Issue URL}</p></li><li><p>ghコマンドでIssueをview、Sentry MCPでエラー詳細を取得</p></li><li><p>該当コードを特定し、原因を分析</p></li><li><p>cliやMCPでログやDB状態を確認</p></li><li><p>原因特定 → 修正実装 → <code>/verify</code> で検証</p></li><li><p>（手動） <code>/create-pr --wait</code> でPR作成・CI/レビューコメントの自動修正</p></li></ol><p>以前は「Sentryを開く → コードを探す → ローカルで再現」とコンテキストスイッチが多かったのが、1つのセッションで完結するようになりました。</p><p>PRをopen時にGitHub Actions内でclaude-code-actionがレビュー用Skillを使用して自動レビュー。<code>/create-pr --wait</code> ではレビューコメントへの対応・非対応の判断と自動修正まで行います。</p><hr /><h2>まだ課題として残っていること</h2><h3>コード品質の担保</h3><p>PR数が増えた分、レビューの質を保つ仕組みが追いついていません。CIでの自動レビューは入れていますが、設計判断レベルのレビューは依然として人間に依存しています。</p><h3>検証手段を与えることが最も重要だが、難しい</h3><p>Boris氏は「Claudeに検証手段を与えれば品質が2-3倍になる」と述べています。単体テストの自動化は比較的簡単ですが、検証環境がローカルのポートや社内システムに依存している場合が問題です。現状は <code>/verify</code> で変更種別に応じたテスト実行を自動化しつつ、CI上では動かないものや並列で動かすと壊れるものがあり、妥協している部分があります。</p><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/bcherny/status/2007179861115511237">https://x.com/bcherny/status/2007179861115511237</a></p><hr /><h2>終わりに</h2><p>「コードを理解しレビューする時代は終わる」——冒頭のこの言葉に今の自分なりの答えを出すなら、「コードの理解は終わらないが、その対象が変わる」だと思います。</p><p>読むべきは実装の詳細ではなく:</p><ul><li><p>意図と制約がどう設計されているか（AGENTS.md / rules）</p></li><li><p>判断の境界がどこに引かれているか（Permission / Hooks）</p></li><li><p>プロセスが再現可能か（Skills / docs）</p></li></ul><p>これは「コードを読まなくていい」という話ではなく、「何を読み、何を機械に任せるか」の設計が仕事になった、という話かなと理解しています。</p><p>今回すべては紹介しきれなかったので、また書きたいと思います。</p><hr /><h2>付録</h2><h3>A. 導入しているMCP/cli一覧</h3><p>ProjectレベルにはOAuth認証可能なMCPのみ設定を許可しています。一部ですがよく使うものたちはこれらです。</p><table style="min-width: 100px;"><colgroup><col style="min-width: 25px;" /><col style="min-width: 25px;" /><col style="min-width: 25px;" /><col style="min-width: 25px;" /></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>種類</p></th><th colspan="1" rowspan="1"><p>ツール</p></th><th colspan="1" rowspan="1"><p>用途</p></th><th colspan="1" rowspan="1"><p>形式</p></th></tr><tr><td colspan="1" rowspan="1"><p>仕様参照</p></td><td colspan="1" rowspan="1"><p>Notion</p></td><td colspan="1" rowspan="1"><p>仕様書の検索・参照</p></td><td colspan="1" rowspan="1"><p>MCP (OAuth)</p></td></tr><tr><td colspan="1" rowspan="1"><p>エラー調査</p></td><td colspan="1" rowspan="1"><p>Sentry</p></td><td colspan="1" rowspan="1"><p>エラー詳細・スタックトレース取得</p></td><td colspan="1" rowspan="1"><p>MCP (OAuth)</p></td></tr><tr><td colspan="1" rowspan="1"><p>監視</p></td><td colspan="1" rowspan="1"><p>Datadog</p></td><td colspan="1" rowspan="1"><p>ログ・メトリクス・モニター確認</p></td><td colspan="1" rowspan="1"><p>MCP (OAuth)</p></td></tr><tr><td colspan="1" rowspan="1"><p>データ分析</p></td><td colspan="1" rowspan="1"><p>BigQuery</p></td><td colspan="1" rowspan="1"><p>SQLクエリ実行</p></td><td colspan="1" rowspan="1"><p>MCP + bq cli</p></td></tr><tr><td colspan="1" rowspan="1"><p>ドキュメント</p></td><td colspan="1" rowspan="1"><p>Context7</p></td><td colspan="1" rowspan="1"><p>ライブラリ公式ドキュメント参照</p></td><td colspan="1" rowspan="1"><p>MCP</p></td></tr><tr><td colspan="1" rowspan="1"><p>デザイン</p></td><td colspan="1" rowspan="1"><p>Figma</p></td><td colspan="1" rowspan="1"><p>デザイン参照・コンポーネント確認</p></td><td colspan="1" rowspan="1"><p>MCP (OAuth)</p></td></tr><tr><td colspan="1" rowspan="1"><p>DB操作</p></td><td colspan="1" rowspan="1"><p>gcloud spanner / wrench</p></td><td colspan="1" rowspan="1"><p>Spannerクエリ・スキーマ管理</p></td><td colspan="1" rowspan="1"><p>cli</p></td></tr><tr><td colspan="1" rowspan="1"><p>GitHub</p></td><td colspan="1" rowspan="1"><p>gh</p></td><td colspan="1" rowspan="1"><p>PR・Issue・API操作</p></td><td colspan="1" rowspan="1"><p>cli</p></td></tr><tr><td colspan="1" rowspan="1"><p>Workflow</p></td><td colspan="1" rowspan="1"><p>temporal</p></td><td colspan="1" rowspan="1"><p>Workflow検索・履歴確認</p></td><td colspan="1" rowspan="1"><p>cli</p></td></tr></tbody></table><p>ルール:</p><ul><li><p>最初は読み取り専用から</p></li><li><p>許可ツールを明示</p></li><li><p>書き込み系は段階導入</p></li><li><p>監査しやすい操作だけ許可</p></li></ul><h3>C. AGENTS.md紹介</h3><p>実際のAGENTS.mdから一部抽出したものです。</p><pre><code class="language-markdown"># Journeyプロダクト

## 役割
あなたは Journey プロジェクトで作業する開発者です。

## プロジェクト構成
journey/
├── apps/
│   ├── client/         # フロントエンド（React + Zustand + tRPC）
│   ├── web/            # バックエンド API（Express + tRPC）
│   └── taskjob/        # TaskJob（Cloud Run jobs + Go）
├── packages/           # 共有パッケージ
├── docs/               # ドキュメント
└── schema/             # Spanner スキーマ

## ドキュメント
|development:{README.md,getting-started.md,temporal-workflow.md}
|architecture:{system-architecture.md,components/*/overview.md}
|operations:{kubernetes-environments.md,observability.md}

## 開発ルール
- pnpm --filter を使用（cd しない）
- TDD で実装

## 推奨ツール
- GitHub 操作: `gh` コマンドを使用（URL から情報取得する場合も `gh api` 等を使う）
- Issue 管理: Journey の Issue（Epic/Task）は必ず `/managing-github-project` スキルを使用する。`gh issue create` を直接使わない（Project 追加・Sub-issue 紐付け・フィールド設定が漏れるため）
- PR 作成: コミット・プッシュ・PR作成を一括実行する場合は `/create-pr` スキルを使用する
- DBクエリ: データベースへのクエリ実行時は必ず `db-reader` agent（`subagent_type=db-reader`）を使用する。Spanner / BigQuery / BigTable に対応。BigQuery MCP は破壊的クエリ（DELETE/UPDATE/DROP等）実行に注意。※本番環境はREAD権限のみ

</code></pre><h3>D. 番外編: 実務で役立ったTips</h3><ul><li><p>コンテキスト確認： <code>/context</code> AGENTS.mdやrulesの認識状況を確認。statuslineにコンテキスト使用量を表示しておく</p></li><li><p>PRにIssueリンクをつける： AGENTS.mdに「planファイルにIssue URLを含める」ルールを書き、PRテンプレートにrefsとしていれるようにrulesに書いた。</p></li><li><p>Codex併用：ドキュメント構成やテスト戦略の壁打ちにはCodexが有効。実装はClaude Code、セカンドオピニオンはCodex</p></li><li><p>planファイル生成位置固定： <code>plansDirectory</code> を <code>./tmp/plans</code> に固定。重要なものだけ <code>/create-pr</code> 時に <code>.claude/plans/</code> にcommitさせる。</p></li><li><p>複数リポジトリ横断作業： <code>additionalDirectories</code> + <code>CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1</code>（addtionalDirectoryで作業する際にそこのAGENTS.mdを読む）+ <code>CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1</code>（cdしても起動ディレクトリに戻る）</p></li></ul><h3>E. 参考リンク</h3><ul><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.anthropic.com/claude-code">Claude Code公式ドキュメント</a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://code.claude.com/docs/en/hooks-guide">Hooks Guide</a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://code.claude.com/docs/en/memory#rules">Rules</a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="http://AGENTS.md">AGENTS.md</a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/bcherny/status/2007179832300581177">Boris Cherny on X</a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://vercel.com/blog/agents-md-outperforms-skills-in-our-agent-evals">Vercel -</a> <a target="_blank" rel="noopener noreferrer nofollow" href="http://AGENTS.md">AGENTS.md</a> <a target="_blank" rel="noopener noreferrer nofollow" href="https://vercel.com/blog/agents-md-outperforms-skills-in-our-agent-evals">outperforms skills</a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://agentskills.io/home">Agent Skills</a></p></li><li><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/secretlint/secretlint">secretlint</a></p></li></ul><p></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[NewtからCraft Cross CMSへ200超の記事を移行する - データ移行スクリプトの設計と実装]]></title>
            <link>https://tech.plaid.co.jp/migration-to-craft-cross-cms</link>
            <guid>https://tech.plaid.co.jp/migration-to-craft-cross-cms</guid>
            <pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[NewtからCraft Cross CMSへ201記事と1238画像を移行。移行スクリプトの設計、APIクライアントの実装、データ変換処理、サイト側の修正まで、各工程の技術的詳細をコード例とともに解説します。]]></description>
            <content:encoded><![CDATA[<h2>はじめに</h2><p>プレイドでは、2025年9月に <a target="_blank" rel="noopener noreferrer nofollow" href="https://ecosystem.plaid.co.jp/product/karte-craft/xcms">Craft Cross CMS</a> をリリースしました。</p><p>もともとプレイドのエンジニアブログでは、NewtのヘッドレスCMSを利用していましたが、今回のタイミングでCraft Cross CMSへと移行したので、どのように移行プロジェクトを進めたか、まとめたいと思います。</p><img data-asset-id="69702a4ce2fda935576cd025" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=69702a4ce2fda935576cd025" alt="Craft Cross CMSのAIネイティブなヘッドレスCMS管理画面" width="2554" height="2248" /><p></p><h2>移行プロジェクトの概要</h2><p>今回の移行プロジェクトでは、以下をスコープとしました。</p><ul><li><p>エンジニアブログの201記事をすべて移行する</p></li><li><p>利用している画像ファイルは1238ファイル</p></li><li><p>サイト側のデザイン変更や機能追加は行わず、取得元を移行するのみに留める</p></li><li><p>移行時に必要になった機能はCraft Cross CMSに適宜追加する</p></li></ul><p>作業量としては、Craft Cross CMSへの機能追加も含めて25人日程度、移行作業のみで15人日程度でした。移行作業の内訳としてはNewtからCraft Cross CMSへのデータ移行が10人日、サイトのソースコード修正が3人日、管理画面の設定やその他の作業が2人日程度でした。</p><p>以下の順番で作業を進めていきました。</p><ol><li><p>要件定義</p></li><li><p>Craft Cross CMSへのデータ移行</p></li><li><p>サイトのソースコード修正</p></li><li><p>管理画面の設定（webhook・プレビュー）</p></li></ol><p>ただし、実際にはステップ2 → ステップ3を直線的に進めたわけではありません。まず50記事程度を移行した段階でサイト側の修正・表示確認を行い、問題がないことを確認してから残りの全記事を移行しました。リッチテキストのHTML形式の違いなど、データ移行だけでは気づけない問題があるためです。</p><p></p><h2>ステップ1: 要件定義</h2><p>今回の移行では「現状のエンジニアブログの機能・デザインを変えないまま、取得元のCMSを変更すること」を目的としました。</p><p>この目的に沿って、できるだけ円滑に移行が完了するよう、以下のような要件定義を行い、移行を進めていきました。</p><p></p><h3>移行対象のコンテンツ・アセット</h3><p>今回は「公開済みの全記事（201記事）を移行対象」とすることにしました。</p><p>また、アセットについては「利用されているもののみ（1238ファイル）を移行対象」としました。アップロードされているものの、どこからも参照されていないアセットについては邪魔なデータとなってしまうので、移行対象から外すこととしました。</p><p></p><h3>サイト側の修正</h3><p>もともと、エンジニアブログで使っている技術スタックは以下の通りでした。</p><ul><li><p>フレームワーク: Next.js</p></li><li><p>ホスティング: Netlify</p></li><li><p>CMS: Newt</p></li></ul><p>すでにヘッドレスCMSで作成されており、また今回サイト側のデザインや機能追加を目的とした移行ではなかったため、サイト側の修正は「デザイン変更や機能追加は行わず、取得元を移行するのみ」としました。</p><p>※ 以下、サイトのソースコード修正を行う箇所では、Next.jsの記法で記載しています。</p><p></p><h3>モデル（スキーマ）の修正</h3><p>移行に伴い、モデル（スキーマ）については「投稿」「著者」「タグ」の3モデルを移行対象としました。</p><p>モデルのフィールドについては見直しを行い、利用していないフィールドを削除しつつ、カスタムフィールド（複数のフィールドを組み合わせられるオブジェクト型のフィールド）を活用して、一部のフィールドはまとめることにしました。</p><p>また、よく移行で問題になる「公開日時」のデータですが、システム側が自動で定義する「sys.createdAt」等に登録するのではなく、ユーザー定義の「公開日」フィールドを1つ作成し、そのデータをブログ上で表示することにしました。画面に表示する用途であればユーザー定義のフィールドを作成することがおすすめです。</p><img data-asset-id="69702d787433bc3eabd7a987" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=69702d787433bc3eabd7a987" alt="定義した投稿モデル" width="2554" height="1858" /><h3>移行期間中の入稿停止</h3><p>移行期間中も、「特に入稿停止の期間は設けない」ことにしました。</p><p>もともと1ヶ月に数記事程度の公開ペースで間隔に余裕があったのと、更新停止の期間を設けると、エンジニア全体への周知のコストが大きくなるためです。</p><p></p><h2>ステップ2: Craft Cross CMSへのデータ移行</h2><p>続いて、移行プロジェクトのメインとなる、NewtからCraft Cross CMSへのデータ移行です。</p><p>Craft Cross CMSでは、コンテンツ・アセット・モデルといった様々なリソースを管理する<a target="_blank" rel="noopener noreferrer nofollow" href="https://developers.karte.io/reference/post_v2beta-cms-asset-upload">Management API</a> が用意されており、以下のような操作をAPI経由で実行できます。</p><ul><li><p>コンテンツの作成・取得・更新・削除・公開・非公開</p></li><li><p>アセットの作成・取得・更新・削除・公開・非公開</p></li><li><p>モデルの作成・取得・更新・削除</p></li></ul><p>今回の移行ではこのManagement APIを活用し、Node.js（TypeScript）でスクリプトを書いて、自動でデータ移行を行います。ただし、どうしても自動化するのが難しい場合、一部は手動での作業も行いました。</p><p>※ 以下、データ移行に関するコード説明では、Node.js（TypeScript）の記法で記載しています。</p><p>また、「移行プロジェクトの概要」で触れた通り、実際にはステップ2と3を行き来しながら進めています。詳しい理由は後述の「問題1: リッチテキスト（HTML）の比較が難しい」で説明します。</p><p></p><h3>移行の進め方</h3><p>移行の進め方は以下のように行いました。</p><ol><li><p>アセットのアップロード・更新</p></li><li><p>コンテンツの作成（タグ &gt; 著者 &gt; 投稿の順番）</p></li></ol><p>上記のような順番としたのは、参照元のコンテンツを作成する時に、参照先のアセットやコンテンツのidを知っておく必要があるためです。</p><p>詳しくは後述しますが、アセット・コンテンツの作成時に新旧のidのマッピングを保持しておき、適切に置き換えることで、正しく参照を設定できるようにします。</p><p>大きく差分比較・差分同期・データ削除の3種類のスクリプトを用意しました。順番に実行できるよう、アセット用のスクリプトと、投稿・著者・タグ用のスクリプトをそれぞれ定義しています。</p><ul><li><p>差分比較（diff）</p></li><li><p>Craft Cross CMSへの差分同期（sync）</p></li><li><p>データ削除（delete）</p></li></ul><pre><code class="language-tsx">// package.jsonのscriptsでの定義
"scripts": {
  "diff:assets": "tsx scripts/assets/diff.ts",
  "sync:assets": "tsx scripts/assets/sync.ts",
  "delete:assets": "tsx scripts/assets/delete.ts",
  "diff:contents:post": "tsx scripts/contents/post/diff.ts",
  "sync:contents:post": "tsx scripts/contents/post/sync.ts",
  "delete:contents:post": "tsx scripts/contents/post/delete.ts",
  ...
 },</code></pre><p>処理のイメージは以下の通りです。コンテンツが全件同期されるまで、差分比較と差分同期を繰り返しながら、進めていきます。最初は10件同期し、問題がなければ次は20件、その次は40件…というように、徐々に件数を増やしながら進めていきました。序盤はHTMLの正規化漏れなど、スクリプト側の問題が見つかりやすいため少量で回し、安定してきたら一気に件数を増やすという考え方です。問題が発生した場合は、スクリプトを修正するなど問題に対応し、データを一度削除してから、再度同期を行いました。</p><img data-asset-id="697182ad5de259ac01bbb87f" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=697182ad5de259ac01bbb87f" alt="データ移行の処理の流れ。差分比較と差分同期を繰り返しながら進めていく" width="1778" height="1444" /><p></p><p>はじめに「クライアントの作成」「リッチテキストの入稿」について説明したあと、それぞれのメソッドについて解説します。</p><p></p><h3>クライアントの作成</h3><p>Craft Cross CMSではまだSDKの提供がされていないので、オリジナルのクライアントを作成しました。以下のように作成し、コンテンツの作成・公開等の処理を実行できるようにします。</p><p>ポイントは以下です。</p><ul><li><p>レートリミットに引っかかった場合に備えて、最大3回のリトライを行う</p></li><li><p><code>TResponse</code> によって、レスポンスの型を呼び出し側から指定できるようにする</p></li></ul><pre><code class="language-tsx">const sleep = (ms: number) =&gt; new Promise((resolve) =&gt; setTimeout(resolve, ms));

export const craftPost = async &lt;TResponse = unknown&gt;(
  path: string,
  body: unknown,
): Promise&lt;TResponse&gt; =&gt; {
  const url = `${process.env.CRAFT_MANAGEMENT_API_ORIGIN}${path}`;
  const jsonBody = JSON.stringify(body);
  const maxRetries = 3;
  const retryDelay = 60000; // 60秒

  for (let attempt = 1; attempt &lt;= maxRetries; attempt++) {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.CRAFT_MANAGEMENT_API_ACCESS_TOKEN}`,
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: jsonBody,
    });

    if (response.ok) {
      const json = (await response.json()) as TResponse;
      return json;
    }

    // 429エラーの場合はリトライ
    if (response.status === 429 &amp;&amp; attempt &lt; maxRetries) {
      console.warn(
        `[CraftClient] 429 Too Many Requests (attempt ${attempt}/${maxRetries}). Retrying in ${retryDelay / 1000}s...`,
      );
      await sleep(retryDelay);
      continue;
    }

    // その他のエラーまたは最終リトライ失敗
    const errorText = await response.text();
    throw new Error(`Craft API error: ${response.status} ${response.statusText} - ${errorText}`);
  }

  throw new Error('Unexpected error in craftPost');
};</code></pre><p>このクライアントを利用して、 <code>path</code> と <code>body</code> を指定する形で、以下のようにメソッドを実行できます。</p><p><code>body</code> で送る情報はエンドポイントごとに異なるので、<a target="_blank" rel="noopener noreferrer nofollow" href="https://developers.karte.io/reference/post_v2beta-cms-content-create">リファレンス</a> を参考に設定してください。 </p><pre><code class="language-tsx">// タグの作成
export const createTag = async (data: CraftTagInput): Promise&lt;CraftTag&gt; =&gt; {
  const body = {
    modelId: process.env.CRAFT_TAG_MODEL_ID,
    data,
  };
  return await craftPost&lt;CraftTag&gt;('/v2beta/cms/content/create', body);
};

// タグの公開
export const publishTag = async (tagId: string): Promise&lt;CraftTag&gt; =&gt; {
  const body = {
    modelId: process.env.CRAFT_TAG_MODEL_ID,
    contentId: tagId,
  };
  return await craftPost&lt;CraftTag&gt;('/v2beta/cms/content/publish', body);
};</code></pre><p>また、アセットの作成・更新時には、JSON形式ではなく、FormData形式でデータを送る必要があります。上記のメソッドとは別に定義します。</p><p><code>Content-Type</code> は自動で設定されるので、設定しないよう注意しましょう。</p><pre><code class="language-tsx">export const craftPostFormData = async &lt;TResponse = unknown&gt;(
  path: string,
  formData: FormData,
): Promise&lt;TResponse&gt; =&gt; {
  // 省略

  for (let attempt = 1; attempt &lt;= maxRetries; attempt++) {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.CRAFT_MANAGEMENT_API_ACCESS_TOKEN}`,
        Accept: 'application/json',
      },
      body: formData,
    });

    // 省略
};</code></pre><p>このクライアントを利用して、以下のようにアセットをアップロードできます。</p><pre><code class="language-tsx">// アセットのアップロード
export const uploadAsset = async (formData: FormData): Promise&lt;CraftAsset&gt; =&gt; {
  const craftAsset = await craftPostFormData&lt;CraftAsset&gt;('/v2beta/cms/asset/upload', formData);
  return craftAsset;
};</code></pre><p>このクライアントを活用して、差分比較・差分同期・データ削除のメソッドを作成します。</p><p></p><h3>リッチテキストの入稿</h3><p>次にリッチテキストの入稿についてです。Craft Cross CMSのリッチテキストフィールドでは、入稿時にJSON形式でデータを入力することが求められます。HTMLからJSONへの変換用に <a target="_blank" rel="noopener noreferrer nofollow" href="https://www.npmjs.com/package/@craft-cross-cms/rich-text-core">@craft-cross-cms/rich-text-core</a> というライブラリが提供されているので、そちらを活用します。</p><p>まずはライブラリをインストールします。</p><pre><code>npm install @craft-cross-cms/rich-text-core</code></pre><p>実装のポイントは以下です。</p><ul><li><p>Node.js環境で利用するので、<code>@craft-cross-cms/rich-text-core/server</code> からインポートする</p></li><li><p><code>generateJSON</code> でhtmlをJSONに変換する</p></li></ul><pre><code>import {
  buildTiptapExtensions,
  generateJSON,
  type JSONContent,
} from '@craft-cross-cms/rich-text-core/server';

export const htmlToProseMirror = (
  html: string,
): {
  json: JSONContent;
} =&gt; {
  const json = generateJSON(html, buildTiptapExtensions({}));
  return {
    json,
  };
};</code></pre><p></p><h3>差分比較</h3><p>今回の移行では、移行期間中の入稿停止を行わないので、最新時点での差分比較をチェックする必要があります。Newt・Craft Cross CMSそれぞれからAPIで情報を取得し、差分を比較できるようにしました。</p><p>また、この差分比較で、Newtのみにあるアセット・コンテンツを特定し、次のCraft Cross CMSへの同期を実行する時に、移行対象が明確になるようにします。</p><p>差分比較の実行時に、以下のようなJSONを保存しました。</p><p>例えば、タグの差分比較を行うと、 <code>data/diff/tags.json</code> に以下のようなデータを保存するようにしました。ポイントとなるのは以下の項目です。</p><table style="min-width: 50px;"><colgroup><col style="min-width: 25px;" /><col style="min-width: 25px;" /></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>項目</p></th><th colspan="1" rowspan="1"><p>説明</p></th></tr><tr><td colspan="1" rowspan="1"><p>diff.onlyInNewt</p></td><td colspan="1" rowspan="1"><p>Newtにしかないコンテンツの配列（移行対象）</p></td></tr><tr><td colspan="1" rowspan="1"><p>diff.perfectMatch</p></td><td colspan="1" rowspan="1"><p>完全一致したコンテンツのペアの配列</p></td></tr><tr><td colspan="1" rowspan="1"><p>diff.needsUpdate</p></td><td colspan="1" rowspan="1"><p>内容に不一致のあるコンテンツのペアの配列</p></td></tr></tbody></table><pre><code class="language-json">{
  "summary": {
    "newtTotal": 157,
    "craftTotal": 7,
    "onlyInNewtCount": 150,
    "onlyInCraftCount": 0,
    "perfectMatchCount": 5,
    "needsUpdateCount": 2,
  },
  "diff": {
    "onlyInNewt": [
      {
        "_id": "xxxxxx",
        "_sys": {...},
        "name": "Datadog",
        "slug": "datadog",
      },
      ...
    ],
    "onlyInCraft": [],
    "perfectMatch": [
      {
        "newt": {
          "_id": "xxxxxx",
          "_sys": {...},
          "name": "Python",
          "slug": "python",
        },
        "craft": {
          "id": "xxxxxx",
          "sys": {...},
          "name": "Python",
          "slug": "python",
        },
        "hasFieldDiff": false,
        "fieldDiff": {
          "publishedAt": false,
          "name": false,
          "slug": false,
          "order": false
        }
      },
      ...
    ],
    "needsUpdate": [
      {
        "newt": {
          "_id": "xxxxxx",
          "_sys": {...},
          "name": "SRE",
          "slug": "sre",
        },
        "craft": {
          "id": "xxxxxx",
          "sys": {...},
          "name": "sre",
          "slug": "sre",
        },
        "hasFieldDiff": true,
        "fieldDiff": {
          "publishedAt": false,
          "name": true,
          "slug": false,
          "order": false
        }
      },
      ...
    ],
  },
  "timestamp": "2025-01-13T03:39:09.294Z"
}</code></pre><p>また、 <code>perfectMatch</code> ・ <code>needsUpdate</code> では、内容に不一致があるかどうかを表す <code>hasFieldDiff</code> というプロパティを持ちます。不一致の詳細がわかるよう、 <code>fieldDiff</code> というプロパティでどこに不一致があるか確認できるようにしました。上記の例では、 <code>name</code> に不一致があるため、<code>fieldDiff.name</code> がtrueになっています。</p><p></p><p><code>diff</code> 作成メソッドは以下のように定義しました。ポイントは以下です。</p><ul><li><p>slugをキーにして、NewtとCraft Cross CMSのデータのマッチングを行う</p></li><li><p>不一致がある場合は <code>needsUpdate</code> にpushする。ない場合は <code>perfectMatch</code> にpushする</p></li><li><p><code>publishedAt</code> の比較では、日時ではなく、公開状態の違いをチェックする</p></li><li><p>順番も比較し、<code>order</code> の項目で表す</p></li></ul><pre><code class="language-tsx">/**
 * Newt と Craft の Tag コンテンツの差分を計算
 * slug をキーとしてマッチングを行う
 */
export const calculateDiff = (newtTags: NewtTag[], craftTags: CraftTag[]): TagDiff =&gt; {
  // slug をキーとしてマッチングを行う（インデックス情報も保持）
  const craftBySlug = new Map(craftTags.map((tag, index) =&gt; [tag.slug, { tag, index }]));
  const newtBySlug = new Map(newtTags.map((tag) =&gt; [tag.slug, tag]));

  const onlyInNewt: NewtTag[] = [];
  const perfectMatch: MatchedTag[] = [];
  const needsUpdate: MatchedTag[] = [];

  newtTags.forEach((newt, newtIndex) =&gt; {
    const craftData = craftBySlug.get(newt.slug);
    if (!craftData) {
      onlyInNewt.push(newt);
      return;
    }

    const fieldDiff = compareFields(newt, craftData.tag, newtIndex, craftData.index);
    const hasFieldDiff = hasAnyFieldDiff(fieldDiff);

    const matchedTag: MatchedTag = {
      newt,
      craft: craftData.tag,
      hasFieldDiff,
      fieldDiff,
    };

    (hasFieldDiff ? needsUpdate : perfectMatch).push(matchedTag);
  });

  const onlyInCraft = craftTags.filter((craft) =&gt; !newtBySlug.has(craft.slug));

  return {
    onlyInNewt,
    onlyInCraft,
    perfectMatch,
    needsUpdate,
  };
};

/**
 * Tag のフィールドを比較
 */
export const compareFields = (
  newt: NewtTag,
  craft: CraftTag,
  newtIndex: number,
  craftIndex: number,
): FieldDiff =&gt; {
  // publishedAt の比較: 公開状態（公開/未公開）の一致を確認
  // null = 未公開, string = 公開済み（公開日時の値は比較しない）
  const publishedAtDiff =
    (newt._sys.raw.publishedAt === null) !== (craft.sys.raw.publishedAt === null);

  return {
    publishedAt: publishedAtDiff,
    name: newt.name !== craft.name,
    slug: newt.slug !== craft.slug,
    order: newtIndex !== craftIndex,
  };
};

/**
 * フィールド差分があるかチェック
 */
export const hasAnyFieldDiff = (fieldDiff: FieldDiff): boolean =&gt; {
  return Object.values(fieldDiff).some((hasDiff) =&gt; hasDiff);
};</code></pre><p></p><h3>差分同期</h3><p>差分同期では、差分比較時に特定したNewtのみにあるコンテンツをCraft Cross CMSに同期します。</p><p>はじめにNewtのコンテンツを、Craft Cross CMS用に加工し、そのデータを同期します。作成したあと、公開処理も行います。</p><pre><code class="language-tsx">// タグの同期の場合
for (const tag of tagsToCreate) {
  const tagData = transformTag(tag) // データの加工
  const craftTag = await createTag(tagData); // データの作成
  await publishTag(craftTag.id); // データの公開
}

// データの加工（フィールドのマッピング、必要であれば未定義の場合の対応を行う）
const transformTag = (newtTag: NewtTag): CraftTagInput =&gt; {
  return {
    name: newtTag.name,
    slug: newtTag.slug || newtTag.name.toLowerCase().replace(/\s+/g, '-'),
  };
};</code></pre><p></p><h3>データ削除</h3><p>データ削除用のスクリプトは必須ではありませんが、何か問題に気づいた時など、データを一度すべて削除して、再作成したい場合には用意しておくと便利です。</p><p>コンテンツを削除する前に、一度コンテンツを非公開にする必要があるので、注意してください。</p><pre><code class="language-tsx">// タグの削除の場合
for (const tag of craftTags) {
  try {
    await unpublishTag(tag.id) // データの非公開
    await deleteTag(tag.id); // データの削除
  } catch (err) {
    // 省略
  }
}</code></pre><p></p><h3>難しいポイント</h3><p>ここまで、差分比較・差分同期・データ削除のメソッドについて紹介しましたが、データ移行を行う場合、難しいポイントが何点かあります。</p><ul><li><p>リッチテキスト（HTML）の比較が難しい</p><ul><li><p>CMSによって多少形式が異なるので、完全一致で比較するのが難しい</p></li></ul></li><li><p>参照フィールドを使う場合、入稿時にidを知っている必要がある</p><ul><li><p>参照先を入稿した後で、参照元を入稿しなければならない</p></li><li><p>旧CMS（Newt）のidではなく、新CMS（Craft Cross CMS）のidを知る必要がある</p></li></ul></li></ul><p></p><h3><strong>問題1: リッチテキスト（HTML）の比較が難しい</strong></h3><p>リッチテキストで入稿した場合、HTMLやJSON等でデータが返却される場合がほとんどかと思いますが、CMSによって、同じ書式を選択していても、返却されるHTMLが異なる場合があります。</p><p>例えば、Newtでは「リスト」の書式の場合、 <code>&lt;li&gt;</code> タグの内側に <code>&lt;p&gt;</code> タグは含まれませんが、Craft Cross CMSでは <code>&lt;li&gt;</code> タグの内側に <code>&lt;p&gt;</code> タグが含まれます。</p><p>そのため、完全一致で比較することが難しくなります。</p><p></p><h4>対応1: （できる限り）完全一致で比較できるよう、比較時にHTMLを整形して比較する</h4><p>完全一致での比較が難しいと書きましたが、そうは言っても全記事を人力でチェックするには労力がかかります。HTML形式の差分を可能な限り吸収して、できるだけスクリプトでチェックできるようにしました。</p><p>ここでは <code>&lt;p&gt;</code> タグの削除、 <code>&lt;br&gt;</code> タグの正規化の例を記載しましたが、他にも計20個程度のタグの正規化や削除を行って比較しました。</p><p>差分の吸収は、移行前後のCMSの形式によって大変さが変わります。</p><p>もともと利用していたNewtのマークダウンフィールドが、自由にHTMLを書くことができ、様々な形式のHTMLが入稿されていたため、今回は難易度が高くなってしまいました。もし、移行前のCMSでHTMLの自由度が高くない場合、これほど大変にはならないと思います。</p><pre><code class="language-tsx">const removeParagraphTags = (html: string): string =&gt; {
  // すべての&lt;p&gt;と&lt;/p&gt;を削除（属性付きも含む）
  return html.replace(/&lt;p\b[^&gt;]*&gt;|&lt;\/p&gt;/gi, '');
};

const normalizeBrTags = (html: string): string =&gt; {
  // &lt;br /&gt; や &lt;br/&gt; を &lt;br&gt; に統一
  return html.replace(/&lt;br\s*\/?&gt;/gi, '&lt;br&gt;');
};

export const normalizeHtmlFully = (html: string): string =&gt; {
  // 1. pタグを完全に削除
  let normalized = removeParagraphTags(html);
  
  // 2. brタグの統一
  normalized = normalizeBrTags(normalized);
  
  ...
  
  return normalized;
};</code></pre><p></p><h4>対応2: 差分の吸収が難しい場合は、諦めて人力でチェックする</h4><p>中には差分の吸収が難しい場合もあります。</p><p>例えば、Craft Cross CMSのリッチテキストフィールドでは、「数式」の形式に対応しておらず、Newtで扱っていた数式の情報を移行することができません。そのため数式を含む記事では、数式を画像に置き換えて対応を行いました。</p><p>このような場合、データの手動修正が必要になり、またHTMLでの比較もできなくなるため、目検でのデータチェックが必要になります。</p><p>今回の場合、他にも「埋め込み」を利用している場合に、手動修正が必要となりました。</p><p>全部で201記事ありましたが、スクリプトのみで修正対応できたのは139記事、手動での修正対応が必要だったのは62記事でした。またスクリプトのみでHTMLをチェックできたのは173記事、目検での対応が必要だったのは28記事でした。</p><p>すべてスクリプトで対応することが理想ですが、技術的に難しい場合や、コストがかかりすぎる場合もあります。スケジュールやリソースとの兼ね合いで、どの程度人力での対応を許容するか、バランスを探ると良いと思います。</p><p></p><h3><strong>問題2: 参照フィールドを使う場合、入稿時にidを知っている必要がある</strong></h3><p>続いて、参照フィールドを使う場合、コンテンツの作成時にidを指定する必要があります。参照元のコンテンツから先に作成することはもちろんですが、作成時にどのidを指定すべきか知っていなければなりません。</p><p>やり方は様々あるかと思いますが、今回はマッピングデータを保持して、idを変換することにしました。処理の大きな流れは以下のようになります。</p><img data-asset-id="697181f45de259ac01bbb4cf" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=697181f45de259ac01bbb4cf" alt="マッピングデータの処理の流れ。同期時にマッピングを作成し、比較時に存在しないidのマッピングを削除する" width="1762" height="1024" /><h4>対応1: 差分同期時に新旧CMSのidのマッピングを保持する</h4><p>アセット・コンテンツの同期（作成）時に、旧CMS（Newt）のidと、新CMS（Craft Cross CMS）のidとのマッピングを保持することで対応しました。</p><p>具体的には、アセット・コンテンツの作成時に、以下のようなJSONを保存しました。</p><p>例えば、タグの差分比較を行うと、 <code>data/mapping/tags.json</code> に以下のようなデータを保存するようにしました。</p><pre><code class="language-json">{
  "mappings": [
    {
      "newtId": "5b33658febef3d00b545d48c",
      "craftId": "6944f1ab44a9d033a7a7f1eb",
      "slug": "python"
    },
    ...
  ],
  "totalMapped": 157,
  "lastUpdated": "..."
}</code></pre><p></p><p>差分同期を行う時に、マッピングも作成します。ポイントは以下です。</p><ul><li><p>同期を1件するごとに、マッピングデータも1件ずつ作成する</p></li><li><p><code>mergedMappings</code> では、重複除外のフィルタリングを行う</p></li></ul><pre><code class="language-tsx">export const syncOnlyInNewtTags = async (limit?: number) =&gt; {
  // 省略

  const mappingItems: TagMappingItem[] = [];
  for (const [index, tag] of tagsToCreate.entries()) {
    try {
      const craftTag = await createTag(transformTag(tag));
      await publishTag(craftTag.id);
      // データ作成時にマッピングも作成
      mappingItems.push(createMappingItem(tag._id, craftTag.id, tag.slug));
    } catch (err) {
      // 省略
    }
  }

  // マッピング情報を保存（既存のマッピングに追加）
  if (mappingItems.length &gt; 0) {
    const existingMappingData = await readJson('data/mapping', 'tags.json');
    const existingMappings = (existingMappingData as TagMapping | null)?.mappings || [];

    // 重複しないようフィルタリングを行う
    const mergedMappings = [
      ...existingMappings.filter((m) =&gt; !mappingItems.some((item) =&gt; item.newtId === m.newtId)),
      ...mappingItems,
    ];

    const mapping: TagMapping = {
      mappings: mergedMappings,
      totalMapped: mergedMappings.length,
      lastUpdated: new Date().toISOString(),
    };

    await saveJson(mapping, 'data/mapping', 'tags.json');
  }
  return mappingItems;
};

const createMappingItem = (
  newtId: string,
  craftId: string,
  slug: string,
): TagMappingItem =&gt; {
  return {
    newtId,
    craftId,
    slug,
  };
};</code></pre><p></p><h4>対応2: 差分比較時に存在しないマッピングを削除する</h4><p>また、作成したマッピングですが、データを削除した場合にはマッピングも削除する必要があります。 差分比較を行う時に、マッピングも更新します。ポイントは以下です。</p><ul><li><p>idでフィルタリングし、存在しないデータのマッピングを除外する</p></li></ul><pre><code class="language-tsx">export const diffTags = async (): Promise&lt;void&gt; =&gt; {
  const newtTags = await getNewtTags();
  const craftTags = await getCraftTags();

  // マッピングのクリーンアップ
  const craftTagIds = new Set(craftTags.map((tag) =&gt; tag.id));
  await cleanupMapping&lt;TagMapping['mappings'][0]&gt;(
    'data/mapping',
    'tags.json',
    craftTagIds,
    LOG_PREFIX,
  );

  // 省略
};

const cleanupMapping = async &lt;T extends BaseMappingItem&gt;(
  mappingDir: string,
  mappingFile: string,
  existingCraftIds: Set&lt;string&gt;,
  logPrefix: string,
): Promise&lt;BaseMapping&lt;T&gt; | null&gt; =&gt; {
  // マッピングファイルの読み込み
  const mapping = await readJson(mappingDir, mappingFile) as BaseMapping&lt;T&gt; | null;
  if (!mapping) return null;

  // idでフィルタリングし、存在しないデータのマッピングを除外する
  const cleanedMappings = mapping.mappings.filter((item) =&gt; existingCraftIds.has(item.craftId));
  const removedCount = mapping.mappings.length - cleanedMappings.length;

  if (removedCount === 0) return mapping;

  const cleanedMapping: BaseMapping&lt;T&gt; = {
    mappings: cleanedMappings,
    totalMapped: cleanedMappings.length,
    lastUpdated: new Date().toISOString(),
  };

  // マッピングファイルの更新
  await saveJson(cleanedMapping, mappingDir, mappingFile);
  return cleanedMapping;
};</code></pre><p></p><h4>対応3: 参照元のコンテンツ作成時に、マッピングを活用してidを設定する</h4><p>上記で保存した新旧CMSのidのマッピングをもとに、NewtのidをCraft Cross CMSのidに変換できるようにします。以下のようにMap形式のデータを用意しておくと便利です。</p><pre><code class="language-tsx">/**
 * タグマッピングを効率的な検索用のMapに変換する。
 * @param tagMapping - タグマッピングデータ
 * @returns Newt Tag ID から Craft Tag ID へのMap
 */
const createTagIdMap = (tagMapping: TagMapping | null): Map&lt;string, string&gt; =&gt; {
  const map = new Map&lt;string, string&gt;();
  if (tagMapping?.mappings) {
    for (const item of tagMapping.mappings) {
      map.set(item.newtId, item.craftId);
    }
  }
  return map;
};</code></pre><p>ここで作成したMapを用いて、データの加工時にCraft Cross CMSのidに変換します。</p><pre><code class="language-tsx">let tagIds: string[] | undefined;
if (newtPost.tags &amp;&amp; newtPost.tags.length &gt; 0) {
  tagIds = newtPost.tags
    .map((tag) =&gt; tagIdMap.get(tag._id))
    .filter((id): id is string =&gt; !!id);
}</code></pre><p>このようにマッピングデータを活用し、idの変換を行いました。</p><p>以上がデータ移行の作業となります。</p><p></p><h2>ステップ3: サイトのソースコード修正</h2><p>続いて、サイト側のソースコードを修正します。</p><p>今回、サイト側の修正は「デザイン変更や機能追加は行わず、取得元を移行するのみ」としたので、主にAPIでのデータ取得の部分を変更します。</p><p></p><h3>クライアントの作成</h3><p>サイト側でもクライアントを作成する必要があります。データ移行のスクリプトでは、Management APIを利用するためのクライアントが必要でしたが、サイト側ではCDN APIとPreview APIを利用するためのクライアントが必要になります。</p><p>ポイントは以下です。</p><ul><li><p>CDN API用のクライアントと、Preview API用のクライアントをそれぞれ作成する</p></li><li><p>Preview APIでは非公開のコンテンツも取得できるため、Preview用のトークンはフロントエンドに露出しないよう、環境変数の <code>NEXT_PUBLIC_</code> のプレフィックスをつけない</p></li><li><p>CDN APIとPreview APIで、リクエスト先のドメインは共通だが、パスが異なるので注意する</p></li><li><p>クエリは <code>qs.stringify</code> で設定する（使わなくても問題ないが、設定が簡単になる）</p></li></ul><p>また、Management APIのクライアントと以下の点が異なります</p><ul><li><p>レートリミット時のリトライを設定しない（設定しても問題ありませんが、レートリミットが問題になるほどリクエストを行わないので、ここでは設定しません）</p></li></ul><pre><code class="language-tsx">const createClient = (options: { token: string; isPreview?: boolean }) =&gt; {
  return async &lt;TResponse = unknown&gt;(
    path: "/list" | "/get",
    query?: Record&lt;string, unknown&gt;
  ): Promise&lt;TResponse&gt; =&gt; {
    const pathPrefix = options.isPreview ? "/preview" : "";
    let url = `${process.env.NEXT_PUBLIC_CRAFT_CDN_API_ORIGIN}/beta/cms/content${pathPrefix}${path}`;

    if (query) {
      const queryString = qs.stringify(query);
      url += `?${queryString}`;
    }

    const response = await fetch(url, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${options.token}`,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
    });

    if (response.ok) {
      const json = (await response.json()) as TResponse;
      return json;
    }

    // 省略
  };
};

export const cdnClient = createClient({
  token: process.env.NEXT_PUBLIC_CRAFT_CDN_TOKEN!,
});

export const previewClient = createClient({
  token: process.env.CRAFT_PREVIEW_TOKEN!,
  isPreview: true,
});</code></pre><p>クライアントを利用したメソッドの呼び方は以下のようになります。</p><pre><code class="language-tsx">interface ContentListResponse&lt;T&gt; {
  skip: number;
  limit: number;
  total: number;
  items: T[];
}

// 全モデル共通で利用するコンテンツ一覧取得のメソッド
export async function getContents&lt;T&gt;(
  modelId: string,
  query?: Record&lt;string, unknown&gt;,
  options?: { preview?: boolean }
): Promise&lt;ContentListResponse&lt;T&gt;&gt; {
  const client = options?.preview ? previewClient : cdnClient;

  return client&lt;ContentListResponse&lt;T&gt;&gt;("/list", {
    modelId,
    ...query,
  });
}

// CDN APIでタグの全コンテンツを取得するメソッド
export const getAllTags = async () =&gt; {
  const { items: tags } = await getContents&lt;Tag&gt;(
    process.env.NEXT_PUBLIC_CRAFT_TAG_MODEL_ID!,
    {
      limit: 1000,
    }
  );
  return tags;
};</code></pre><p></p><h3>参照データの展開（populate）処理の作成</h3><p>また、Craft Cross CMSでは参照フィールドを利用した場合、idのみが返却されます。NewtではAPI側でデータが展開（populate）された状態で返却されていたため、フロントエンド側で同様の処理を実装する必要があります。</p><p>具体的には、IDをもとに実データを取得して結合する、いわゆる「populate処理」を行うMapを作成しました。例えば、「著者（author）」「タグ（tag）」を参照フィールドとして使っている、「投稿」モデルのpopulate処理は以下のようにしました。</p><p>ポイントは以下です。</p><ul><li><p>著者・タグの全件取得を行った上で、Mapを作成しておく</p></li><li><p>参照フィールドの <code>id</code> の値（ <code>post.author</code> や <code>post.tags</code> に入っているidの値）をもとに、Mapからデータを取得する</p></li></ul><pre><code class="language-tsx">const populatePosts = async (rawPosts: RawPost[]): Promise&lt;Post[]&gt; =&gt; {
  const [authors, tags] = await Promise.all([getAllAuthors(), getAllTags()]);

  const authorMap = new Map(authors.map((author) =&gt; [author.id, author]));
  const tagMap = new Map(tags.map((tag) =&gt; [tag.id, tag]));

  return rawPosts.map((post) =&gt; {
    // 参照フィールド（単数値）のpopulate
    const populatedAuthor = post.author
      ? authorMap.get(post.author) ?? null
      : null;

    // 参照フィールド（複数値）のpopulate
    const populatedTags = post.tags
      .map((tagId) =&gt; tagMap.get(tagId))
      .filter((tag): tag is Tag =&gt; tag !== undefined);

    return {
      ...post,
      author: populatedAuthor,
      tags: populatedTags,
    };
  });
};</code></pre><p></p><h3>取得順序の設定</h3><p>また、今回は「公開日時」のデータとして、システム側が自動で定義する「sys.createdAt」ではなく、ユーザー定義の「公開日（publishedAt）」フィールドを1つ作成しました。</p><p>投稿一覧を取得する際には、その順番で取得します。</p><pre><code class="language-tsx">export const getAllPosts = async () =&gt; {
  const { total, items } = await getContents&lt;RawPost&gt;(
    process.env.NEXT_PUBLIC_CRAFT_POST_MODEL_ID!,
    {
      limit: 1000,
      order: ["-publishedAt"], // 公開日の降順
    }
  );
  const posts = await populatePosts(items);
  return { total, posts };
};</code></pre><p></p><h3>形式が変更したHTMLへの対応</h3><p>エンジニアブログでは、 <code>dangerouslySetInnerHTML</code> を利用して、CMSから返却されたHTMLをそのまま設定していました。ステップ2の「問題1」で、「CMSによって返却されるHTMLが異なる」と記載しましたが、NewtとCraft Cross CMSで返却されるHTMLが異なるので、そのままだとスタイルが崩れてしまいます。</p><p>サイト側でも以下のどちらかの対応をしないといけません。</p><ul><li><p>（スタイルは修正せず）Craft Cross CMSのHTMLを、Newtと同様の形式に修正する</p></li><li><p>（HTMLは修正せず）Craft Cross CMSのHTMLにあわせてスタイルを修正する</p></li></ul><p>今回は「Craft Cross CMSのHTMLを、Newtと同様の形式に修正する」方法を選びました。ステップ2で確認した差分を、反映します。</p><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://cheerio.js.org/">cheerio</a> を活用して、以下のように修正します。ここでは <code>&lt;table&gt;</code> タグ配下に設定されている <code>&lt;p&gt;</code> タグと、 <code>&lt;li&gt;</code> タグ直下に設定されている <code>&lt;p&gt;</code> タグを削除しています。</p><pre><code class="language-tsx">const $ = cheerio.load(post.body.html, {}, false);
$("table p").each((_, elm) =&gt; {
  const pEl = $(elm);
  pEl.replaceWith(pEl.html());
});
$("li &gt; p").each((_, elm) =&gt; {
  const pEl = $(elm);
  pEl.replaceWith(pEl.html());
});

// 省略

post.body.html = $.html();</code></pre><p></p><h3>画像の最適化</h3><p>最後に画像の最適化です。Craft Cross CMSでは <a target="_blank" rel="noopener noreferrer nofollow" href="https://www.fastly.com/jp/products/image-optimization">Fastly Image Optimizer</a> を利用して、画像の変換を行えます。</p><p>以下のようなメソッドを用意しました。ポイントは以下です。</p><ul><li><p><code>width</code> パラメータで、幅の変更</p></li><li><p><code>fit=bounds</code> でアスペクト比を保持</p></li><li><p><code>format=auto</code> で画像フォーマットの自動変更</p></li></ul><pre><code class="language-tsx">export const resizeImage = (url: string, width: number) =&gt; {
  const urlObj = new URL(url); // 相対パスの場合、new URL() でエラーになるため注意
  urlObj.searchParams.set("width", width.toString());
  urlObj.searchParams.set("fit", "bounds");
  urlObj.searchParams.set("format", "auto");
  return urlObj.toString();
};</code></pre><p>他にも様々なクエリが利用できるので、<a target="_blank" rel="noopener noreferrer nofollow" href="https://www.fastly.com/documentation/reference/io/">Fastlyのリファレンス</a> をご確認ください。</p><p>以上が、サイト側のソースコード修正となります。</p><p></p><h2>ステップ4: 管理画面の設定（webhook・プレビュー）</h2><p>最後に管理画面から、webhook・プレビューの設定を行います。</p><h3>webhookの設定</h3><p>Craft Cross CMSでは、「コンテンツの公開時」「コンテンツの非公開時」「コンテンツの更新時」といったイベントをトリガーに<a target="_blank" rel="noopener noreferrer nofollow" href="https://ecosystem.plaid.co.jp/product/karte-craft/craft-functions">Craft Functions</a>（任意のバックエンドプログラム）を実行できます。</p><p>以下のようなCraft Functionsを作成します。</p><p>ここではNetlifyでホスティングしているため、変数に <code>NETLIFY_DEPLOY_HOOK</code> を定義し、webhookを送るURLを定義します。</p><pre><code class="language-jsx">const LOG_LEVEL = '&lt;% LOG_LEVEL %&gt;';
const NETLIFY_DEPLOY_HOOK = '&lt;% NETLIFY_DEPLOY_HOOK %&gt;';

export default async function (data, { MODULES }) {
  const { initLogger } = MODULES;
  const logger = initLogger({ logLevel: LOG_LEVEL });

  try {
    const response = await fetch(NETLIFY_DEPLOY_HOOK, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });

    if (!response.ok) {
      const text = await response.text();
      const errorMessage = `Request failed: ${response.status} ${text}`;
      logger.error(errorMessage);
      throw new Error(errorMessage);
    }

    // 省略
    
  } catch (err) {
    // 省略
  }
}</code></pre><p>そして「API v2 設定」よりアプリを作成し、「Hook設定」よりトリガーとして以下を定義します。</p><p>※管理画面上では「KARTE CMS」という名称で表示されます。</p><ul><li><p>KARTE CMS: コンテンツの公開時</p></li><li><p>KARTE CMS: コンテンツの非公開時</p></li></ul><p>これで、コンテンツが公開・非公開されたタイミングで（更新して公開した場合も含みます）、Netlifyで自動でデプロイが実行されます。</p><img data-asset-id="696f27ede2fda935576c2e75" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=696f27ede2fda935576c2e75" alt="アプリのHook設定画面。トリガーにコンテンツの公開と非公開が設定されている" width="1276" height="614" /><p></p><p>また、Craft Functionsでは、任意のバックエンドプログラムを実行できるため、webhook以外にも様々なユースケースに対応できます。</p><ul><li><p>コンテンツ公開時のSlack通知</p></li><li><p>Craft RAGへの自動連携</p></li><li><p>検索インデックスの自動更新（Algoliaなどの検索サービスと連携している場合）</p></li><li><p>要約・翻訳の自動生成</p></li></ul><p>JavaScriptで記述でき、条件分岐や複数APIの組み合わせも可能なので、組織固有の業務フローに合わせた自動化を実装できます。</p><p></p><h3>プレビューの設定</h3><p>「コンテンツ設定」で該当モデルを選択し、「プレビュー設定」から設定できます。</p><p>「サイト上でプレビュー」を選択し、「プレビューURL」を登録しましょう。</p><img data-asset-id="696f28097433bc3eabd7013e" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=696f28097433bc3eabd7013e" alt="プレビュー設定画面。ここからプレビューURLを登録する" width="1275" height="466" /><p></p><p>これですべての設定が完了しました。</p><p></p><h2>移行時・移行後に感じたCraft Cross CMSの使用感</h2><p>以上でデータ移行の作業は完了したのですが、最後に移行時・移行後に感じたCraft Cross CMSの使用感を書いておきます。</p><h3>移行時に必要になるAPIは一通り揃っている</h3><p>今回はアセット・コンテンツの操作をAPI経由で行いましたが、どちらもCRUD処理を行えるAPI（Management API）が揃っていて、効率的に移行作業を実行できました。特に、記事内では詳細を記述していませんが、画像のメタデータの設定は、<a target="_blank" rel="noopener noreferrer nofollow" href="https://developers.karte.io/reference/post_v2beta-cms-asset-update">画像の更新処理を行うAPI </a>を利用したおかげで、かなり効率的に作業できました。</p><p>またモデルやカスタムフィールドタイプのCRUD処理を行うAPIも揃っているので、モデルやカスタムフィールドタイプをたくさん利用している場合は、これらもAPI経由で作成できます。</p><p>コンテンツ数・アセット数・モデル数が増えれば増えるほど、移行作業を自動化することで工数を削減できるので、大量のデータを移行したい場合は便利かと思います。</p><p></p><h3>リッチテキストの移行は大変</h3><p>ただし、リッチテキストを移行する場合は、自動での対応のみでは難しい場合がありました。</p><p>HTMLの形式の違いがあったり、対応している書式に違いがあると、何かしらの対応が必要になってしまいます。今回の場合も、HTMLを加工したり、手動での対応にかなり時間を使いました。</p><p>事前に利用しているCMSにもよりますが、自由度の高いものであればあるほど、難易度は高くなってしまうかと思います。</p><p></p><h3>画像最適化が便利</h3><p>Craft Cross CMSでは <a target="_blank" rel="noopener noreferrer nofollow" href="https://www.fastly.com/jp/products/image-optimization">Fastly Image Optimizer</a> を利用していて、様々なクエリをサポートしています。代表的なものには以下のものがあります。</p><ul><li><p><code>format=auto</code> クエリによる画像フォーマットの最適化（<code>webp</code> や <code>avif</code> への明示的な変換も可能です）</p></li><li><p><code>quality</code> クエリによる品質の変更</p></li><li><p><code>width</code>・<code>height</code> クエリによる画像サイズの変更</p></li><li><p><code>fit</code> クエリによるリサイズ時の制御</p></li></ul><p>これらのクエリを活用することで、デバイスや通信環境に応じた最適な画像配信が可能になります。エンジニアブログにおいても、Newtの時に利用していた画像最適化を簡単に実装できました。</p><p></p><h3>AIを活用したコンテンツ作成が便利</h3><p>Craft Cross CMSにはAIを活用したコンテンツ作成支援機能があり、移行後の運用で活用しています。キーワードや概要からの下書き生成、文章の改善提案やトーン＆マナーのチェックなど、執筆・レビューの両面で業務効率が向上しました。</p><img data-asset-id="6970db5f5de259ac01bb5724" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6970db5f5de259ac01bb5724" alt="AI Copilotによるコンテンツ作成支援" width="2736" height="1498" /><p></p><h2>おわりに</h2><p>今回は、プレイドのエンジニアブログをNewtからCraft Cross CMSへ移行したプロジェクトについて紹介しました。201記事と1238ファイルの画像を移行するという規模でしたが、Craft Cross CMSのManagement APIを活用することで、効率的にデータ移行を進めることができました。</p><p>ヘッドレスCMSの移行は、データの互換性やAPIの違いなど、様々な課題に直面します。しかし、適切な計画と段階的なアプローチ、そして自動化と手動対応のバランスを取ることで、大規模な移行も現実的な工数で実現可能です。</p><p>この記事が、CMSの移行を検討されている方々の参考になれば幸いです。移行に関するご質問やCraft Cross CMSについてのお問い合わせがございましたら、お気軽にご連絡ください。</p><p></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Amazon SESの機能を活用したメールのレピュテーション対策]]></title>
            <link>https://tech.plaid.co.jp/email-reputation-amazon-ses</link>
            <guid>https://tech.plaid.co.jp/email-reputation-amazon-ses</guid>
            <pubDate>Tue, 10 Feb 2026 02:10:14 GMT</pubDate>
            <description><![CDATA[メール配信サービスを運用する上で重要なレピュテーション低下を防ぐ対策について、Amazon SESの機能を使った事例を紹介します。]]></description>
            <content:encoded><![CDATA[<h1>はじめに</h1><p>こんにちは。プレイドのKARTE Messageチームでエンジニアをしている林です。</p><p>本記事では、大量のメール配信を実現するのに避けては通れないレピュテーション対策についてお話しします。</p><p>メール送信時に利用するサーバーのIPアドレスやFromドメインに対して付けられる評価をレピュテーションと呼びます。スパムメールのようなメールをたくさん配信しているとレピュテーションが下がり、メールプロバイダー(GmailやYahoo!メールなど)から受信を拒否される場合があります。詳しくは<a target="_blank" rel="noopener noreferrer nofollow" href="https://tech.plaid.co.jp/karte_message_mass_delivery_architecture">KARTE Messageの大量メール配信を支える技術</a>をご覧ください。</p><h1>レピュテーション観点での課題と解決策</h1><p>KARTE Messageでは、これまでレピュテーション観点での課題が多数存在しており、一部のプロバイダからスパムメールと判定されることもありました。</p><p>また、IPアドレスの運用やハードバウンスを極力発生させない仕組み作りに膨大なコストがかかり、注力したい機能開発にリソースが割けないこともありました。</p><p>これらの運用コストを削減するために、SESの機能をどのように活用しているか紹介していきます。</p><h2>マネージドIPプールを活用したIPアドレス管理</h2><p>SESでは、送信するドメインに設定セットと呼ばれるルールのグループを適用して送信します。設定セットには、どのIPプールからメールを送るか設定することができます。</p><p>IPプールには主に3つの種類があります。</p><ul><li><p>共有IPプール(shared IP pools)</p><ul><li><p>SESを使っている他のAWSアカウントと共有しているIPプール</p></li><li><p>ノイジーネイバー問題が発生し得る</p></li></ul></li><li><p>標準の専用IPプール(standard dedicated IP pools)</p><ul><li><p>他のAWSアカウントから分離されたIPプール</p></li><li><p>AWSアカウントがIPを手動で購入して運用する</p></li></ul></li><li><p>マネージドな専用IPプール(managed dedicated IP pools)</p><ul><li><p>他のAWSアカウントから分離されたIPプール</p></li><li><p>SES側がAWSアカウントの送信量に応じてIPのスケーリングをする</p><p>(※ ウォームアップ時に一部共有IPプールから配信されることがある)</p></li></ul></li></ul><p>これまでKARTE Messageでは標準の専用IPプールを使った配信をしており、次のような運用をしていました。</p><ul><li><p>定期的にKARTE Messageを利用するクライアントの配信量や配信速度を見積もり、それに応じてIPを新規に追加</p></li><li><p>追加したIPのウォームアップ</p></li><li><p>追加したIPが<a target="_blank" rel="noopener noreferrer nofollow" href="https://e-words.jp/w/RBL.html">DNSBL</a>に含まれている場合は、Blacklistからの解除対応</p></li></ul><p>マネージドIPプールを使った配信に切り替えると、このようなIPの調整をSES側に任せることができます。</p><p>KARTE Messageでは、マネージドIPプールを複数個作って各プールに複数のクライアントを割り振って運用することにしました。</p><img data-asset-id="6989b5c13f63112a4598539c" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6989b5c13f63112a4598539c" alt="" width="1786" height="1292" /><p></p><p>仮にいずれかのマネージドIPプールでIPレピュテーションが下がってしまった時に、問題のないクライアントは別のプールに移して配信し、配信量が増えた分だけSES側がIPの数を調整してくれます。これでIPの調整コストの削減とリスク分散の両立ができるようになりました。<br /></p><img data-asset-id="6989b62b27410945b2b732e6" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6989b62b27410945b2b732e6" alt="" width="1490" height="1572" /><h2>テナントを活用したハードバウンス率・苦情率の管理</h2><p>SESは、AWSアカウントのハードバウンス率または苦情率が一定数を超えるとレビュー対象となり、場合によっては送信を停止されてしまう可能性があります。またテナントの機能がリリースされるまではこれをモニタリングできる機能が無かったので、送信者側でチェックする必要がありました。</p><p>KARTE MessageではBigQueryを活用してハードバウンス率と苦情率のチェックをしていました。次の図はその概略を示したものです。</p><img data-asset-id="6989b6a63f63112a459855bc" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6989b6a63f63112a459855bc" alt="" width="2606" height="1628" /><ol><li><p>WorkerはQueueのステータスをチェックした後、SESのAPIにリクエストしてメールを送る</p></li><li><p>配信ログをBigQueryに送る</p></li><li><p>SchedulerがBigQueryへ定期的にクエリを実行し、クライアントのハードバウンス率や苦情率をチェック</p></li><li><p>一定の閾値を超えるとSchedulerがQueueのステータスを更新</p></li><li><p>配信が止まる</p></li></ol><p>この方式だと一部の問題があるクライアントだけ配信を停止し、それ以外のクライアントは継続して配信させることができます。しかし、BigQueryのスロットが詰まってしまうとチェックができなくなります。また、クライアントが複数個の送信元ドメインを使い分けて運用している場合、問題ない配信をしている送信元ドメインも停止させてしまっていました。送信元ドメイン単位のチェックは実装コストが高く、クライアント単位でのチェックに留めていました。</p><p>以上のように、ハードバウンス率や苦情率を送信者側でコントロールするのは非常にコストが高いです。</p><p>SESのテナントを活用すると、設定セットごとにハードバウンス率と苦情率をモニタリングできるようになり、BigQueryで集計してチェックする必要がなくなりました。</p><p>また、設定セットは送信元ドメイン単位で割り当てるようにしています。BigQueryを使ったチェックではクライアント単位で停止させていたのが、問題のある送信元ドメインだけ配信停止できるようになりました。</p><h2>メールアドレスのオートバリデーションを活用したスクリーニング</h2><p>新規契約したクライアントや今まで配信をしたことのないエンドユーザー群に対して新たに配信をしたい場合、ハードバウンス数が急激に増えることがあります。</p><p>KARTE Messageでは今まで送信を試みたメールアドレスのうち、一度でもハードバウンスしたメールアドレスは配信対象から除外する形を取っています。上記のようなケースでは数回の配信に分けることでハードバウンス数の急上昇を抑えつつ、次回以降配信しないようにスクリーニングを行っています。</p><p>これまでは、クライアントから配信量と想定されるバウンス数を確認し、全体の配信量に対して影響が大きくならないハードバウンス率になるようにハンドリングしており、ビジネスチームとのコミュニケーションやバウンス数チェックのオペレーションのコストが大きくなっていました。</p><p>この手間を省くために、SESの新機能であるメールアドレスのオートバリデーションとテナントを組み合わせて使っています。</p><p>オートバリデーションはSESがメールを送る前に受信アドレスのチェックをしてくれる機能です。SES側で受信できる可能性が高いと判断したメールアドレスだけに配信を試み、それ以外はハードバウンスとみなします。オートバリデーションの料金は1000通あたり$0.01です。</p><p>KARTE Messageでは、スクリーニングを行いたい時のみオートバリデーションを有効化して、テナントでモニタリングしているハードバウンス率の閾値を緩和する運用にしています。スクリーニングは初回の配信以外ほとんど行うことがないので、オートバリデーションにかかるコストを抑えつつ、これまでのスクリーニングに関する運用コストを削減することができました。</p><h1>最後に</h1><p>これらの取り組みによって、今まで守りの運用に割かれていたリソースを削減し、一部のプロバイダで受信されなくなる、スパムメールと判定されるということがかなり減りました。</p><p>メールのレピュテーション対策は<s>とてもめんどくさい</s>気にするポイントが多いので、クラウドサービス側の機能に任せられるのは非常にありがたいです。</p><p>Amazon SESは大量配信を支えるアップデートが続いているため、今後も新機能を積極的に取り入れ、運用の効率化をしていきたいです。</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[高トラフィックな分散システムのSLO改善事例]]></title>
            <link>https://tech.plaid.co.jp/high-traffic-distributed-systems-slo-improvements</link>
            <guid>https://tech.plaid.co.jp/high-traffic-distributed-systems-slo-improvements</guid>
            <pubDate>Thu, 05 Feb 2026 03:00:00 GMT</pubDate>
            <description><![CDATA[SLO改善の過程で直面した3つの主要な問題と、それらに対する解決アプローチについて紹介します。]]></description>
            <content:encoded><![CDATA[<h1>はじめに</h1><p>KARTEでは、秒間10万を超えるトラフィックを支えるシステム運用を行っています。私たちのチームでは、この分散システムの健全性を保つため、日常的にバーンレートやバーンダウンチャートを含むSLOのモニタリングを行っています。</p><p>その運用の中で、一部のSLOにおいて将来的にブリーチが発生する可能性があると判断し、今回、SLOの改善と目標値の引き上げを実施することにしました。本記事では、その過程で直面した3つの主要な問題と、それらに対する解決アプローチについて紹介します。</p><h1>改善対象のシステムとSLOについて</h1><p>改善対象となったシステムの詳細や SLO の具体的なしきい値については、基本的に非公開となるのですが、問題ない範囲で公開します。</p><p>今回改善を行ったSLOは、受信したリクエストが一定時間内に正しく完了する割合を示す指標です。</p><p>処理の流れは以下の通りになります。</p><ul><li><p>外部からのリクエストを受信するサーバ（以下：RequestReceiver）がリクエストを受け取る</p></li><li><p>RequestReceiver は受信したリクエストをデータベースに書き込む</p></li><li><p>RequestReceiver は、単位キーごとにまとめて処理を行うサーバ（以下：BatchProcessor）に対して処理リクエストを送信する</p></li><li><p>BatchProcessor は、データベースから該当キーに紐づく直近のリクエスト履歴を取得し、集約処理を実行する</p></li><li><p>RequestReceiver は処理結果が確定するまでポーリングで待機する</p></li><li><p>一定時間以内に対応する処理結果が取得できた場合を成功、取得できなかった場合を失敗とする</p></li></ul><img data-asset-id="6983f02595ec0c35d380a8de" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6983f02595ec0c35d380a8de" alt="" width="1548" height="1504" /><p>補足:</p><ul><li><p>RequestReceiverとBatchProcessorはGCPのMIG(Managed Instance Group)で動作しています。</p></li><li><p>RequestReceiverが単位キーのリクエストを複数のインスタンスで受け付けることを考慮して、単位キーあたりでバッチ処理を行っています。</p></li></ul><h1>解消した問題</h1><h2>問題1: RequestReceiver → BatchProcessor間で503, 504が定期的に大量に発生</h2><p>秒間十万を超えるリクエストをKARTEでは受け付けており、時折数万件を超える503, 504エラーが発生していました。</p><h3>調査のアプローチ</h3><p>RequestReceiver から BatchProcessor へのアクセスは GCP の Load Balancer を経由しています。503, 504エラーが発生しているものの、RequestReceiver からのリクエストが Load Balancer で弾かれて BatchProcessor に到達していないため、アプリ側のエラーログが残っていませんでした。</p><p>そこで調査のために Load Balancer のログを Datadog で見ようとしましたが、Datadog では Load Balancer のメトリクスについてバックエンドのホスト単位でレスポンスコードを見ることができず、解像度が低いという課題がありました。そこで、Load Balancer のログを一時的に取得し、Looker Studio で可視化しました。</p><img data-asset-id="6983f09256ac0965ce7ae28f" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6983f09256ac0965ce7ae28f" alt="" width="1884" height="1422" /><p>Looker Studio で結果を見ると、特定のホストから504が最初に発生し、その後503を返却することがわかりました。504発生から503を返すまでの時間幅が、Load Balancer のヘルスチェックに失敗してからサービスアウトされるまでの猶予期間と一致していました。</p><h3>対応</h3><ul><li><p>BatchProcessor が503を返却した場合、503を返却したサーバと別サーバに対してリトライを入れる対応を実施</p></li><li><p>Load Balancer のヘルスチェックの間隔を短くして、なるべく早く問題のあるインスタンスをサービスアウトさせる</p></li></ul><p>補足:</p><p>BatchProcessor は単位キーごとに大きなローカルキャッシュを構築してバッチ処理を行うため、Load Balancer のセッションアフィニティを利用しています。 そのため、リトライしても同一インスタンスにルーティングされ、503の場合は再試行が無駄になる設計上の制約がありました。そこで、リトライ時にはセッションアフィニティのkeyを変更してリトライさせるようにしました。この場合、リトライ先のインスタンスには該当キーのローカルキャッシュが存在しないため、キャッシュミスが発生しますが、503エラーで失敗するよりも望ましいと判断しました。</p><h3>共有したいこと</h3><p>Datadog のような観測ツールは強力ですが、コストや制約から必ずしもすべての情報を常時収集できるわけではありません。 今回のケースでは、Load Balancer のログを Cloud Logging 経由で BigQuery にエクスポートし、SQL でクエリを書いて Looker Studio で可視化することで、クイックにバックエンドのホスト単位での詳細な分析を実現しました。</p><p>この手法の利点は以下の通りです。</p><ul><li><p>GCP のログエクスポート機能により簡単にLBのログを収集可能</p></li><li><p>BigQuery の SQL により柔軟な集計・分析が可能</p></li><li><p>SQLの結果をシームレスに Looker Studio で可視化出来るので視覚的な分析が容易</p></li><li><p>Datadog でカーディナリティーの高いカスタムタグを追加するとコストが増加するため、一時的な深堀り調査にはこの方法が効果的</p></li></ul><h2>問題2: 503が起きる場合、必ずインスタンスがハングして異常終了してしまう</h2><h3>調査のアプローチ</h3><p>インスタンスが異常終了する原因を特定する必要がありましたが、アプリケーションがロックでハングしていたため、通常のアプリケーションログからは有効な情報が得られませんでした。</p><p>そこで、Datadog の APM と Profiling を活用しました。</p><p>まず APM のメトリクスを分析したところ、該当時間帯に Lock Contention(ロック競合待ち時間)が継続的に高い値を示していることを発見しました。</p><img data-asset-id="6983f0c056ac0965ce7ae2cc" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6983f0c056ac0965ce7ae2cc" alt="" width="1516" height="362" /><p></p><p>次に、Profiling の Comparison 機能を使用して、通常時と Lock Contention が高騰している時間帯の Wall Time(スレッドが実際に消費した時間)を比較しました。この比較分析により、どのメソッドがロック待ちの原因となっているかを特定できます。結果、データベースへのread操作が何らかの理由で一時的に遅延した際に、複数スレッドが同一のオブジェクトに対してロックを取得しようとして競合が発生していることが判明しました。</p><p>※ 実際の画像をお見せしたいのですが、セキュリティの観点によりDatadogから画像引用しています</p><img data-asset-id="6983f0e995ec0c35d380aa05" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6983f0e995ec0c35d380aa05" alt="" width="2800" height="1422" /><p>参照: <a target="_blank" rel="noopener noreferrer nofollow" href="https://www.datadoghq.com/blog/code-optimization-datadog-profile-comparison/">https://www.datadoghq.com/blog/code-optimization-datadog-profile-comparison/</a></p><h3>対応</h3><p>タイムアウトの原因は根深い問題があったため、この解決は別プロジェクトとして取り組むこととし、今回はデータベースへのread操作にタイムアウトを設定して、タイムアウト時はエラーとして扱うように変更しました。</p><h3>結果</h3><p>タイムアウトにより最悪数秒は応答に時間がかかるようになったため、RequestReceiverのポーリングタイムアウトとの兼ね合いで、SLOの数値自体は大きく改善しませんでした。しかし、インスタンスがハングして異常終了する問題は完全に解消され、サービスの安定性は向上しました。</p><h3>共有したいこと</h3><p>Datadogの APM と Profiling は、アプリケーションのパフォーマンス問題を特定する上で非常に便利です。</p><p>特に今回のようなケースでは、以下の点で有効でした。</p><ul><li><p><strong>継続的なプロファイリング</strong>: 本番環境で常時プロファイリングが行われているため、問題発生時の詳細なデータを事後的に分析できる</p></li><li><p><strong>時系列でのメトリクス追跡</strong>: CPU使用率、Lock Contention、Memory allocation などのメトリクスをスレッド単位・時系列で追跡可能</p></li><li><p><strong>Comparison機能</strong>: 正常時と異常時のプロファイルを比較することで、パフォーマンス劣化の原因となっているコードパスを迅速に特定できる</p></li><li><p><strong>ログでは捉えられない問題の可視化</strong>: 今回のようにアプリケーションがハングしてログが出力されない状況でも、プロファイリングデータから問題箇所を特定できる</p></li></ul><p>つい先日も、OOM(Out Of Memory) エラーで悩んでいるチームがProfilingを導入した結果、その日のうちに原因を特定して改善できていました。アプリケーションレベルのパフォーマンス問題に直面した際は、積極的に活用することをお勧めします。</p><h2>問題3: インスタンスタイプを切り替えたあとにエラーバジェットの消費速度が加速</h2><h3>調査のアプローチ</h3><p>上記2つの対応により目標の数値をクリアできる水準に達していました。しかし、パフォーマンス改善のためにインスタンスタイプを切り替えたところ、再び目標数値を下回る水準になってしまいました。 アプリケーションのログやLoadBalancerのステータスコードには全く異常がないので調査が難航しました。 コードを読み込み、BatchProcessor の ログが格納されたBigQueryのレコードで個別調査するなど地道な努力をして、考えうる可能性をほぼ潰しました。</p><p>それでも原因が特定できなかったため、問題の発生箇所を以下のように絞り込んで、システムの初期設計に関わったエンジニアに相談しました</p><ul><li><p>RequestReceiverからBatchProcessorに対してリクエストは適切に行われているにもかかわらずバッチ処理がスキップされているように見える</p></li><li><p>インスタンス起動後、数分間のみこの事象は発生する</p></li></ul><p>ここでNTP が怪しいという話になり、RequestReceiver で保存するリクエストが未来に対して行われていることがわかりました。</p><p>根本原因は以下の一連の流れでした。</p><ol><li><p>インスタンスタイプをよりパフォーマンスの良いものに変更したことで、インスタンスの起動速度が向上</p></li><li><p>起動が速くなったため、NTP による時刻同期が完了する前にアプリケーションがリクエストを受け付けるようになった</p></li><li><p>時刻同期前のサーバの時刻が未来にズレているケースでは、RequestReceiver が データベース にリクエストを保存する際のタイムスタンプが未来の時刻になっていた</p></li><li><p>BatchProcessor は「現在時刻までのリクエスト」を取得してバッチ処理を行うため、未来のタイムスタンプを持つリクエストがバッチ処理対象から除外されてしまった</p></li></ol><p>つまり、インフラの改善(インスタンスタイプの変更)が、意図せず時刻同期の問題を顕在化させていました。</p><p>補足:</p><p>NTP は設計上、起動直後に数 ms 程度の時刻ずれが発生し得ます。これは、NTP がネットワーク越しの時刻問い合わせ結果をもとに、往復遅延やジッタを考慮して統計的に時刻を推定し、段階的に時刻補正を行う仕組みであるためです。そのため、起動直後に即座に正確な時刻へ収束することは保証されません。</p><p>より厳密な時刻保証が必要な場合は PTP を利用することで、起動直後から高精度な時刻同期が可能です。一方で、PTP は利用可能なインスタンスタイプやゾーンに制約があり、今回変更先のインスタンスタイプでは未サポートでした。</p><h3>対応</h3><p>BatchProcessor でバッチ処理をする際、RequestReceiver 側のタイムスタンプが現在時刻より未来の場合は、時刻同期のズレと判断し、その分だけsleepして待機してから処理を行うように修正しました。</p><p>他の解決策として、DBのサーバサイドタイムスタンプを利用することや、NTP同期完了までリクエストを受け付けない方法などを考えましたが、以下の理由から現在の対応を選択しました。</p><ul><li><p>DBの特性及び現在のスキーマ設計上、DBのサーバサイドタイムスタンプを利用するのは困難</p></li><li><p>時刻のズレは起動時の1~5ms程度だが、NTP同期完了までの数分間は問題が発生</p></li><li><p>NTP同期完了待ちはインスタンス起動時間が延び、Auto Scalingの応答性に影響</p></li><li><p>起動直後の数分間のみ集中して発生する一時的な問題であり、現時点ではこの対応で十分と判断</p></li></ul><h3>共有したいこと</h3><p>可能性を一つづつ排除していき、それでも迷ったときは、システムの初期設計に関わったエンジニアに相談することが重要です。 開発時に懸念していた潜在的な問題や、設計上のトレードオフ、考慮していた制約条件など、ドキュメントには残っていない幅広い知識を持っています。今回も、そうした知見が問題解決の突破口となりました。</p><h1>まとめ</h1><p>3つの問題を解消、軽減することで、SLOを一段と引き上げることができました。SLO改善は終わりのない戦いですが、こうした地道な積み重ねがサービスの信頼性向上につながっていくと信じています。</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[KARTE Message 配信基盤で起きたIP枯渇とその対処]]></title>
            <link>https://tech.plaid.co.jp/message-ip-exhaustion-and-solution</link>
            <guid>https://tech.plaid.co.jp/message-ip-exhaustion-and-solution</guid>
            <pubDate>Tue, 03 Feb 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[GKEにおけるPodに割り振るIPの仕組みの簡単な説明と、KARTE Messageの配信基盤で起きたPodのIP枯渇問題とそれにどのように対処したか。]]></description>
            <content:encoded><![CDATA[<h1>はじめに</h1><p>こんにちはプレイドの<a target="_blank" rel="noopener noreferrer nofollow" href="https://cxclip.karte.io/products/info/message-beta/">KARTE Message</a>チームでエンジニアをしている<a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/prooooograming">土谷</a>です。</p><p>今回はKARTE Messageで起きたGKEのPodのIPアドレスの枯渇問題に関して、何が起きてどのように対応したのかを紹介します。</p><h1>KARTE Messageについて</h1><p>KARTE Messageはマルチチャネル(Mail/アプリPush/LINE[^1] )での大量配信を行えるMAツールです。</p><p>最大2000rpsを超える速度で配信しており、大量かつ高速な配信ができるMAツールとなっています。</p><p>詳しくは<a target="_blank" rel="noopener noreferrer nofollow" href="https://tech.plaid.co.jp/karte_message_mass_delivery_architecture">過去のブログ</a>で紹介しているので、そちらをご覧ください。</p><h1>GKEのIPに関して</h1><p>本題に入る前に、GKE VPCネイティブクラスタのIPの割り振りについて整理しておきます。</p><h2><strong>Pod の IP アドレス範囲（Alias IP）</strong></h2><p>クラスタ内でPodに割り当てられる専用のネットワーク帯域です。</p><h2><strong>VPC セカンダリ IP 範囲</strong></h2><p>GKEではVPCサブネットの「セカンダリIP範囲」をPod用の帯域として利用します。</p><h2><strong>ノードあたりの最大 Pod 数</strong></h2><p> 1つのNodeに割り振られるIPアドレスの数は、実際のPod数ではなく「Nodeあたりの最大Pod数」の設定に基づき、あらかじめNodeごとにCIDRブロックとして確保されます。Podの数とIPの数に関しては<a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.cloud.google.com/kubernetes-engine/docs/how-to/flexible-pod-cidr?hl=ja#cidr_ranges_for_clusters">ドキュメント</a>をご覧ください。</p><h1>発生した問題</h1><p>今回のブログのタイトルの通りで、配信基盤のNode PoolでIPが不足しPodが起動できないエラーが発生しました。エラーが発生したのが配信処理のJobがまとめられているNode Poolだったため、配信の遅延の可能性があり、即座に対応を開始しました。</p><h2>対応1: IPアドレスの追加</h2><p>まずは単純にIPが足りないエラーなので、IPアドレスを増やすことを試みました。VPCサブネットに新しいIP範囲を追加し、クラスタおよびNode Poolで利用するように設定しました。</p><pre><code># 1. VPCサブネットにセカンダリIP範囲を追加
resource "google_compute_subnetwork" "example-vpc" {
  # 中略
  secondary_ip_range {
+   ip_cidr_range = "xxx.xxx.xxx.xxx/yy"
+   range_name    = "additional-pod-range-1"
  }
}

# 2. クラスタに新しい範囲を認識させる
resource "google_container_cluster" "example-cluster" {
  # 中略
  ip_allocation_policy {
    # 中略
    additional_pod_ranges_config {
+     pod_range_names = ["additional-pod-range-1"]
    }
  }
}

# 3. Node Pool で利用する範囲を切り替える
resource "google_container_node_pool" "example-node-pool-v2" {
  # 中略
  network_config {
+   create_pod_range = false
+   pod_range        = "additional-pod-range-1"
  }
}</code></pre><h3>結果</h3><p>これにより数千個単位のIPを追加したにも関わらず、一瞬で使い切る結果となりました。実際に立っているPod数を確認すると数百程度だったので、実際のPod数以上にIPを消費していることがわかりました。</p><p>PodのIPアドレスの割り振りは、実際のPodの数ではなくNodeあたりのPod数の設定によるのでNodeにPodが何台立っているかを確認したところ、1Nodeあたり1Podしか立っていませんでした。しかし、Nodeあたりの最大Pod数は110台に設定されていたので、1Podに対して256のIPアドレスが消費される状態となっていました。</p><h2>対応2: NodeあたりのPod数の調整</h2><p>次に考えられる対応は以下のどちらかでした。</p><ol><li><p>インフラリソースの調整をしてNodeに対して十分なPodが立つ状態を作る</p></li><li><p>Nodeあたりの最大Pod数を調整し、IPアドレスの消費量を抑える</p></li></ol><p>Podの処理の性質上リソースの消費量が大きく、マシンスペックを上げても1NodeにPodが110台載ることは現実的でなかったので、先にIPアドレスの無駄遣いを解消することを実施しました。</p><h3>内容</h3><p>マシンスペックを大きくしても1Nodeには20台もPodは立たない想定で、1NodeあたりのPod数は32としました</p><pre><code>resource "google_container_node_pool" "example-node-pool-v3" {
  # 中略
~  max_pods_per_node = 110 -&gt; 32
}</code></pre><h3>結果</h3><p>これによりIPアドレスの無駄遣いが解消されて、IP枯渇が解消しました！</p><p>が、これによりIPアドレスのボトルネックが外れ、「1Node 1Pod」の状態のままPodがスケールした結果Node 数が比例して増え続け、<strong>インフラコストが 1.2 〜 1.5 倍</strong>程度まで増えてしまいました。</p><h2>対応3: Nodeのスペック調整</h2><p>1Pod増えると1Node増える構造がコスト増加の原因のため、Nodeのマシンスペックを調整しNodeに載るPodの数を調整しました。</p><p>以下を考慮しマシンスペックを決定しました。</p><ul><li><p><strong>コスト効率</strong>：マシンスペックの変更によるコストの変化</p></li><li><p><strong>耐障害性</strong>：マシンスペックを大きくした場合、Nodeで問題が発生した場合に影響を受けるPod数が増える</p></li><li><p><strong>スケール特性</strong>：Affinity 設定でPodを分散配置しているため、Nodeのスケールインが遅くなる可能性がある</p></li></ul><p>最終的に、CPU/メモリのバランスが良いStandardタイプのコア数を増やしたインスタンスを選択しました。</p><h3>結果</h3><p>1Nodeあたりに複数のPodが安定して収容されるようになり、コストは元の水準まで戻りました。以前よりもスケーラビリティに余裕が生まれたため、実質的には運用効率が向上する結果となりました。</p><h1>まとめ</h1><p>振り返ってみると基本的な内容での対応が多かったですが、社内でもIP枯渇の対応事例はなく、個人的にもKubernetesの運用経験として非常に良い経験となりました。</p><p>KARTE Messageはローンチからサービスも大きくなり過去問題なかったインフラ設定でも、運用しているうちに1Nodeに1Podという状態になってしまっていました。こういったビジネスのスケールと共にインフラ基盤が変化していく過程を経験できたのは非常に良い経験でした！</p><p>株式会社プレイドでは一緒に働く仲間を募集しています！</p><p>興味がある方はぜひ<a target="_blank" rel="noopener noreferrer nofollow" href="https://recruit.plaid.co.jp/product">採用ページ</a>をご覧ください。お待ちしております！</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Gemini議事録からGitHub Issue作成を自動化する仕組み]]></title>
            <link>https://tech.plaid.co.jp/create-issue-automation</link>
            <guid>https://tech.plaid.co.jp/create-issue-automation</guid>
            <pubDate>Mon, 02 Feb 2026 03:00:00 GMT</pubDate>
            <description><![CDATA[ミーティングの議事録からGitHub Issueを自動作成する仕組みを紹介します。Google MeetのGemini機能で生成された議事録を元に、Claude CodeやSub Agentを活用してタスクを抽出・Issue化するフローや、実装上の技術的な工夫点について解説しています。]]></description>
            <content:encoded><![CDATA[<p>はじめまして。Core Platform Dept.で<a target="_blank" rel="noopener noreferrer nofollow" href="https://karte.io/product/journey/">Journey機能</a>の開発に携わっている<a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/tsutomu_ikeda">Tsutomu Ikeda</a>です。</p><p>今回は、日々のミーティングの議事録から、話し合った内容を元にGitHub Issueを自動的に作成する仕組みを構築した話を紹介します。</p><h2>背景と課題</h2><p>朝会やWeeklyミーティングで「これやっておきます」と発言したにもかかわらず、その内容をすっかり忘れてしまうことありませんか？私は多々発生してしまっています。自分が発言したことを忘れずに記録してリマインドできる仕組みがあったら嬉しいなと思い、自動でIssueを作成するbotを作成しました。</p><p>なぜ発言したことを忘れてしまうのか。これにはIssueを作成する作業自体の面倒さが要因の1つとしてあります。</p><p>「やる」と決めたタスクをIssueにするまでには、以下のようなステップが必要です。</p><ol><li><p>GitHubを開く</p></li><li><p>タイトルを考える</p></li><li><p>どこまでやるか（スコープ）を考える</p></li><li><p>だいたいこの辺から着手するというあたりを付けてメモする</p></li></ol><p>これらのステップを踏んでいるうちに面倒になり、「後でやろう」と放置した結果、結局やらないままになってしまうことがありました。</p><p>一方で、現在社内で利用されているGoogle MeetのGeminiによる議事録作成機能で抽出される「推奨されるネクストステップ」の精度が実用的なレベルだと感じていました。せっかくの情報を見ずに捨ててしまうのはもったいない、これをIssue作成に活用できないかと考えました。</p><h2>システムの全体像</h2><p>構築したシステムの全体の流れは以下のようになっています。</p><ol><li><p>ミーティングが終了し、Googleドライブに議事録ファイルが作成される。</p></li><li><p>GAS（Google Apps Script）が議事録の作成を検知し、その内容を元にGitHubへPull Request (PR) を作成する。</p></li><li><p>PR作成がSlackに通知される。</p></li><li><p>PR作成をトリガーに、GitHub Actionsで次の2つのワークフローが実行される。</p><ol><li><p>Issue作成ワークフロー</p></li><li><p>ADR（Architecture Decision Record）やアーキテクチャドキュメントの更新ワークフロー</p></li></ol></li></ol><p>この仕組みにより、ミーティングが終わると自動的に議事録の内容が解析され、Issue作成やドキュメント更新が走るようになりました。</p><h2>実装における工夫点</h2><p>このシステムを構築する上で、特に工夫した技術的なポイントをいくつか紹介します。</p><h3>1. Sub Agent (Task) を活用した並列実行と最適化</h3><p>Issueを作成する際には、主に「アクションアイテムの抽出」「関連するソースコードの調査」「既存Issueとの重複チェック」という3つの観点での評価することにしました。この評価を効率化するために<a target="_blank" rel="noopener noreferrer nofollow" href="https://code.claude.com/docs/ja/sub-agents">Sub Agent</a>（Task）を活用しています。Sub Agentを活用することで以下のようなメリットが得られます。</p><ul><li><p><strong>並列実行:</strong> 独立したタスクとして並列に処理できるため、全体の実行時間が短縮されます。</p></li><li><p><strong>コンテキスト削減:</strong> 個別のタスクがそれぞれセッションを持つことで、全体のコンテキストが肥大化することを防げます。</p></li><li><p><strong>批判的なレビュー:</strong> 一度生成された内容に対し、別の視点（エージェント）から批判的にレビューさせることで、LLMが自身の生成内容を妄信してしまうリスクを低減できます。</p></li><li><p><strong>コスト最適化:</strong> 難易度の高いタスクには高性能なモデル（Opusなど）を、比較的単純なタスクには軽量なモデル（Sonnetなど）を割り当てることで、精度とコストのバランスを最適化できます。</p></li></ul><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://gist.github.com/Tsutomu-Ikeda/fd90f527e720c1d6c65547f6caeaec31">Sub Agentを用いたプロンプトの例</a></p><h3>2. コマンド実行時のパーミッションエラー回避</h3><p>GitHub Actions上でClaude Codeを動かす際、セキュリティの観点から<a target="_blank" rel="noopener noreferrer nofollow" href="https://code.claude.com/docs/ja/headless">実行可能なコマンドを </a><code>allowedTools</code><a target="_blank" rel="noopener noreferrer nofollow" href="https://code.claude.com/docs/ja/headless">（ホワイトリスト）で許可する必要があります。</a></p><p>ここで少しハマったのが<code>gh issue create</code> コマンド自体は許可しているにも関わらず、Issue作成が失敗する現象です。</p><p>原因をログから辿ったところ、Claude CodeがIssue本文を渡す際、コマンド置換<code>$(...)</code>を使用していたことが判明しました。この置換内部のコマンド実行が許可リストに含まれていない操作とみなされ、パーミッションエラーが発生していたのです。</p><p>そこで、本文を一度ファイルに出力し<code>gh issue create --body-file path/to/file</code> を用いて読み込むようプロンプトを改善しました。細かい対応ですが、挙動を安定化させる上で重要なポイントでした。</p><h3>3. Issue作成の閾値調整</h3><p>いくつか議事録を読み込ませた結果、Claude Codeがかなり賢く重複の排除を行ってくれるため「これはIssue化するほどではない」と判断し、Issueが作成されないケースが多く見られました。しかし、今回の目的は精度高くIssueを抽出することではなく、「取りこぼしをなくす」ことです。</p><p>そこで、Issue作成の条件を緩めに調整し、「実行可能であればとりあえずIssue化する」方針に変更しました。不要なIssueであれば後から人間がクローズすれば良いため、まずは漏れなく拾い上げることを優先しました。</p><h2>導入してみての変化</h2><p>導入してから1ヶ月ほど経過した時点で以下のような変化がありました。</p><h3>1. ミーティング終了後のIssue作成作業からの解放</h3><p>数分から数十分の作業ではあるものの、ミーティングで出たやることリストを覚え、忘れずに記載するという脳内メモリを消費する作業から解放されたことで、会議後スムーズに開発作業に集中できるようになりました。</p><h3>2. 議事録を振り返るきっかけができた</h3><p>これまで取るだけで終わっていた議事録からIssue作成まで行えるようになったため、Issue起点やドキュメント更新起点でどのような議論をしていたか振り返る習慣が付きました。</p><p>なんとなく理解していた議論内容もアウトプットベースで見返すことで言語化の手助けになると感じています。</p><h2>今後の展望</h2><p>現状の仕組みでも一定の自動化は出ていますが、まだまだ改善の余地があります。</p><p>出力面においては、ドキュメント更新の精度向上や、簡単な内容のIssueであれば自動的にPR作成まで行ってくれるように進化させたいと考えています。</p><p>一方、入力面でも改善が必要です。毎日溜まっていく議事録が肥大化してしまうため、週次・月次で情報をコンパクション（圧縮・要約）する仕組みを検討しています。また、現在はテキスト化された議事録をインプットとしていますが、元の音声データから直接情報を抽出した方がGeminiとClaudeの2段階を踏むよりデータのロスが少なくなるのではないかと考えています。</p><h2>終わりに</h2><p>プレイドでは、今回紹介したような開発プロセス自体の改善を含め、AIを活用したプロダクト開発を積極的に推し進めています。</p><p>今回の記事で少しでも興味を持った方がいらっしゃれば、ぜひカジュアル面談などでお話ししましょう。</p><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://recruit.plaid.co.jp/product">プレイドの採用ページ</a></p><p></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[レガシー Monorepo を安全かつ素早く pnpm workspace に移行する方法]]></title>
            <link>https://tech.plaid.co.jp/monorepo-pnpm-workspace-migration</link>
            <guid>https://tech.plaid.co.jp/monorepo-pnpm-workspace-migration</guid>
            <pubDate>Wed, 21 Jan 2026 01:55:26 GMT</pubDate>
            <description><![CDATA[レガシーな npm ベースの Monorepo を、既存コードへの影響を最小限に抑えながら pnpm workspace へ安全かつ効率的に移行するための手順とポイントを紹介します。phantom dependencies の洗い出しや ‎`preserveSymlinks` の無効化、‎`--fail-if-no-match` の活用など、実プロジェクトで得た知見をベースに解説します。]]></description>
            <content:encoded><![CDATA[<p>Developer Experience &amp; Performance チームでエンジニアをしている大矢です。今回はレガシーな monorepo 構成において、比較的コストをかけずに npm から pnpm workspace に移行する際のポイントについて解説します。</p><h1>背景</h1><p>プレイドでは 2019 年から Microservice 構成を採用していますが、具体的には <a target="_blank" rel="noopener noreferrer nofollow" href="https://tech.plaid.co.jp/self-contained-systems">Self Contained System</a> （SCS）というような構成になっています。1つの SCS にはフロントエンドのコードや API が package で切られて、いわゆる Monorepo の構成になっています。今回の事例では apiv2 という system を取り扱います。apiv2 は <a target="_blank" rel="noopener noreferrer nofollow" href="https://ecosystem.plaid.co.jp/karte-api">KARTE API</a> を構成する system で内部的に使われている名前です。KARTE API のように SaaS の機能を API として提供し自由度高く利用できることは AI 時代において重要度が高く、開発者体験を改善する必要がありました。</p><p>apiv2 system のディレクトリ構成は次のようになっていました（量が多いのでかなり省略しています）。</p><pre><code class="language-bash">├── docker-compose.yml
├── Makefile
├── node_modules
├── package.json
├── specs # openapi spec が格納されたディレクトリ、package から参照されることがある。
└── packages
  ├── api-dead-letter-queue-subscriber
  │ ├── __tests__
  │ ├── build
  │ ├── node_modules
  │ ├── package.json
  │ ├── package-lock.json
  │ ├── src
  │ ├── tsconfig.build.json
  │ └── tsconfig.json
  ├── api-gateway-v2
  │ ├── __tests__
  │ ├── build
  │ ├── node_modules
  │ ├── nodemon.json
  │ ├── package.json
  │ ├── package-lock.json
  │ ├── src
  │ ├── tsconfig.build.json
  │ └── tsconfig.json
  ├── api-hook-v2
  │ ├── ...
  ├── apiv2-cache-syncer
  │ ├── ...
  ├── common # front-react を含むほぼ全ての package から参照される
  │ ├── ...
  ├── common-server # front-react 以外のほぼ全ての package から参照される
  │ ├── ...
  ├── front-react # vite を使用
  │ ├── ...
  ├── hook-subscriber 
  │ ├── ...
  ├── internal-api
  │ ├── ...
  ├── migration-batch
  │ ├── ...
  ├── public-api-v2
  │ ├── ...
  ├── remote-mcp
  │ ├── ...
  └── web
    ├── ..
</code></pre><p>system のルートディレクトリに <code>package.json</code> があり、共通に使える npm package （typescript など）を管理します。各 package にも <code>package.json</code> と <code>package-lock.json</code> があり、それぞれに必要な npm package を管理しています。普通 monorepo（npm workspace, pnpm workspace, yarn workspace など）というと lock ファイルはルートディレクトリに1つですが、プレイドでは <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/plaidev/link-local-dependencies">https://github.com/plaidev/link-local-dependencies</a> という独自ツールを使用しています。 <code>link-local-dependencies</code> は <code>postinstall</code> スクリプトで実行されることを想定していて、 <code>package.json</code> に定義された <code>localDependencies</code> プロパティに従ってシンボリックリンクを貼るというシンプルな仕組みです。</p><p>apiv2 system の規模感をまとめると下記の通りです。</p><ul><li><p>13 package で構成</p></li><li><p>src ディレクトリに含まれるアプリケーションのコードが約 500 ファイル</p></li></ul><h2>pnpm workspace 移行のモチベーション</h2><p><code>localDependencies</code> は独自ツールのため、問題が起きた時にインターネットで調べても何も情報が出てこなかったり、初見でつまづくことが多いという問題がありました。また <code>package-lock.json</code> が package ごとにあるため重複が起きやすく、install に時間がかかったりディスクの消費が必要以上に大きくなったりします。一括で package のバージョンを上げたい場合や monorepo を構成する全ての package で node_modules をインストールしたい場合には、  <code>for dir in $(ls -d packages/*/); do (cd "$dir" &amp;&amp; npm install) &amp; done; wait</code> のようなスクリプトを実行する必要があります。</p><p>pnpm は開発も活発で、広く使われています。ディスク効率が良くて依存解決が高速なため新しい system では採用することが多いです。便利な機能もいくつかあり、supportedArchitecture ****は複数の linux アーキテクチャの環境で動かしたい場合に便利ですし、 <code>pnpm -r {command}</code> のように実行すると workspace 内の依存関係を考慮してコマンドを並列実行できるというのも嬉しいポイントです。</p><p>今回の開発者体験改善の取り組みでは、pnpm workspace への移行の他にも jest → vitest の移行、CiecleCI から GitHub Actions への移行も行いましたが、pnpm workspace への移行が最も大変で学びが多かったためこの記事ではメインで書いています。</p><h1>移行の原則</h1><p>移行の際に気をつけた原則について書いています。これは pnpm の移行に限らず npm package のメジャーバージョンのアップデートやリアーキテクチャなどの際にも気をつけています。</p><h2>必要最小限の変更</h2><p>改善をしていると、いろいろなことが気になってついつい PR が大きくなってしまうことがあります。細かいスクリプトの調整や、依存モジュールのバージョンアップ、TypeScript の any を直すなどです。coding agent に実装を任せる場合にも、きちんとコントロールしないとどんどんいろんなところを直し始めると思います。上手くいけば時間の節約になりますが、細かいところを改善した結果別の問題を引き起こす可能性があります。今回の改善では 1 つの PR の変更箇所を最小限にするということを心がけています。</p><h2>なるべく早い段階で問題を網羅する</h2><p>CI が通ったのでリリースしてみると、検証環境や本番環境で動かないということがあると思います。CI が通ったとしても検証環境・本番環境でどのようなエラーが起き得るか？その可能性を減らすために何がチェックできると良いか？を考えて事前に対策することが重要です。また、CI に全部任せるというのも危険です。ローカルで CI 環境と同じようなチェックができないために、何度もリモートブランチに push して CI の結果を待ち、エラーを確認してローカルで修正するというのもよくあるパターンですが、気付かないうちに時間と金銭的コストを消費している可能性があります。CI ではランナーを立ち上げたり、リポジトリをチェックアウトしたり、モジュールのインストールやキャッシュからの復元にオーバーヘッドがかかります。 </p><p>今回の改善では可能な限りローカルでの確認の時点で、CI や検証環境・本番環境で起きるエラーを予防して進めています。</p><h1>pnpm workspace 移行のポイント</h1><p>apiv2 を pnpm に移行する際には次のような変更が入ることになります。</p><ul><li><p>pnpm によって node_modules の構成が大きく変わり、厳密に依存解決が行われる</p></li><li><p>npm コマンドが pnpm コマンドになり、workspace 機能が使えるようになる</p></li><li><p>pnpm の便利なオプションが使える</p></li></ul><h2>厳密な依存解決によって起きる問題への対処</h2><p>pnpm を使用した node_modules の構成の例として、pnpm 公式ドキュメントに書いてあるものを参照します。bar を依存関係にもつ foo を install したときの node_modules の構成はこのようになります。</p><pre><code class="language-bash">node_modules
├── foo -&gt; ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
  ├── bar@1.0.0
  │ └── node_modules
  │   └── bar -&gt; &lt;store&gt;
  └── foo@1.0.0
    └── node_modules
      ├── foo -&gt; &lt;store&gt;
      └── bar -&gt; ../../bar@1.0.0/node_modules/bar</code></pre><p>node_modules 直下には <code>.pnpm</code> ディレクトリと、dependencies に存在する npm package のシンボリックリンクがあります。ここで、bar は dependencies に直接書かれていないため、node_modules 直下には存在せず、アプリケーションから直接 import することはできません。これを npm で構成する場合には次のようなディレクトリ構成になるでしょう。</p><pre><code class="language-bash">node_modules
├── bar
│ ├── dist
│ ├── package.json
│ └── src
└── foo
  ├── dist
  ├── package.json
  └── src</code></pre><p>foo から bar を import することはできますが、アプリケーションから直接 bar を import することもできます（phantom dependencies と言います）。pnpm に移行すると、このように元々解決できていた module が解決できなくなることがあります。</p><h3>解決できない module の網羅的な検出</h3><p>解決できない module を import しようとした場合は <code>tsc</code> 実行時に基本的にはエラーになります。pnpm では <code>--no-bail</code> オプションを使うことで workspace の全ての package でコマンドを実行する際に、途中で失敗しても最後まで処理を続けてくれます（ <code>pnpm -r --no-bail type-check</code> ）。これで一度のコマンドで網羅的に解決できない module がある箇所を洗い出すことができます。</p><p>また CommonJS の場合 <code>require</code> が使われると <code>tsc</code> だけではこれを検出することができません。 knip の unlisted dependencies や eslint の import/no-extraneous-dependencies を使ってこのようなケースも検出することができます。eslint はすでに全ての package で使われていましたが、 <code>eslint-plugin-import</code> をインストールする必要があるというのと、全ての <code>eslintrc</code> を書き換えるのが面倒だったため、今回は knip を使用（<code>knip --include=unlisted,unresolved</code>）しました。ちなみに knip は他にも不要な export を検出してくれたりいろいろ便利ですが、「必要最小限の変更」の原則に従って pnpm への移行に関係ないルールは使いませんでした。</p><p>暗黙的に解決できていた module は新規に dependencies に追加する必要がありますが、その際に 「そのパッケージが現在実際に使っているバージョンを維持するため、<code>package-lock.json</code> に記録された解決済みのバージョンをそのまま採用します。ここで欲張ってついでに最新版にアップデートしようなどとすると、それがまた新たな問題を引き起こし、PR が肥大化する可能性があります。</p><h3>preserveSymlinks を無効にする</h3><p>vite や tsconfig では、 <code>preserveSymlinks</code> というオプションがあります。これはシンボリックリンクを辿る前のパスから依存関係を解決するというもので、pnpm を使う場合に問題になります。どういうことか先ほどの pnpm 公式の例で説明します。アプリケーションは <code>foo</code> に直接依存していて、 <code>foo</code> からは <code>bar</code> が読み込まれます。</p><pre><code class="language-json">node_modules
├── foo -&gt; ./.pnpm/foo@1.0.0/node_modules/foo // - (1)
└── .pnpm
  ├── bar@1.0.0
  │ └── node_modules
  │   └── bar -&gt; &lt;store&gt;
  └── foo@1.0.0
    └── node_modules
      ├── foo -&gt; &lt;store&gt; // - (2)
      └── bar -&gt; ../../bar@1.0.0/node_modules/bar // - (3)</code></pre><p><code>preserveSymlinks</code> が <code>true</code> の場合</p><ol><li><p><code>require('foo')</code> が解決されると <code>(1)</code> が読み込まれます</p></li><li><p>foo の実体は <code>(2)</code> なので <code>(2)</code> から <code>require('bar')</code> が実行されます</p></li><li><p><code>require('bar')</code> は <code>(1)</code> の位置から辿ろうとしますが、 <code>node_modules/bar</code> が存在しないため解決エラーになります</p></li></ol><p><code>preserveSymlinks</code> が <code>false</code> の場合には、3のステップで <code>(2)</code> の位置から辿るので、 <code>(3)</code> の <code>node_modules/bar</code> が解決できます。</p><p>KARTE のリポジトリではレガシーな system ではこのオプションが <code>true</code> になっていることが多いです。 front-react package のような vite を使った package から localDependencies を使って CommonJS を読み込むときにこのオプションがついていないと解決できなかったからみたいですが、なぜ解決できなかったのかはよくわかっていません（そして、よくわからないままいろんなところでコピペされて本当にこれが必要なのか誰もわからない状態でした）。ただし、pnpm の仕組み上 <code>preserveSymlinks</code> は常に <code>false</code> にしておくのが良いでしょう（デフォルトが <code>false</code> です）。</p><p>front-react package から依存している common package（サーバとフロント両方から読み込まれる package） は CommonJS でビルドされているため、<code>preserveSymlinks</code> をやめると vite でそのまま読み込むことはできません。これを避けるための選択肢としてはいくつかあります。</p><ul><li><p>ビルド前の src を直接参照する</p></li><li><p>common package を ES Modules と CommonJS の dual package とする。もしくは ES Modules だけにしてサーバも ES Modules にする。</p></li><li><p>vite-commonjs-plugin を使う</p></li></ul><p>今回、common package の exports にはこのように、export の default として CommonJS のファイルへのパスが書かれていました。</p><pre><code class="language-json">{
  "name": "~common",
  "version": "1.0.0",
  "private": true,
  "description": "",
  "main": "build/src/constants/index.js",
  "types": "build/src/constants/index.d.ts",
  "exports": {
    ".": {
      "types": "./build/src/constants/index.d.ts",
      "default": "./build/src/constants/index.js"
    },
    "./build/src/types": {
      "types": "./build/src/types/index.d.ts",
      "default": "./build/src/types/index.js"
    },
    "./types": {
      "types": "./build/src/types/index.d.ts",
      "default": "./build/src/types/index.js"
    },
    ...（省略）</code></pre><p>ES Modules を扱うようにしたり、src を直接参照する方法だと、この exports の部分を書き換える必要があります。exports を書き換えると Node.js で動くアプリケーションからの参照にも影響がある可能性があります。「必要最小限の変更」の原則に従って vite-commonjs-plugin を使うことにしました。これは最も簡単で、 <code>vite.config.js</code> に plugin を足すだけです。</p><pre><code class="language-tsx">import { defineConfig } from 'vite';
import commonjs from 'vite-plugin-commonjs';

export default defineConfig({
  plugins: [commonjs()],
  ...
})</code></pre><h2>pnpm コマンドへの移行</h2><p><code>npm run</code> を使って <code>package.json</code> の scripts に書いてあるコマンドを実行するような箇所は、すべて <code>pnpm run(省略可能)</code> に移行します。これは機械的にできそうですが、一部注意が必要です。</p><h3>node_modules/.bin 直接実行をやめる</h3><p>node 実行時のオプションとして <code>max_old_space_size</code> を渡すために <code>package.json</code> の scripts に次のように書いているケースがあります。</p><pre><code class="language-json">{
  "scripts": {
    "test": "NODE_ENV=test node --max_old_space_size=4096 ../../node_modules/.bin/jest"
    ...(省略)
  }
}</code></pre><p>これは pnpm に関係なくやめるべきですが、pnpm workspace にすることで動かなくなることが多いです。 次のように環境変数を使って書き直します。</p><pre><code class="language-json">{
  "scripts": {
    "test": "NODE_ENV=test NODE_OPTIONS=--max_old_space_size=4096 jest"
    ...(省略)
  }
}</code></pre><h3>failIfNoMatch を有効にする</h3><p><code>./packages/web/package.json</code> に書かれている npm script を実行する際に、npm だと次のように書けます。</p><p><code>npm --prefix packages/web type-check</code></p><p>pnpm では workspace にある package の script を実行する場合には <code>--filter</code> オプションをつけます。しかし次のコマンドは一見あってそうですが間違いです（type-check は実行されず、正常終了になります）。</p><pre><code class="language-bash">$ pnpm --filter packages/web type-check || echo $?
No projects matched the filters in "systems/apiv2"
0</code></pre><p>pnpm では <code>--filter</code> オプションをつけて npm script を実行しようとした時に、filter にマッチする package がない場合は正常終了します。これが原因で、pnpm workspace に移行した時に CI で lint や type-check が実行されていないという問題が起きたことがありました。 <code>failIfNoMatch</code> オプションをつけることでマッチする package がない場合には異常終了にすることができ、問題に早く気づくことができます。</p><pre><code class="language-bash">$ pnpm --filter packages/web --fail-if-no-match type-check || echo $?
No projects matched the filters in "systems/apiv2"
1</code></pre><p>全てのコマンドにこのオプションをつけるのは面倒です。pnpm では <code>pnpm-workspace.yaml</code> ファイルにいろんなオプションをつけることができるのでそこで指定できないかと考えたのですが、公式ドキュメントには特にこのオプションに関する記述がありませんでした。</p><p>調べてみたところ <code>pnpm-workspace.yaml</code> に <code>failIfNoMatch</code> オプションをつけることはできるがドキュメントがないだけ（<a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/pnpm/pnpm/issues/10113）で、vscode">https://github.com/pnpm/pnpm/issues/10113）で、vscode</a> でこのオプションを指定したときにエラーになるのは、schemaStore という様々な json や yaml などのスキーマの補完・バリデーションを良い感じにしてくれる仕組みに <code>failIfNoMatch</code> の更新が反映されていないから（<a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/SchemaStore/schemastore/pull/5081）でした。">https://github.com/SchemaStore/schemastore/pull/5081</a>）でした。</p><h1>おわりに</h1><p>今回の移行で得た最大の学びは、「必要最小限の変更」と「早期の網羅的な検証」を徹底することでした。pnpm の厳密な依存解決は既存の暗黙依存を炙り出しますが、knip や eslint などの静的解析ツールを使用することで CI/本番での事故を手前で減らせます。さらに、<code>--fail-if-no-match</code> を workspace のデフォルトにすることで、「動いていないのに通る」CI をなくし、再現性のある運用に近づけました。</p><p>この記事が、レガシーな Monorepo を抱えるチームが「いま触りたくない」状態でも、一歩目を小さく安全に前進させる助けになれば幸いです。</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[「契約による設計」を応用し、エラー通知のノイズを激減させる — KARTE Messageにおけるエラーハンドリング改善]]></title>
            <link>https://tech.plaid.co.jp/karte_message_error_handling</link>
            <guid>https://tech.plaid.co.jp/karte_message_error_handling</guid>
            <pubDate>Wed, 14 Jan 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[KARTE Messageで行った、契約による設計を参考にしたエラー通知の切り分け方法を紹介]]></description>
            <content:encoded><![CDATA[<h1>はじめに</h1><p>こんにちは、プレイドの<a target="_blank" rel="noopener noreferrer nofollow" href="https://cxclip.karte.io/products/info/message-beta/">KARTE Message</a>チーム エンジニアの<a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/prooooograming">土谷</a>です。</p><p>今回はKARTE Messageの管理画面の開発で行った、契約による設計を参考にしたエラー通知の切り分け方法を紹介します。</p><h1>KARTE Messageについて</h1><p>KARTE Messageはマルチチャネル(Mail/アプリPush/ LINE<sup>[1]</sup> )での大量配信を行えるMAツールです。</p><p>最大2000rps近くの速度で配信しており、大量かつ高速な配信ができるMAツールとなっています。</p><p>詳しくは<a target="_blank" rel="noopener noreferrer nofollow" href="https://tech.plaid.co.jp/karte_message_mass_delivery_architecture">過去のブログ</a>で紹介しているので、そちらをご覧ください。</p><p></p><h1>KARTE Messageにおけるエラー通知の課題</h1><h2>多様なユーザー入力とエラーの関係</h2><p>KARTE Messageでは、自由度の高いユーザー入力を取り扱っています。</p><ul><li><p>配信対象を抽出するためのクエリ</p></li><li><p>相対日付（「n日前」など）による複雑なフィルタ条件</p></li><li><p>配信スケジュール（開始日とタイミングの組み合わせ）</p></li></ul><p>これらの入力値の中には、構文エラーや論理的に無効な設定が含まれる場合があります。これらは「ユーザーが修正すべきもの」であり、必ずしも「システム的に問題がある状態」ではありません。</p><h2>従来の通知の仕組みと課題</h2><p>KARTE Messageでは、独自実装した<code>log.error()</code>を呼び出すと、Datadogへの記録と同時にSentry（およびSlack）へ通知が飛ぶ仕組みになっています。
しかし、ユーザー入力に起因するエラーまで全てSentryに通知されていたため、以下の問題が発生していました。</p><ul><li><p><strong>オオカミ少年化</strong>: 通知が多すぎて、誰も即座には反応しなくなる。</p></li><li><p><strong>管理コストの増大</strong>: SentryのIssueが膨れ上がり、トリアージが困難になる。</p></li></ul><h2>「契約による設計」をベースにしたエラーの再定義</h2><p>ユーザー起因のエラーとシステム起因のエラーを明確に分けるため、<strong>契約による設計</strong>の概念を取り入れました。</p><h3>1. 事前条件(PreCondition)</h3><p> メソッド実行時に呼び出し側が満たすべき条件です。</p><ul><li><p><strong>例</strong>: キャンペーンの公開時に開始時刻が現在時刻よりも前（過去） に設定されている。</p></li><li><p><strong>対応</strong>: ユーザーに修正を促す（400系エラー）。エンジニアの即時対応は不要。</p></li></ul><h3>2. 事後条件(Postcondition)</h3><p>メソッド終了時に保証されるべき条件です。</p><ul><li><p><strong>例</strong>: キャンペーン公開時にネットワークエラーでDBへのデータの書き込みに失敗する。</p></li><li><p><strong>対応</strong>: システムの異常、外部サービスのダウンなど。エンジニアが調査・対応すべき（500系エラー）。</p></li></ul><hr /><h1>実装例</h1><h3>エラークラスの定義</h3><p>エラーハンドリングをしやすくするため、それぞれ型を実装しています。</p><pre><code class="language-tsx">export class PreConditionError extends Error {
  constructor(public readonly message: string) {
    super(message);
    this.name = 'PreConditionError';
  }
}

export class PostConditionError extends Error {
  constructor(
    public readonly message: string, 
    public readonly originalError?: unknown
  ) {
    super(message);
    this.name = 'PostConditionError';
  }
}</code></pre><h3>handlerでのハンドリング</h3><p>アプリケーションの処理が完了して、レスポンスを返す処理のあるhandlerで以下のようなエラーハンドリングを行っています。</p><pre><code class="language-tsx">export const errorHandler = (_request, res, error: unknown) =&gt; {
  if (error instanceof PreConditionError) {
    // ユーザー起因なので Info ログ（通知しない）
    logger.info('PreConditionError occurred', { error });
    return res.status(400).send({ error_message: error.message });
  } 
  
  if (error instanceof PostConditionError) {
    // システム起因なので Error ログ（Sentry通知）
    logger.error(error.message, { 
      isUnknownError: false, 
      originalError: error.originalError 
    });
    return res.status(500).send({ error_message: "Internal Server Error" });
  }

  // 想定外のエラーも通知対象とする
  logger.error("Unknown Error occurred", { error });
  return res.status(500).send({ error_message: "An unexpected error occurred" });
};</code></pre><h2>導入後の変化とこれからの課題</h2><h3>良かったこと</h3><p>Slack通知が「対応が必要なもの」だけに絞られたことで、チーム内のアラートに対する意識が改善しました。「通知が鳴った＝すぐに確認」という文化が徐々に醸成されてきています。</p><p>開発時も全てエラーとしてしまうのではなく、ユースケースから考えてどのようにエラーハンドリングすべきかを考えるきっかけにもなっています。</p><h3>残っている課題</h3><p>ライブラリが内部で発生させるランタイムエラーなど、本来は事前条件違反だが実装時には判断できないものがあり、事後条件違反として誤アラートされるケースがあります。これらをどう分類し直すかは、今後の改善ポイントです。</p><h1>最後に</h1><p>KARTE Messageは大規模な配信をしているので、そちらの配信基盤に目が向きがちですがシステムの安定稼働のためwebアプリケーションの開発・改善にも力を入れています！</p><p>興味がある方は<a target="_blank" rel="noopener noreferrer nofollow" href="https://recruit.plaid.co.jp/product">採用ページ</a>をご覧ください。お待ちしております！</p><hr /><p>[1]: LINEとの連携機能は現状β版として提供しています。</p><p></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Datadog の Workflow Automation を使って再起不能になった Pod を安全に削除する]]></title>
            <link>https://tech.plaid.co.jp/datadog-workflow-automation-pod-delete</link>
            <guid>https://tech.plaid.co.jp/datadog-workflow-automation-pod-delete</guid>
            <pubDate>Wed, 03 Dec 2025 03:42:45 GMT</pubDate>
            <description><![CDATA[DatadogのWorkflow Automationで、再起不能なGKEのPodを安全に自動削除する具体的手順と設計ポイントを紹介します。クールタイム管理、Desired/Readyの健全性チェック、Slack通知と承認で誤作動を防ぎつつ、運用の手間を減らします。Helm設定の注意点や権限付与、メトリクス活用まで実例でまとめました。]]></description>
            <content:encoded><![CDATA[<h1>はじめに</h1><p>この記事は <a target="_blank" rel="noopener noreferrer" href="https://qiita.com/advent-calendar/2025/datadog">Datadog Advent Calendar 2025</a> の3日目の記事です。</p><p>こんにちは、プレイドの Developer Experience &amp; Performance チームでエンジニアをしている大矢です。Datadog の好きな機能は&nbsp;<a target="_blank" rel="noopener noreferrer" href="https://docs.datadoghq.com/continuous_integration/">CI Visibility</a>&nbsp;と&nbsp;<a target="_blank" rel="noopener noreferrer" href="https://docs.datadoghq.com/dashboards/widgets/split_graph/">Split Graph</a>&nbsp;です。</p><p>Datadog の <a target="_blank" rel="noopener noreferrer" href="https://docs.datadoghq.com/ja/actions/workflows/">Workflow Automation</a> は Datadog の <a target="_blank" rel="noopener noreferrer" href="https://docs.datadoghq.com/ja/actions/actions_catalog/">Action</a> を組み合わせたワークフローを組むことができる機能です。Datadog の API で提供されているような機能を中心として様々なクラウドプロバイダーや SaaS ツールとも連携できます。</p><h1>背景：なぜ Pod を削除するのか</h1><p>私たちが運用しているサービス KARTE は GKE 上に構築されています。普通、Pod にはヘルスチェックが設定されており、サービスが応答できない状態になったらコンテナを再起動するようになっています。しかし、うまくコンテナを再起動するようにコントロールできないケースや、Pod を消して作り直した方が早いケースがこれまでに何度かありました。</p><p>例えば、GKE の既知の問題として Image Streaming を有効にしている際に、Image Pull のタイミングによってはファイルが存在せずに実行できないということがありました（<a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.cloud.google.com/kubernetes-engine/docs/troubleshooting/known-issues?hl=ja#image-screaming-missing-files">https://docs.cloud.google.com/kubernetes-engine/docs/troubleshooting/known-issues?hl=ja#image-screaming-missing-files</a>）。Image Streaming を有効にしているノードの中で、特定のイメージのファイルが欠落することがあるという状態でした。このケースでは、コンテナは再起動されますが同じノードで再起動しようとするため、何度再起動しても正常に起動することはありません。しかし、Pod を削除してしまえば別のノードに配置されるため、起動できる可能性があります。</p><p>このように 「Pod を消せば解決する」パターンはいくつかあり、社内では Pod を削除するマニュアルを作って、既知の問題には Pod を削除して解決することがしばしばありました。しかし、Datadog の画面上から問題の Pod 名を抽出した後に、GCP の管理画面もしくは CLI 上から Pod 削除を実行するというもので、地味に面倒くさいというのと決まりきった作業なのに自動化できていないという問題点がありました。</p><h1>Private Action Runner のセットアップ</h1><p>今回作成する Workflow では Pod を削除する Action を使用します。Kubernetes Core というカテゴリにあるアクションを使用する場合には Private Action Runner をインストールする必要があります。</p><p>App Builder モードと Workflow Automation モードがありますが、今回は Workflow Automation でしか使用しないため Workflow Automation モードを使用します。</p><p><a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.datadoghq.com/ja/actions/private_actions/#workflow-automation-mode">https://docs.datadoghq.com/ja/actions/private_actions/#workflow-automation-mode</a></p><img data-asset-id="6944eab358acce41ce43dab1" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6944eab358acce41ce43dab1" alt="" width="1484" height="980" /><p>Datadog の管理画面上から手順を進めていくと、次のようなコマンドを実行するように指示されます。</p><pre><code class="language-bash">docker run \
  -e DD_BASE_URL=https://us5.datadoghq.com \
  -e RUNNER_ENROLLMENT_TOKEN=xxx \
  -e STATSD_ENABLED=true \
    gcr.io/datadoghq/private-action-runner:v1.14.0 \
  --enroll-and-print-config</code></pre><p>これを実行すると Helm charts に設定するべき config が生成されます。Pod を削除するアクションのみを許可した場合の config は次のようになりました。</p><pre><code class="language-yaml">actionsAllowlist:
    - com.datadoghq.kubernetes.core.deletePod
allowlist:
    - '*'
allowIMDSEndpoint: false
ddBaseURL: https://us5.datadoghq.com
modes:
    - pull
port: 9016
privateKey: xxx
urn: xxx
reportMetrics: true</code></pre><p>注意点としては3つあります。</p><p>まず1つ目は secret の扱いです。Kubernetes を運用する場合 GitOps のように GitHub に Manifest をコミットして管理することが多いと思います。その場合には secret をそのままコミットするわけにはいきません。<a target="_blank" rel="noopener noreferrer" href="https://github.com/DataDog/helm-charts/tree/main/charts/private-action-runner#using-kubernetes-secrets-for-runner-identity">runnerIdentitySecret</a> を使用して Secret リソースを参照するようにし、Secret リソース自体は External Secrets Operator などのツールを使って管理します。</p><p>2つ目にスキーマが違う問題がありました。 下記の設定はエラーになります。</p><pre><code class="language-yaml"># kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmCharts:
- name: private-action-runner
  includeCRDs: true
  valuesFile: values.yaml
  releaseName: private-action-runner
  version: v1.18.0
  repo: https://helm.datadoghq.com

# values.yaml
$schema: https://raw.githubusercontent.com/DataDog/helm-charts/private-action-runner-1.18.0/charts/private-action-runner/values.schema.json
runner:
  runnerIdentitySecret: "datadog-runner-identity"
  # Replace this section with the output of the private action runner enrollment process with the `--enroll-and-print-config` flag
  config:
    allowIMDSEndpoint: false
    ddBaseURL: https://us5.datadoghq.com
    modes:
      - pull
    port: 9016
    reportMetrics: true
    actionsAllowlist:
        - com.datadoghq.kubernetes.core.deletePod
    allowlist:
        - '*'</code></pre><pre><code class="language-bash">$ kustomize build .
Error: Error: values don't meet the specifications of the schema(s) in the following chart(s):
private-action-runner:
- runner.config: Additional property reportMetrics is not allowed
- runner.config: Additional property allowlist is not allowed</code></pre><p>allowlist と reportMetrics のパラメータはドキュメントを見る限りすでに許可されていませんでした。そのためこれらのパラメータは消しました。</p><p>3つ目に actionsAllowlist についてですが、これを設定するだけでは private action runner が Pod を削除することはできませんでした。理由としては、この Helm charts で作成された Role リソース（private-action-runner に Bind されています）に Pod delete が割り当てられていないからです。</p><pre><code class="language-yaml">apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: private-action-runner
rules:
  - apiGroups:
      - ""
    resources:
      - pods
    verbs:
      - get
      - list</code></pre><p>結局、ドキュメントを見ていると kubernetesActions というパラメータがあったのでこれを設定することで Pod を削除する権限を与えることができました。</p><pre><code class="language-yaml"># values.yaml
$schema: https://raw.githubusercontent.com/DataDog/helm-charts/private-action-runner-1.18.0/charts/private-action-runner/values.schema.json
runner:
  runnerIdentitySecret: "datadog-runner-identity"
  # Replace this section with the output of the private action runner enrollment process with the `--enroll-and-print-config` flag
  config:
    allowIMDSEndpoint: false
    ddBaseURL: https://us5.datadoghq.com
    modes:
      - pull
    port: 9016
  kubernetesActions:
    pods: ["get","list", "delete"]</code></pre><p>あとは、Private Action Runner 作成の手順に従って、Connection を作成します。Credentials は Service account authentication を選ぶのがスムーズです。</p><img data-asset-id="6944eab4afe7650e7f8b0c22" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6944eab4afe7650e7f8b0c22" alt="" width="808" height="484" /><p>Private Action Runner をインストールした際には、シンプルに Pod 削除をするだけの Workflow を作って検証するのが良いと思います。まずは pod 名を指定して削除するだけの Workflow を作り、テスト実行しました。</p><img data-asset-id="6944eab458acce41ce43dab8" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6944eab458acce41ce43dab8" alt="" width="410" height="372" /><h1>作った Workflow</h1><p>最終的には次のようなワークフローを作りました。説明しやすさのために4つのセクションに色分けをしています。</p><img data-asset-id="6944eab558acce41ce43dabf" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6944eab558acce41ce43dabf" alt="" width="889" height="828" /><ol><li><p>トリガーとパラメータの準備</p></li><li><p>10分以内に同じ Deployment の Pod 削除が実行されていないかのチェック</p></li><li><p>Ready Pod が Desired Pod よりも少なくないことのチェック</p></li><li><p>通知と Pod 削除の実行</p></li></ol><p>となっています。それぞれ解説していきます。</p><h2>1. トリガーとパラメータの準備</h2><p>Workflow Automation ではトリガーを設定します。いくつかやり方がありますが、今回のように異常を検知してその異常に対して何かアクションをするようなケースでは Monitor が適しています。Monitor の Mention として Workflow を設定できます。</p><p><code>@workflow-pod-delete(pod_name="{{pod_name.name}}",slack_channel="#hogehoge")</code></p><p>Mention にはパラメータを渡すことができます。どの Pod を削除するかというのはどの Pod で問題が起きているかによるので、パラメータとして渡す必要があります。ログやメトリクスのモニターを作る場合には pod_name で Group By して Multi Alert を設定することで、<code>{{ pod_name.name }}</code> を変数展開して渡すことが可能です。他にも、パラメータをうまく活用することで Workflow を汎用化することができます。例えば slack_channel というパラメータを用意していますが、これをすることで様々なチームでこの Workflow を利用することができます。モニターのメンションとして slack チャンネルを指定することもできますが、Workflow のパラメータとして slack_channel を用意している理由については後述します。</p><p>トリガーの次に deployment name というステップで Deployment 名を抽出しています。Data Transformation というアクションを使用し、次のような式を書いています。</p><p><code>$.Trigger.pod_name.split('-').slice(0, -2).join('-')</code></p><p>これだけで、Pod 名から Deployment 名を取得することが可能です。Deployment 名をパラメータとして渡すこともできます（モニターを pod_name と deployment_name の Multi Alert にすれば良い）が、使う側でパラメータが増えるというのと無駄な Group By が増えるのでこのようにちょっとしたパラメータの準備をしています。</p><h2>2. 10分以内に同じ Deployment の Pod 削除が実行されていないかのチェック</h2><p>Pod を削除する処理が短い時間で何度も行われているということは、何か想定していない事象が起きている可能性が高いです。Workflow や Monitor の設定方法が間違っていた場合、正常に動いているのに Pod を削除し続けてしまい障害になるということもあり得ます。そのような事故を防ぐための予防策として、10分間のクールタイムを設定したいと考えました。</p><p>これには <a target="_blank" rel="noopener noreferrer" href="https://docs.datadoghq.com/ja/actions/datastores/">Datastore</a> という機能を利用しています。「Get Timestamp」ステップでは Deployment 名をキーとして、その Deployment に対して Pod 削除が実行された最新のタイムスタンプを取得しています。次の「has 10 min passed」では、そのタイムスタンプから10分以上経過しているかを JavaScript でチェックしています。もし10分以内に Pod 削除を実行していなければ、「Put item」で Deployment 名をキーとして現在のタイムスタンプを保存します。</p><h2>3. Ready Pod が Desired Pod よりも少なくないことのチェック</h2><p>Desired Pod に対して Running Pod が足りてない場合、Pod 削除を実行することにより Pod が不足し、リクエストを捌ききれなくなる可能性があります。このような場合には Pod 削除を自動で実行することは避けておいた方が無難です。</p><p>Desired Pod は Datadog Action の Query scalar data で取得し、Ready Pod は Kubernetes Action の List pod で取得しています。Query scalar data は Datadog の Metrics に対する集計値を取得することができます。Query window の最小値が1分なためどうしてもラグがありますが、 <code>kubernetes_state.deployment.replicas_desired</code> のメトリクスの最大値としています。</p><img data-asset-id="6944eab5afe7650e7f8b0c2a" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6944eab5afe7650e7f8b0c2a" alt="" width="506" height="861" /><p>Kubernetes Action で Deployment の replicas を直接取得する方法もありそうでしたが、Private Action Runner に Deployment の get の権限をつける必要がありそうで、面倒だったのでこのやり方にしています。</p><p>Ready Pod の取得では List pod を叩いた後に、status を確認して Ready なものの数を確認します。List pod では pod の状態が取得できるため、次のようなクエリを書いて Ready なものを抽出します。</p><p><code>$.Steps.List_pod.items.filter(value =&gt; value.status.containerStatuses.every(s =&gt; s.ready)).length</code></p><p>Desired Pod 数の取得と Ready Pod 数の取得を並列で行い、それらを比較することで自動で Pod を削除できるかの判断をします。</p><h2>4. 通知と Pod 削除の実行</h2><p>2, 3 のそれぞれの条件が True であれば自動的に Pod の削除を実行します。「Send pre delete」のステップでは Pod 削除を実行する前のログとしての通知を slack に送信します。次に「Delete pod」で削除を実行します。Pod がすでに存在しない場合など、「Delete pod」が失敗することもあるので、失敗時にも通知されるようにしています。</p><p>また、2, 3 の条件のいずれかが False であれば、Pod を削除することのリスクが高い可能性があります。しかし場合によっては Pod を削除した方が良い可能性もあるので、「Make a decision」というアクションを使って Approve されたら「Delete pod」を実行するというようにしています（単純に「Make a decision」を使ってみたかったというのもあります）。</p><h1>まとめ</h1><p>この記事では、Datadog の Workflow Automation を使って、安全に Pod を削除する方法を紹介しました。今回作成した Workflow によって問題が起きた Pod を削除するという手間を省くことができるようになりました。</p><p>Workflow Automation で使える Action はとても豊富で、他にも色々な活用方法がありそうです。Preview ですが <a target="_blank" rel="noopener noreferrer" href="https://www.datadoghq.com/blog/datadog-agent-builder/">Agent Builder</a> という機能を使うことで AI Agent によるアクションを Workflow に埋め込むこともできそうなので、試してみたいと思います。</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[KARTEの分析システムのレガシーな開発環境を高速にする。pnpm, Rspackの導入で改善できたこと。]]></title>
            <link>https://tech.plaid.co.jp/speedup-pnpm-rspack</link>
            <guid>https://tech.plaid.co.jp/speedup-pnpm-rspack</guid>
            <pubDate>Wed, 26 Nov 2025 01:15:07 GMT</pubDate>
            <content:encoded><![CDATA[<p>こんにちは。KARTE Webの開発を担当するチームでエンジニアをしているnaoyashigaです。</p><p>2025年の年初に行なった改善について書きます。</p><p>（筆が遅くて公開が遅れました。。。）</p><p>今回は歴史の長いKARTEの分析システムにおいてpnpm（<code>9.12.3</code>）とRspack（<code>1.2.7</code>）の導入を行いました。長年運用しているシステムでの課題やpnpmとRspack導入によって得られたメリットを共有できればと思います。</p><h1>課題</h1><p>KARTEの分析システムではフロントエンドはTypeSscript・JavasScriptをメインに、バックエンドはNode.js（<code>20.12.2</code>）を用いて開発しています。package管理ツールはnpm（<code>10.5.0</code>）を利用していました。開発体験上、いくつかの課題がありました。</p><h2>課題1: npmパッケージのinstallに時間がかかる</h2><p>KARTEの分析システムはモノレポ構成になっており複数画面のフロントエンドとバックエンドを一つのレポジトリで管理しています。モノレポ上で管理しているpackageの数は18あり、いくつかは5年以上前から存在していました。そのためインストールするnpmパッケージの数が多くインストールに時間がかかっていました。特に開発を行うローカルPCでのインストールに時間がかかることが開発体験の質の低下を招いているという問題がありました。</p><h2>課題2: モノレポ環境を活かすことができていない</h2><p>モノレポ構成ですがnpm workspaceのようなモノレポ最適化構成を適用できていない状態でした。これは社内ライブラリの「<a target="_blank" rel="noopener noreferrer" href="https://github.com/plaidev/link-local-dependencies">link-local-dependencies</a>」の使用が背景にあります。これはsymlink作成によって異なるpackage間のnpm packageの共有を実現するというライブラリです。弊社のシステム構成上では有用なライブラリでしたが近年のモノレポ系の開発支援ツールの進化に追従しておらずworkspaceの導入が難しいという事情がありました。</p><h2>課題3: webpackのbuildに時間がかかる</h2><p>歴史的な経緯からモノレポ構成上のあるパッケージの責務が肥大化しておりそのパッケージのビルドに時間がかかっていました。前述の課題1と同様に開発ローカルPCでの環境構築に時間がかかるという問題がありました。またビルドにはwebpackを使っていましたが近年webpackは積極的にアップデートされておらず長期的には他のbuildツールに移行しなければ運用が難しいという問題もありました。</p><h1>解決策</h1><p>前述の課題を解決するためにいくつかの改善を行いました。</p><h2>解決策1: pnpmの導入によるインストール速度の改善</h2><p>pnpmはnpmよりもパッケージのインストールが高速です。npmの場合、あるnpmパッケージを使うプロジェクトが100個ある場合、そのnpmパッケージを100個保存します。一方pnpmではContent-addressable storeにファイルを保存します。このstoreでは異なるファイルのみを保存するのでバージョンが違うnpmパッケージでも同じ内容のファイルなら1つだけ保存します。結果としてnpmと比較してインストール対象のファイル数が減るのでインストールが高速になります。さらにディスク上のスペースも節約できます。</p><ul><li><p>参考</p><ul><li><p><a target="_blank" rel="noopener noreferrer" href="https://pnpm.io/ja/motivation">https://pnpm.io/ja/motivation</a></p></li></ul></li></ul><h2>解決策2: pnpm workspaceの活用と社内ライブラリからの脱却</h2><p><a target="_blank" rel="noopener noreferrer" href="https://pnpm.io/ja/workspaces">pnpm workspace</a>を使用するために、これまで利用していた社内ライブラリの「<a target="_blank" rel="noopener noreferrer" href="https://www.npmjs.com/package/@plaidev/link-local-dependencies">link-local-dependencies</a>」の使用をやめました。pnpm workspaceは、モノレポ環境におけるパッケージ間の依存関係管理をより効率的に行えるよう設計されており、これによりモノレポのメリットを最大限に活かすことが可能になりました。</p><h2>解決策3: webpackからRspackへの移行</h2><p>肥大化したパッケージにおいてwebpackからRspackへの移行を行いました。RspackはRustで書かれており並列処理を積極的に活用することで高速なビルドを実現します。また対象の肥大化したパッケージにおけるnpmパッケージの構成上、viteよりもwebpackからのマイグレーションが比較的容易であることも大きな選定理由でした。</p><h1>やらないことを決めた</h1><p>今回の改善のスコープを明確にする上で事前にやらないことを決めました。スコープが広すぎると動作検証の範囲が広くなり時間がかかるなど長期化し、いつまで経っても終わらない恐れがあるためです。またあまりにも長いと進捗が鈍く開発者のモチベーションも下がります！！（大事）</p><p>なので以下の2点はスコープから除外することにしました。</p><h3>pnpm catalogsの導入見送り</h3><p><a target="_blank" rel="noopener noreferrer" href="https://pnpm.io/ja/catalogs">pnpm catalogs</a>はcatalogにnpmパッケージのバージョンを指定することでpnpm workspace環境においてnpmパッケージを再利用することができる機能です。例えばTypeScriptのバージョンを複数のパッケージ間で揃えるといったようなことができます。今回はpnpmに移行すること自体のコストが大きかったのでcatalogsは使いませんでした。またアップデートを行った<strong>2024年年末- 2025年1月ではpnpm catalogsの導入事例が少なかった</strong>ため導入を見送ったという経緯もあります。現在2025年11月では社内の複数システムでpnpm catalogsが導入されていることもありKARTEの分析システムでも利用を計画しています。</p><h3>パッケージ分割の最適化を除外</h3><p>現在、コードベースは歴史的な経緯から複数のパッケージに分割されていますが全体を俯瞰した上での依存関係を考慮した最適な分割はまだ実現できていません。パッケージ分割の最適化はnpmパッケージのアップデートを容易にしたり並列ビルドによるCIの高速化といったメリットをもたらします。しかしこの最適化にはコードの広範囲な変更が伴いマージにかなりの時間を要する見込みです。そのため今回の改善フェーズからは除外することに決定しました。</p><h1>pnpmへの移行</h1><p>課題1と2のインパクトが大きいことから先にpnpm移行を行い、次にRspackへの移行を行いました。</p><h2>まずは、早く失敗してみる</h2><p>モノレポの規模が大きいため、事前に把握できない問題があるだろうと予想していました。<a target="_blank" rel="noopener noreferrer" href="https://tech.plaid.co.jp/nodejs_vup_to_20">以前のNode.jsのバージョンアップ</a>の際にも同様の経験があったため、まずは早く失敗し課題を特定していく方針で進めました。</p><h2>一部のpackageのvupが難しい → npm管理とする</h2><p>いくつかのパッケージでバージョンアップが難しいことが判明しました。これらは一旦、npm管理のままにすることにしました。</p><p><strong>一つ目のパッケージ</strong></p><p>rollupのバージョンが<code>0.48.2</code>と古く、関連するnpmパッケージの更新やrollup.config.jsの修正に時間がかかりそうだったため一旦npm管理としました。依存するnpmパッケージの量も少ないことからnpmのままでもインストール時間の最適化に大きな影響はないと判断しました。</p><p><strong>二つ目のパッケージ</strong></p><p>こちらのパッケージはコンテナ環境で起動するブラウザ内でスクリーンショット画像を撮影するというものです。pnpm管理にしてinstall、buildは問題なかったのですが検証環境でスクリーンショットが撮影できないことがあり原因の特定が難しい状況でした。pnpm移行のボトルネックになっていたのでnpm管理にすることにしました。このパッケージは歴史的な経緯によりKARTEの分析システムのパッケージの一つとして存在していましたがスクリーンショット撮影という責務だけを担っていることから近い将来にはKARTEの分析システムから独立させる計画があります。これによりKARTEの分析システムのpnpm環境最適化を進める予定です。</p><h2>localDependenciesの利用をやめる</h2><p>社内ライブラリ「<code>link-local-dependencies</code>」では、パッケージの特定のディレクトリにシンボリックリンクを貼っていました。</p><p>package.jsonにおいてpostinstall時に<code>@plaidev/link-local-dependencies</code>を実行することでlocalDependenciesフィールドで設定したパッケージ名とディレクトリがリンクされるという仕組みです。</p><pre><code class="language-json">  "localDependencies": {
    "@plaidev/sample-package": "../../../../sample-package/build"
  },
  "scripts": {
    ...
    "postinstall": "npx -y --userconfig .npmrc @plaidev/link-local-dependencies",
    ...
  },</code></pre><p>pnpm workspaceではパッケージの特定のディレクトリのみを参照する方法が提供されていません。このため、pnpm workspace移行の際にlocalDependencies経由でインストールしていたパッケージのimport pathを変更する必要がありました。</p><pre><code class="language-json">  "localDependencies": {
    "@plaidev/sample-package": "../../../../sample-package"
  }</code></pre><p>この変更により大量の差分が発生するため、事前にパス変更のPRを作成し、複数PRに分けて修正を進めました。規模の大きいパッケージも含まれていたため、変更ファイル数は約1000に及びました。</p><p>pnpm workspace移行後は以下のようになります。</p><p>まずpnpm-workspace.yamlでworkspace対象packageを定義します。</p><pre><code class="language-jsx">packages:
  - packages/sample-package</code></pre><p>次にpackages/sample-package/package.jsonにてパッケージの名前を定義します。</p><pre><code class="language-jsx">{
  "name": "@plaidev/sample-package",
  ...
}  </code></pre><p>最後にpackage.jsonでworkspaceを指定します。</p><pre><code class="language-jsx">  "dependencies": {
	  "@plaidev/sample-package": "workspace:*",
	}</code></pre><h2>npmパッケージのバージョンアップ</h2><p>pnpmに移行するとnpm時より依存解決が厳密になり、バージョンアップが必要なnpmパッケージがありました。</p><h3>vue-template-compiler</h3><p><code>vue-template-compiler</code>でVueのバージョンをfileプロトコルで指定している部分に問題がありました。</p><pre><code class="language-json">    "devDependencies": {
        "vue": "file:../.."
    }</code></pre><p>これは<code>vue-template-compiler</code>と同じnode_modulesフォルダ内にあるVue.jsを使用するということになります。pnpmではルートのnode_modulesにパッケージが配置されます。このときVue.jsのバージョン2系とVue.jsのバージョン3系の両方がルートのnode_modulesに存在する場合、vue-template-compilerが正しいバージョンのVue.jsを利用することができない場合があります。</p><p>この問題に関して<a target="_blank" rel="noopener noreferrer" href="https://pnpm.io/ja/9.x/package_json#pnpmpackageextensions">pnpmのpackageExtension</a>で明示的にVue.js 2のバージョンを指定すると解決できました。</p><p>ルートのpackage.jsonに以下を設定しました。</p><pre><code class="language-json">{
...
  "pnpm": {
    "packageExtensions": {
      "vue-template-compiler@2.6.14": {
        "peerDependencies": {
          "vue": "2.6.14"
        }
      }
    }
  }
}</code></pre><ul><li><p>参考記事</p><ul><li><p><a target="_blank" rel="noopener noreferrer" href="https://zenzes.me/today-i-learned-utilizing-pnpm-packageextensions-to-fix-broken-dependencies/">https://zenzes.me/today-i-learned-utilizing-pnpm-packageextensions-to-fix-broken-dependencies/</a></p></li></ul></li></ul><h3>その他のnpmパッケージ</h3><p><code>less</code>や<code>csv</code>など、フロントエンドでの出力に大きな影響を与える可能性のあるパッケージについてはバージョンアップ用のPRを作成し入念な動作確認を行いました。</p><h2>Webpack関連</h2><h3>vue-demiの設定</h3><p>あるパッケージのbuild時に以下のエラーが出ていました。</p><pre><code class="language-bash">Module not found: Error: Can't resolve 'vue-demi' </code></pre><p><code>webpack</code> の設定ファイルを調査すると以下の <code>alias</code> が記述されていました。</p><pre><code class="language-json">resolve: {
    alias: {
     // ...
      'vue-demi': 'vue-demi/lib/index.esm.js'
    },
    ...
}</code></pre><p>この設定は、<code>webpack</code> がバージョン <code>4.41.2</code>、<code>pinia</code> がバージョン <code>2.0.4</code> だった当時の環境で導入されたものです。<code>vue-demi</code> はVue 2 と Vue 3 の両方で動作するブリッジ機能を提供するライブラリであり<code>pinia</code> の内部で利用されています。</p><p>当時<code>pinia</code> はES Modules (ESM) を優先するライブラリでしたが<code>import</code> 構文で <code>vue-demi</code> を参照している箇所がなぜか <code>webpack</code> によってCommonJS (CJS) として解決されバンドルされてしまう問題が発生していました。この問題を回避するため、<code>alias</code> を用いて <code>vue-demi</code> のESM版 (<code>vue-demi/lib/index.esm.js</code>) を明示的に指定することでモジュール依存関係を正しく解決していました。</p><p>しかし今回のpnpm移行前の段階で<code>webpack</code> はバージョン <code>5.72.0</code>、<code>pinia</code> はバージョン <code>2.0.22</code> にそれぞれアップデートされていました。これらのバージョンでは、上記の <code>alias</code> 設定は不要となっていたため、削除しました。</p><h2>意図しないnpmパッケージのインポートを発見</h2><p>pnpmに移行することでpackage.jsonに明示的に書かれていないnpmパッケージを利用しているケースを発見することができました。（<a target="_blank" rel="noopener noreferrer" href="https://www.kochan.io/nodejs/why-should-we-use-pnpm.html">参考: pnpm作者による記事</a>）</p><h3>date-fnsをtypoしていた</h3><p><code>date-fns</code>を<code>data-fns</code>とtypoしていたことが判明しました。偶然にも<code>data-fns</code>というライブラリが存在し、さらに他のnpmパッケージが<code>date-fns</code>を使用していたため、そちらが呼び出されていたために、これまでtypoに気づくことがありませんでした。</p><ul><li><p>date-fns</p><ul><li><p><a target="_blank" rel="noopener noreferrer" href="https://www.npmjs.com/package/date-fns">https://www.npmjs.com/package/date-fns</a></p></li></ul></li><li><p>data-fns</p><ul><li><p><a target="_blank" rel="noopener noreferrer" href="https://www.npmjs.com/package/data-fns">https://www.npmjs.com/package/data-fns</a></p></li></ul></li></ul><h3>dnd-kit/utilities</h3><p>npm管理では<code>dnd-kit/core</code>が<code>utilities</code>にも依存してインストールされていたため、問題なく動作していました。しかし、pnpm移行後は明示的な依存関係の宣言が必要となり、<code>dnd-kit/utiities</code>のinstallが必要でした。</p><h3>component-cookie</h3><p>npm管理の時に<code>cookie</code>というパッケージを指定していましたが、npmのフラットな<code>node_modules</code>構造によってたまたま<code>cookie</code>というライブラリが存在し、それを読み込んでいた可能性があります。本来は<code>component-cookie</code>が正しく、<code>package.json</code>でも<code>"component-cookie": "1.1.1"</code>が指定されていたため、修正を行いました</p><h1>Rspackへの移行</h1><h2>公式のmigration手順を参考にする</h2><p>Rspack公式ドキュメントにwebpackからのマイグレーション手順に従ってrspackインストールやloaderの置き換えを進めます。</p><p><a target="_blank" rel="noopener noreferrer" href="https://rspack.rs/guide/migration/webpack">https://rspack.rs/guide/migration/webpack</a></p><p>プラグインについては「Plugin compatibility」を参考にします。</p><p><a target="_blank" rel="noopener noreferrer" href="https://rspack.rs/guide/compatibility/plugin">https://rspack.rs/guide/compatibility/plugin</a></p><p>Rspackでは多くのwebpackプラグインに対して互換性があることがわかります。</p><h2>Karmaはwebpackで動かす</h2><p>Rspack環境だとテストランナー「Karma」を使ったテストにおいてDOM APIを使うテストが実行できない不具合がありました。調査の結果、DOM APIに依存しないNode.jsのAPIのみで実行できるテストはRspackでも問題ないことがわかりました。そこでDOM API依存のテストは別でwebpack実行環境を作成することにしました。理由としては<a target="_blank" rel="noopener noreferrer" href="https://www.npmjs.com/package/karma">Karmaは新規開発が停止している</a>こと、<a target="_blank" rel="noopener noreferrer" href="https://github.com/web-infra-dev/rspack/issues/7590">公式のkarma-rspackは存在せず作成予定もない</a>ことからRspack環境でKarmaを利用することが難しいと判断したからです。テストのためにwebpack環境を別で作成したことで認知負荷が高まりメンテナンスがしづらいという問題はあります。これについては将来的にKarmaを使わないテストコードを作成することで解決する予定です。</p><h1>改善の結果</h1><h2>ローカルPCでのインストール・ビルド時間の削減</h2><p>開発ローカルPC（Apple M1 Max, メモリ64GB）において改善前後の計測も行いました。</p><h3>インストールの改善</h3><p>KARTEの分析システム全体のインストール時間を計測しました。</p><ul><li><p><strong>改善前 (</strong><code>npm</code><strong>):</strong> 13分6秒 (786.47秒)</p></li><li><p><strong>改善後 (</strong><code>pnpm</code><strong>):</strong> 6分18秒 (378.09秒)</p></li><li><p><strong>改善率:</strong> 51.93%の時間短縮、<strong>約1.99倍の高速化</strong>。</p></li></ul><h3>ビルドの改善</h3><p>webpackからRspack移行における前後比較も行いました。</p><ul><li><p><strong>改善前 (</strong><code>webpack</code><strong>):</strong> 1分48秒 (108.07秒)</p></li><li><p><strong>改善後 (</strong><code>rspack</code><strong>):</strong> 26.837秒</p></li><li><p><strong>改善率:</strong> 75.17%の時間短縮、<strong>約4.03倍の高速化</strong>。</p></li></ul><h2>CIの高速化</h2><p>CIでの改善は前述のローカル開発環境の改善と比較すると改善量はやや少なくなります。これはCIで複数のジョブが稼働しており今回の改善の影響をあまり受けないジョブもあるからです。（後述の図参照）</p><p>改善前後の三ヶ月において各項目における平均時間を比較しました。<br /></p><img data-asset-id="6944eab258acce41ce43daa3" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6944eab258acce41ce43daa3" alt="" width="1134" height="1203" /><p>上図よりCIのジョブの構成は</p><ul><li><p>e2eテスト環境構築に必要なジョブ群</p></li><li><p>e2eテストジョブ</p></li><li><p>その他のジョブ</p></li></ul><p>に分けられます。</p><h3>①CI全体の時間</h3><ul><li><p>改善前: 14.70 min</p></li><li><p>改善後: 13.97 min</p></li><li><p><strong>改善率</strong>: 4.97%の時間短縮、約1.05倍の高速化</p></li></ul><h3>②e2eテスト環境構築</h3><ul><li><p>改善前: 5.66 min</p></li><li><p>改善後: 4.61 min</p></li><li><p><strong>改善率</strong>: 18.55%の時間短縮、約1.23倍の高速化</p></li></ul><h3>③肥大化パッケージのビルド</h3><ul><li><p>改善前: 5.15 min</p></li><li><p>改善後: 2.74 min</p></li><li><p><strong>改善率</strong>: 46.80%の時間短縮、約1.88倍の高速化</p></li></ul><p>Rspackへの移行により「肥大化パッケージのビルド」が特に高速になりました。また移行前は「e2eテスト環境構築」のボトルネックが「肥大化パッケージのビルド」でしたが、高速化したことにより別のジョブがボトルネックになりました。この新しいボトルネックは今回の改善の範囲外の部分で時間がかかっているためCI全体としてはあまり高速化できていません。近い将来、新しいボトルネックも早くすることでCI全体の短縮も行いたいと思います。</p><h3>CI失敗時の比較</h3><img data-asset-id="6944eab2afe7650e7f8b0c1b" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6944eab2afe7650e7f8b0c1b" alt="" width="1134" height="1203" /><p>CI失敗時の実行時間を改善前後で比較します。インストールとビルドが速くなることでCI失敗検知の時間も短くなったことがわかります。</p><h3>①CI全体の時間</h3><ul><li><p>改善前: 12.10 min</p></li><li><p>改善後: 12.04 min</p></li><li><p>改善率: ほぼ変わらず</p></li></ul><h3>②肥大化パッケージのビルド</h3><ul><li><p>改善前: 3.01 min</p></li><li><p>改善後: 0.80 min</p></li><li><p><strong>改善率</strong>: 73.42%の時間短縮、<strong>約3.76倍の高速化</strong>。</p></li></ul><h3>③e2eテスト環境構築</h3><ul><li><p>改善前: 3.57 min</p></li><li><p>改善後: 2.74 min</p></li><li><p><strong>改善率</strong>: 23.25%の時間短縮、約1.30倍の高速化。</p></li></ul><h2>CIのキャッシュ</h2><p>pnpmについては<code>.pnpm-store</code>や<code>node_modules</code>をキャッシュすることも試しました。インストールは確かに高速ですがキャッシュのsaveとrestoreに時間がかかることがわかりました。トータルの所要時間を計算するとキャッシュあり・なしで時間はほぼ変わりませんでした。実装を簡易的にするためキャッシュは用いていません。</p><p>Rspackについてもキャッシュを使っていません。キャッシュを使わなくてもwebpackと比較して十分高速だったからです。まずRspackではwebpackにあるようなfilesytem cacheをサポートしていません。メモリキャッシュはありますがproductionではデフォルトでfalseです。またPersistent cacheという永続的なキャッシュ機能がありますがexpreimentな機能であり不具合が起こったときの調査が難しいので採用していません。webpackではfileキャッシュのsave, restoreのために数分かかることもありましたがそれがゼロになったことが速度改善に大きく寄与しました。</p><ul><li><p>参考</p><ul><li><p><a target="_blank" rel="noopener noreferrer" href="https://pnpm.io/ja/continuous-integration">https://pnpm.io/ja/continuous-integration</a></p></li><li><p><a target="_blank" rel="noopener noreferrer" href="https://rspack.rs/config/cache">https://rspack.rs/config/cache</a></p></li></ul></li></ul><h1>これからの展望</h1><p>今回はpnpm、Rspackへの移行を行いました。これからやれることは無数にあります。パッケージの依存関係の切り分けを最適にしてもっと並列でCIを回せるようにしたり、責務を小さくすることでnpmパッケージをアップデートしやすくもしたいです。気づけばpnpmのバージョンは10になったしcatelog機能も導入できるでしょう。Rspackもどんどん進化しており、<a target="_blank" rel="noopener noreferrer" href="https://github.com/web-infra-dev">Bytedance社のweb-infra-devチーム</a>の開発スピードは凄まじいです。Build analyzerの<a target="_blank" rel="noopener noreferrer" href="https://github.com/web-infra-dev/rsdoctor">Rsdoctor</a>の<a target="_blank" rel="noopener noreferrer" href="https://www.npmjs.com/package/@rsdoctor/mcp-server">MCP Server</a>も登場しました。ビルド情報をAIに渡して改善を行ったり不具合調査にも役立てたいですね。</p>]]></content:encoded>
        </item>
    </channel>
</rss>