<?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>Fri, 31 Jul 2026 06:18:20 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[インターンでメインプロダクトのデプロイ基盤改善を実現 ー「使われ方」を意識した開発体験の向上ー]]></title>
            <link>https://tech.plaid.co.jp/intern-deployment-infrastructure-improvement</link>
            <guid>https://tech.plaid.co.jp/intern-deployment-infrastructure-improvement</guid>
            <pubDate>Fri, 31 Jul 2026 01:00:00 GMT</pubDate>
            <description><![CDATA[はじめまして。2026年1月に、カナダの公立カレッジから同じ都市にあるSFU（Simon Fraser University）という4年制大学に編入し、Computer Scienceを専攻しているMioです。編入後、最初の学期を終えたタイミングで一度日本に戻り、6月から7月末までの2か月間、プレイドのDeveloper Experience（DX）チームでインターンをさせていただきました。これまでインターンをした経験はなく、今回初めて組織に所属してエンジニアリングに取り組みました。]]></description>
            <content:encoded><![CDATA[<p>はじめまして。</p><p>2026年1月に、カナダの公立カレッジから同じ都市にあるSFU（Simon Fraser University）という4年制大学に編入し、Computer Scienceを専攻しているMioです。</p><p>編入後、最初の学期を終えたタイミングで一度日本に戻り、6月から7月末までの2か月間、プレイドのDeveloper Experience（DX）チームでインターンをさせていただきました。</p><p>これまでインターンをした経験はなく、今回初めて組織に所属してエンジニアリングに取り組みました。</p><h2>目次</h2><ol><li><p>私のバックグラウンド紹介</p></li><li><p>Developer Experienceチームについて</p></li><li><p>プレイドのインターンに参加した理由</p></li><li><p>本番に「設定だけ」を反映できるようにしたい</p><ul><li><p>背景：なぜ設定だけを反映したいのか</p><ul><li><p>既存のデプロイはどうなっていたのか</p></li><li><p>何が問題だったのか</p></li><li><p>どのように解決しようとしたのか</p></li><li><p>設計・実装で考えたこと</p></li><li><p>最終的にどうなったのか</p></li><li><p>まとめ —「動く」だけではない開発者体験</p></li></ul></li></ul></li><li><p>最後に</p><ul><li><p>これからインターンに挑戦する方へ</p><ul><li><p>余談</p></li></ul></li></ul></li></ol><h2>私のバックグラウンド紹介</h2><p>私は、20歳でカナダへ渡るまで英語はほとんど話せず、Computer Scienceに関する知見もほぼありませんでした。</p><p>渡航後にESLで英語を一から学びながら、基礎的な数学の勉強を独学で始め、現地のカレッジへの進学をきっかけにComputer Scienceを学び始めました。このように、経験のないところから一つずつ理解を積み重ねてきた経験は、今の自分の学び方や、新しい領域でもまず挑戦してみる姿勢につながっています。</p><p>現在はカナダの大学でComputer Scienceを専攻しながら、授業で学ぶだけでなく、AIを活用した個人開発やWebサイトの開発・運用にも取り組んでいます。自分で必要な技術を調べながら、試行錯誤して実際に動くものを作ることを通して学んできたことが、これまでの自分の大きな特徴だと思っています。</p><p>一方で、個人開発ではアプリケーション側に触れることが多く、インフラやデプロイ、大規模な開発環境についてはあまり経験がありませんでした。そこで今回のインターンでは、そうした自分にとって新しい領域に挑戦し、実際の開発組織でどのようにサービスや開発基盤が運用されているのかを学びたいと思い、参加しました。</p><h2>Developer Experienceチームについて</h2><p>今回のインターンでは、上で説明したように私の希望、そしてご縁があり、Developer Experience（DX）チームに所属しました。</p><p>DXチームは、プレイドの代表的なプロダクトであるKARTE関連の機能開発を直接担当するチームではありません。開発者がより安全に、効率よく、そして気持ちよく開発できるように、開発環境や開発体験を改善することを目的としたチームです。</p><p>私が所属していた期間は、私を含めて5人ほどの小規模なチームで、メンバー全員がほかのチームや業務と兼任しながら活動していました。</p><p>実際に開発や運用の現場に関わることで、</p><ul><li><p>このCIはなぜ時間がかかっているのか</p></li><li><p>この作業は本当に毎回人が判断する必要があるのか</p></li></ul><p>といった、日々の開発体験の中にある課題を見つけ、改善していきます。</p><p>そのため、チームの業務範囲はかなり広く、CI/CD、デプロイフロー、Terraformなどを使ったインフラ管理、開発用CLI、社内の運用フローなど、プロダクト開発を支えるさまざまな領域に関わります。このようなDXチームのタスクの特徴柄、チーム開発というよりは、個人開発に近い形状で様々なタスクに取り組みました。</p><h2>プレイドのインターンに参加した理由</h2><p>プレイドのインターンには以前から興味を持っていました。</p><p>プレイドでインターンをしていた先輩から話を聞いたことに加え、過去に公開されていたインターンブログを読んだことがきっかけです。</p><p>インターンであっても、実際のプロダクトや開発基盤に関わり、自分で課題を考えながら改善を進めている記事が多く、「自分もこの環境で働いてみたい！」と思うようになりました。</p><p>実際に参加してみると、期待していたとおり、インターン用に切り離された小さなタスクだけを、限られた権限の中で担当するわけではありませんでした。社員と大きく変わらない権限と責任を持ち、周囲にも影響する実際の課題に取り組ませていただきました。</p><p>また、Claude CodeやCursor、Codexなど、さまざまなAIエージェントを活用して開発を進められる環境だったため、開発スピードを上げながら幅広いIssueを担当できました。</p><p>例えば：</p><ul><li><p>GitHub Actionsのバージョン更新・統一やRenovateによる自動更新運用の導入</p></li><li><p>Hotfix機構などのデプロイ基盤の改善</p></li><li><p>DatadogのTerraform stateの分割</p></li><li><p>既存の共通デプロイフローに載っていなかった Cloud Run 系システムを、共通のリリース運用へ統合するための基盤整備</p></li><li><p>ペネトレーションテスト等の脆弱性診断をするための新たな環境の要件整理・設計・構築</p></li></ul><p>などなど、様々なイシューに取り組みました。</p><p>すべてをご紹介することは難しいため、この記事では、インターン序盤に携わったタスクの中でも、所属していたDXチームらしい「開発者の体験」を意識して進めたものを紹介します。</p><h2>本番に「設定だけ」を反映できるようにしたい</h2><h3>はじめに（この章の流れ）</h3><p>「本番で動いているプログラム本体（コンテナイメージ）は変えずに、設定だけを変更する」</p><p>言葉にすると単純ですが、既存のデプロイフローでは、設定だけを切り離して反映することはできませんでした。</p><p>この章では、プレイドデプロイ基盤について知らない方にも内容を追ってもらえるように、まず既存の仕組みから順番に説明していきます。</p><p>また、今回のタスクや実装を理解するには、次の点を整理する必要があります。</p><ul><li><p>プレイドでは、アプリケーションをどのようなデプロイフローで本番環境へ反映しているのか</p></li><li><p>評価環境と本番環境の違いを、どのように管理しているのか</p></li><li><p>なぜ「設定だけを変更したい」ときにも、アプリケーションの更新が含まれてしまうのか</p></li></ul><p>そこで、まずはGitOpsやKustomize、マニフェストといった、基盤を利用した社内でのデプロイの仕組みを紹介します。</p><p>その前提を知ってもらった上で、本番環境で動いているアプリケーションのバージョンを変えずに、ヘルスチェックや環境変数などの<strong>設定だけ</strong>を反映する仕組みを、どのように設計・実装したのかを紹介します。</p><p>この章全体は、次の流れで進みます。</p><ol><li><p>プレイドの既存のデプロイフローを説明する</p></li><li><p>なぜ設定だけを反映できなかったのかを整理する</p></li><li><p>解決方法をどのように検討したのかを紹介する</p></li><li><p>実装中に見つかった課題と、その修正を説明する</p></li><li><p>完成した仕組みと実際の使い方を紹介する</p></li></ol><p>社内のコードや運用についての事前知識は前提とせず、必要な用語や仕組みを説明しながら進めます。</p><h3>背景：そもそも、なぜ設定だけを反映したいのか</h3><p>今回取り組んだのは、緊急時に<strong>コンテナイメージは変更せず、設定の差分だけを安全に本番環境へ反映できるようにする</strong>Issueです。</p><p>ここでいう設定とは、次のようなものです。</p><ul><li><p>ヘルスチェックの条件</p></li><li><p>環境変数</p></li><li><p>CPUやメモリの上限</p></li><li><p>Pod数などの実行環境に関する設定</p></li></ul><p>これらはアプリケーションのプログラムそのものではありません。しかし、障害対応中には、設定を変更するだけでサービスを<strong>一時的に復旧</strong>できることがあります。</p><p>例えば、ヘルスチェックの条件が厳しすぎてPodが起動できない場合、その条件を一時的に緩めることで復旧できるかもしれません。</p><p>今回のIssueの背景には、インシデント対応時にこの「設定だけを変更する」という操作ができず、止血が遅れてしまったという課題がありました。</p><p>まずは、　なぜインシデント時に設定だけをすぐに反映できなかったのかを説明するために、既存のデプロイの流れから紹介します。</p><p>実際の設計と実装から読みたい方は、「3. どのように解決しようとしたのか」まで進んでください。</p><h3>1. 既存のデプロイはどうなっていたのか</h3><h4>1.1 従来のデプロイの流れ</h4><p>プレイドでは、本番環境への反映をGitOpsで管理しています。</p><p>Gitリポジトリに書かれた定義を正として、その内容を環境へ反映する仕組みです。</p><p>対象のリポジトリには、大きく分けて次の2種類の情報が書かれています。</p><ol><li><p><strong>どのプログラムを動かすか</strong></p><p>コンテナイメージのタグなど、<strong>アプリケーションのバージョンを表す情報</strong>です。</p></li><li><p><strong>そのプログラムをどのように動かすか</strong></p><p>ヘルスチェック、環境変数、CPUやメモリなど、<strong>実行環境に関する設定</strong>です。</p></li></ol><p>通常のリリースでは、新しいプログラムと、それを動かすために必要な設定をまとめて本番へ反映します。</p><p>その際、環境ごとのマニフェストを生成するために使っているのが、<strong>Kustomize</strong>です。</p><h4>1.2 Kustomizeを使ったマニフェスト生成</h4><p>プレイドでは、Kubernetes上でアプリケーションを動かすための設定を、<strong>マニフェスト</strong>と呼ばれるYAMLファイルで管理しています。</p><p>ただし、評価環境と本番環境では、動かすアプリケーションの構成は似ていても、イメージタグや環境変数、Pod数など、一部の設定が異なります。</p><p>環境ごとにすべてのマニフェストを個別に管理すると、同じ内容を複数箇所に書くことになり、修正漏れや環境間のずれが起こりやすくなります。</p><p>そこで、マニフェストの共通部分と環境ごとの差分を分けて管理できる、<strong>Kustomize</strong>という仕組みを利用しています。</p><p>大まかには、次のような構造です。</p><pre><code>共通の定義（base）
├── Deployment
├── Service
└── 共通の設定

環境ごとの上書き（overlay）
├── evaluation
└── production</code></pre><p>共通の定義を土台として、評価環境では評価環境用のoverlayを、本番環境では本番環境用のoverlayを重ねます。</p><img data-asset-id="6a6c3742f718e70c4bad68ab" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a6c3742f718e70c4bad68ab" alt="" width="1510" height="729" /><p>この記事で登場する<code>generate</code>とは、<code>kustomize build</code>を中心に、共通定義と環境ごとのoverlayを組み合わせて、<strong>実際にKubernetesへ反映できる最終的なマニフェストを生成する一連の処理</strong>を指します。</p><p>※厳密にはKustomize以外の前後処理も含みますが、この記事ではまとめて<code>generate</code>と呼びます。</p><p>つまり、本番デプロイでは、おおまかに次の処理が行われます。</p><ol><li><p>共通の定義を読み込む</p></li><li><p>本番環境用のoverlayを適用する</p></li><li><p>本番環境へ反映する最終的なマニフェストを生成する</p></li><li><p>生成されたマニフェストを本番環境へ反映する</p></li></ol><p>プレイドにおけるKubernetesのデプロイ基盤がどのように作られてきたかについては、
過去のエンジニアブログ
Our seeking to stable k8s deployment.
でも紹介されています。</p><p>※当時の構成を紹介した記事のため、現在とは異なる部分もありますが、GitOpsやマニフェスト生成を採用した背景を知るための参考になります！</p><p>大まかな流れは次のとおりです。</p><img data-asset-id="6a6c3a4ef718e70c4bad7186" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a6c3a4ef718e70c4bad7186" alt="" width="4687" height="3230" /><p>このフローで重要なのが、<strong>本番へ出す前に、必ず評価環境へ先にデプロイする</strong>というルールです。</p><p>新しいイメージは、まず評価環境へデプロイされます。そこで動作を確認したあと、同じイメージを本番環境へデプロイします。</p><p>これにより、評価環境で確認していないプログラムが、いきなり本番へ反映されることを防いでいます。</p><h4>1.3 リポジトリと本番環境のタグが一時的にずれる</h4><p>評価環境へ新しいイメージをデプロイすると、新しいイメージタグがリポジトリ上の定義に書き込まれます。</p><p>ただし、その時点では本番デプロイはまだ行われていません。</p><p>そのため、評価環境へのデプロイ後から本番デプロイまでの間は、一時的に次の状態になります。</p><ul><li><p>リポジトリには、評価環境で確認中の新しいタグが書かれている</p></li><li><p>本番環境では、まだ一つ前のタグが動いている</p></li></ul><p>例えば、次のような状態です。</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>リポジトリ</p></td><td colspan="1" rowspan="1"><p><code>v2</code></p></td></tr><tr><td colspan="1" rowspan="1"><p>評価環境</p></td><td colspan="1" rowspan="1"><p><code>v2</code></p></td></tr><tr><td colspan="1" rowspan="1"><p>本番環境</p></td><td colspan="1" rowspan="1"><p><code>v1</code></p></td></tr></tbody></table><p><code>v2</code>は評価環境へはデプロイされていますが、まだ本番環境にはデプロイされていません。</p><p>この記事では、このように<strong>リポジトリ上のタグと、実際に本番で稼働しているタグが異なる状態</strong>を「タグの差分がある状態」と呼びます。</p><p>これは異常な状態ではなく、評価環境で確認してから本番へ出すという運用上、意図的に発生するものです。</p><p>問題になるのは、このタイミングで本番の設定だけを変更したくなった場合です。</p><h3>2. 何が問題だったのか</h3><h4>2.1 なぜ「設定だけ」を反映できなかったのか</h4><p>本番環境へ反映するマニフェストは、リポジトリに書かれた定義をもとに生成されます。</p><p>そのため、タグの差分がある状態で環境変数やヘルスチェックなどの設定を変更し、通常どおり本番へ反映すると、設定だけではなく、リポジトリに書かれている新しいイメージタグも一緒に反映されます。</p><p>先ほどの例で考えると、本番ではまだ<code>v1</code>が動いています。</p><p>ここで、ヘルスチェックの設定だけを変更したいとします。</p><p>しかし、リポジトリにはすでに<code>v2</code>が書かれているため、その定義から本番用マニフェストを生成すると、次の2つが同時に含まれます。</p><ul><li><p>ヘルスチェックの変更</p></li><li><p>イメージタグの<code>v1</code>から<code>v2</code>への更新</p></li></ul><p>つまり、担当者としては設定だけを変更したいのに、通常のデプロイを実行すると、まだ本番へ出す予定ではなかった<code>v2</code>まで一緒にデプロイされてしまいます。</p><img data-asset-id="6a6c374cf718e70c4bad68d7" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a6c374cf718e70c4bad68d7" alt="" width="2501" height="537" /><p>止血のために設定だけを変更したいにもかかわらず、意図していないプログラム（イメージ）の更新まで巻き込んでしまうわけです。</p><p>かといって、イメージの更新を避けるために本番への反映自体を止めると、必要な設定変更も行えず、障害対応が遅れてしまいます。</p><p>実際に、証明書の期限切れに伴うインシデント対応の中で、<strong>本番で動いているイメージタグを維持したまま、マニフェストの設定だけを切り替えたい</strong>という場面がありました。</p><p>しかし、当時のデプロイフローにはそのための操作が用意されておらず、止血までに時間がかかってしまいました。</p><p>そこで今回、タグの差分がある場合でも、次のどちらを行うか明示的に選択できるようにしました。</p><ul><li><p>本番で稼働中のイメージタグを維持し、設定だけを反映する</p></li><li><p>既存の流れどおり、リポジトリに書かれた新しいイメージタグと設定をまとめて反映する</p></li></ul><p>ここからは、この仕組みをどのように設計し、既存のデプロイフローへ組み込んだのかを紹介します。</p><h4>3.1 最初に考えた案</h4><p>最初は、タグをどのように退避し、現在本番で動いているタグへ戻すかという処理を中心に考えていました。</p><p>そのため、機能の入口については、<strong>設定だけを反映するための専用コマンドを新しく作る</strong>というシンプルな案から始めました。</p><p>必要なときに専用コマンドを実行し、タグを現在本番で稼働しているものに固定したうえで、設定差分だけを生成するというものです。</p><p>単体の機能としては分かりやすく、実際に動かすこともできました。</p><p>しかし、メンターさんとの会話を通して、実際の運用を考えると、この実装にはいくつか問題があることに気づきました。</p><ul><li><p>普段使わない専用コマンドであるため、実装者以外から認知されにくく、本当に必要な緊急時に存在を思い出してもらえない可能性がある</p></li><li><p>通常のデプロイと設定だけのデプロイで入口が分かれると、片方だけが古くなったり、壊れたりする可能性がある</p></li><li><p>ローカル操作とCIで異なる経路を通ると、同じ操作のつもりでも結果が変わる可能性がある</p></li></ul><p>そのため、タグを戻す処理そのものだけでなく、<strong>その処理をデプロイフローのどこへ置くべきか</strong>を考え直すことになりました。</p><h4>3.2 既存のデプロイCLIへ組み込む</h4><p>最終的には、専用コマンドを一つ増やすのではなく、すでに全員が使っている既存のデプロイCLIへ機能を組み込む方針に変更しました。</p><p>既存のデプロイ処理では、ローカルからの実行やCIなど、さまざまな実行経路が、最終的にKustomizeを使って環境別のマニフェストを作る<code>generate</code>処理へ集まります。ここでいう<code>generate</code>は、前述したとおり、<code>kustomize build</code>を中心に環境別の最終的なマニフェストを生成する処理です。</p><p>そこで、生成処理の直前に、config-onlyが必要な状況かどうかを判定する処理を追加しました。</p><p>主に確認する条件は、次の3つです。</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>本番環境向けの生成である</p></td><td colspan="1" rowspan="1"><p><code>environment === 'production'</code></p></td></tr><tr><td colspan="1" rowspan="1"><p>リポジトリと稼働環境のタグに差分がある</p></td><td colspan="1" rowspan="1"><p><code>detect(...)</code>が返す<code>targets</code></p></td></tr><tr><td colspan="1" rowspan="1"><p>今回の変更に設定差分が含まれている</p></td><td colspan="1" rowspan="1"><p><code>t.coupling</code></p></td></tr></tbody></table><p>まず、本番環境向けの生成処理の直前に、config-only用の前処理を呼び出します。</p><pre><code class="language-tsx">if (environment === 'production') {
  // 本番 generate の直前に判定を1段入れる
  await prodConfigOnly.applyConfigOnlyPrePass(
    components,
    configOnly,
    { generate },
  );

  await generateSystems(
    'production',
    components,
    outputDirname,
  );

  // ...
}</code></pre><p>前処理の中では、タグの差分と設定差分を確認し、config-onlyを実行するかどうかを決定します。</p><pre><code class="language-tsx">const mode = resolveConfigOnlyMode({ flag, isTTY });

if (mode === 'no') {
  return; // 通常デプロイ。検知もしない
}

const targets = await detect(components, { generate, io });
const coupledTargets = targets.filter((target) =&gt; target.coupling);

if (coupledTargets.length === 0) {
  return; // 設定差分がなければ何もしない
}

let decision;

if (mode === 'yes') {
  decision = 'config-only';
} else {
  decision = await prompt(targets);
}

if (decision === 'deploy') {
  return;
}

if (decision === 'abort') {
  throw new Error('config-only: 中止しました');
}

// 本番用の定義だけを、現在稼働しているタグへ戻す
for (const target of targets) {
  write({
    leafKustPath: target.leafKustPath,
    driftList: target.drift,
  });
}</code></pre><p>条件を満たし、config-onlyが選択された場合は、マニフェストの生成に使う本番用の定義を、現在稼働しているタグに合わせます。</p><p>ここで変更しているのは、<strong>生成元となる本番用の定義であり、この時点で稼働中の本番環境をロールバックしているわけではありません。</strong></p><p>タグを合わせた状態でマニフェストを生成することで、まだ本番へ出したくないイメージの更新を含めず、設定変更を中心とした差分を作れるようになります。</p><p>使い方としては、専用コマンドを新設するのではなく、既存のコマンドに<code>--config-only</code>というオプションを追加しました。</p><p><strong>新しい操作方法を覚えてもらうのではなく、普段使っているコマンドの振る舞いを、必要なときだけ切り替えられるようにしたことがポイントです。</strong></p><h3>4. 設計・実装で考えたこと</h3><h4>4.1 どの定義を書き換えるのか</h4><p>タグを現在稼働しているものへ戻す処理自体はシンプルですが、どの定義を書き換えるかは慎重に考える必要がありました。</p><p>共通の定義を書き換えてしまうと、本番環境だけでなく、評価環境で使用するタグまで巻き戻ってしまいます。</p><p>そのため、タグを書き換える対象は、<strong>本番環境用の上書き層（overlay）だけ</strong>に限定しました。</p><p>本番用のoverlayに現在稼働しているタグを書き込み、その状態で<code>generate</code>することで、評価環境の定義には影響を与えず、本番環境向けの設定差分だけを生成できます。</p><img data-asset-id="6a6c3c0ef718e70c4bad7923" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a6c3c0ef718e70c4bad7923" alt="" width="3041" height="537" /><h4>4.2 上書きによって消える候補タグをどう扱う？</h4><p>もう一つ迷ったのが、本番用のoverlayを書き換えることで消える、<strong>これから本番へ出す予定だった新しいタグ</strong>の扱いです。</p><p>最初は、元のタグを別のファイルへ退避し、次の通常デプロイで復元する案を考えました。</p><p>しかし、退避や復元の処理を追加すると、次のようなことまで考える必要があります。</p><ul><li><p>退避ファイルをどこで管理するか</p></li><li><p>複数の操作が重なった場合にどうするか</p></li><li><p>復元に失敗した場合にどうするか</p></li><li><p>どのタグが正しい状態なのかをどう判断するか</p></li></ul><p>小さな機能のために、新しい状態管理の仕組みを持つことになってしまいます。</p><p><strong>そこで、既存のデプロイ運用をもう一度見直しました。</strong></p><p>本番環境へ新しいイメージをデプロイする前には、<strong>必ず</strong>評価環境へのデプロイが行われます。そして、評価環境へのデプロイが実行されると、リポジトリ上のタグも新しいものへ進み直します。</p><p>この前提があるなら、消えたタグを特別に退避しなくても、次の評価環境へのデプロイによって自然に回収できます。</p><p>最終的には、退避や復元の仕組みを持たず、次の流れにしました。</p><ol><li><p>本番環境用のタグを、現在稼働しているタグへ戻す</p></li><li><p>その状態でマニフェストを生成する</p></li><li><p>設定差分だけを本番へ反映する</p></li><li><p>次の評価環境へのデプロイで、タグを再び前進させる</p></li></ol><p>独自の復元処理を増やすのではなく、既存の運用フローを通ることで、自然に元の状態へ戻るようにした形です。</p><h4>4.3 本当に設定差分があるかをどう判断するか</h4><p>タグがドリフトしているだけでは、「設定だけを変更したい状況」とは判断できません。</p><p>単に、評価環境で確認済みの次のイメージを、本番へデプロイしたいだけの可能性もあります。</p><p>そこで、本番環境用の定義を書き換える前に、一度一時ディレクトリへマニフェストを生成し、本当に設定差分が含まれているかを確認する前処理を追加しました。</p><p>これによって、次の2つの状態を区別できるようになりました。</p><ul><li><p>タグのドリフトだけがある</p></li><li><p>タグのドリフトに加えて、設定差分もある</p></li></ul><p>設定差分がない場合にはconfig-onlyを提案せず、通常のデプロイフローをそのまま通ります。</p><h4>4.4 ローカルとCIで振る舞いを変える</h4><p><code>--config-only</code>には、<code>auto</code>、<code>yes</code>、<code>no</code>の3つのモードを用意しました。</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><code>auto</code></p></td><td colspan="1" rowspan="1"><p>対話可能なローカル端末では、条件を満たした場合に選択肢を表示する</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>yes</code></p></td><td colspan="1" rowspan="1"><p>対話せず、config-onlyを実行する</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>no</code></p></td><td colspan="1" rowspan="1"><p>config-onlyの検知や処理を行わず、通常どおりデプロイする</p></td></tr></tbody></table><p>ローカルでの操作では、必要に応じて利用者へ確認できます。</p><p>一方、CIでは対話できないため、<code>auto</code>が指定されていても、勝手にconfig-onlyへ切り替わらないようにしています。</p><p>緊急時の操作を簡単にしつつ、自動実行の中で意図しない判断が行われないようにするためです。</p><h4>4.5 config-onlyを選んだのに、一部のタグが更新されてしまう問題</h4><p>config-onlyの最初の実装では、<strong>設定差分が見つかった対象だけ</strong>を、現在本番で稼働しているタグへ戻していました。</p><p>一見すると十分に思えます。ですが、このCLIでは、1回の<code>generate</code>で複数のシステムやコンポーネントのマニフェストをまとめて生成できるため、設定だけを変更したい対象に加えて、設定差分がなくタグの差分だけがある対象も同じ生成処理へ含まれることがあります。</p><p>具体的にはこんな感じです。
以下のように、AとBをまとめて<code>generate</code>するとします。</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>A</p></td><td colspan="1" rowspan="1"><p><code>v1</code></p></td><td colspan="1" rowspan="1"><p><code>v2</code></p></td><td colspan="1" rowspan="1"><p>あり</p></td></tr><tr><td colspan="1" rowspan="1"><p>B</p></td><td colspan="1" rowspan="1"><p><code>v3</code></p></td><td colspan="1" rowspan="1"><p><code>v4</code></p></td><td colspan="1" rowspan="1"><p>なし</p></td></tr></tbody></table><p>ここでconfig-onlyを選んだ場合に期待する結果は、Aの設定差分だけを変更し、AとBのタグはどちらも<strong>現在のまま維持できることです。</strong></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>A</p></td><td colspan="1" rowspan="1"><p>タグは<code>v1</code>のまま、設定だけを変更する</p></td></tr><tr><td colspan="1" rowspan="1"><p>B</p></td><td colspan="1" rowspan="1"><p>タグを<code>v3</code>のまま変更しない</p></td></tr></tbody></table><p>しかし、最初の実装でタグを固定できていたのは、設定差分があるAだけでした。</p><p>Aはconfig-onlyの<strong>処理対象になる</strong>ため、タグが<code>v1</code>へ戻されます。一方、Bには<strong>設定差分がなかったため</strong>処理対象にならず、リポジトリ上の候補タグ<code>v4</code>を含むマニフェストがそのまま生成されます。</p><p>つまり、Aの設定だけを反映するつもりでconfig-onlyを選んだにもかかわらず、同じ実行に含まれていたBのイメージまでまとめて更新されてしまう実装になっていました。</p><p>この問題を受けて、次の2つの対象を分けて扱うように修正しました。</p><ul><li><p><strong>config-onlyを提案するか判断する対象</strong>：設定変更がある対象</p></li><li><p><strong>config-onlyが選ばれたあとにタグを固定する対象</strong>：同じ実行に含まれ、タグに差分があるすべての対象</p></li></ul><p>Aに設定変更があることをきっかけにconfig-onlyを提案し、config-onlyが選ばれたあとは、AだけでなくBのタグも現在本番で稼働しているものへ固定します。</p><p>これにより、config-onlyを選んだ実行では、対象全体のイメージタグを動かさず、必要な設定変更だけを反映できるようになりました。</p><h3>5. 最終的にどうなったのか</h3><h4>5.1 全体の処理フロー</h4><img data-asset-id="6a6c3756d1f3e6188aa58e61" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a6c3756d1f3e6188aa58e61" alt="" width="1421" height="3292" /><h4>5.2 実際の実行の流れ</h4><p>例えば、次のコマンドを実行します。</p><pre><code class="language-bash">node --run generate -- --env production --systems microservice-a</code></pre><p><code>--config-only</code>を省略した場合は、<code>auto</code>として扱われます。</p><p>ローカル端末で、タグのドリフトと設定差分の両方が検出されると、次のような選択肢が表示されます。</p><pre><code>設定変更を反映しようとしています。
ただし、現在稼働中とは異なる候補タグも検出されました。

対象: microservice-a / production-04
  microservice-a-web      現在: 4921e2b  →  候補: ae64a61
	microservice-a-worker   現在: 4921e2b  →  候補: ae64a61

実行内容を選択してください:

  c) 設定のみ反映 (config-only)
     設定だけを更新します。タグは現在のまま変更しません。

  d) 設定とタグを反映 (deploy)
     設定を更新し、アプリの稼働タグも候補タグに更新します。

  a) 中止 (abort)
     何も変更しません。

選択 [c/d/a]: c</code></pre><p>config-onlyを選択すると、対象となる本番用の定義が、現在稼働しているタグへ書き換えられます。</p><pre><code>[INFO] [prod_config_only] config-only: microservice-a/production-04 を稼働中タグへ巻き戻しました</code></pre><p>対話を行わず、明示的にconfig-onlyを実行することもできます。</p><pre><code class="language-bash">node --run generate -- \
  --env production \
  --systems microservice-a \
  --config-only yes</code></pre><p>この場合は選択肢を表示せず、config-onlyの処理が実行されます。</p><pre><code>[INFO] [prod_config_only] config-only: microservice-a/production-04 を稼働中タグへ巻き戻しました</code></pre><h3>まとめ ―「動く」だけではない開発者体験</h3><p>今回のタスクでは、単に「設定だけを反映する処理」を実装すればよかったわけではありません。</p><ul><li><p>なぜ設定とタグが一緒に反映されてしまうのか</p></li><li><p>例外処理をどこへ置けば、緊急時にも忘れられずに使えるのか</p></li><li><p>既存のデプロイルールを壊さず、どのように元の状態へ戻すのか</p></li><li><p>CLIで選択した内容と、実際に生成される差分をどう一致させるのか</p></li></ul><p>こうしたことを一つずつ考える必要があり、そのために、まずは既存のデプロイ基盤を理解するところから始めました。</p><p>インターンの初期にこのタスクを担当できたことで、デプロイの仕組みやGitOps、マニフェスト生成の流れに対する理解が大きく深まりました。このときに得た知識は、その後に取り組んだ別のタスクでも役立ち、よりスムーズに調査や実装へ入れるようになりました。</p><p>「処理が動くかどうか」だけではなく、利用者が選んだ内容と、実際に生成される差分が本当に一致しているかを何度も確認しながら実装を詰めていったのも印象に残っています。</p><p>今回のタスクで最も難しかったのは、新しい機能そのものを実装することよりも、専用コマンドとして切り出していた処理を、既存CLIの意味や使い方を壊さずに組み込むことでした。</p><p>最初に作った専用コマンドから、既存CLIへ組み込む方針へ大きく変更したのは、<strong>緊急時にも普段と同じ操作感で使えて、必要な例外処理が、忘れられにくい正しい場所で実行されるようにするため</strong>でした。</p><p>この経験を通して、実装者の視点だけで設計するのではなく、<strong>これからその機能を使う開発者の立場に立って考えることの大切さ</strong>を学びました。</p><p>一般的なプロダクト開発でユーザー体験を大切にするのと同じように、開発者向けのツールや基盤でも、利用者が迷わず、安全に、期待どおりの操作ができることが重要です。</p><p><strong>機能が存在するだけではなく、必要な場面で自然に見つけられ、安心して使える状態まで設計する。</strong></p><p>今回のタスクは、Developer Experienceを考えるとはどういうことなのかを、実装を通して学べた経験でした。</p><h2>最後に</h2><p>今回、まだまだ未経験なことが多く、未熟な私を信じてチームに迎え入れてくださった皆さんに、心から感謝しています。</p><p>特に、日々メンターとして気にかけて支えてくださった大矢さんには、技術的なことだけでなく、タスクの進め方や考え方についても、本当にたくさんのことを教えていただきました。本当に本当に、ありがとうございました！</p><p>また、同じDXチームの皆さん、ランチや1on1で助言をくださった方をはじめ、インターン期間中に関わってくださったすべての方に感謝しています。短い期間の中でいただいた言葉やアドバイスは、忘れないように書き留めています。インターンが終わったあとも、これを読み返しながら活用しこうと思っています！</p><p>プレイドで経験した初めてのインターンは、間違いなく、私のエンジニア人生における一つのターニングポイントになりました。</p><p>改めて、プレイドと、関わってくださったすべての皆さん、本当にありがとうございました！！</p><h3>これからインターンに挑戦する方へ</h3><p>この記事を読んで、プレイドでインターンをしようか迷っている学生の皆さんへ。</p><p>一文で表すなら、自由度が高い分、自分から動けば動くほど、経験できることがいくらでも広がるような環境だと思います。</p><p>とにかくプレイドは、本当に良い環境、良い会社でした！！興味がある方には、ぜひインターンに挑戦してみてください！絶対に後悔しません！</p><h3>余談</h3><p>オフィスにある技術書を借りて読めたり、オフィスの設備やモニター、チェアが快適だったり、貸与されるMacBook Proのスペックが高かったり…インターン環境としては良すぎるぐらい、とても充実していました。何よりも、様々な経歴の尊敬できるエンジニアの方々と話せる機会を、自分から1on1を組んだりイベントに参加して作っていけるような、そんな伸び伸びとした環境でした！</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[プレイドインターン体験記：Mastraを使ってKARTE Signals AIチャットのメモリ機能を1から設計・実装した話]]></title>
            <link>https://tech.plaid.co.jp/intern-mastra-karte-signals-ai-chat-memory</link>
            <guid>https://tech.plaid.co.jp/intern-mastra-karte-signals-ai-chat-memory</guid>
            <pubDate>Fri, 26 Jun 2026 04:00:00 GMT</pubDate>
            <description><![CDATA[プレイドのインターン体験記。KARTE SignalsのAI分析チャットのGAリリースに向け、AIエージェント開発フレームワーク「Mastra」を用いたワーキングメモリの実装やプロンプト最適化、ハルシネーション抑制にフロントからバックエンドまで一気通貫で挑んだ、裁量権の大きい開発の裏側を綴ります。]]></description>
            <content:encoded><![CDATA[<h2>はじめに</h2><p>こんにちは。プレイドで2025年12月から2026年5月までの半年間、KARTE Signalsチームでインターンに参加していました森 大地です。普段大学では自律型攻撃エージェントの実装と評価のようなAI×セキュリティを題材に勉強しています。本インターンではKATRE SignalsのAIチャットのバグ修正、機能開発を中心に取り組んできました。本記事では私のインターンでの取り組みやこの会社について感じたことについて書けたらと思います。</p><h2>プレイドのインターンに参加したきっかけ</h2><p>まずこの会社を知ったきっかけはセキュリティキャンプに参加した際に、協賛企業としてプレイドが参加していて、食事会でエンジニアのnabekenさんとお話ししたのがきっかけです。そこでこの会社は日本でも有数の規模の顧客行動データを解析、活用をしていて**「データによって人の価値を最大化する」**という企業理念がとても共感できたとともに、高い技術力と全社的にセキュアな意識が高いことが分かり、ぜひ会社についてもっと知ってみたいと感じるようになりました。また、ノベルティが歯ブラシとラムネというのが他の協賛企業に比べて、とてもユニークで印象的でした。</p><h2>KARTE Signalsとは</h2><p><strong>KARTE Signals</strong>&nbsp;は、KARTEで収集した1st Party Dataを活用し、事業成果につながる広告効果の可視化ができる「Signals Dashboard」と、CV補完や高精度ターゲティングによって広告配信の最適化を支援する「Signals Connector」を提供するサービスです。</p><p>そこに伴う分析結果のサポートや改善アクションの提案に利用のできるAI分析チャットが2026年4月28日にGAリリースされましたが、そのチャットに発生するバグ修正や新規機能開発などに取り組んできました。</p><h2>取り組んだ内容について</h2><p>AIチャットの機能開発、バグ修正を一任させていただいていた中で、今回は主に以下の3点の機能実装について説明できればと思います。</p><ul><li><p>ワーキングメモリ（スレッドを跨いでも保持するメモリ）機能の追加</p></li><li><p>システムプロンプトの最適化</p></li><li><p>ツール使用状況・Reasoning （AIの推論プロセス）機能によるハルシネーションの抑制</p></li></ul><p>実装の前提として、今回のAIチャットはAIエージェント開発フレームワークMastraを使って構築しています。Mastraはエージェントのメモリ管理・ツール呼び出し・スキル管理などの機能を標準で備えており、AIチャットの複雑な処理を効率よく実装できます。また、セキュリティスキャンにはGCPのModel Armor、メモリの永続化にはMongoDBを活用しています。</p><h3>ワーキングメモリの実装</h3><p>AIチャットに「ワーキングメモリ」機能を実装しました。これはスレッドをまたいで情報を記憶し続ける機能です。通常のAIチャットはスレッドが切り替わると文脈がリセットされますが、ワーキングメモリを使うことで「このクライアントはECサイト運営者」「夏季キャンペーンに注力中」といった情報をAIが保持し続け、次のスレッドでも、状況を説明し直さずに的確な分析が返ってくるようになります。</p><img data-asset-id="6a3cd02d089072246796c9ad" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a3cd02d089072246796c9ad" alt="" width="1332" height="1191" /><p>この機能はGAリリースに向けてゼロから設計・実装しました。バックエンドの設計だけでなく、自分でデザイナーさんとのMTGを設定させていただき、メモリボタンの配置場所やUIの見せ方を一緒に考えながらFigmaでデザインを作っていただき、そのデザインに沿ってフロントエンドの実装まで一気通貫で担当しました。</p><p>また実装がある程度形になった段階で、「ぽちぽち会」という場を自ら設け、チームメンバーに実際のAIチャット機能を触ってもらいました。その場でもらったリアルタイムのフィードバックをもとに最終調整を加えることで、より使いやすい機能に仕上げることができました。</p><img data-asset-id="6a3cd04b089072246796c9dc" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a3cd04b089072246796c9dc" alt="" width="1444" height="2344" /><p>技術選定では、まずMastraが標準で持っている<code>updateWorkingMemory</code>の使用を検討しました。しかし、これは自由形式テキストでまとめて保存する方式のため、後から特定の情報だけを削除・更新することができません。たとえば「先週の広告費」を覚えさせた情報が時間が経って古くなっても、その記憶だけをUIから選んで消す手段がなく、不正確な情報がAIの文脈に残り続けてしまいます。そこでChatGPTのようにメモリを個別に管理・削除できる仕組みを実現したいと考え、<code>CustomMemory</code>クラスを独自に実装し、情報をkey-valueのペアで管理する設計にしました。たとえば<code>industry: ECサイト</code>、<code>current_strategy: 夏季向けキャンペーン重点</code>のような形式で保存されるため、UIから特定のメモリアイテムを選んで削除・更新できるようになっています。</p><img data-asset-id="6a3cd07eafbea6eb136789f6" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a3cd07eafbea6eb136789f6" alt="" width="842" height="529" /><p>（上の画像はイメージです。）</p><p>自動保存の対象は「普遍的・再利用可能な情報」に絞りました。業界の傾向・注力戦略・出力形式の好みなど、スレッドをまたいで役立つ情報です。分析結果やKPI目標は会話ごとに変わるものなので保存対象外にしています。さらにユーザー自身がプロンプトでルールを追加できる設計にすることで、クライアントごとに最適化されたAIに育てていける余地を残しています。</p><p>セキュリティ面では、GCPのModel Armorを活用してメモリへの保存内容をスキャンし、プロンプトインジェクションの疑いがある入力はブロックする仕組みを入れています。</p><p>メモリの保存件数が上限（20件）に達した場合はUIで通知する仕様にしています。上限を無制限にするとコンテキストが汚染されて精度が下がるため、ここは意図的に制限を設けています。</p><h3>システムプロンプトの最適化</h3><p>AIチャットが扱う機能が増えるにつれて、システムプロンプト（LLMへの指示）が肥大化し、精度の低下やコストの増大が課題になってきました。この問題にいくつかの角度からアプローチしました。</p><p>一つは、システムプロンプトを「Instructions」「Tools」「Skills」の3層に分解し、常時読み込む量を最小化する設計です。以前はスキルを独自実装で管理しようとしていましたが、その方式では外部で定義されたスキルを取り込むことが難しいと判断し、MastraのWorkspace機能という公式の仕組みに移行しました。<code>skills/&lt;skillName&gt;/SKILL.md</code>というファイルを置くだけでスキルが自動登録され、LLMはリクエストに応じて必要なスキルだけをオンデマンドでロードするため、コンテキストを無駄に消費しません。また、Mastraのバージョンアップに追従するための独自対応も不要になりました。</p><pre><code>karte-ai/apps/backend/src/mastra/agents/signals/
├── dashboardAnalyzerAgent.ts
└── skills/
    ├── summarizePerformance/
    │   └── SKILL.md
    ├── proposeImprovements/
    │   └── SKILL.md
    ├── aggregateData/
    │   └── SKILL.md
    └── operateDashboard/
        └── SKILL.md</code></pre><pre><code class="language-mermaid">sequenceDiagram
  participant U as ユーザー
  participant L as LLM
  participant S as skills/*/SKILL.md

  Note over L: SkillsProcessor が自動生成（workspace 設定時）
  U-&gt;&gt;L: ① リクエスト
  L-&gt;&gt;S: ② skill でスキルをロード
  S--&gt;&gt;L: ③ SKILL.md 本文を返却（オンデマンド）
  L-&gt;&gt;U: ④ 実行・回答</code></pre><p>もう一つの取り組みとして、システムプロンプトを英語で記述した場合にトークン数と精度がどう変わるかを検証しました。英語にすることでトークン数が削減され、コンテキストウィンドウを節約でき、結果として精度も上がるんじゃないかという試みでした。ただ実際に試してみると、<code>Output in Japanese</code>のような指示をシステムプロンプトに入れても、ユーザーが日本語で入力しているのに英語で回答が返ってくるケースが発生しました。また、システムプロンプトへの変更はLLMが毎回同じ挙動をするとは限らないという難しさもあり、小さな変更でも十分な回数の検証が必要だと実感した取り組みでした。</p><p>ダッシュボードとの日付連携では、ダッシュボードが表示している期間と「今日」の日付をAIに正確に渡すことで、「先週」「去年の同月」といった相対表現を正しく解釈できるようにしました。これがないと、ダッシュボードは過去データを表示しているのにAIが現在の日付を基準に回答してしまうという噛み合わせのズレが生じます。また、ワーキングメモリに情報を保存する際にも具体的な日付を付与することで、「この戦略は2026年3月時点のもの」のように時期と紐づいた形で記憶を残すことができます。こうしてAIがメモリを参照することで、クライアントの業界・戦略・出力形式の好みを毎回説明し直さなくても会話が成立するようになります。回答のブレが少なくなり、「このクライアントならこういう切り口で分析してほしい」という期待に沿った回答が安定して返ってくることが、ユーザーにとっての直接的なメリットです。</p><h3>ツール使用状況・Reasoning 表示</h3><p>AIが回答を生成する過程で何をしているのか見えないことは、開発者にとって大きな課題です。計算を本当にしているのか、どのツールを呼び出したのかが分からないまま結果だけが返ってくると、ハルシネーションの検出が難しくなります。そこでAIの思考プロセスとツール使用状況を開発者向けに可視化する機能を実装しました。</p><p>各ツールの実行状態をリアルタイムで確認できるため、LLMが「計算しているつもり」で実は暗算している、といったケースを即座に検知できるようになりました。計算ツールが呼び出されなかった場合は警告が表示されるため、回答の品質チェックが格段にしやすくなりました。</p><p>加えて、Reasoningの可視化についても開発者向けのデバッグツールとして内部実装・検証を進めています。LLMが答えを導くまでの推論プロセスを追跡できることで、ハルシネーションの根本原因の特定が容易になります。</p><h3>その他細々機能実装</h3><p>AIチャットに停止機能を追加し、停止後に入力欄へ実行中のプロンプトが保持される仕様にしました。長い処理の途中でプロンプトを修正したくなった場合でも、ゼロから入力し直す手間が省けます。GeminiのAIチャットにある同様の機能がとても使いやすいと感じたことがきっかけで、自分でIssueを立てるところから実装まで一貫して取り組みました。</p><p>データベース管理についても、メモリデータの管理をKARTEプロジェクト（クライアント）ごとに分離しました。万が一プロンプトインジェクションやDBの混線が発生した場合でも、別クライアントのメモリデータが混入するリスクを予防的に排除できます。</p><p>また、GCPで動かしているAIチャットのモデルのバージョン管理・更新にも携わっています。モデルのバージョンを上げれば良いというものではなく、実際に動作を検証した上で選定する必要があります。各バージョンでの日本語応答の品質や挙動を検証し、ユーザー体験を最大化できるバージョンへの更新対応と検証結果をチームに共有しました。</p><h2>インターンを通して</h2><p>このインターンでは、インターン生としてではなく<strong>1人の社員</strong>として扱っていただける環境で、<strong>自走力</strong>が強く求められました。それでも自分から声をあげると、周囲のメンバーがいつでも親身に助けてくれ、その安心感が自分の成長を後押ししてくれたと感じています。エンジニア一人ひとりのレベルが<strong>全員高い</strong>にもかかわらず、それぞれの得意領域へのリスペクトがあり、対等にコミュニケーションが取れる雰囲気のよい職場でした。また、クライアント様に実際にサービスを使っていただき、現場からのフィードバックをもとに実装を柔軟に改善する経験を通じて、臨機応変に対応する力が養われたと感じています。</p><p>加えて印象的だったのが、AIをはじめとした技術的進化に対して社員の多くが能動的にキャッチアップしていることです。チームや立場をまたいだ勉強会に人数が殺到するほど技術的好奇心が高く、そんな環境の中でメンバーみんなで高め合っていける、そこがこの会社の強みだと感じました。中でも、面談からメンターとして一貫してサポートしてくださったterashinさん、セキュリティについて深くお話していただいたkobachanさんをはじめ、多くの社員さんに温かく支えていただいたおかげで、インターンを通じて大きく成長できたと感じています。</p><h2>インターンを考えている学生へ</h2><p>フルスタックかつ最前線のAI駆動開発が行われている環境です。インターン生であっても、機能の設計・実装・リリースまでを1人の社員として主導できる、インターンとは思えない大きな裁量が与えられます。自分でIssueを立てるところから始まり、大小さまざまな課題に一貫して向き合う中で、どこでも通用する自走力が自然と身についていきます。能動的に動ける人、技術的好奇心が強い人にぜひおすすめしたいインターンシップです。</p><p>私はAIに興味があってSignalsチームで開発に携わりましたが、プレイドには他にも幅広い領域でのポジションがあります。他のインターン体験記やテックブログも参考に、自分の興味に合ったポジションをぜひ探してみてください。</p><p>↓下記リンクからぜひ応募してみてください。<br /><a target="_blank" rel="noopener noreferrer nofollow" href="https://plaid-newgrads.snar.jp/jobboard/detail.aspx?id=2B9662ZbwLU">https://plaid-newgrads.snar.jp/jobboard/detail.aspx?id=2B9662ZbwLU</a></p><img data-asset-id="6a3cd09cafbea6eb13678a36" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a3cd09cafbea6eb13678a36" alt="" width="4080" height="3072" /><p></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bigtable Authorized Viewを用いたスケーラブルなテナント分離アーキテクチャ]]></title>
            <link>https://tech.plaid.co.jp/mila-bigtable-authorized-view-tenant-isolation</link>
            <guid>https://tech.plaid.co.jp/mila-bigtable-authorized-view-tenant-isolation</guid>
            <pubDate>Wed, 17 Jun 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[本記事では、内製OLAP DB「mila」における、Bigtable Authorized Viewを用いたスケーラブルなテナント分離についてお話しします。]]></description>
            <content:encoded><![CDATA[<h2>内製OLAP DBとBigtable Authorized Viewの活用</h2><hr /><p>私たちが提供するKARTEでは、日々膨大な量のユーザーの行動データが取得され、取得したデータに対して分析が行われます。現在、毎月1ペタバイト（PB）強のデータがインサートされており、総データ量は8PB、月間解析量は180PBに達しています。</p><p>この膨大なデータを高速に分析するため、私たちはユーザーの行動データ分析に特化した内製OLAP DB「mila」を開発しています。</p><p>解析対象のデータは、弊社のリアルタイム解析基盤を通じてBigQueryに保存後、独自のパイプラインにより専用のフォーマットに変換されmilaのデータソースに永続化されます。</p><p>私たちは、このパイプラインの中間部分で使用する分散QueueとしてBigtableを使用しております。また、milaはマルチテナントなシステムであり、その中でデータの混在や権限面での事故を防ぐため、アプリケーション層ではなくインフラ層でのテナント分離を注力して行っています。(例えば、milaはGKE上でホスティングされており、k8sのnamespaceを利用してGKE上でのテナント分離を実現しています。)</p><p>そのため、Bigtableの使用においても、前述のような大量のデータ書き込み・読み取り性能を維持しつつ、数百規模のテナント間で分離されていることが求められます。これらを実現するアプローチとして、私たちはBigtable Authorized Viewの利用を検討しました。</p><p>本記事では、Bigtable Authorized Viewを用いたテナント分離の実現と、パフォーマンスの検証結果についてご紹介します。</p><h2>データ同期パイプラインの全体像</h2><hr /><p>まず初めに、Bigtableの使用箇所とユースケースを明らかにするため、データ同期パイプラインの全体についてお話しします。下の図がパイプラインの概略図になります。
図には、Google Cloudのリソースに加えて、二つのmilaのコンポーネントが存在しています。(BigQueryからのデータを受け取るIngester、データフォーマットの変換と永続化を行うIndexer)</p><img data-asset-id="6a3267dd4b4a5444a8d48b63" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a3267dd4b4a5444a8d48b63" alt="" width="3170" height="949" /><p>データは以下の流れでBigQueryからmilaに同期されます。</p><ol><li><p>リアルタイム解析基盤を通じて解析対象のデータがBigQueryに格納されます。</p></li><li><p>その後、新しいレコードはBigQueryのContinuous Queryによりインクリメンタルに抽出され、IngesterがSubscribeするPub/SubのTopicに転送されます。</p></li><li><p>Pub/Subからデータを受け取ったIngesterは、次のコンポーネントであるIndexerに中継します。この時、書き込みをIndexerに知らせるために2で使用したPub/Subとは異なるPub/Subを用いています。</p></li><li><p>Indexerは受け取った行データをmilaの内部フォーマットに変換し(Indexing処理)、データの本体をGoogle Cloud Storage、メタデータをSpannerにそれぞれ格納します。</p></li></ol><p>ここで、IngesterとIndexerでの実際のデータの受け渡しとIndexerでの処理の失敗に備えた永続化のための分散QueueとしてBigtableを使用しています。</p><p>また、Bigtable以外のテナント分離についてですが、Google Cloudのリソースは基本的には別々で管理・作成し、データと権限の分離を行っています。milaのIngesterとIndexerに関しては、GKE上のnamespaceで隔離されたテナント間で異なるPodが動いています。</p><h2>Bigtable Authorized Viewを用いたテナント分離</h2><hr /><p>マルチテナント環境において、テナントごとに物理的なテーブルを作成する方法は、テーブル数が増加した際のパフォーマンス劣化や管理コストの増大から非推奨とされています。[1]</p><p>そのため、私たちは全てのテナントのデータを単一のBigtableテーブルに保存し、テナントごとに異なるBigtable Authorized Viewを用いてテナント分離を実現しました。</p><p>ここでは、分散QueueとしてBigtableを利用する行キーの設計について述べたのち、テナント分離のためのAuthorized Viewの活用についてお話しします。</p><h3>行の設計</h3><p>milaでは、全てのデータはテナントごとに分離されテナント内では特定のキー(ユーザーのIDなど)でシャーディングされ保存されます。</p><p>行キーの設計としては、特定テナントのデータアクセスの集中によるホットスポットを避けるため、以下のようにテナントのIDに加えて、シャードの値を入れたハッシュ値をプレフィックスに用いています。</p><p><code>Hash(tenantId + shard + tableId)::tenantId::tableId::shard::timestamp</code></p><p>シャード内でアクセスが集中した場合は、ホットスポットを避けることはできませんが、KARTEでは特定のユーザーで大量のデータが生成されるケースは、システムのバグを除き存在しないため上記のキーの設計で問題ありません。</p><h3>Authorized View 活用とテナント分離方法</h3><p>Authorized Viewでは、セキュリティとデータ分離を実現するために、以下の3つの方法でアクセス可能なデータの範囲を限定できます。[2]</p><ol><li><p>行キーの接頭辞</p></li><li><p>列修飾子の接頭辞 または列修飾子</p></li><li><p>行キー接頭辞と列修飾子を組み合わせたもの</p></li></ol><p>ここで、テナントIDを行キーの接頭辞（Keyの先頭）に使用してしまうと、前述の通り、特定のテナントにアクセスが集中した場合にホットスポットが発生する可能性があります。</p><p>そのため、私たちはこのリスクを避け、列修飾子の接頭辞を用いることでテナント分離を実現することにしました。これにより、Key全体は均等に分散されたままで、各テナントは自身のデータのみに安全にアクセスすることができるようになります。</p><h2>Bigtable Authorized Viewの有無におけるパフォーマンスの検証</h2><hr /><p>以上の構成を用い、Bigtable Authorized Viewを使用した場合と、使用しない通常アクセスの場合でパフォーマンスにどのような差が生じるかを検証しました。特に、想定される数百規模のテナントが同時にアクセスする環境下での影響を評価しています。</p><h3>ワークロードの設定</h3><p>複数のスレッドから各テナントのAuthorized Viewを経由したアクセス(書き込み/読み取り)と、Bigtableへの直接のアクセスをそれぞれ実行します。検証では同じインスタンスにアクセスを行い、Authorized Viewの有無によってアクセスするテーブルを分離しています。</p><p>ワークロードの数値は本番の状況を模したものであり、詳細は以下のとおりです。</p><ul><li><p>対象テナント数: 300</p></li><li><p>1テナントあたりのスループット: 300 rows/sec （システム合計: 90k rows/sec）</p></li><li><p>1テナントあたりのリクエスト: 50 req/sec （システム合計: 15k req/sec）</p></li><li><p>1行当たりのデータサイズ: 0.5 KB</p></li></ul><h3>計測結果</h3><p>Cloud Monitoringを用いて、CPU使用率およびレイテンシの分析を行った結果を示します。なお、レイテンシについてはp99(99パーセンタイル)の数値を採用しています。</p><p>メトリクスには、Cloud Monitoringの以下の指標を使用しました。</p><ul><li><p>CPU使用率: bigtable.googleapis.com/cluster/cpu_load</p></li><li><p>レイテンシ: bigtable.googleapis.com/client/operation_latencies</p></li></ul><p>次のグラフにおけるラベルは、それぞれ以下の測定対象を示しています。</p><ul><li><p><code>bt-bench-auth</code> : Authorized Viewを経由したBigtableへのアクセス</p></li><li><p><code>bt-bench-raw</code> : Bigtableへの直接アクセス</p></li></ul><h4>CPU 使用率</h4><img data-asset-id="6a3268024b4a5444a8d48bc7" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a3268024b4a5444a8d48bc7" alt="" width="1000" height="600" /><p>Authorized Viewの有無によるCPU使用率において顕著な差は見られませんでした。負荷のスパイクも発生しておらず、Authorized Viewの導入がCPUの負荷に与える影響は軽微であると考えられます。</p><h4>レイテンシ (Read / Write)</h4><img data-asset-id="6a326815dde307b67749e4f8" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a326815dde307b67749e4f8" alt="" width="1000" height="600" /><img data-asset-id="6a32681e4b4a5444a8d48bf7" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a32681e4b4a5444a8d48bf7" alt="" width="1000" height="600" /><p>読み取り・書き込みともにAuthorized Viewを経由した方がわずかにレイテンシが高い、またはほぼ同等のパフォーマンスを示すことが確認できました。
双方の環境において局所的なレイテンシのスパイクが見えますが、その発生頻度や挙動に有意な差はありません。そのため、Authorized Viewの使用がレイテンシの安定性に影響を及ぼしていないと考えられます。</p><h2>まとめ</h2><hr /><p>本記事では、Bigtable Authorized Viewを用いたスケーラブルなテナント分離について、具体的なユースケースとパフォーマンス検証を交えて紹介しました。私たちは、データ同期パイプラインにおける分散キューとしてBigtableを採用し、マルチテナント環境での安全なデータ分離を実現するためにAuthorized Viewを活用しました。</p><p>パフォーマンス検証の結果、想定される数百規模(300テナント)の環境下においてもAuthorized ViewによるCPU使用率やレイテンシの劣化はほとんど見られませんでした。権限制御に伴うオーバーヘッドをほぼ無視して、数百規模のテナント分離を高いパフォーマンスのまま実現できる点は、非常に使いやすいと感じました。</p><p>また、追加の課金も発生しない点に加え、Authorized Viewを利用した権限制御は複数の方法で行うことができます。そのため、今回のテナント分離以外にも、細かいアクセス制御が求められるユースケースにおいてBigtable Authorized Viewは適していると考えます。</p><p>[1] <a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.cloud.google.com/bigtable/docs/schema-design?hl=ja#multi-tenancy">https://docs.cloud.google.com/bigtable/docs/schema-design?hl=ja#multi-tenancy</a></p><p>[2] <a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.cloud.google.com/bigtable/docs/authorized-views?hl=ja#view-definition">https://docs.cloud.google.com/bigtable/docs/authorized-views?hl=ja#view-definition</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[プレイドインターン体験記：パーサー拡張・分散ジョブ制御・クエリ高速化 ── 内製OLAP「mila」の全レイヤーに挑んだ4ヶ月]]></title>
            <link>https://tech.plaid.co.jp/intern-in-house-database-development-whole-layer</link>
            <guid>https://tech.plaid.co.jp/intern-in-house-database-development-whole-layer</guid>
            <pubDate>Sun, 31 May 2026 15:00:00 GMT</pubDate>
            <description><![CDATA[プレイドの解析基盤として様々な仕組みを備えた内製OLPA DB milaの開発に携わり、多くのことを学びました。この記事では、インターンでの取り組みと学びを振り返ります。]]></description>
            <content:encoded><![CDATA[<h1>はじめに</h1><p>こんにちは。</p><p>2026年の2月から5月まで、プレイドのCore Platformチームでエンジニアインターンとしてお世話になりました、高田晴成(たかた はるなり)と申します。慶應義塾大学環境情報学部を4月に卒業し、今年の9月からカナダの大学院で修士課程を始める予定です。</p><p>研究分野は耐障害性のある分散システムとデータベースで、プレイドが内製する分散OLAP(オンライン分析処理)データベースmilaに強く惹かれ、インターンに参加しました。</p><img data-asset-id="6a1d267c409f5832f19245bf" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d267c409f5832f19245bf" alt="" width="4000" height="3000" /><p>OLAPデータベースを内製する企業でのインターンは初めてで、プレイドの解析基盤として様々な仕組みを備えたmilaの開発に携わり、多くのことを学びました。この記事では、インターンでの取り組みと学びを振り返ります。</p><h1>内製OLAPデータベースmilaとは</h1><p>はじめに、私がインターンで開発に携わった内製OLAPデータベースの「mila」について説明します。</p><p>弊社ではこれまでに顧客データを中心とした様々な分析サービスを提供しており、蓄積されたデータの総量は8PBを超えています。エージェントを使った分析を含め、多種多様な顧客分析のニーズに対応してきましたが、その過程で、汎用的な分析ニーズを主眼に置いた既存の分析用DBでは解決が難しい課題があることも判明しました。</p><p>そこで、顧客分析に特化した内製OLAPデータベースの開発に着手していました。</p><p>顧客分析に特化した特徴の例として、ユーザーID統合、ユーザIDをキーにしたデータ配置、ファネル・リテンション分析などがあります。</p><p>milaの詳細に関する記事については、今後掲載予定です。</p><h1>インターンでの取り組み</h1><h2>新規の独自拡張DDLの追加</h2><p>2つのテーブル名をAtomicにスワップする以下のコマンドを実装しました。</p><pre><code>ALTER TABLE &lt;table 1&gt; SWAP WITH &lt;table 2&gt;</code></pre><img data-asset-id="6a1d269bc00bbb4fd164ab93" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d269bc00bbb4fd164ab93" alt="" width="2303" height="728" /><p>milaではSQLパーサーとして<a target="_blank" rel="noopener noreferrer nofollow" href="https://calcite.apache.org/">Apache Calcite</a>を使用しており<code>.ftl</code> (FreeMarker Template Language)で記述することでカスタムのSQL構文ルールを追加することができます。</p><p>今回は、以下のルールを追加することでDDL文の拡張をしました。</p><pre><code>SqlAlterTable SqlAlterTableSwap(Span s) :

{

    boolean ifExists;

    SqlIdentifier t1, t2;

}

{

    ifExists = IfExistsOpt()

    t1 = CompoundIdentifier() &lt;SWAP&gt;&lt;WITH&gt; t2 = CompoundIdentifier()

    { return new SqlAlterTableSwapWith(s.end(this), ifExists, t1, t2); }

}</code></pre><p>次に、クエリを実際に実行する部分です。テーブル名などのメタデータはSpannerで管理しており、以下の3ステップをSpannerのトランザクション内で実行することで、Atomicなテーブル名の入れ替えを実現しました。</p><pre><code>&lt;tmp&gt; &lt;- &lt;table1&gt;

&lt;table1&gt; &lt;- &lt;table2&gt;

&lt;table2&gt; &lt;- &lt;tmp&gt;</code></pre><h2>Jobのキャンセル機構の構築</h2><p>milaにおいて、データベースのメンテナンスやデータの同期は、クエリ処理とは別にmila内のjob基盤で行われています。</p><p>具体的には、以下のようなjobの処理があります。</p><ul><li><p>データ取り込み - データソースのBigQueryからデータを同期</p></li><li><p>ストレージ最適化 - 小さいファイルをマージ、不要ファイルを削除</p></li><li><p>ガベージコレクション - 不要メタデータの削除</p></li></ul><p>Jobの中には、処理に長時間かかるものもあり、その間に「やっぱり止めたい」と気づいても、途中で止める手段がありませんでした。</p><p>Jobのキャンセルができないと、間違ったジョブが完了するまでリソースを消費し続けてしまいます。特に、複数のコンポーネントから構成され、大量なデータを処理するmilaにおいてはこれは顕著な問題です。</p><p>そこで、Jobのキャンセル機構を実装しました。</p><p>当ブログでは、データの同期Jobを例に説明します。</p><p>同期Jobにおいては、主に以下の三つのコンポーネントが全体の処理を担います。</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>Broker</p></td><td colspan="1" rowspan="1"><p>外部APIリクエストを受け付け、JobをRedisのキューに登録</p></td></tr><tr><td colspan="1" rowspan="1"><p>Controller</p></td><td colspan="1" rowspan="1"><p>RedisからJobをpollし、定義されたステップを順番に実行して、状態をRedisに保存するバックグラウンドループ。</p></td></tr><tr><td colspan="1" rowspan="1"><p>Job-Worker</p></td><td colspan="1" rowspan="1"><p>Redisからシャードタスクをpollし、BQ Storage Read APIでデータを取得して、milaフォーマット（body file / dict file）に変換しGCSに書き込むワーカー。</p></td></tr></tbody></table><p></p><p>全体の流れは以下の通りです:</p><ol><li><p>クライアントがBrokerに対してJob開始のAPIを呼び出す</p></li><li><p>BrokerがRedisにJobを登録</p></li><li><p>ControllerがRedisをpoll、Jobを取得</p></li><li><p>ControllerがシャードタスクをRedisのキューに登録</p></li><li><p>Job-Worker<br />├─ Redisからタスクをpoll<br />├─ タスクを処理<br />└─ Redisに完了報告</p></li><li><p>Controllerが全シャード完了を確認、Redisに完了フラグをset</p></li></ol><img data-asset-id="6a1d2f0f409f5832f1925e47" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d2f0f409f5832f1925e47" alt="" width="2260" height="1986" /><h4>基本方針</h4><p>Redisに登録されたJobにキャンセルフラグを立て、各コンポーネントがpolling時にチェックするというシンプルな設計を採用しました。</p><ol><li><p>クライアントが /cancelJob API を呼び出す</p></li><li><p>Broker が Redis キャンセルフラグをセット</p></li><li><p>Controller /Job-worker が次のpolling時にフラグを検知</p></li><li><p>処理を中断し、リソースをクリーンアップ</p></li></ol><p>キャンセルの検知はpolling形式で設計しました。ControllerもJob-workerも、もともとRedisを定期的にpollingする設計になっています。キャンセル通知のために新たなPush機構を入れるより、既存のpollingループにチェックを追加する方がシンプルで確実でした。</p><h3>BigQueryジョブのキャンセル </h3><p>ジョブによってはBigQueryなどの外部リソースへの<strong>クエリ発行</strong>を行うものもあります。</p><p>このようなケースでも、Controllerがキャンセルを検知したタイミングで、BigQuery側の処理に対して連動してキャンセルを実行します。</p><p>例えば、BigQueryへのクエリ発行後、その完了を待機している最中にキャンセル要求が発生した場合を考えます。このとき、Controllerは対象のBigQueryジョブIDを保持しているため、キャンセル検知と同時にBigQueryのAPIを呼び出して該当ジョブを即座に停止させることができます。この確実なキャンセル機構により、不要なクエリ実行に伴う課金を最小限に抑えることが可能です。</p><img data-asset-id="6a1d2f87409f5832f1925f88" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d2f87409f5832f1925f88" alt="" width="2638" height="1980" /><h2>クエリの高速化</h2><p>milaチームでは、内部的に他のDB(Clickhouse, Apache Druid, StarRocks)と性能比較を行なっており、特定のクエリパターンでは、パフォーマンスに改善の余地があることがわかっていました。<br />データソースをREES46として、ECサイトの商品アクセスランキングTOP100を取得するクエリを高速化するタスクを担いました。</p><pre><code>SELECT
    product_id,
    COUNT() as access_count,
    COUNT() FILTER(WHERE event_type = 'view') as view_count,
    COUNT() FILTER(WHERE event_type = 'cart') as cart_count,
    COUNT() FILTER(WHERE event_type = 'purchase') as purchase_count
FROM rees46_events
WHERE TIME_IN_INTERVAL(__time, '2019-10-01T00:00:00Z/2020-01-01T00:00:00Z')
  AND product_id IS NOT NULL
GROUP BY product_id
ORDER BY access_count DESC, product_id
LIMIT 100</code></pre><p>高速化に取り組むにあたり、まずボトルネックを特定する必要がありました。そこで<a target="_self" rel="noopener noreferrer nofollow" href="https://github.com/async-profiler/async-profiler">async-Profiler</a>を使用しました</p><p>async-profilerは低いオーバーヘッドで正確なデータを取得するために開発されたサンプリングプロファイラです。サンプリングは以下の手順で行われます。</p><ol><li><p><strong>OSシグナルの送信:</strong> </p></li></ol><p>Linuxの <code>perf_events</code> を使用し、対象のJavaスレッドに対して一定周期（例：10msごと）で <code>SIGPROF</code>（タイマーシグナル）を送信します。</p><ol start="2"><li><p> <strong>割り込み処理の実行:</strong></p></li></ol><p>シグナルを受信したスレッドは、実行中の処理を一時中断し、割り込み処理プログラム（シグナルハンドラ）を実行します。<br />FlameGraph（フレームグラフ）は、収集したスタックトレースの統計データを視覚的に階層化して表示するグラフです。async-profilerで取得したプロファイリングの結果はFlameGraphに起こすことが可能です。</p><img data-asset-id="6a1d26e0409f5832f1924673" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d26e0409f5832f1924673" alt="" width="3432" height="1712" /><p>FlameGraphの見方は以下の通りです。</p><ul><li><p><strong>横軸（幅）</strong>：全体のサンプリング数に占める、そのメソッドの実行時間の割合を示します。左から右への方向は時系列ではなく、アルファベット順などでソートされています。</p></li><li><p><strong>縦軸（高さ）</strong>：コールスタックの深さを示します。下から上に向かって、呼び出し元のメソッドから呼び出し先のメソッドへと積み上がります。</p></li></ul><p>グラフ上で<strong>横幅が広く表示されているバー</strong>は、実行時間の占有率が高い処理、すなわちシステムのボトルネックの可能性が高い箇所です。</p><ul><li><p><strong>最上部に位置する広いバー</strong>：そのメソッド自体の処理（自メソッド内の計算など）に時間がかかっています。</p></li><li><p><strong>下部〜中堅に位置する広いバー</strong>：そのメソッドから呼び出されている複数の子メソッドの実行時間の合計が長いことを示します。</p></li></ul><p>この横幅の広いバー（占有率の高い処理）を特定し、該当コードのロジックや呼び出し頻度を見直すことで、効率的なパフォーマンスチューニングが可能になります。</p><p>async-profilerのプロファイリングを基に実施したパフォーマンス改善事例をいくつか紹介します。</p><h3>ハッシュテーブルの高速化</h3><h4>1. メモリアクセス回数の削減による高速化</h4><h5>背景</h5><p><br />Javaの標準的なHashMapはヒープ上にオブジェクトを生成するため、大量のグループキーを扱う場合にGC負荷が問題になります。milaで<code>DirectBuffer</code>を使用してオフヒープメモリを直接操作することで、GCの負荷を下げ、安定した集計処理を実現しています。</p><pre><code>// apache.datasketchesのWritableMemoryImplインスタンス
WritableMemory memory = WritableMemory.writableWrap(
    directBuffer, // DirectByteBuffer（ネイティブメモリ）
    ByteOrder.nativeOrder(),
    memoryRequestServer
);</code></pre><p>対象の処理は単体では極めて短時間ですが、プロファイリングの結果、呼び出しの回数が非常に高いことが判明しました。したがって、1回あたりの処理時間をわずかでも削減することが、クエリ全体の高速化に大きく貢献すると判断しました。</p><h5>変更内容</h5><p></p><p>8バイトキーのハッシュ値計算において、memory.getInt() を2回呼び出していた処理を、memory.getLong() の1回呼び出しへと統合。取得した8バイトのデータから、CPUのビットシフト演算を用いて必要な値を抽出する方式に変更しました。</p><pre><code>// 変更前:

// 1回目：メモリから低位の4バイト（int）を取ってくる
int lower_data = memory.getInt(position);

// 2回目：メモリから高位の4バイト（int）を取ってくる
int upper_data = memory.getInt(position + 4);

// ハッシュ値を計算して返す
return 31 * (31 + lower_data) + upper_data;</code></pre><pre><code>// 変更後:

// 1回だけ：メモリから8バイト（long）を丸ごと一括で取ってくる
long combined_data = memory.getLong(position);

// 【CPU内部で処理】
// キャスト（型変換）するだけで、下位32ビットが取り出せる
int lower_data = (int) combined_data;

// 右に32ビット論理シフトするだけで、上位32ビットが取り出せる
int upper_data = (int) (combined_data &gt;&gt;&gt; 32);

// ハッシュ値を計算して返す（計算式はまったく同じ）
return 31 * (31 + lower_data) + upper_data;    </code></pre><h5>効果</h5><p>コストの高いメモリアクセス命令の回数を半減させ、当処理を行う関数の実行時間が41.4%削減されました。</p><h4>2. 8バイトアライメント調整によるハッシュテーブルアクセス速度低下の防止    </h4><h5>背景</h5><p>GROUP BYクエリでは、グループキー（例：user_id）ごとに集計値（例：COUNT(*)やSUM(amount)）を保持する必要があります。このとき、数百万〜数千万のグループキーを効率的に管理するため、ハッシュテーブルを使用しています。</p><img data-asset-id="6a1d26f4409f5832f1924691" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d26f4409f5832f1924691" alt="" width="2205" height="723" /><p>ハッシュ値計算とは別に、ハッシュテーブル本体へのキー・値の読み書き（getLong/putLong）もプロファイリングでホットスポットとして現れていました。コードを確認したところ、データが8バイト境界に揃っていないケースがあり、メモリにアラインされていない状態でアクセスを行っていることが判明しました。</p><h5> 変更内容</h5><p>getLong/putLong を行うデータ構造（MemoryOpenHashTable）において、各オフセットとバケットサイズを8バイト境界に整列（アライメント）させる変更を行いました。</p><img data-asset-id="6a1d2728c00bbb4fd164ac85" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d2728c00bbb4fd164ac85" alt="" width="1041" height="446" /><p>  </p><h5>効果</h5><p>メモリ使用量は増加するものの、境界またぎによるCPUの余分な読み込み・結合処理を排除し、アクセス速度の低下を防止しました。    </p><h4>3. ヒープの再構築のオーバーヘッドの除去    </h4><h5>背景</h5><p>「アクセス数の多い商品TOP 100」のようなクエリでは、数百万件のデータから上位100件だけを取得します。このとき、全データをメモリに保持してからソートすると、メモリを大量に消費してしまいます。この問題を解決するため、ヒープ構造（優先度付きキュー）が採用されていました。ヒープには常に最小値（または最大値）が先頭にあるという性質があります。</p><p>データ追加時の動作：</p><ol><li><p>新しいデータが来たら、ヒープに追加</p></li><li><p>ヒープのサイズが100件を超えたら、最小値（=100位以下）を削除</p></li><li><p>結果として、常に上位100件だけがメモリに保持される</p></li></ol><p>この方式により、何百万件のデータが流れてきても、メモリには常に100件分しか保持しません。データ追加時のヒープ操作は高速（O(log N)）なので、この段階では効率的です。</p><p>しかし、問題は最終結果を取り出す段階（drain処理）にありました。ヒープから要素を1件取り出すたびに、残りの要素を並べ替える「再構築」が発生します。100件取り出す場合、計100回の再構築オーバーヘッドが生じていました。FlameGraphにも大きな山として表示されており、ボトルネックであることは明らかでした。</p><img data-asset-id="6a1d2742c00bbb4fd164acff" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d2742c00bbb4fd164acff" alt="" width="3344" height="1712" /><h5>変更内容</h5><p>データの追加時はヒープを使い、取り出し時は別の方式を使うよう変更しました：</p><ul><li><p>追加時: 従来通りヒープを使用（上位100件だけをメモリに保持）</p></li><li><p>取り出し時: 配列にコピー → 1回だけソート → 順次出力</p></li></ul><h5>効果</h5><p>ヒープの少ないメモリで上位N件を追跡できるというメリットは維持しつつ、取り出し時の再構築オーバーヘッドを排除しました。結果、クエリの全体処理時間が0.7倍ほどに短縮されました。</p><h1>milaの開発を通じて得た学び</h1><p>4ヶ月間、milaの開発にどっぷり浸かり、多くのことを学びました。</p><p>ここでは、特に印象深かった3つの学びを紹介します。</p><h2>1. データベース開発のイロハ</h2><p>milaは、データフォーマットからクエリエンジンに至るまで、<strong>ほとんどのコンポーネントを内製開発</strong>している特徴的なデータ基盤です。</p><p>プレイドが抱える膨大なトラフィックを捌くためのチューニングや、OLAPならではの大規模データを高速に処理するための工夫が、システムの随所に施されています。</p><p>普段データベースを利用したり、概念を勉強しているだけではわからない、<strong>実装上の緻密な最適化やデータ構造・アルゴリズムがパフォーマンスに与える直接的な影響を、身をもって体感することができました。</strong></p><p>クエリの高速化を例に挙げると、以前は「GroupByは遅い」程度の理解しかありませんでした。実際にGroupByエンジンのコードを読み、なぜボトルネックになりやすいのかがコードレベルでわかり、その上でハッシュテーブルの構造やメモリ配置がどうパフォーマンスに効くのかを目の当たりにしました。</p><p>既存のデータベースを利用するだけでは見えにくい内部の仕組みを、内製開発の視点から体験できたことは大きな財産になりました。</p><h2>2. 実践的な分散システム設計</h2><p>milaは、<strong>Broker</strong>, <strong>Computing Node</strong>,  <strong>Job-Worker</strong>, <strong>Redis</strong>など、役割の異なる複数のコンポーネントから構成される複雑な分散システムです。</p><p>大学で分散システムの研究をしており、理論的な難しさは理解しているつもりでした。しかし、本番環境で大量のリクエストを捌く生きたシステムの開発を経験し、その難しさを改めて痛感しました。</p><p>特に印象深かったのは、<strong>クエリのキャンセル機構</strong>の開発です。</p><p>分散システムでは、ネットワークの遅延やメッセージが届くタイミングによって、システム全体の挙動がパターンのように変化します。</p><ul><li><p>どのタイミングでキャンセルリクエストが届くか</p></li><li><p>その時、各コンポーネントはどの処理フェーズにいるのか</p></li></ul><p>これらによって、キャンセルに参加すべきコンポーネントや必要なクリーンアップ処理が動的に変わるため、あらゆるエッジケースを想定した設計が求められました。机上の研究だけでは味わえない、<strong>リアルな分散システム開発の醍醐味と複雑さ</strong>を肌で感じることができました。</p><h2>3. パフォーマンス改善の手法</h2><p>milaのクエリ高速化というミッションを通じて、システムソフトウェアにおける<strong>再現性のあるパフォーマンス改善サイクル</strong>を体得しました。</p><p>具体的には、勘に頼るのではなく、以下のようなエンジニアリングアプローチを徹底しました。</p><ol><li><p><strong>プロファイリング（計測）</strong>: async-profilerや、FlameGraph 等でシステムの性能やリソース消費を記録、可視化する。</p></li><li><p><strong>ボトルネックの特定（観察）</strong>: グラフやデータを観察し、どこで処理が滞っているのか、真の原因（ボトルネック）を突き止める。</p></li><li><p><strong>コードリーディングと対策（改善）</strong>: 該当箇所のコードを深く読み込み、アルゴリズムの最適化や無駄な処理の削減を施す。</p></li></ol><p>この「計測 → 観察 → コード読解 → 改善」のサイクルは、データベース開発に限らず、あらゆるシステムソフトウェアのパフォーマンス改善に活きる汎用的なスキルだと実感しています。</p><h1>プレイドで働いて感じたこと</h1><p>プレイドの皆様、そしてmilaチームの皆様、4ヶ月間大変お世話になりました。</p><p>振り返ると、刺激が多く、快適に働ける素晴らしい職場環境でした。特に魅力的だと感じた点を紹介します。</p><h5><strong>風通しの良さ</strong></h5><p>milaチームはメンバー間のコミュニケーションが活発で、課題に対して綿密に議論しながら進めていく姿勢がとても印象的でした。</p><p>フラットに意見を交わし、最適解を導き出すカルチャーは大きな学びになりました。</p><h6><strong>高い技術力と技術への熱量</strong></h6><p>milaチームはメンバーの皆様がそれぞれ高い技術力を持っており、問題に直面しても必ず相談できる方がいて心強いです。</p><p>日頃からmilaの開発に役立ちそうな記事や論文を積極的に共有し、どう活かせるかを議論する姿、DEIMなどの学会で先端研究を調査する姿には、エンジニアとして多くの刺激を受けました。</p><h5><strong>ウェルカムランチ制度と他職種との交流</strong></h5><p>入社直後にウェルカムランチ制度を通じて、チーム内外の方とフランクに話す機会を頂けました。</p><p>エンジニア以外の職種の方からも直接業務の話を伺え、それぞれの専門分野で熱量を持って仕事に取り組む姿が伝わってきて、刺激になりました。</p><h5><strong>最強のコーヒーメーカーがある</strong></h5><p>エンジニアとして働く上で重要なのが集中力です。</p><p>集中力を高める上で重要なのがカフェインです。</p><p>また、美味しくカフェインを摂取することでリラックスにもつながります。</p><p>プレイドのオフィスには優れたコーヒーメーカーがあり、私は出社をしたら必ず利用してコーヒーを飲んでいました。また、メンターの方と作業を分担してカフェラテを作ったのもいい思い出です。</p><img data-asset-id="6a1d275b409f5832f1924851" src="https://gj7drspl.assets.cross-cms.com/beta/cms/assets/image?apiKey=af2e601c12d3932e258de66ec1d0ffd9&amp;imageId=6a1d275b409f5832f1924851" alt="" width="3197" height="2005" /><h1>終わりに</h1><p>4ヶ月間のインターンを通じて、OLAPデータベースの内部実装から分散システムの設計、パフォーマンスチューニングまで、幅広い経験を積むことができました。</p><p>特に、実際のプロダクション環境で8PBものデータを扱うシステムの開発に携われたことは、大学での研究だけでは得られない貴重な経験でした。理論と実践のギャップを埋め、エンジニアとして一段成長できたと感じています。</p><p>9月からはカナダの大学院で分散システムとデータベースの研究を続けます。milaでの経験は、研究の方向性を考える上でも大きな糧になると確信しています。</p><p>最後に、日々の業務で丁寧に指導してくださったmilaチームの皆様、インターンの機会をくださったプレイドの皆様に心より感謝申し上げます。</p><p>ありがとうございました。</p>]]></content:encoded>
        </item>
        <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>
    </channel>
</rss>