<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Pepabo Tech Portal</title>
  <id>https://tech.pepabo.com/</id>
  <link href="https://tech.pepabo.com/"/>
  <link href="https://tech.pepabo.com/feed.xml" rel="self"/>
  <updated>2026-08-05T00:00:00+09:00</updated>
  <author>
    <name>GMO Pepabo, Inc.</name>
  </author>
  <entry>
    <title>Agentic Engineering時代における「作り上げる力」を鍛える！2026年度新卒エンジニア研修を実施しました</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/08/05/training-2026/"/>
    <id>https://tech.pepabo.com/2026/08/05/training-2026/</id>
    <published>2026-08-05T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>ugo</name>
    </author>
    <content type="html">&lt;h2 id="はじめに"&gt;はじめに&lt;/h2&gt;

&lt;p&gt;新卒エンジニア研修を担当しました、ugo、yukyan、てつを、どすこい、haruotsuです！&lt;/p&gt;

&lt;p&gt;2026年度も新たな新卒エンジニアを迎え、講師陣一同で研修を実施しました。本記事では、各研修を設計・実施した講師陣が、カリキュラムの設計意図や工夫、実施の様子を紹介します。新卒エンジニア研修の設計に携わる方々の参考になれば幸いです。ぜひ最後までご覧ください。&lt;/p&gt;

&lt;ol id="markdown-toc"&gt;
  &lt;li&gt;&lt;a href="#はじめに" id="markdown-toc-はじめに"&gt;はじめに&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#2026年度新卒エンジニア研修概要" id="markdown-toc-2026年度新卒エンジニア研修概要"&gt;2026年度新卒エンジニア研修概要&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#webアプリケーション研修バックエンド" id="markdown-toc-webアプリケーション研修バックエンド"&gt;Webアプリケーション研修(バックエンド)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#フロントエンド研修" id="markdown-toc-フロントエンド研修"&gt;フロントエンド研修&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#モデリング設計研修" id="markdown-toc-モデリング設計研修"&gt;モデリング/設計研修&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#インフラ仮想化研修" id="markdown-toc-インフラ仮想化研修"&gt;インフラ/仮想化研修&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#sre研修" id="markdown-toc-sre研修"&gt;SRE研修&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#障害対応100本ノックの概要" id="markdown-toc-障害対応100本ノックの概要"&gt;障害対応100本ノックの概要&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#設計意図" id="markdown-toc-設計意図"&gt;設計意図&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#効果" id="markdown-toc-効果"&gt;効果&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#セキュリティ研修" id="markdown-toc-セキュリティ研修"&gt;セキュリティ研修&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#セキュリティレビュー体験" id="markdown-toc-セキュリティレビュー体験"&gt;セキュリティレビュー体験&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#脆弱性診断の体験" id="markdown-toc-脆弱性診断の体験"&gt;脆弱性診断の体験&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#機械学習データエンジニアリング研修" id="markdown-toc-機械学習データエンジニアリング研修"&gt;機械学習＆データエンジニアリング研修&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#おわりに" id="markdown-toc-おわりに"&gt;おわりに&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="2026年度新卒エンジニア研修概要"&gt;2026年度新卒エンジニア研修概要&lt;/h2&gt;

&lt;p&gt;今年の新卒エンジニア研修は&lt;strong&gt;「Agentic Engineering時代における作り上げる力」&lt;/strong&gt;をテーマに、次の3つを目的として設計しました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;自分が何を知らないかを知り、自律的に学び続けられる状態になる&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;配属後すぐに事業インパクトを出せる助走をつける&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AIを前提とした技術選定・判断・実装ができ、顧客体験まで考えられるようになる&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AIがコードを書いてくれる時代だからこそ、専門職としてのエンジニアとなることを重視しました。
AIの出力の表面をなぞるのではなく、背後の技術体系を理解し実践していること。
将来の事業価値に、開発がどのように結びつくのか議論できること。
リリースした先の運用まで責任を持てること。AIが速く賢くなっても、説明責任と運用責任は人間が引き受けるものだと考えました。&lt;/p&gt;

&lt;p&gt;こうした狙いのもと、Webアプリケーション開発を足掛かりとしました。そのうえで、AIエージェントを用いた開発で重要となるもの、AIエージェントを組み込んだサービス開発で重要となるものを基準に、研修の単元を選定しました。&lt;/p&gt;

&lt;p&gt;各研修は次の順で実施しました。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;実施順&lt;/th&gt;
      &lt;th&gt;研修&lt;/th&gt;
      &lt;th&gt;日数&lt;/th&gt;
      &lt;th&gt;担当&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;Webアプリケーション研修(バックエンド)&lt;/td&gt;
      &lt;td&gt;5.5日&lt;/td&gt;
      &lt;td&gt;harachan、shiorin&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;フロントエンド研修&lt;/td&gt;
      &lt;td&gt;4日&lt;/td&gt;
      &lt;td&gt;nacal、てつを&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3&lt;/td&gt;
      &lt;td&gt;モデリング/設計研修&lt;/td&gt;
      &lt;td&gt;3日&lt;/td&gt;
      &lt;td&gt;donokun、はらちゃん&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;インフラ/仮想化研修&lt;/td&gt;
      &lt;td&gt;9日&lt;/td&gt;
      &lt;td&gt;drumato、homirun、n01e0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5&lt;/td&gt;
      &lt;td&gt;SRE研修&lt;/td&gt;
      &lt;td&gt;5日&lt;/td&gt;
      &lt;td&gt;pochy、homirun&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;6&lt;/td&gt;
      &lt;td&gt;セキュリティ研修&lt;/td&gt;
      &lt;td&gt;2日&lt;/td&gt;
      &lt;td&gt;n01e0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;7&lt;/td&gt;
      &lt;td&gt;機械学習＆データエンジニアリング研修&lt;/td&gt;
      &lt;td&gt;2日&lt;/td&gt;
      &lt;td&gt;miyakey&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;去年の新卒エンジニア研修について気になる方は、&lt;a href="https://tech.pepabo.com/2025/10/14/training-2025/"&gt;目指せシニアエンジニア！2025年度新卒エンジニア研修資料を一部公開します&lt;/a&gt;もぜひご覧ください。&lt;/p&gt;

&lt;p&gt;ここからは各研修の講師陣が、それぞれのカリキュラムを紹介します。&lt;/p&gt;

&lt;h2 id="webアプリケーション研修バックエンド"&gt;Webアプリケーション研修(バックエンド)&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;担当: harachan、shiorin&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Webアプリケーション研修(バックエンド)は、新卒エンジニア研修の一番最初のステップとして、harachanとshiorinで担当しました。この研修でまず体感してもらいたかったことは3つあります。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Webアプリケーションを1から作る際にどんな言葉が出てきて何が行われているのかという「索引(インデックス)」を持つこと&lt;/li&gt;
  &lt;li&gt;チーム・複数人で開発を進めるとはどういうことかを知ること&lt;/li&gt;
  &lt;li&gt;AIとともにチーム開発を円滑に進める方法を考えること&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;「MVC」「マイグレーション」「セッション」といった言葉を全く知らない状態から、聞いたときに「ああ、あの辺りの話か」となんとなく思い浮かぶ状態までいければ十分、というくらいの解像度を目指しました。索引を先に持っておけば、その先の研修や配属後に実際に必要になったタイミングで、自分で調べにいけるはずだと考えたためです。&lt;/p&gt;

&lt;p&gt;題材にはRuby on Railsを選びました。20年分のWeb開発のベストプラクティスが型として詰まっているためです。そのRailsチュートリアルを5.5営業日(昨年からおよそ半分に圧縮した日数)で完走することを、受講者全員が満たすべき要件としてセットしました。ただ内容の理解が置き去りにならないよう、Claude Codeとともに実装を進めながらPRを立ててもらいました。そのPRをまず受講者同士でレビューし、そのあと講師である私たちがレビューする、という2段階の体制をとりました。&lt;/p&gt;

&lt;p&gt;受講者同士のレビューでは、誰かと一緒にPRを返しながら開発を進めるとはどういうことか、レビューではどんな観点を見るとよいか、そしてレビューにおける責任を実感してもらうことを狙いました。講師レビューでは、AIがただコードを書いて動いただけの状態にならないよう、「このパターンだったらどうなる？」「どうしてこの実装になっているんだろう？」と問いを投げて理解を深めてもらいました。同時に、普段の業務でどんな観点・コメントの仕方でレビューしているかを見て学び取ってもらうことも意識しました。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/08/05/training-2026/images/web-app-review-01.png" alt="レビューの様子1" /&gt;
&lt;img src="/blog/2026/08/05/training-2026/images/web-app-review-02.png" alt="レビューの様子2" /&gt;
&lt;cite&gt;受講者同士でPRレビューを進めている様子&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;実際にやってみると、8名それぞれが自分の実装を進めながら、レビューという新しいタスクにも同時に向き合うことになり、そのバランスの取り方に苦戦する様子が見られました。&lt;/p&gt;

&lt;p&gt;一番最初に受ける研修だからこそ、与えられた要件を甘く見ずにマストとして完遂する姿勢は、配属後の実務でも重要になると考えました。そこで研修の後半に一度立ち止まってもらい、受講者同士だけで相談する時間を設けました。講師はあえて入らず、残りの日数で完走するにはどうすればいいか、「完走」を具体的にどこに置くか、全員がその要件を満たすにはどうすればいいかを考えてもらいました。伝えた要件は、Railsチュートリアルの最後の章まで到達し、そこで作る機能がすべて動くようにすること、そして冒頭に掲げていたインデックスを少しでも貼れる状態にすることの2つです。&lt;/p&gt;

&lt;p&gt;受講者たちが自分たちで考え直した進め方を、講師陣は「無理のない計画か」「要件を満たしているか」という観点を中心にレビューしました。残りの少ない日数で本当にやり切れるのか、不安に思っている部分は一緒に解消していきました。特に「インデックスを貼れる状態にする」という抽象的な要件は、何を満たせば達成と言えるのかを受講者側と講師側ですり合わせました。そうやってお互いに「これならいける」と思える状態を作ったうえで、残りの日数を進めてもらいました。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/08/05/training-2026/images/webapp-training-estimation-meeting.jpg" alt="受講者たちが集まって見積もり会を行っている様子" /&gt;
&lt;cite&gt;受講者たちが集まって見積もり会を行っている様子&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;この相談の中では、受講者たちから「PRを通じて講師側が身につけてほしいと考えていたスキルはもう掴めたので、ここから先は進捗を優先してレビューを任意にしたい」という提案がありました。当初必須としていた相互レビューについて、求められていたことの本質を自分たちで言語化し、それを踏まえて講師陣と交渉して合意を得て運用を変えたのです。マネージャーなど要件を出した相手と優先順位や認識をすり合わせ、説得して合意を得るという実務そのものの動きを研修の中で経験できていて、講師としてもとても嬉しい出来事でした。&lt;/p&gt;

&lt;p&gt;その結果、全員が昨年の半分の日数である5.5営業日でRailsチュートリアルを完走できました。何より大きかったのは、受け身だった空気が「どうすれば達成できるか」を自分たちで考える能動的なマインドに変わったことです。講師があえて手を引いて、受講者同士で考える時間を作ったことが効いていました。講師側が進め方を決めすぎず、受講者自身に委ねたほうが、双方にとって良い結果につながる。この研修を通して、そういう気づきを得られました。最終日には振り返り会も開き、次の研修にどう向き合うといいかまで自分たちで考えられるようになっていました。新卒エンジニア研修に最終日まで一緒に取り組む「チーム」として、能動的に立ち向かう一歩を踏み出せたと感じています。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/08/05/training-2026/images/webapp-training-instructors.jpg" alt="Webアプリケーション研修を担当したharachanとshiorin" /&gt;
&lt;cite&gt;Webアプリケーション研修を担当したharachanとshiorin&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;Rubyコミュニティに触れるきっかけをつくるため、5/21に開催された&lt;a href="https://railstokyo.connpass.com/event/385285/"&gt;RailsTokyo#4&lt;/a&gt;へ、受講者全員で参加しました。業務時間扱いで参加し、社内外のRubyistと交流する時間も持てました。「コミュニティへ参加する楽しさ」を体感してもらう一歩になったと考えています。&lt;/p&gt;

&lt;p&gt;AIと一緒に開発を進められる時代だからこそ、要件を自分ごととして捉えてチームでやり切る経験を、配属前の早い段階でしてもらえたことには大きな意味がありました。この研修で掴んだ「まずは自分たちで考え抜く」感覚を、今後の研修や実際の業務でも活かしてもらえれば嬉しいです。&lt;/p&gt;

&lt;h2 id="フロントエンド研修"&gt;フロントエンド研修&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;担当: nacal、てつを&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;今年のフロントエンド研修は「最高のフロントエンド」について考えてもらうことをテーマとし、nacalとてつをの2名で、4日間実施しました。&lt;/p&gt;

&lt;p&gt;研修全体のテーマとして「Agentic Engineering時代における作り上げる力」が置かれている中で、AIを活用すれば、ある程度動くフロントエンドは誰でも容易に実装できるようになってきています。その中でフロントエンドを実装するエンジニアとして、最高のプロダクトにするには何をすべきか、何を意識すべきかを考えてもらいます。その周辺知識を入り口に深く潜り込んでいくことを主な目的として、このテーマを設定しました。&lt;/p&gt;

&lt;p&gt;具体的な研修内容としては、SUZURI APIを利用したECサイトのWebフロントエンドを実装してもらい、それぞれ工夫した点や意識したポイントについて発表してもらいました。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/08/05/training-2026/images/frontend-training-ui-review.jpg" alt="成果発表の一例。VLMがブラウザを操作してUIの使いやすさをタスクベースで定量評価し、失敗タスクの分析をもとにAIが生成した添削付きの改善モック画像" /&gt;
&lt;cite&gt;成果発表の一例。VLMにブラウザを操作させてUIをタスクベースで定量評価し、失敗タスクの分析をもとにAIが生成した添削付きの改善モック&lt;/cite&gt;&lt;/p&gt;

&lt;p&gt;受講者には、Webフロントエンド未経験の人もいました。それでも成果発表では、アクセシビリティやWeb Core Vitalsといった定量的な指標を自ら発見して評価項目とする人や、理想のUXから逆算して技術スタックを提案する人が現れました。それぞれが自分のアプローチで「最高のフロントエンド」を考えて実現するところまでやり切ってくれて、短い期間で横断的にフロントエンド領域の理解を深められたと感じています。&lt;/p&gt;

&lt;p&gt;「最高のフロントエンド」に唯一の正解はありません。だからこそ、それぞれが自分なりの切り口で問いを立て、答えにたどり着くまで試行錯誤したこと自体が、フロントエンドに限らずどんな領域でも通用する財産になるはずです。&lt;/p&gt;

&lt;p&gt;AIが「動くもの」を素早く生み出せるようになった今、その先の「最高とは何か」を自ら問い、技術で形にする力の価値は、これまで以上に高まっていくと考えています。この研修が、受講者それぞれにとって「最高のプロダクトとは何か」を追い求め続けるための出発点になれば嬉しいです。&lt;/p&gt;

&lt;h2 id="モデリング設計研修"&gt;モデリング/設計研修&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;担当: donokun、harachan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;設計研修は、ペパボの新卒エンジニア研修で「設計」を独立したコンテンツとして扱う初めての試みです。donokunとharachanの2名で、3日間実施しました。&lt;/p&gt;

&lt;p&gt;配属後の受講者には、コードを書く前に「何をどう変えるか」を文書にまとめ、レビューを通じて合意してから実装に進む、という仕事の進め方が待っています。はじめて「設計を書いてみて」と言われたとき、そもそも何を出せば設計と呼べるのかがわからない。この最初のつまずきをなくすことを研修の目的に置き、ゴールを「Webアプリケーションへの変更の提案を書け、その提案の良し悪しを判断し、レビューを受けて補強できる状態」と定めました。&lt;/p&gt;

&lt;p&gt;研修は「書き方」「考え方・伝え方」の2章で構成しました。&lt;/p&gt;

&lt;p&gt;「書き方」は、設計を表現する道具を身につける章です。静的な構成を表すC4モデルの抽象化レイヤと、動的な振る舞いを表す図（シーケンス図、状態遷移図、データフロー図）を学び、実際に図を描いて講師のフィードバックを受けます。&lt;/p&gt;

&lt;p&gt;「考え方・伝え方」は、表現された設計の良し悪しを判断する章です。品質特性、なかでも変更容易性と複雑さの関係を学びます。Railsチュートリアルで作ったアプリケーションへの機能追加を題材に、提案を書いてレビューを受けて改稿するまでを実践します。&lt;/p&gt;

&lt;p&gt;最終日には、受講者が機能追加の実現方式を複数案比較した提案ドキュメントを書き上げ、レビューを受けて改稿するところまで完走しました。実務で設計を任されたときに何を作ることが求められているのか、その像を持って配属に向かってもらえたはずです。&lt;/p&gt;

&lt;p&gt;配属後、施策を任された際に、この研修で体験した「要件を抽出して設計書に落とし込む」経験が活かせたようで、スムーズに業務を行えたという嬉しいフィードバックもありました。&lt;/p&gt;

&lt;p&gt;課題も見つかりました。演習中、コーディングエージェントの提示する知識や意見に受講者が振り回される、という場面があったのです。AIの出力を自分の評価軸で判断するには、各技術領域の知識がもう少し必要でした。設計研修をエンジニア研修の後半に配置する、演習の題材を実際のプロダクトに近づける、といった改善案を来年に引き継ぎます。&lt;/p&gt;

&lt;script defer="" class="speakerdeck-embed" data-id="0317761caf744c6c9c704fd49229d211" data-ratio="1.7777777777777777" src="//speakerdeck.com/assets/embed.js"&gt;&lt;/script&gt;

&lt;h2 id="インフラ仮想化研修"&gt;インフラ/仮想化研修&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;担当: drumato、homirun、n01e0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src="/blog/2026/08/05/training-2026/images/infra-and-virtualization.png" alt="研修資料のスクリーンショット" /&gt;&lt;/p&gt;

&lt;p&gt;インフラ/仮想化研修は、2年間続いていたコンテナ研修をさらに拡大した、今年度から新しく設計された研修です。drumatoとhomirun、n01e0の3名で、9日間実施しました。&lt;/p&gt;

&lt;p&gt;ソフトウェアエンジニアに求められる貢献のベースラインはAIによって引き上げられました。それぞれのエンジニアは自身の専門性と関心領域についてパフォーマンスを出すだけではなく、自身の価値を提供するまでの一連のサイクルを完遂する、そのためにパフォーマンスの領域を広げる力が求められています。&lt;/p&gt;

&lt;p&gt;そこで、本研修の前提を &lt;strong&gt;今日において、知とパフォーマンスの領域はAIによって広げられる&lt;/strong&gt; とおきました。価値提供に関わるすべてのプロセスに関われるエンジニアとして、不必要にレベルを下げずにインフラ/仮想化技術を学ぶ内容です。&lt;/p&gt;

&lt;p&gt;具体的には、以下のような内容を含む研修として実施されました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;LinuxのNetwork namespaceを利用したネットワーク入門&lt;/strong&gt;。VM上でNetwork namespaceを利用して仮想ホストを構築してもらいました。プロトコルキャプチャによるネットワークプロトコルの理解や、ルーティングの設定を体験してもらいました。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;clone(2)&lt;/code&gt;を利用したミニマムなコンテナランタイム自作&lt;/strong&gt;。受講者それぞれが好きな言語でコンテナランタイムを自作しながら、既存のコンテナランタイムと比較してもらいました。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Virtualization frameworkを利用したVM自作&lt;/strong&gt;。コンテナ技術とのトレードオフを理解してもらうために、Virtualization frameworkを利用したVM作成を体験してもらいました。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Kubernetesクラスタの思想と設計から学ぶ実運用の課題&lt;/strong&gt;。ここまでの技術の整理として、ペパボで主に採用されているKubernetesを仕組みから理解してもらいました。そのうえで、実際のサービス運用における関心と課題を導入しました。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;講義全体を通して意識したことは、技術書やWeb上の記事などで当たり前に解説されているこれらインフラ技術について考察してもらうということでした。「このような技術がないとどう困るだろうか」「この技術は何を解決し、どのような関心を持ち込んだか」といった観点です。AI時代において特に重要なのは課題抽出ができ、意図が説明できることです。この具体的なシミュレーションとして適切な題材だったと考えています。&lt;/p&gt;

&lt;p&gt;研修後半では、受講者それぞれに作りたいものを考えてもらい、講師と議論しながら1つのレポートを完成させてもらいました。テーマはAIエージェント用のサンドボックス/ロードバランサ/eBPFイベントトレース/PyTorch Compiler/Gateway API Controller/楕円曲線暗号など多彩でした。それぞれの技術特性と解決される課題について議論できました。&lt;/p&gt;

&lt;h2 id="sre研修"&gt;SRE研修&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;担当: pochy、homirun&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;過去二年間&lt;a href="https://tech.pepabo.com/2024/07/23/o11y-training-2024/"&gt;「オブザーバビリティ研修」&lt;/a&gt;として続いた枠でしたが、今年は満を持して「SRE研修」と名付けました。&lt;/p&gt;

&lt;p&gt;その内容は「障害対応100本ノック」としました。この節では、実施した内容と設計意図、その効果を記します。&lt;/p&gt;

&lt;h3 id="障害対応100本ノックの概要"&gt;障害対応100本ノックの概要&lt;/h3&gt;

&lt;p&gt;この研修では、受講者には5営業日の間、システムの管理者として障害対応をしつづけることを求めました。&lt;/p&gt;

&lt;p&gt;この研修で実施したことを説明します。準備として講師は研修用のKubernetesクラスタを用意し、その上に3つのサービスを稼働させました。このとき、それぞれのサービスには主に可用性の観点で脆弱な実装やアーキテクチャを複数（100個）仕込みました。また、それぞれのサービスのソースコードを管理するリポジトリは用意したものの、リリースフローの自動化やIaCは不十分な状態としました。
研修初日に、受講者にそのクラスタへのadmin権限を与えました。講師は5営業日間、事前に仕込んだ障害が起きるようにシステムに負荷をかけ続けました。受講者には、比喩や誇張ではなく実際に100本の障害へ対応することを求めました。&lt;/p&gt;

&lt;p&gt;この研修においてはロールプレイとして、受講者はそのシステムの管理者であるとしました。また、講師2名は、「Slackにはいるものの、たまたま2名とも遠くへ旅行に行っている」という設定にしました。すなわち、「詳しい人」は実際に作業できないが、「現場にいて権限がある人」がなんとかして障害へ対応しなければいけない、という状況設定です。&lt;/p&gt;

&lt;p&gt;また、座学用のテキストも用意しました。ただ状況を与えるだけでは何をしたらいいか分からなくなる懸念があったので、リリース自動化や監視、可観測性の整備などに必要な知識はテキストファイルにして読める場所に配置しておきました。具体的にはArgoCD、GitHub Actions、Grafanaなどの技術要素と、それらが必要になる背景を読み物として執筆しました。&lt;/p&gt;

&lt;p&gt;5営業日の最後の2時間程度は、ポストモーテムの時間としました。起きた障害に対して、何が起きていたのか、何がうまくいったか、次はどのようにしたらもっとうまくできるかを講師がファシリテーションしつつ受講者間で議論してもらいました。&lt;/p&gt;

&lt;h3 id="設計意図"&gt;設計意図&lt;/h3&gt;

&lt;p&gt;この研修は、ロールプレイによる実践を主として、SREのプラクティスをその身を以て学ぶことを目的としました。&lt;/p&gt;

&lt;p&gt;研修設計を考える際に主にインスパイアされたのは&lt;a href="https://www.theregister.com/software/2026/03/19/fixing-claude-with-claude-anthropic-reports-on-ai-sre/5224819"&gt;AnthropicによるSREについての報告記事&lt;/a&gt;でした。曰く、複雑なシステムで根本原因を究明するためには「痛い目に遭う」必要があるとのことです。生成AI技術が全盛を極め続けるこの時代においては、知りたい技術や知識は湯水のように手に入ります。だからといって、SREで必要なトラブルシューティング能力がすぐさま手に入るわけではないと私は感じています。そこで、ひたすら「痛い目に遭う」ような研修を思いつきました。&lt;/p&gt;

&lt;p&gt;3サービスは、講師がそれまでにペパボの中で運用を担当したサービスの中から、アーキテクチャが異なる3つを模したものにしました。様々なアーキテクチャのシステムに立ち向かえる能力を養うことと、実務ですぐに使える知識を獲得することがこの決定の目的です。&lt;/p&gt;

&lt;p&gt;仕込んだ障害には、すべて過去にペパボの中で起きた障害の機序を使いました。ペパボの中ではポストモーテム文化が浸透しているため、過去の障害履歴を参照することが容易です。起こした障害の例は次のとおりです。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;大量のN+1クエリによるレスポンス遅延&lt;/li&gt;
  &lt;li&gt;キャッシュ戦略の不備による参照データの競合&lt;/li&gt;
  &lt;li&gt;k8s podのliveness probeの瑕疵による可用性毀損&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;パフォーマンス問題やデータ不整合問題などさまざまなレイヤの問題を配置することによって、原因究明のために可観測性技術を習得しなければいけない状況を作りました。
受講者には研修が完了した後に、起きた障害はすべて過去のポストモーテムから引用したものであることを伝えました。自分たちの対応が実務に繋がるものだと伝えること、そしてペパボのポストモーテム文化を知ってもらうことを目的とした設計です。&lt;/p&gt;

&lt;p&gt;リリースフローの自動化やIaCが不徹底な状態から取り組んでもらったのは、ソフトウェアの偉大さを感じてもらうためです。SREのプラクティスの1つに、トイルの解消があります。自動化が何もない状況で障害対応をしてもなかなか捗りません。そこで、最初に対応が遅くなったとしても少しずつ自動化を進めることで、時間のレバレッジによって最終的には多量の成果を出すことができる経験をしてもらいたいという意図を込めました。&lt;/p&gt;

&lt;p&gt;受講者8名に対して3サービスを用意した意図は、実際の運用で頻発するコミュニケーションを実践してもらうためです。3サービスが与えられたことで、受講者は自然と3チームに分かれました。しかし3サービスはKubernetesクラスタやDBインスタンス、ArgoCDサーバーを共有する構成になっていました。すなわち、「複数サービスが共有しているリソースがボトルネックになる」事象が発生します。これによって、チーム間でどのようにコミュニケーションをとってボトルネックの解消に取り組むと効果が出やすいのか、あるいは出にくいのかを体感してもらえる研修になりました。ペパボのエンジニア組織が掲げるバリューの1つに「&lt;a href="https://tech.pepabo.com/engineers/#value:~:text=%E3%81%97%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99%E3%80%82-,%E3%81%99%E3%81%B9%E3%81%A6%E3%81%8C%E8%87%AA%E5%88%86%E3%81%94%E3%81%A8,-%E3%83%81%E3%83%BC%E3%83%A0%E3%81%AE%E8%AA%B2%E9%A1%8C"&gt;すべてが自分ごと&lt;/a&gt;」というフレーズがあります。担当サービスの外だからという理由で誰かが直してくれるのを祈っているだけでは何も解決しないので、どうにかして直そうとする姿勢が必要であると知る機会にしてほしいという意図も込めました。&lt;/p&gt;

&lt;h3 id="効果"&gt;効果&lt;/h3&gt;

&lt;p&gt;概ね、期待した効果が得られたのではないかと考えています。&lt;/p&gt;

&lt;p&gt;講師として印象的だったのは、チーム間での越境の動きです。チーム分けは講師が明示したものではなく自然発生的にできたものではあるものの、一度決まったチームを越境することは心理的な負担が大きいと私は思っています。そのような中で、共有リソースがボトルネックだと見抜いてチューニングに取り組む動きや、あるアプリケーションでのチューニング方法を受講者間で共有する動きが見えました。これは頼もしかったです。また、講師がちらっと口に出した「推測するな、計測せよ」や「監視で気付けない障害が起きていたら負け」といったフレーズがふりかえりの際に受講者の口からも出てきたことは講師冥利に尽きるなあと思いました。&lt;/p&gt;

&lt;p&gt;一方で、AI時代と言っても、5営業日で100本は多いということもわかりました。実際に解消された障害は50本程度で、他は検知されつつも直しきれなかったり、そもそも検知すらされない障害もありました。次からは50本ノックに調整してもよさそうです。&lt;/p&gt;

&lt;h2 id="セキュリティ研修"&gt;セキュリティ研修&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;担当: n01e0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;今年もセキュリティ研修を担当しました。セキュリティ対策室のn01e0です。日程は色々あって2日間での開催となりました。&lt;a href="https://tech.pepabo.com/2025/10/14/training-2025/#%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E7%A0%94%E4%BF%AEn01e0"&gt;昨年&lt;/a&gt;と大きな違いはありませんが密度が高くなっています。&lt;/p&gt;

&lt;p&gt;期間が短い為、座学はほとんど設けず導入にとどめ、ほとんどの時間をハンズオンに費やしてもらいました。&lt;/p&gt;

&lt;h3 id="セキュリティレビュー体験"&gt;セキュリティレビュー体験&lt;/h3&gt;

&lt;p&gt;初日は他のイベントも重なっており、作業時間があまり多く取れなかったため、セキュリティレビュー体験を行いました。
Webアプリケーション研修で作成したリポジトリに対し、事前に私が作成した脆弱性のある機能追加のPRをレビューしてもらいました。&lt;/p&gt;

&lt;p&gt;今回は受講者が8人いる為、それぞれのリポジトリに対して異なる機能や様々な脆弱性を埋め込むのが講師としての課題でした。しかし研修目的だと言うと、Agentもすんなり受け入れて意図的な脆弱性を埋め込んでくれました。気を使ってくれたのか、指示していない・意図していない脆弱性もいくつか埋め込んでくれました。助かります。&lt;/p&gt;

&lt;p&gt;セキュリティレビューはもちろんですが、コードレビューをする際、「どのような意思決定の結果、マージされるコードが出来上がったのか」が大事だと私は考えています。&lt;/p&gt;

&lt;p&gt;そのため、今回の研修では「一度出したPRを修正するのが面倒なVibe Coder」として振る舞い、以下の点を明示してもらうようにしました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;具体的に脆弱性の原因となっているコード&lt;/li&gt;
  &lt;li&gt;なぜそれが脆弱性となるのか&lt;/li&gt;
  &lt;li&gt;どういったリスクがあるのか&lt;/li&gt;
  &lt;li&gt;悪用の可能性はあるのか&lt;/li&gt;
  &lt;li&gt;どのように修正すれば良いのか&lt;/li&gt;
  &lt;li&gt;どんなテストを追加すればよいのか&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;これは暗に「PRの裏に相手のCoding Agentがいる」という現状に合わせた内容でもあります。レビューでの指摘を具体的にするほど、相手のAgentにも具体的な指示を伝えられます。&lt;/p&gt;

&lt;h3 id="脆弱性診断の体験"&gt;脆弱性診断の体験&lt;/h3&gt;

&lt;p&gt;昨年に引き続き、Rails製の脆弱なやられアプリの脆弱性を発見・修正してもらうハンズオンです。&lt;/p&gt;

&lt;p&gt;私が目指す&lt;a href="https://tech.pepabo.com/2026/03/19/ai-and-security-team/#%E3%81%BE%E3%81%A8%E3%82%81"&gt;脆弱性診断の民主化&lt;/a&gt;を意識し、ツールやテクニックは共有しつつ、受講者主体で動いてもらいました。
既にAgentの使い方が身についており、続々と大量の脆弱性を発見してくれます。教える事など無いんじゃないか？と感じる所ですが、先輩風を吹かせたいので頑張ります。&lt;/p&gt;

&lt;p&gt;「XSSを見つけた」「LFIを見つけた」という所までは、Agentに簡単な指示を出すだけでもできます。しかし診断では「それらの脆弱性に最大でどういうリスクがあるのか」を理解・説明しなければなりません。そのうえで一般論でなく、今回のアプリケーションにおける具体的な攻撃を考えて、対応の優先度や方針を決めます。&lt;/p&gt;

&lt;p&gt;「LFIで本来見えないはずのファイルが見える」で終わらせないことを意識してもらいました。「LFIで&lt;code&gt;/proc/self/env&lt;/code&gt;を見るとDBのクレデンシャルが書いてある」「XSSで管理者アカウントのCookieを窃取できる」「マウントされたホストのディレクトリを操作できる」。こういった事を実際に動かしながら理解してもらえて良かったです。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/08/05/training-2026/images/security.png" alt="マウントされたホストのディレクトリを消し飛ばしている様子" /&gt;&lt;/p&gt;

&lt;h2 id="機械学習データエンジニアリング研修"&gt;機械学習＆データエンジニアリング研修&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;担当: miyakey&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ペパボ研究所のmiyakeyです。機械学習＆データエンジニアリング研修として「AIエージェントを前提としたサービス設計と開発」を担当しました。&lt;/p&gt;

&lt;p&gt;例年この研修では機械学習やAI活用の基礎知識を扱ってきましたが、今年はAIエージェントがサービス体験そのものを形づくり、開発プロセスの前提にもなりつつある状況を受けて内容を大きく組み替えました。狙いは、AIエージェントを「サービス体験の一部」として設計できること、そしてコーディングエージェントと協働しながら「何を作るか・何を満たすべきか」を自分たちで定義できることの2つです。AIから成果物を受け取るだけでなく、AIが投げかけてくる問いを受け取り、自分たちの判断基準を更新していける力を、設計と開発の両面から鍛えることを目的としました。&lt;/p&gt;

&lt;p&gt;演習では、二人一組で「Webサービス内でユーザーまたは運用者を支援するAIエージェント機能を1つ設計・実装する」ことに取り組んでもらいました。作るものは完成度の高いアプリではなく、エージェントの振る舞いとその機序を説明できるプロトタイプです。設計にあたっては3つの要件を明示し、そのまま最終発表の評価軸としました。AIチャットや単純な自動化ではなくエージェントとして実現する理由があること（必然性）。Tool実行・状態保持・ワークフローとして実装可能であること（実現性）。どんな入力になぜそのToolを使い、なぜその応答になるのかを説明できること（了解性）です。「動くもの」はAIの力で誰でも作れる時代だからこそ、その振る舞いに必然性があり、機序を人間が説明できるかを問うことに重きを置いています。&lt;/p&gt;

&lt;p&gt;実装は、Google Agent Development Kit（ADK）でToolを実装し、LM Studio上のローカルLLMに接続する制約を課しました。あえて手元のローカルモデルで動かすのは、強力なモデルなら雑な設計でもそれらしく動いてしまうためです。限られた性能で意図通りに動かそうとすると、Toolの切り分けや状態の持たせ方といった設計そのものと向き合わざるを得ません。エージェントの機序を自分の手で理解してもらううえで、この制約はよく効いたと感じています。&lt;/p&gt;

&lt;p&gt;2日間の研修はDay1で設計とレビュー、Day2で実装・評価・発表という構成とし、「何を満たすべきか」を先に言語化する流れを体験してもらいました。最終発表では、設計と機序の理解を一緒に確かめました。短い期間ながら各チームが「この場面でこそエージェントが要る」という必然性を自分の言葉で説明し、ローカルLLMとToolで振る舞いを形にし、なぜそう動くのかを説明しきってくれました。この経験を、配属後のサービス開発でAIや機械学習を積極的に活用していく土台にしてもらえれば嬉しいです。&lt;/p&gt;

&lt;h2 id="おわりに"&gt;おわりに&lt;/h2&gt;

&lt;p&gt;2026年度の新卒エンジニア研修は、「Agentic Engineering時代における作り上げる力」をテーマに実施しました。AIとの開発が当たり前になった時代で求められる力を、各講師がそれぞれの領域で問い直すカリキュラムとなりました。&lt;/p&gt;

&lt;p&gt;振り返ってみると、どの研修にも共通していたのは、AIの出力をそのまま受け取るのではなく、その背後にある技術体系や課題を自分の言葉で説明できることを受講者に求めていた点です。Railsチュートリアルを自分たちで計画を立て直して完走する、障害対応で「痛い目に遭う」、エージェントの振る舞いの必然性と機序を説明しきる。形は違えど、「動くものはAIが作れる。ではエンジニアは何に責任を持つのか」という問いに、受講者たちは正面から向き合ってくれました。&lt;/p&gt;

&lt;p&gt;この記事が、新卒エンジニア研修の設計のヒントになれば幸いです。&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>ロリポップ！ゼロトラストリンクがLinuxとESP32に対応したのでStackChanで試してみる</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/08/03/stackchan-zero-trust-link/"/>
    <id>https://tech.pepabo.com/2026/08/03/stackchan-zero-trust-link/</id>
    <published>2026-08-03T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>kentaro</name>
    </author>
    <content type="html">&lt;p&gt;人とAIを安全につなぐGMOペパボの新サービス「&lt;a href="https://ztna.lolipop.jp/"&gt;ロリポップ！ゼロトラストリンク&lt;/a&gt;」の、Linux版とESP32版クライアントを公開しました。&lt;/p&gt;

&lt;p&gt;これまでクライアントがあったのはmacOS、Windows、iOS、Androidで、どれも人が手で触る端末です。今回のLinux版とESP32版のリリースで、サーバとマイコンが加わりました。&lt;/p&gt;

&lt;ol id="markdown-toc"&gt;
  &lt;li&gt;&lt;a href="#pcからサーバiotデバイスまで" id="markdown-toc-pcからサーバiotデバイスまで"&gt;PCからサーバ、IoTデバイスまで&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#これまでのiotのつなぎ方との違い" id="markdown-toc-これまでのiotのつなぎ方との違い"&gt;これまでのIoTのつなぎ方との違い&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#stackchanに喋らせる" id="markdown-toc-stackchanに喋らせる"&gt;StackChanに喋らせる&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#esp32版の使い方" id="markdown-toc-esp32版の使い方"&gt;ESP32版の使い方&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#依存を追加する" id="markdown-toc-依存を追加する"&gt;依存を追加する&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#接続情報を用意する" id="markdown-toc-接続情報を用意する"&gt;接続情報を用意する&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#つなぐ" id="markdown-toc-つなぐ"&gt;つなぐ&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#通信する" id="markdown-toc-通信する"&gt;通信する&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#aiエージェントクラウドとつなぐ" id="markdown-toc-aiエージェントクラウドとつなぐ"&gt;AIエージェントクラウドとつなぐ&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#おわりに" id="markdown-toc-おわりに"&gt;おわりに&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="pcからサーバiotデバイスまで"&gt;PCからサーバ、IoTデバイスまで&lt;/h2&gt;

&lt;p&gt;ロリポップ！ゼロトラストリンク（以下、ZTL）は、離れた場所にある機器同士をポート公開なしで直接つなぐサービスです。参加した機器にはネットワーク内だけで通用するIPアドレスが振られ、そのアドレスで相手に届きます。&lt;/p&gt;

&lt;p&gt;Linux版はサーバやコンテナ用で、systemdで常駐させます。root権限もカーネルのTUNデバイスも使わないモードがあるので、権限を絞ったコンテナの中でも動かせます。&lt;/p&gt;

&lt;p&gt;ESP32版はESP-IDFのコンポーネントです。後述する通り、既存のファームウェアに依存を1つ足して関数を3つ呼ぶと、マイコン自身がネットワークに参加します。センサーやロボットにESP32が載ってさえいれば、開発機や本番サーバと対等に通信できます。なお、ESP32版は実験的な位置づけでのリリースです。&lt;/p&gt;

&lt;h2 id="これまでのiotのつなぎ方との違い"&gt;これまでのIoTのつなぎ方との違い&lt;/h2&gt;

&lt;p&gt;IoTデバイスとクラウドのやり取りには、向きが2つあります。デバイスがクラウドのサービスを呼ぶ向きと、外からデバイスへ指示を送る向きです。&lt;/p&gt;

&lt;p&gt;前者の定番は、クラウド側のサービスをインターネットへ公開し、デバイスに持たせたAPIキーで認証する構成です。世界中から届く窓口を開けておいて、キーの強さだけで守ることになります。後者は、NATの内側のデバイスに外から直接は届かないので、双方がMQTTのようなブローカーへ接続しておき、そこ経由で指示を押し込むのが定番です。どちらの向きでも、インターネットへの公開か、中継サービスの追加が要ります。&lt;/p&gt;

&lt;p&gt;ゼロトラストリンクではデバイス自身がネットワークに参加するので、どちらの向きもアドレス指定の直接通信になります。デバイスはNATの内側のまま、ネットワーク上の他のノードから届くようになります。クラウド側のサービスも、インターネットに公開しておく必要がなくなります。&lt;/p&gt;

&lt;p&gt;アクセス許可の単位は通信元のアドレスではなく、認証されたデバイスです。ZTLのダッシュボードからデバイスを一元管理できます。&lt;/p&gt;

&lt;h2 id="stackchanに喋らせる"&gt;StackChanに喋らせる&lt;/h2&gt;

&lt;p&gt;Linux版とESP32版のクライアントの利用例として、&lt;a href="https://docs.m5stack.com/en/StackChan/"&gt;StackChan&lt;/a&gt;を題材に説明します。StackChanとは、オープンソース発の卓上ロボットで、今回はM5Stackの公式キット（型番K151）を組み立てて使いました。制御部はキット同梱のM5Stack CoreS3で、ファームウェアはここに書き込みます。ソースは&lt;a href="https://github.com/kentaro/rino-stackchan"&gt;kentaro/rino-stackchan&lt;/a&gt;にあります。&lt;/p&gt;

&lt;div style="text-align: center;"&gt;
  &lt;video width="90%" controls=""&gt;
    &lt;source src="/blog/2026/08/03/stackchan-zero-trust-link/stackchan-promo.mp4" type="video/mp4" /&gt;&amp;lt;/source&amp;gt;
    ※現在の環境は動画再生に対応していません
  &lt;/video&gt;
&lt;/div&gt;

&lt;p&gt;&lt;img src="/blog/2026/08/03/stackchan-zero-trust-link/architecture.png" alt="入出力だけのデバイスと、トンネル越しのホストの構成図" width="90%" style="display:block;margin:0 auto" /&gt;&lt;/p&gt;

&lt;p&gt;StackChanを上図の構成で動かします。StackChanは話しかけられた声をマイクで拾ってZTLのESP32版クライアントを通じてネットワークに流し、返ってきたAIエージェントによる発話音声を鳴らします。音声認識、発話内容の生成、音声合成をするサーバはネットワークの向こう側にあって、ZTLのLinux版クライアントを通じてつながっています。&lt;/p&gt;

&lt;h2 id="esp32版の使い方"&gt;ESP32版の使い方&lt;/h2&gt;

&lt;p&gt;ESP32版の使い方について、より詳しく見ていきましょう。&lt;/p&gt;

&lt;h3 id="依存を追加する"&gt;依存を追加する&lt;/h3&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight shell"&gt;&lt;code&gt;idf.py add-dependency &lt;span class="s2"&gt;"gmo-pepabo/lolipop-ztl"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;導入は簡単で、上記の通りで&lt;code&gt;lolipop-ztl&lt;/code&gt;コンポーネントとその下で動く通信の実装が入ります。コンポーネントは&lt;a href="https://components.espressif.com/components/gmo-pepabo/lolipop-ztl"&gt;ESP Component Registry&lt;/a&gt;で公開しています。&lt;/p&gt;

&lt;h3 id="接続情報を用意する"&gt;接続情報を用意する&lt;/h3&gt;

&lt;p&gt;ZTLネットワークへ参加するには、デバイスごとの接続情報が必要です。デバイス認証フロー（&lt;a href="https://datatracker.ietf.org/doc/html/rfc8628"&gt;RFC 8628&lt;/a&gt;のOAuth 2.0 Device Authorization Grant）を通じて接続情報を入手します。テレビでサブスクにログインするとき、画面のコードをスマホで入力するあの方式です。&lt;/p&gt;

&lt;p&gt;デバイスは起動するとまず保存済みの接続情報を探します。なければサービスへコードを要求し、受け取ったコードと承認URLを画面に出します。ユーザーがブラウザの承認ページでコードを入力すると、ポーリングしていたデバイスに接続情報が発行され、デバイスはそれを保存して接続します。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/08/03/stackchan-zero-trust-link/device-auth.png" alt="デバイス認証フローのシーケンス図" width="90%" style="display:block;margin:0 auto" /&gt;&lt;/p&gt;

&lt;p&gt;コードにするとこうなります。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;ztl_config_t&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ztl_config_resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 初回起動。接続情報がまだ無いのでデバイス認証を実行する。&lt;/span&gt;
    &lt;span class="c1"&gt;// 起動直後は回線が未確立なことがあるため、成功するまで繰り返す&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;run_device_auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;vTaskDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdMS_TO_TICKS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;ztl_config_resolve&lt;/code&gt;が保存済みの接続情報を読みます。2回目以降の起動はこれだけです。&lt;/p&gt;

&lt;p&gt;初回だけ&lt;code&gt;ztl_device_auth&lt;/code&gt;を呼びます。コードの表示手段はデバイスごとに違うので、そこはコールバックでホストアプリに任される作りです。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;device_auth_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;user_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                               &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;verification_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                               &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;verification_uri_complete&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                               &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cb_arg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verification_uri_complete&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;verification_uri_complete&lt;/span&gt;
                                                    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;verification_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;face_show_auth_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 画面にQRコードとコードを出す&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;run_device_auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ztl_config_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;esp_err_t&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ztl_device_auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rino-stackchan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"esp32 (ESP-IDF v5.4)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                    &lt;span class="n"&gt;device_auth_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;face_hide_auth_code&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ESP_OK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;StackChanには画面があるので、今回作成した例では、デバイス承認のためのURLのQRコードと確認用コードを画面に表示しました。ユーザーがそれをスマホで読んで承認する流れです。画面のないデバイスなら、シリアルログにコードを出すことになります。&lt;/p&gt;

&lt;h3 id="つなぐ"&gt;つなぐ&lt;/h3&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;ztl_callbacks_t&lt;/span&gt; &lt;span class="n"&gt;cbs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state_cb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;on_state_change&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;ztl_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ztl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ztl_connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cbs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;state_cb&lt;/code&gt;が状態変化のコールバックです。StackChanを用いた今回の例では、接続中や再接続中を画面のステータス行に出しています。&lt;/p&gt;

&lt;h3 id="通信する"&gt;通信する&lt;/h3&gt;

&lt;p&gt;つながったら、相手のアドレスを指定して通信します。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TCP&lt;/span&gt;
&lt;span class="n"&gt;ztl_tcp_socket_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ztl_tcp_connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ztl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host_ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// UDP&lt;/span&gt;
&lt;span class="n"&gt;ztl_udp_socket_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;usock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ztl_udp_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ztl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ztl_udp_set_rx_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_udp_rx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;トンネルを通るのは、この&lt;code&gt;ztl_*&lt;/code&gt;のAPIで書いた通信だけです。ふだんのlwIPソケットは従来どおりの経路に出るため、&lt;code&gt;esp_http_client&lt;/code&gt;もトンネル越しには使えません。StackChanが叩く先はどれもHTTPなので、このTCPのAPIの上に、ヘッダを組んで&lt;code&gt;Content-Length&lt;/code&gt;ぶん読み切るだけの小さなクライアントを書きました。&lt;/p&gt;

&lt;p&gt;UDPは、ネットワーク上の他のノードから話しかけるための口として開けています。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"こんにちは、調子はどう？"&lt;/span&gt; | nc &lt;span class="nt"&gt;-u&lt;/span&gt; &amp;lt;デバイスのアドレス&amp;gt; 9000
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;これをMacから打つと、返事が声で返ってきます。&lt;/p&gt;

&lt;p&gt;コードの全体は&lt;a href="https://github.com/kentaro/rino-stackchan"&gt;kentaro/rino-stackchan&lt;/a&gt;にあります。CoreS3でつまずいた設定（PSRAMやTLSのバッファ、マイクとスピーカーがI2Sのクロックを共有する件）もREADMEに書いてあります。&lt;/p&gt;

&lt;h2 id="aiエージェントクラウドとつなぐ"&gt;AIエージェントクラウドとつなぐ&lt;/h2&gt;

&lt;p&gt;StackChanのAIとしての本体は、&lt;a href="https://lolipop.jp/ai/agent-cloud/"&gt;ロリポップ！AIエージェントクラウド&lt;/a&gt;で動いているHermes Agentです。ロリポップ！AIエージェントクラウドは、OpenClawやHermes Agentをクラウド上で動かせるサービスです。クラウドでの常時稼働と安全性を両立するために、外部にポートを公開せず、操作はWebの管理画面かSlackやDiscordなどのチャットに限っています。&lt;/p&gt;

&lt;p&gt;そこでZTLの出番です。筆者は、Hermes Agentの動く環境にLinux版クライアントを入れて、ZTLのネットワークへ参加させています。参加すると、環境の中で&lt;code&gt;127.0.0.1&lt;/code&gt;に閉じたままのチャットAPIや音声合成が、同じネットワークのStackChanからはアドレス指定で見えるようになります。どこにもポートを開けないまま、机の上のロボットとクラウドのAIが会話しています。&lt;/p&gt;

&lt;h2 id="おわりに"&gt;おわりに&lt;/h2&gt;

&lt;p&gt;Linux版とESP32版で、「ロリポップ！ゼロトラストリンク」はPCやモバイルだけでなく、サーバからIoTデバイスまで使えるようになりました。ESP32版の導入は簡単です。手元にESP32があれば、ぜひ何かひとつZTLのネットワークを通じてつないでみてください。&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>集約ジョブパターンで解決するモノレポCIのrequired check問題</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/07/03/monorepo-ci-orchestrator-migration/"/>
    <id>https://tech.pepabo.com/2026/07/03/monorepo-ci-orchestrator-migration/</id>
    <published>2026-07-03T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>litencatt</name>
    </author>
    <content type="html">&lt;p&gt;こんにちは！ロリポップ・ムームードメイン事業部ムームードメイングループの中村(&lt;a href="https://x.com/litencatt"&gt;@litencatt&lt;/a&gt;）です。&lt;/p&gt;

&lt;p&gt;今回は、ムームードメインのモノレポで運用していたCIの統合ゲート（複数のCIの結果を1つの required check に集約する仕組み）を、GitHub Actionsネイティブの集約ジョブ方式に移行した話です。約500行のカスタムJavaScript + checks API + ポーリングで構成されていたプロキシ方式を廃止して、保守対象のコードは約335行に、障害モードは6種類まるごと消えました。&lt;/p&gt;

&lt;p&gt;集約ジョブ方式は GitHub Actions の標準機能のみで構成されており、GitHub.com / GHES（GitHub Enterprise Server）を問わず、どのランナー環境でも導入できます。&lt;/p&gt;

&lt;h3 id="tldr"&gt;TL;DR&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;モノレポで &lt;code&gt;paths:&lt;/code&gt; フィルタ付き CI を required check（マージ前に成功が必須のチェック）にすると、該当パスに変更がない PR で skipped のまま永久ブロックされる（GitHub の既知制約）&lt;/li&gt;
  &lt;li&gt;これを解決するために checks API + ポーリングのプロキシを自前実装していたが、~500行のコードに6種の障害モードを抱える状態に&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;needs&lt;/code&gt; + &lt;code&gt;if: always()&lt;/code&gt; の集約ジョブパターン&lt;/strong&gt;に移行し、プラットフォームのネイティブ機能だけで required check 問題を解消&lt;/li&gt;
  &lt;li&gt;既存 CI は &lt;code&gt;workflow_call&lt;/code&gt;（再利用可能ワークフロー呼び出し）で再利用し、コードの重複なく移行&lt;/li&gt;
  &lt;li&gt;移行時の注意点: concurrency group 衝突、&lt;code&gt;workflow_run&lt;/code&gt; 非発火、check run 名の変化&lt;/li&gt;
&lt;/ul&gt;

&lt;ol id="markdown-toc"&gt;
  &lt;li&gt;&lt;a href="#tldr" id="markdown-toc-tldr"&gt;TL;DR&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#背景モノレポciのrequired-check問題" id="markdown-toc-背景モノレポciのrequired-check問題"&gt;背景：モノレポCIの「required check問題」&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#ci統合ゲートの変遷" id="markdown-toc-ci統合ゲートの変遷"&gt;CI統合ゲートの変遷&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#第0世代個別ciをrequired-checkに登録2026年3月" id="markdown-toc-第0世代個別ciをrequired-checkに登録2026年3月"&gt;第0世代：個別CIをrequired checkに登録（〜2026年3月）&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#第1世代ポーリング方式のプロキシ2026年34月" id="markdown-toc-第1世代ポーリング方式のプロキシ2026年34月"&gt;第1世代：ポーリング方式のプロキシ（2026年3〜4月）&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#第2世代イベント駆動化2026年6月" id="markdown-toc-第2世代イベント駆動化2026年6月"&gt;第2世代：イベント駆動化（2026年6月）&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#理想形の発見とpoc" id="markdown-toc-理想形の発見とpoc"&gt;理想形の発見とPoC&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#pocで動作検証" id="markdown-toc-pocで動作検証"&gt;PoCで動作検証&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#新アーキテクチャci-オーケストレータ" id="markdown-toc-新アーキテクチャci-オーケストレータ"&gt;新アーキテクチャ：CI オーケストレータ&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#主要な設計決定" id="markdown-toc-主要な設計決定"&gt;主要な設計決定&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#実装で遭遇した落とし穴と解決" id="markdown-toc-実装で遭遇した落とし穴と解決"&gt;実装で遭遇した落とし穴と解決&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#concurrency-group-衝突問題" id="markdown-toc-concurrency-group-衝突問題"&gt;concurrency group 衝突問題&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#workflow_run-が発火しなくなる問題" id="markdown-toc-workflow_run-が発火しなくなる問題"&gt;workflow_run が発火しなくなる問題&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#check-run-名の変化" id="markdown-toc-check-run-名の変化"&gt;check run 名の変化&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#変更検知の整合性" id="markdown-toc-変更検知の整合性"&gt;変更検知の整合性&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#移行結果の比較" id="markdown-toc-移行結果の比較"&gt;移行結果の比較&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#他プロジェクトへの導入ガイド" id="markdown-toc-他プロジェクトへの導入ガイド"&gt;他プロジェクトへの導入ガイド&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#1-各-ci-の-on-を-workflow_call-に変更" id="markdown-toc-1-各-ci-の-on-を-workflow_call-に変更"&gt;1. 各 CI の &lt;code&gt;on:&lt;/code&gt; を &lt;code&gt;workflow_call&lt;/code&gt; に変更&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#2-オーケストレータを作成" id="markdown-toc-2-オーケストレータを作成"&gt;2. オーケストレータを作成&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#3-required-check-を-aggregate-に切り替え" id="markdown-toc-3-required-check-を-aggregate-に切り替え"&gt;3. required check を &lt;code&gt;aggregate&lt;/code&gt; に切り替え&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#注意点" id="markdown-toc-注意点"&gt;注意点&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#まとめ" id="markdown-toc-まとめ"&gt;まとめ&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="背景モノレポciのrequired-check問題"&gt;背景：モノレポCIの「required check問題」&lt;/h2&gt;

&lt;p&gt;ムームードメインのモノレポでも、ご多分に漏れずこの問題にぶつかりました。&lt;/p&gt;

&lt;p&gt;GitHub Actions では &lt;code&gt;paths:&lt;/code&gt; フィルタを使って「特定ディレクトリに変更があったときだけCIを実行する」ことができます。しかし、その CI をブランチ保護の required status check（PRをマージする前に成功が必須となるチェック）に登録すると、該当パスに変更がない PR では CI が起動せず skipped のまま永久にマージブロックされます。これは &lt;a href="https://github.com/orgs/community/discussions/44490"&gt;GitHub Community でも長く議論されている既知の制約&lt;/a&gt;です。&lt;/p&gt;

&lt;p&gt;この制約に対する一般的な対処パターンは3つあります。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;変更検知 + 集約ジョブ&lt;/strong&gt;: 変更検知ジョブで対象サービスを判定し、条件付きで各CIを起動。末尾の集約ジョブ（&lt;code&gt;needs&lt;/code&gt; + &lt;code&gt;if: always()&lt;/code&gt;）で全体の合否を出し、これを required check にする。今回採用した方式&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;常時起動するプロキシジョブ&lt;/strong&gt;: 常に起動するジョブが checks API（GitHub が提供する、チェック結果を外部から作成・更新できる REST API）やポーリングで各CIの結果を収集し、1つのチェック結果にまとめる。柔軟だが複雑化しやすい&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;全CIを常時起動&lt;/strong&gt;: 全CIを毎回起動し、内部で skip 判定する。シンプルだがランナーのリソースを無駄に消費する&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;ムームードメインのモノレポには複数の言語・フレームワークで構成されたサービスが共存しており、それぞれに独立した CI ワークフローがあります。この記事では、パターン2（プロキシ方式）を採用してから最終的にパターン1（集約ジョブ方式）に移行するまでの変遷を紹介します。&lt;/p&gt;

&lt;h2 id="ci統合ゲートの変遷"&gt;CI統合ゲートの変遷&lt;/h2&gt;

&lt;p&gt;この統合ゲートは一度にできたものではなく、問題が出るたびに直して、を繰り返してきました。&lt;/p&gt;

&lt;h3 id="第0世代個別ciをrequired-checkに登録2026年3月"&gt;第0世代：個別CIをrequired checkに登録（〜2026年3月）&lt;/h3&gt;

&lt;p&gt;最初期は、各サービスの CI ワークフローをそれぞれ独立した required check として登録していました。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/07/03/monorepo-ci-orchestrator-migration/gen0.png" alt="第0世代: 個別CIをrequired checkに登録した構成。service-a のみ変更した PR では service-b-ci と service-c-ci が skipped のまま永久ブロックされる" width="60%" style="display:block;margin:0 auto" /&gt;&lt;/p&gt;

&lt;p&gt;この構成はシンプルですが、前述の「required check問題」がそのまま発生します。&lt;code&gt;service-a/&lt;/code&gt; のみ変更した PR では &lt;code&gt;service-b-ci&lt;/code&gt; と &lt;code&gt;service-c-ci&lt;/code&gt; が起動せず、永久にマージブロックされます。&lt;/p&gt;

&lt;h3 id="第1世代ポーリング方式のプロキシ2026年34月"&gt;第1世代：ポーリング方式のプロキシ（2026年3〜4月）&lt;/h3&gt;

&lt;p&gt;required check 問題を解決するため、常時起動するプロキシジョブを導入しました。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/07/03/monorepo-ci-orchestrator-migration/gen1.png" alt="第1世代: ポーリング方式のプロキシ。統合ゲートが常時起動し、変更パスを判定した上で各CIの完了をポーリングで最大36分間待機する" width="60%" style="display:block;margin:0 auto" /&gt;&lt;/p&gt;

&lt;p&gt;このジョブが変更パスを見て「どのCIが必要か」を判定し、checks API で該当 CI の完了をポーリングします。全ての必要な CI が成功すれば success、1つでも失敗すれば failure を返します。これを唯一の required check にすることで、不要な CI の skipped によるブロックを回避しました。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;この時点での課題&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;CI が完了するまで &lt;strong&gt;最大約36分間ランナーを占有&lt;/strong&gt; する（ポーリングで待ち続けるため）&lt;/li&gt;
  &lt;li&gt;CI が36分以内に終わらないと &lt;strong&gt;タイムアウトで誤失敗&lt;/strong&gt; する（CI自体は成功しているのにゲートが失敗）&lt;/li&gt;
  &lt;li&gt;CI 完了からゲート反映まで &lt;strong&gt;最大60秒の遅延&lt;/strong&gt; がある（ポーリング間隔）&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id="第2世代イベント駆動化2026年6月"&gt;第2世代：イベント駆動化（2026年6月）&lt;/h3&gt;

&lt;p&gt;ポーリングの課題を解消するため、&lt;code&gt;workflow_run&lt;/code&gt; イベント（別のワークフローの完了をトリガーに起動する仕組み）を活用したイベント駆動方式に改修しました。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/07/03/monorepo-ci-orchestrator-migration/gen2.png" alt="第2世代: イベント駆動化。各CIの完了時に workflow_run イベントで即座に Check Run を更新する。seed ジョブはバックストップとしてポーリングも実施" width="60%" style="display:block;margin:0 auto" /&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;workflow_run&lt;/code&gt; イベントにより、各 CI の完了を即座に検知して Check Run を更新できるようになりました。ポーリングはバックストップに降格し、CI 完了からゲート反映までのラグがほぼゼロになりました。&lt;/p&gt;

&lt;p&gt;しかし、この改修で新たな問題が発生しました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;checks API の cross-run update 禁止&lt;/strong&gt;: GitHub が2025年2月に導入した&lt;a href="https://github.blog/changelog/2025-01-13-github-actions-prevent-workflows-from-creating-or-approving-pull-requests-breaking-change/"&gt;仕様変更&lt;/a&gt;により、ある workflow run が作った Check Run を別の workflow run から更新できなくなり、本番で HttpError が発生しました。即日修正として、update ではなく常に create する方式に切り替えましたが、異なる run 間の通信に依存する設計自体が脆弱です&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ブランチ保護の不整合&lt;/strong&gt;: 自分たちの GHES 環境では、API で作成した独立 Check Run がブランチ保護の required check を満たさないケースがあり、commit status（もう1つのステータス報告手段）の併用が必要になりました。ゲートロジックがさらに複雑化しました&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;セキュリティ脆弱性の発見&lt;/strong&gt;: レビュー中に、&lt;code&gt;pull_request&lt;/code&gt; イベントが PR のブランチに含まれるワークフロー定義を実行するため、ゲートロジック自体を PR で改ざんできるバイパス経路が指摘されました。CODEOWNERS による保護を追加しましたが、補償的コントロールが増える一方でした&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;改修のたびに新たな問題が見つかり、補償的な対策が積み重なる一方でした。この時点でプロキシ方式は限界だと判断して、別のやり方を探し始めました。&lt;/p&gt;

&lt;h2 id="理想形の発見とpoc"&gt;理想形の発見とPoC&lt;/h2&gt;

&lt;p&gt;プロキシ方式の問題は、別ワークフローの結果を外部プロキシで集約するという設計そのものにありました。1つのワークフロー内で完結させられないか？と考えました。&lt;/p&gt;

&lt;p&gt;調べていくと、GitHub Actions の標準機能だけで実現できることがわかりました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;workflow_call&lt;/code&gt;&lt;/strong&gt;（再利用可能ワークフロー呼び出し）: 既存の CI ワークフローを別のワークフローから &lt;code&gt;uses:&lt;/code&gt; で呼び出せる。ジョブ定義をコピーせず再利用が可能&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;needs&lt;/code&gt; + &lt;code&gt;if: always()&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;needs&lt;/code&gt; で依存する子ジョブの結果を &lt;code&gt;needs.*.result&lt;/code&gt; で参照でき、&lt;code&gt;if: always()&lt;/code&gt; を付ければ子ジョブが skipped や failure でも集約ジョブ自体は実行される&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;skipped は成功扱い&lt;/strong&gt;: 条件付きで起動しなかった子ジョブは &lt;code&gt;skipped&lt;/code&gt; になり、集約ジョブ側で成功として扱える&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;数百行のカスタム JavaScript + checks API + ポーリングでやっていたことが、プラットフォームの標準機能だけでできるわけです。&lt;/p&gt;

&lt;h3 id="pocで動作検証"&gt;PoCで動作検証&lt;/h3&gt;

&lt;p&gt;実際に PoC を作成し、以下の4項目を検証しました。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;ローカルの reusable workflow（&lt;code&gt;uses: ./.github/workflows/x.yml&lt;/code&gt;）が正常に呼び出せる&lt;/li&gt;
  &lt;li&gt;条件不成立で起動しなかった &lt;code&gt;uses&lt;/code&gt; ジョブが &lt;code&gt;needs.*.result == 'skipped'&lt;/code&gt; を返す&lt;/li&gt;
  &lt;li&gt;失敗した &lt;code&gt;uses&lt;/code&gt; ジョブが &lt;code&gt;needs.*.result == 'failure'&lt;/code&gt; を返す&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;needs&lt;/code&gt; + &lt;code&gt;if: always()&lt;/code&gt; の集約ジョブが上記を正しく判定できる&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;結果は全て成功。PoC の作成から検証完了まで1時間程度でした。&lt;/p&gt;

&lt;h2 id="新アーキテクチャci-オーケストレータ"&gt;新アーキテクチャ：CI オーケストレータ&lt;/h2&gt;

&lt;p&gt;移行後の構成は以下の通りです。&lt;code&gt;ci-orchestrator.yml&lt;/code&gt; という1つのワークフローに全てが収まっています。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/07/03/monorepo-ci-orchestrator-migration/new-arch.png" alt="新アーキテクチャ: CIオーケストレータ。changes ジョブで変更を検知し、該当サービスの CI のみ workflow_call で起動。aggregate ジョブが全体の合否を集約して required check になる。変更なしのサービスは skipped = 成功扱い" width="60%" style="display:block;margin:0 auto" /&gt;&lt;/p&gt;

&lt;h3 id="主要な設計決定"&gt;主要な設計決定&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;変更検知は &lt;code&gt;git diff&lt;/code&gt; インライン&lt;/strong&gt;。&lt;code&gt;changes&lt;/code&gt; ジョブ内のシェルスクリプトで &lt;code&gt;git diff --name-only&lt;/code&gt; を実行し、パスプレフィックスで判定しています。&lt;code&gt;dorny/paths-filter&lt;/code&gt; のような外部 action は使わず、依存ゼロで構成しました。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect changed service areas&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;detect&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;BASE="${{ github.event.pull_request.base.sha }}"&lt;/span&gt;
      &lt;span class="s"&gt;HEAD="${{ github.event.pull_request.head.sha }}"&lt;/span&gt;
      &lt;span class="s"&gt;CHANGED=$(git diff --name-only "$BASE"..."$HEAD")&lt;/span&gt;

      &lt;span class="s"&gt;# service-a: service-a/** から除外パスを引く&lt;/span&gt;
      &lt;span class="s"&gt;EXCLUDED="service-a/docs/"&lt;/span&gt;
      &lt;span class="s"&gt;if echo "$CHANGED" | grep -E "^service-a/" | grep -vE "^($EXCLUDED)" | grep -q .; then&lt;/span&gt;
        &lt;span class="s"&gt;echo "service_a=true" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
      &lt;span class="s"&gt;elif echo "$CHANGED" | grep -qF ".github/workflows/service-a-ci.yml"; then&lt;/span&gt;
        &lt;span class="s"&gt;echo "service_a=true" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
      &lt;span class="s"&gt;else&lt;/span&gt;
        &lt;span class="s"&gt;echo "service_a=false" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
      &lt;span class="s"&gt;fi&lt;/span&gt;
      &lt;span class="s"&gt;# 他のサービスも同様...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;secrets は &lt;code&gt;secrets: inherit&lt;/code&gt;&lt;/strong&gt;。子ワークフローが多数の secrets を参照している場合、明示的な mapping は現実的ではありません。&lt;code&gt;secrets: inherit&lt;/code&gt; で呼び出し元の secrets を全て継承させています。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;service-a&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changes&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.changes.outputs.service_a == 'true'&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/service-a-ci.yml&lt;/span&gt;
  &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;集約ジョブの判定は &lt;code&gt;needs.*.result&lt;/code&gt; のシェル判定のみ&lt;/strong&gt;。外部スクリプトは不要です。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;aggregate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;changes&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;service-a&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;service-b&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;service-c&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Evaluate gate&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;FAIL=0&lt;/span&gt;
        &lt;span class="s"&gt;for PAIR in \&lt;/span&gt;
          &lt;span class="s"&gt;"service-a:${{ needs.service-a.result }}" \&lt;/span&gt;
          &lt;span class="s"&gt;"service-b:${{ needs.service-b.result }}" \&lt;/span&gt;
          &lt;span class="s"&gt;"service-c:${{ needs.service-c.result }}"; do&lt;/span&gt;
          &lt;span class="s"&gt;NAME="${PAIR%%:*}"&lt;/span&gt;
          &lt;span class="s"&gt;R="${PAIR#*:}"&lt;/span&gt;
          &lt;span class="s"&gt;case "$R" in&lt;/span&gt;
            &lt;span class="s"&gt;success|skipped) ;;&lt;/span&gt;
            &lt;span class="s"&gt;*) echo "BLOCKED: $NAME=$R"; FAIL=1 ;;&lt;/span&gt;
          &lt;span class="s"&gt;esac&lt;/span&gt;
        &lt;span class="s"&gt;done&lt;/span&gt;
        &lt;span class="s"&gt;[ "$FAIL" = "1" ] &amp;amp;&amp;amp; exit 1&lt;/span&gt;
        &lt;span class="s"&gt;echo "aggregate: SUCCESS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;ラベル管理・通知は別ジョブに分離&lt;/strong&gt;。CI 失敗時のラベル付与や PR コメントでの通知は &lt;code&gt;ci-labels&lt;/code&gt; ジョブとして &lt;code&gt;aggregate&lt;/code&gt; の後段に配置しています。外部 API（GitHub API でのラベル操作等）のエラーが required check の安定性に影響しないようにするためです。&lt;/p&gt;

&lt;h2 id="実装で遭遇した落とし穴と解決"&gt;実装で遭遇した落とし穴と解決&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;workflow_call&lt;/code&gt; 化は概念的にはシンプルですが、実際にやると予想外の挙動にいくつか遭遇しました。&lt;/p&gt;

&lt;h3 id="concurrency-group-衝突問題"&gt;concurrency group 衝突問題&lt;/h3&gt;

&lt;p&gt;GitHub Actions の &lt;code&gt;concurrency&lt;/code&gt; 設定は、同じグループ名のジョブが同時に走らないよう制御する仕組みです。&lt;code&gt;workflow_call&lt;/code&gt; で呼ばれた reusable workflow 内では、&lt;code&gt;github.workflow&lt;/code&gt;（現在のワークフロー名を返す変数）が&lt;strong&gt;呼び出し元のワークフロー名&lt;/strong&gt;に解決されます。そのため、子ワークフロー側に &lt;code&gt;concurrency: group: ${{ github.workflow }}-${{ github.ref }}&lt;/code&gt; が残っていると、全ての子ワークフローが同じ concurrency group に入り、&lt;strong&gt;相互にキャンセルされる&lt;/strong&gt;という事態が発生しました。&lt;/p&gt;

&lt;p&gt;解決までに複数のアプローチを試しました。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code&gt;workflow_ref&lt;/code&gt; ベースの group 分離 → 不十分&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;caller&lt;/code&gt; input を渡して group を分離 → 環境によっては効かない&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;reusable workflow 側の &lt;code&gt;concurrency&lt;/code&gt; を削除&lt;/strong&gt;（最終解）&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;最終的に、concurrency 制御はオーケストレータ側の1箇所のみで行う構成に落ち着きました。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ci-orchestrator.yml（呼び出し元）のみで制御&lt;/span&gt;
&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.workflow }}-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id="workflow_run-が発火しなくなる問題"&gt;workflow_run が発火しなくなる問題&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;workflow_call&lt;/code&gt; 経由で実行された reusable workflow は&lt;strong&gt;独立した workflow run（ワークフローの個別の実行インスタンス）を生成しません&lt;/strong&gt;。これは GitHub Actions の仕様です。&lt;/p&gt;

&lt;p&gt;影響として、各CIの完了時に &lt;code&gt;workflow_run&lt;/code&gt; トリガ（別ワークフローの完了を検知して起動する仕組み）で動いていた後続処理（CI失敗時のラベル管理、PR作者への通知等）が発火しなくなりました。&lt;/p&gt;

&lt;p&gt;対応として、該当ロジックをオーケストレータ内の後段ジョブ（&lt;code&gt;ci-labels&lt;/code&gt;）に移設しました。&lt;code&gt;aggregate&lt;/code&gt; ジョブの outputs（どのCIが失敗したか）を受け取り、ラベルの付与・削除や通知コメントの投稿を行います。&lt;/p&gt;

&lt;h3 id="check-run-名の変化"&gt;check run 名の変化&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;workflow_call&lt;/code&gt; 経由で実行されたジョブの check run 名（PR のチェック一覧に表示される名前）は、&lt;strong&gt;「親ワークフロー名 / ジョブ名」&lt;/strong&gt; 形式に変わります。例えば、従来 &lt;code&gt;service-a-ci&lt;/code&gt; だった名前が &lt;code&gt;CI / service-a&lt;/code&gt; になります。&lt;/p&gt;

&lt;p&gt;これにより、旧来のジョブ名で完全一致の待機をしていた他のワークフロー（自動マージ等）が永久に pending になるという問題が発生しました。&lt;/p&gt;

&lt;p&gt;対応として、他のワークフローの待機対象を個別の CI 名ではなく、集約ジョブ名の &lt;code&gt;aggregate&lt;/code&gt; 1つに一本化しました。集約ジョブが成功 = 全CI成功なので、個別の CI 名を知る必要がなくなります。&lt;/p&gt;

&lt;h3 id="変更検知の整合性"&gt;変更検知の整合性&lt;/h3&gt;

&lt;p&gt;オーケストレータの &lt;code&gt;git diff&lt;/code&gt; による変更検知と、各 CI の旧 &lt;code&gt;paths:&lt;/code&gt; 条件は正確に整合させる必要があります。ずれがあると以下の問題が起きます。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;集約ジョブが待つのに CI が起動しない&lt;/strong&gt;: デッドロック（集約ジョブが永久に完了しない）&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CI が起動するのに集約ジョブが待たない&lt;/strong&gt;: ゲートバイパス（CI の結果を無視してマージできてしまう）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;また、レビューで指摘されて気づいたのですが、&lt;strong&gt;CI 定義ファイル自身（&lt;code&gt;.github/workflows/*-ci.yml&lt;/code&gt;）の変更も検知に含める&lt;/strong&gt;必要があります。含めないと、CI 定義のみを変更する PR で対応する CI がスキップされてしまいます。&lt;/p&gt;

&lt;h2 id="移行結果の比較"&gt;移行結果の比較&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;指標&lt;/th&gt;
      &lt;th&gt;プロキシ方式&lt;/th&gt;
      &lt;th&gt;集約ジョブ方式&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;CI 完了→ゲート確定のラグ&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;~30〜100 秒&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;数秒&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;ランナー占有（ゲート側）&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;最大 ~36 分&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~1 分 + 数秒&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;保守対象コード&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;~500 行（JS + テスト + WF定義）&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~335 行（WF定義のみ）&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;「CI 完了を待つ」仕組み&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;自前実装（ポーリング / イベント受信 / checks API）&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;GitHub の &lt;code&gt;needs&lt;/code&gt; 機構&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;攻撃面&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;checks API + 外部スクリプト + ワークフロー名同定&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;&lt;code&gt;needs.*.result&lt;/code&gt; のシェル判定のみ&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;既知の障害モード&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;6種（HttpError / bootstrap / 偽装 / 改ざん / timeout / 遅延）&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;構造的に発生しない&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;新規 CI 追加時の作業&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;3箇所の同期が必要&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;1ファイルに追記するだけ&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;一番大きいのは、既知の障害モードが構造的に発生しなくなったことです。プロキシ方式の問題は全て「別ワークフローの結果を外部プロキシで集約する」設計から来ていたので、その設計をやめれば問題も消えました。&lt;/p&gt;

&lt;h2 id="他プロジェクトへの導入ガイド"&gt;他プロジェクトへの導入ガイド&lt;/h2&gt;

&lt;p&gt;この方式は GitHub Actions の標準機能のみで構成されており、外部 action への依存もありません。以下の手順で導入できます。&lt;/p&gt;

&lt;h3 id="1-各-ci-の-on-を-workflow_call-に変更"&gt;1. 各 CI の &lt;code&gt;on:&lt;/code&gt; を &lt;code&gt;workflow_call&lt;/code&gt; に変更&lt;/h3&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service-a/**"&lt;/span&gt;

&lt;span class="c1"&gt;# after&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;pull_request&lt;/code&gt; トリガと &lt;code&gt;concurrency&lt;/code&gt; ブロックは削除します（前述の concurrency group 衝突を回避するため）。&lt;/p&gt;

&lt;h3 id="2-オーケストレータを作成"&gt;2. オーケストレータを作成&lt;/h3&gt;

&lt;p&gt;変更検知（&lt;code&gt;changes&lt;/code&gt;）→ 各 CI の条件付き呼び出し（&lt;code&gt;uses:&lt;/code&gt;）→ 集約ジョブ（&lt;code&gt;aggregate&lt;/code&gt;）の構成でオーケストレータを作成します。&lt;code&gt;changes&lt;/code&gt; ジョブの検知パスは、各 CI の旧 &lt;code&gt;paths:&lt;/code&gt; 条件と正確に整合させてください。&lt;/p&gt;

&lt;h3 id="3-required-check-を-aggregate-に切り替え"&gt;3. required check を &lt;code&gt;aggregate&lt;/code&gt; に切り替え&lt;/h3&gt;

&lt;p&gt;ブランチ保護の required check を、旧来の個別 CI 名から &lt;code&gt;aggregate&lt;/code&gt; に切り替えます。check run 名は checks API 上のジョブ名（UI 表示の「CI / aggregate」ではなく &lt;code&gt;aggregate&lt;/code&gt;）です。&lt;/p&gt;

&lt;h3 id="注意点"&gt;注意点&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;concurrency group の衝突&lt;/strong&gt;: reusable workflow 側の &lt;code&gt;concurrency&lt;/code&gt; を削除し、オーケストレータ側のみで制御する&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;workflow_run&lt;/code&gt; が発火しなくなる&lt;/strong&gt;: &lt;code&gt;workflow_run&lt;/code&gt; トリガで動いていた後続処理がある場合、オーケストレータ内に移設する必要がある&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;check run 名の変化&lt;/strong&gt;: 他のワークフローで旧 CI 名に依存している箇所がないか確認する&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;変更検知の整合性&lt;/strong&gt;: CI 定義ファイル自身の変更も検知に含める&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="まとめ"&gt;まとめ&lt;/h2&gt;

&lt;p&gt;CI の統合ゲートは以下の変遷をたどりました。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;個別 CI を required check に登録&lt;/strong&gt; → skipped で永久ブロック&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ポーリング方式のプロキシ&lt;/strong&gt; → ランナー長時間占有、タイムアウト誤失敗&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;イベント駆動化（workflow_run + checks API）&lt;/strong&gt; → GitHub 仕様変更で HttpError、補償的対策の積み重ね&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;集約ジョブ方式（needs + if: always()）&lt;/strong&gt; → 構造的に問題が発生しない&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;課題を直すたびに新しい問題が出てきて、最終的にプラットフォームの標準機能に任せるのが一番シンプルだった、という結論になりました。&lt;/p&gt;

&lt;p&gt;移行時に遭遇した落とし穴（concurrency group 衝突、&lt;code&gt;workflow_run&lt;/code&gt; 非発火、check run 名の変化）は &lt;code&gt;workflow_call&lt;/code&gt; 化で踏みやすいものなので、先に知っておくと手戻りが減ると思います。&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>【レポート】毬藻企画とGMOペパボでWebアクセシビリティのイベントを開催しました！</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/06/12/suzuri-a11y-walkthrough/"/>
    <id>https://tech.pepabo.com/2026/06/12/suzuri-a11y-walkthrough/</id>
    <published>2026-06-12T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>tarutaru</name>
    </author>
    <content type="html">&lt;ol id="markdown-toc"&gt;
  &lt;li&gt;&lt;a href="#この記事について" id="markdown-toc-この記事について"&gt;この記事について&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#イベントの背景概要" id="markdown-toc-イベントの背景概要"&gt;イベントの背景・概要&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#当日の様子" id="markdown-toc-当日の様子"&gt;当日の様子&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#アクセシビリティウォークスルー" id="markdown-toc-アクセシビリティウォークスルー"&gt;アクセシビリティウォークスルー&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#改善提案ディスカッション" id="markdown-toc-改善提案ディスカッション"&gt;改善提案ディスカッション&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#イベントを振り返って" id="markdown-toc-イベントを振り返って"&gt;イベントを振り返って&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#さいごに" id="markdown-toc-さいごに"&gt;さいごに&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="この記事について"&gt;この記事について&lt;/h2&gt;

&lt;p&gt;GMOペパボの&lt;a href="https://x.com/tarutaruio"&gt;tarutaru&lt;/a&gt;です。&lt;/p&gt;

&lt;p&gt;2026年3月24日、アクセシビリティエキスパート集団である&lt;a href="https://marimokikakugk.com/"&gt;毬藻企画合同会社&lt;/a&gt;さんとの共催で、「&lt;a href="https://pepabo.connpass.com/event/385692/"&gt;【GMOペパボ・毬藻企画】スクリーンリーダーによる実践的アクセシビリティウォークスルー 〜SUZURI byGMOペパボ編〜&lt;/a&gt;」というイベントを開催しました。&lt;/p&gt;

&lt;p&gt;GMOペパボにとっては初めてのアクセシビリティイベント。この記事では、イベント当日の内容・様子など振り返っていきます。&lt;/p&gt;

&lt;h2 id="イベントの背景概要"&gt;イベントの背景・概要&lt;/h2&gt;

&lt;p&gt;GMOペパボでは、「人類のアウトプットを増やす」というミッションのもと、誰もが表現活動の機会を持てる世界を目指してアクセシビリティ推進にも取り組んでいます。ただ、組織としての浸透も各サービスの改善も、まだ「道半ば」というのが正直なところで、SUZURIもその例外ではありませんでした。&lt;/p&gt;

&lt;p&gt;そんな折に、SUZURIでグッズ販売をしてくださっている毬藻企画の&lt;a href="https://x.com/securecat"&gt;森田さん&lt;/a&gt;からお声がけいただきました。お話を重ねるなかで、「SUZURIのアクセシビリティ改善に取り組むなら、まずは現状の課題を知るところから。せっかくならオープンにやってみるといいんじゃないか」と、今回のイベントをご提案いただきました。&lt;/p&gt;

&lt;p&gt;お話を受けて、弊社の事例を公開することが、アクセシビリティに取り組まれている方の学びや刺激になれば。そして、その外に向けた発信が巡り巡って社内のアクセシビリティ推進の後押しにもなれば。そんな期待もあって、今回共催という形でイベントを開催することにしました。&lt;/p&gt;

&lt;p&gt;本イベントは、弊社のサービス「&lt;a href="https://suzuri.jp/"&gt;SUZURI byGMOペパボ&lt;/a&gt;」に対して、&lt;strong&gt;アクセシビリティウォークスルー&lt;/strong&gt;を実施いただくイベントとして開催されました。あまり聞き馴染みのないワードだと思いますが、アクセシビリティウォークスルーは、認知的ウォークスルー（初見のユーザーがUIを一手順ずつ操作していくプロセスを追体験し、つまずきを抽出する手法）とエキスパートレビューを組み合わせたもので、毬藻企画さんによる造語になります。&lt;/p&gt;

&lt;p&gt;当日は、全盲のエンジニアである&lt;a href="https://yncat.net/"&gt;catさん（野澤 幸男さん）&lt;/a&gt;をゲストとしてお招きし、SUZURIをスクリーンリーダー（NVDA）で操作しながらウォークスルーを実施いただきました。ウォークスルー中は、毬藻企画の&lt;a href="https://x.com/magi1125"&gt;伊原さん&lt;/a&gt;がファシリテートと解説を担当。ウォークスルー後には、catさん・伊原さんに加え、毬藻企画の&lt;a href="https://x.com/mt_dew2"&gt;坂巻さん&lt;/a&gt;、SUZURIデザイナーの&lt;a href="https://tech.pepabo.com/authors/%E3%83%84%E3%83%90%E3%82%B5/"&gt;白石さん&lt;/a&gt;・&lt;a href="https://x.com/na0v0_matsu"&gt;並松さん&lt;/a&gt;も加わり問題点の振り返りと改善方法のディスカッションを行いました。&lt;/p&gt;

&lt;p&gt;今回は現地参加のみの開催でしたが、デザイナー・エンジニアなど計40名の方にご参加いただきました。当日ご参加いただいた皆さんありがとうございました。&lt;/p&gt;

&lt;h2 id="当日の様子"&gt;当日の様子&lt;/h2&gt;

&lt;h3 id="アクセシビリティウォークスルー"&gt;アクセシビリティウォークスルー&lt;/h3&gt;

&lt;p&gt;ウォークスルー本番の前に、比較材料としてまず晴眼者である私が、マウス操作でSUZURIの商品を購入するデモを行いました。SUZURI公式ショップで「忍者スリスリくんのパーカー」を探して、カートに入れ、注文を完了する。私は特に引っかかるところもなく注文を終え、晴眼者にとっては数分で完了するタスクでした。&lt;/p&gt;

&lt;p&gt;これに続く形でcatさんにはアクセシビリティウォークスルーを始めていただきました。catさんには、SUZURI内の毬藻企画さんのショップで「アクセシスタディーズのアクリルスタンド」を購入する、というタスクを遂行してもらいました。商品を探して、カートに入れ、注文を完了させる。流れ自体は基本的に同じですが、それをスクリーンリーダーで行うことで先ほどの晴眼者のウォークスルーとどのくらいギャップがあるのか、を参加者の方には体感いただきました。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/06/12/suzuri-a11y-walkthrough/walkthrough.jpg" alt="ウォークスルー中の会場の様子。参加者が着席して見守るなか、登壇者2名がスクリーン脇に立っている。メインスクリーンにはSUZURIのページが映し出され、サブモニターにはUDトークの字幕が表示されている" /&gt;&lt;/p&gt;

&lt;p&gt;結果だけ言ってしまうと、ウォークスルー中には本当にさまざまな引っかかりが発生しました。一つの商品を購入するだけでこれだけのつまづきがあるのかと、普段から慣れ親しんだ自分たちのサービスが、まるで全然知らないサービスに見えるくらい衝撃を受けました。&lt;/p&gt;

&lt;p&gt;ここですべてのつまづきを取り上げることはできないので1つだけピックアップさせていただきますが、イベントの中で会場が一番ざわついたのが、商品のサイズ選択の場面でした。&lt;/p&gt;

&lt;p&gt;SUZURIでは、サイズを選ぶUIとしてHTMLの&lt;code&gt;select&lt;/code&gt;要素が使用されていました。これ自体はなんの違和感もないことですが、catさんがスクリーンリーダーでこの&lt;code&gt;select&lt;/code&gt;要素を操作してサイズを選択し、カートに入れるボタンを押してみたところ、カートに入れることができませんでした。カートに入れられなかった理由は”サイズが未選択だから”。先ほど確かにサイズを選択したのをこの目で確認したはずなのに、実際にはサイズは選ばれていない状態で、皆の頭に「？」が浮かぶ状況でした。&lt;/p&gt;

&lt;p&gt;実際には何が起きていたかというと、実はこの&lt;code&gt;select&lt;/code&gt;要素、&lt;code&gt;select&lt;/code&gt;要素としての機能は無効化されており、マウスのクリックでカスタムのドロワーUIを開くためのトリガーとして使用されていました。サイズ選択は、そのドロワー側で選択しないとサイズが確定しない仕様になっていたのです。ところが、スクリーンリーダー（キーボード）からは通常の&lt;code&gt;select&lt;/code&gt;として操作も選択もできてしまったため「選んだのに、選べていない」という状態になっていたのです。&lt;/p&gt;

&lt;p&gt;catさんはこの問題について、こう表現しました。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;お風呂用洗剤はお風呂を洗うために作られたのに他の目的で使うと何が起こるかわからない。つまり、用法用量を守って使ってほしい。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;ネイティブの&lt;code&gt;select&lt;/code&gt;要素には、ブラウザが保証する期待された動作があります。それを別の目的で使えば、期待どおりに動かないケースが出てくる。シンプルだけど重い指摘でした。（※ なお、このサイズ選択の問題については現在改善対応を進めており、近日中に修正される予定です。）&lt;/p&gt;

&lt;p&gt;このサイズ選択のほかにも、フォーカスが移動せずモーダルウインドウの存在に気づけなかったり、支払い方法のラジオボタンのグルーピングが把握しづらかったり、広告バナーを誤クリックしてしまったりと、多くのつまずきが発生しましたが、それでもcatさんは最終的に注文を完了させ、無事に（？）ウォークスルーを終えることができました。&lt;/p&gt;

&lt;h3 id="改善提案ディスカッション"&gt;改善提案ディスカッション&lt;/h3&gt;

&lt;p&gt;ウォークスルーを終えた後は、catさん・伊原さん・坂巻さん・白石さん・並松さんの5名で、問題点の振り返りと改善方法を議論しました。モーダルウインドウへのフォーカス移動やアイコンのラベル付けなど、改善策はいくつも挙がりましたが、中でも印象に残ったのは、「グッズの探索しやすさ」をめぐるやり取りでした。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/06/12/suzuri-a11y-walkthrough/discussion.jpg" alt="改善提案ディスカッション中の会場の様子。参加者が着席して見守るなか、複数の登壇者がスクリーン脇から発言している。メインスクリーンにはSUZURIのページが映し出され、サブモニターにはUDトークの字幕が表示されている" /&gt;&lt;/p&gt;

&lt;p&gt;きっかけは、並松さんからの問いかけでした。ウォークスルー中、catさんが「アクリルスタンド」と書かれたリンクを押すと、毬藻企画ショップ内ではなく、SUZURI全体のアクリルスタンドの商品一覧（毬藻とは関係のないアクスタが並ぶ画面）に飛んでしまう、という場面がありました。並松さんはここを取り上げて、「グッズを探しやすくするには、どんな工夫があり得るんでしょうか？」と切り出しました。&lt;/p&gt;

&lt;p&gt;これに対しcatさんは、商品一覧から選ぶこと自体は不便ではなかったとしつつ、「そもそも『デザインを選ばないとTシャツやアクスタが出てこない』構造に気づくまでに時間がかかった」と振り返りました。これを受けて、伊原さんがSUZURIの構造的な特徴をこう整理してくれました。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;SUZURIには「Tシャツやアクリルスタンドといったハードウェアとしての商品」と「その上に乗るデザイン」という二つの概念があり、これらが同じ画面に同居しているので、自分がいま触れているのが商品の話なのかデザインの話なのかがユーザーには見えづらい。catさんがリンクを押して関係のない商品一覧に飛んでしまったのも、まさにこの概念の混在が原因。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;また、地続きの話として白石さんから興味深い補足が入りました。SUZURIショップのトップに並ぶナビゲーション（「デザイン」「グッズ」など）は、実はショップオーナー側でカスタマイズできる仕様になっており、SUZURI公式ショップでは「グッズ」というタブが並んでいるのに対し、今回の毬藻企画ショップのナビゲーションには「グッズ」が存在していなかったそうです。catさんがショップの構造を掴むのに時間がかかった一因も、おそらくここにあったのではないか。同じSUZURIでも、ショップによって構造の理解しやすさが揺らいでしまう、そんな現状そのものが課題として見えてきた、そんな議論でした。&lt;/p&gt;

&lt;p&gt;最初はUIの小さな引っかかりに聞こえた話が、気づけば「SUZURIというサービスの情報構造をどう設計するか」という、もう一回り大きな問いに変わっていく。アクセシビリティは「スクリーンリーダーでどう読み上げさせるか」だけではなく、その手前にある情報設計のレイヤーまで含めて考えるべきテーマなのだと、改めて気づかされた時間でした。一つの問いからここまで議論が広がっていくその奥行きが、ディスカッションの中でもとくに面白く、印象に残ったセッションでした。&lt;/p&gt;

&lt;h2 id="イベントを振り返って"&gt;イベントを振り返って&lt;/h2&gt;

&lt;p&gt;イベントを終えた今正直に書くと、「まだアクセシビリティの取り組みが浅いSUZURIを題材にして本当に大丈夫か」という不安は企画当初からずっとありました。社内でも協議を重ね結果開催に至りましたが、その不安は当日まで消えませんでした。&lt;/p&gt;

&lt;p&gt;それでも、いざイベントが始まってみると、catさんや伊原さんがユーモアを交えながら明るくウォークスルーを進めてくださり、ディスカッションでも「どうすれば良くなるか」が前向きに議論されていきました。参加者の皆さんもその一つひとつに真剣に耳を傾けてくださっていて、会場には終始「目の前の事象に一緒に向き合う」良い空気が流れていました。SNS上でもハッシュタグ &lt;a href="https://x.com/hashtag/pepabo_marimo"&gt;#pepabo_marimo&lt;/a&gt; で学びをポジティブにシェアしてくださり、読みながらほっとしたのを覚えています。&lt;/p&gt;

&lt;p&gt;そうした空気のなかで、私自身もたくさんの学びを持ち帰った一日でした。中でも気づかされたのは、catさんが操作のほとんどの時間を「ページの構造を把握する／要素を把握する／情報を把握する」ことに費やしていた、という事実です。それはおそらく、晴眼者が視覚情報で無意識のうちにやっていることでもあります。この現状把握でつまずきが多いと、目的のアクションにはなかなかたどり着けないし、たどり着くまでに想像以上のエネルギーがかかってしまう。catさんはあらゆる手段を駆使して現状を把握されていましたが、それはエンジニアであるcatさんだからこその引き出しの多さでもあったはずです。誰もがこの把握をスムーズに行える状態を、当たり前のものとして目指していきたいな、と改めて感じる時間でした。&lt;/p&gt;

&lt;p&gt;そしてもうひとつ、企画者として嬉しかったことがあります。当日参加していた弊社デザイナーが、翌日にはもう自分の担当サービスでアクセシビリティ改善のPRを立てていたのです。「社外に向けたイベントが、社内にもいい刺激になれば」とひそかに期待していたことが、想像よりずっと早く、目に見える形で動き出していました。&lt;/p&gt;

&lt;p&gt;ディスカッションの終盤、catさんがこう言ってくださったのが、強く印象に残っています。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;みんなでいいものができていけばいいんじゃないかな。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;このひとことに、今回のイベントの空気が全部詰まっていたように思います。アクセシビリティは、一社あるいは一人だけで完結するテーマではありません。社内外で関わる人たちと一緒に課題に向き合いながら、少しずつ良いものに変えていく。今回のイベントが、その小さな一歩目になっていたら嬉しいです。&lt;/p&gt;

&lt;h2 id="さいごに"&gt;さいごに&lt;/h2&gt;

&lt;p&gt;以上、イベントのふりかえりでした。&lt;/p&gt;

&lt;p&gt;改めて、catさん、毬藻企画の皆さん、当日ご参加くださった皆さん、そして運営に関わってくださった皆さん、本当にありがとうございました！&lt;/p&gt;

&lt;p&gt;今回のイベントで見つかったSUZURIの課題は、これからひとつずつ改善していければと思っています。数も多いので時間がかかるものもあるかもしれませんが、ゆっくり見守っていただけたら嬉しいです。&lt;/p&gt;

&lt;p&gt;ちなみに、初開催ということもあり、運営面での反省点・学びもありました。たとえば、UDトークの字幕にスクリーンリーダーの読み上げ音声まで一緒に拾われてしまい、品質に影響が出てしまったり。こうした学びは、次回以降のイベントでしっかり活かしていきます。&lt;/p&gt;

&lt;p&gt;今回のイベントは、毬藻企画さんとcatさんのお力を大いにお借りしての開催となりました。弊社としても、アクセシビリティを通して良いものづくりにつながる取り組みを、これからも続けていきたいです。今回はスクリーンリーダーが切り口でしたが、ほかにもさまざまな角度からアクセシビリティに触れられる場を作っていき、皆さんと一緒に学ばせていただけたらと思っています。&lt;/p&gt;

&lt;p&gt;アクセシビリティに関わるみなさんも、これから興味を持つみなさんも、一緒に学び、いいものづくりをしましょう！&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>GA 直後の Amazon S3 Files を SUZURI の本番 EKS に投入し コンテナイメージを 1/20 に圧縮した</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/05/13/s3-files-suzuri-lens/"/>
    <id>https://tech.pepabo.com/2026/05/13/s3-files-suzuri-lens/</id>
    <published>2026-05-13T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>shibatch</name>
    </author>
    <content type="html">&lt;ol id="markdown-toc"&gt;
  &lt;li&gt;&lt;a href="#はじめに" id="markdown-toc-はじめに"&gt;はじめに&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#課題assets-が-コンテナイメージに焼き込まれる構造的問題" id="markdown-toc-課題assets-が-コンテナイメージに焼き込まれる構造的問題"&gt;課題：assets が コンテナイメージに焼き込まれる構造的問題&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#lenslens2-が扱う-assets-とは" id="markdown-toc-lenslens2-が扱う-assets-とは"&gt;lens/lens2 が扱う assets とは&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#移行前の状況" id="markdown-toc-移行前の状況"&gt;移行前の状況&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#s3-files-という選択肢" id="markdown-toc-s3-files-という選択肢"&gt;S3 Files という選択肢&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#当初の候補-efs--datasync" id="markdown-toc-当初の候補-efs--datasync"&gt;当初の候補: EFS + DataSync&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#2026-年-4-月-7-日-ga-amazon-s3-files" id="markdown-toc-2026-年-4-月-7-日-ga-amazon-s3-files"&gt;2026 年 4 月 7 日 GA: Amazon S3 Files&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#アーキテクチャ設計" id="markdown-toc-アーキテクチャ設計"&gt;アーキテクチャ設計&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#移行後の全体像" id="markdown-toc-移行後の全体像"&gt;移行後の全体像&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#設計のポイント" id="markdown-toc-設計のポイント"&gt;設計のポイント&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#実装-lens2rustで先行検証" id="markdown-toc-実装-lens2rustで先行検証"&gt;実装: lens2（Rust）で先行検証&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#eks-への-s3-files-マウント" id="markdown-toc-eks-への-s3-files-マウント"&gt;EKS への S3 Files マウント&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#phase-2a-動作確認並列マウント" id="markdown-toc-phase-2a-動作確認並列マウント"&gt;Phase 2a: 動作確認（並列マウント）&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#phase-2b-assets_dir-切り替え" id="markdown-toc-phase-2b-assets_dir-切り替え"&gt;Phase 2b: ASSETS_DIR 切り替え&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#phase-2c-dockerfile-から-assets-を除外" id="markdown-toc-phase-2c-dockerfile-から-assets-を除外"&gt;Phase 2c: Dockerfile から assets を除外&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#ハマりどころ-efs-csi-driver-を動かしてわかったこと" id="markdown-toc-ハマりどころ-efs-csi-driver-を動かしてわかったこと"&gt;ハマりどころ: EFS CSI Driver を動かしてわかったこと&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#1-inline-ephemeral-csi-volume-は非対応" id="markdown-toc-1-inline-ephemeral-csi-volume-は非対応"&gt;1. inline (Ephemeral) CSI volume は非対応&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#2-accessmodes-readonlymany-が非対応" id="markdown-toc-2-accessmodes-readonlymany-が非対応"&gt;2. &lt;code&gt;accessModes: ReadOnlyMany&lt;/code&gt; が非対応&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#3-volumehandle-に-s3files-プレフィックスが必須" id="markdown-toc-3-volumehandle-に-s3files-プレフィックスが必須"&gt;3. &lt;code&gt;volumeHandle&lt;/code&gt; に &lt;code&gt;s3files:&lt;/code&gt; プレフィックスが必須&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#4-mountoptions-iam-の明示指定が必要" id="markdown-toc-4-mountoptions-iam-の明示指定が必要"&gt;4. &lt;code&gt;mountOptions: [iam]&lt;/code&gt; の明示指定が必要&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#5-controller-sa-と-node-sa-に別々の-iam-ロールが必要" id="markdown-toc-5-controller-sa-と-node-sa-に別々の-iam-ロールが必要"&gt;5. controller SA と node SA に別々の IAM ロールが必要&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#補足-s3-files-障害時の挙動と監視" id="markdown-toc-補足-s3-files-障害時の挙動と監視"&gt;補足: S3 Files 障害時の挙動と監視&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#計測結果" id="markdown-toc-計測結果"&gt;計測結果&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#lens2rust本番実測" id="markdown-toc-lens2rust本番実測"&gt;lens2（Rust）本番実測&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#lensjavascript本番実測" id="markdown-toc-lensjavascript本番実測"&gt;lens（JavaScript）本番実測&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#s3-files-の読み取りレイテンシlens-本番実測" id="markdown-toc-s3-files-の読み取りレイテンシlens-本番実測"&gt;S3 Files の読み取りレイテンシ（lens 本番実測）&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#lensjavascriptへのロールアウト" id="markdown-toc-lensjavascriptへのロールアウト"&gt;lens（JavaScript）へのロールアウト&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#lens-ならではの差異-上書きマウント方式" id="markdown-toc-lens-ならではの差異-上書きマウント方式"&gt;lens ならではの差異: 上書きマウント方式&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#lens2-のノウハウがそのまま活きた" id="markdown-toc-lens2-のノウハウがそのまま活きた"&gt;lens2 のノウハウがそのまま活きた&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#まとめ" id="markdown-toc-まとめ"&gt;まとめ&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#成果サマリ" id="markdown-toc-成果サマリ"&gt;成果サマリ&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#s3-files-を選んでよかった点" id="markdown-toc-s3-files-を選んでよかった点"&gt;S3 Files を選んでよかった点&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#さいごに" id="markdown-toc-さいごに"&gt;さいごに&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="はじめに"&gt;はじめに&lt;/h2&gt;

&lt;p&gt;こんにちは、技術部技術基盤グループで SUZURI / minne / カラーミーショップなどのインフラをサービス横断で担当している shibatch です。&lt;/p&gt;

&lt;p&gt;SUZURI は、オリジナルグッズを手軽に作れる・購入できるサービスです。ユーザーがアップロードした画像とあらかじめ用意した商品テンプレート（assets）を合成して、Tシャツやマグカップの完成イメージを生成する「画像合成サービス」が中核を担っています。この処理を担うのが &lt;strong&gt;lens&lt;/strong&gt; と &lt;strong&gt;lens2&lt;/strong&gt; という 2 つのサービスです。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;lens&lt;/strong&gt;: JavaScript + ImageMagick 製の画像合成サービス（主力）&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;lens2&lt;/strong&gt;: Rust + ImageMagick 製の画像合成サービス（新世代、lens から段階的に移行中）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;2 サービスを合わせると &lt;strong&gt;1 日最大約 420 万リクエスト（月間約 1.1 億リクエスト）&lt;/strong&gt; を本番処理しています。&lt;/p&gt;

&lt;p&gt;今回はそのlens/lens2に、2026年4月7日にGAとなったばかりの &lt;strong&gt;Amazon S3 Files&lt;/strong&gt; を本番EKSに投入した話をします。トラフィックの少ないlens2でスモールスタートし、ゴールデンウィークをまたいでレイテンシの実績を積んでからlensへ展開する、という段階的なリスク設計で進めました。その過程と計測結果をお伝えします。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id="課題assets-が-コンテナイメージに焼き込まれる構造的問題"&gt;課題：assets が コンテナイメージに焼き込まれる構造的問題&lt;/h2&gt;

&lt;h3 id="lenslens2-が扱う-assets-とは"&gt;lens/lens2 が扱う assets とは&lt;/h3&gt;

&lt;p&gt;lens/lens2 が扱う assets は、SUZURI で販売されるすべての商品テンプレート——Tシャツ・マグカップ・トートバッグなどの型紙画像、フォントファイル、ウォーターマーク画像——の集合です。新商品が追加されるたびに増え続けます。&lt;/p&gt;

&lt;h3 id="移行前の状況"&gt;移行前の状況&lt;/h3&gt;

&lt;p&gt;移行前は assets がそのまま コンテナイメージに焼き込まれていました。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/05/13/s3-files-suzuri-lens/before-architecture.png" alt="図1: 移行前アーキテクチャ。assets がイメージに焼き込まれているため、ビルド・起動・ECR コストがすべて assets のサイズに比例する" /&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;サービス&lt;/th&gt;
      &lt;th&gt;assets サイズ&lt;/th&gt;
      &lt;th&gt;コンテナイメージ（ECR 圧縮後）&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;lens2&lt;/td&gt;
      &lt;td&gt;1.7 GB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;1.75 GB&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;lens&lt;/td&gt;
      &lt;td&gt;8.6 GB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;10.3 GB&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;これはビルドパイプライン全体に悪影響を与えていました。lens のビルド時間を実測すると次のような内訳です。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;ステップ&lt;/th&gt;
      &lt;th&gt;所要時間&lt;/th&gt;
      &lt;th&gt;原因&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;assets の S3 sync（毎ビルドダウンロード）&lt;/td&gt;
      &lt;td&gt;2m 08s&lt;/td&gt;
      &lt;td&gt;8.6 GB を毎回取得&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;コンテナビルド &amp;amp; push&lt;/td&gt;
      &lt;td&gt;11m 43s&lt;/td&gt;
      &lt;td&gt;assets を含む巨大レイヤーの書き出し&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;合計 (wall-clock)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;14m 31s&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;CI の初期化・checkout 等含む&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;さらに Karpenter による新規ノード追加時（キャッシュなし）の Pod 起動には、イメージサイズが直撃します。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;サービス&lt;/th&gt;
      &lt;th&gt;Image pull（cold）&lt;/th&gt;
      &lt;th&gt;Pod Scheduled→Ready（cold）&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;lens2&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;86〜102 秒&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;lens&lt;/td&gt;
      &lt;td&gt;5 分 03 秒&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;約 5〜5.5 分&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;トラフィックスパイク時にスケールアウトが発動しても、5 分間は新規 Pod を利用できません。SUZURI ではセール開始などをきっかけにトラフィックが急増するため、このスケールアウト遅延は売上機会の取りこぼしに直結します。&lt;/p&gt;

&lt;p&gt;さらに問題があります。lens から lens2 への移行が進むと lens2 の assets も最終的に 8.6 GB 相当まで増える見込みでした。放置すれば状況はさらに悪化します。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id="s3-files-という選択肢"&gt;S3 Files という選択肢&lt;/h2&gt;

&lt;h3 id="当初の候補-efs--datasync"&gt;当初の候補: EFS + DataSync&lt;/h3&gt;

&lt;p&gt;assets を コンテナイメージから切り離し、ネットワークファイルシステム経由でマウントする構成を検討していました。当初の有力候補は &lt;strong&gt;Amazon EFS + AWS DataSync&lt;/strong&gt; の組み合わせです。S3 を source of truth として、DataSync で定期同期した EFS ボリュームを EKS Pod に NFS マウントする構成です。&lt;/p&gt;

&lt;p&gt;ただし、この方式には課題がありました。S3 への push が EFS に反映されるまで同期ラグが生じるため、assets の鮮度管理が複雑になります。また DataSync パイプライン自体の構築・運用コストも無視できません。&lt;/p&gt;

&lt;h3 id="2026-年-4-月-7-日-ga-amazon-s3-files"&gt;2026 年 4 月 7 日 GA: Amazon S3 Files&lt;/h3&gt;

&lt;p&gt;アーキテクチャを検討していたタイミングで、AWS が &lt;a href="https://aws.amazon.com/blogs/aws/launching-s3-files-making-s3-buckets-accessible-as-file-systems/"&gt;Amazon S3 Files&lt;/a&gt; を GA リリースしました。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 バケットをバックエンドとして、NFS 4.1/4.2 でマウントできるファイルシステムを提供する&lt;/strong&gt;サービスです。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/05/13/s3-files-suzuri-lens/s3-files-concept.png" alt="図2: S3 Files の仕組み。S3 バケットが直接 NFS エンドポイントになる。DataSync 不要" /&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;特性&lt;/th&gt;
      &lt;th&gt;値&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;プロトコル&lt;/td&gt;
      &lt;td&gt;NFS 4.1 / 4.2（POSIX セマンティクス準拠 ※ハードリンク等は非対応）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;小ファイルの読み取り（キャッシュ済み）&lt;/td&gt;
      &lt;td&gt;数 ms 以下&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;キャッシュの読み取りスループット&lt;/td&gt;
      &lt;td&gt;4.7 GB/s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;大ファイル（≥ 128 KB）&lt;/td&gt;
      &lt;td&gt;S3 から直接ストリーム（サービス全体の集約スループット テラバイト/秒）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;対応コンピュート&lt;/td&gt;
      &lt;td&gt;EC2 / EKS / ECS / Lambda&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;決め手は DataSync が不要になる点でした。&lt;/strong&gt; S3 が source of truth を維持したまま NFS マウントできるため、同期ラグも余分なパイプラインも生じません。また Lambda でも S3 Files マウントが使えるため、将来的な Lambda 化の道筋も確保されます。&lt;/p&gt;

&lt;p&gt;これならやろうとしていたことがスマートに実現できる、作り込もうとしていたDataSync部分をまるごと捨てて、S3 Filesへの切り替えを決めました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id="アーキテクチャ設計"&gt;アーキテクチャ設計&lt;/h2&gt;

&lt;h3 id="移行後の全体像"&gt;移行後の全体像&lt;/h3&gt;

&lt;p&gt;&lt;img src="/blog/2026/05/13/s3-files-suzuri-lens/after-architecture.png" alt="図3: 移行後アーキテクチャ。assets は S3 Files 経由で Pod に NFS マウントされ、コンテナイメージには含まれない" /&gt;&lt;/p&gt;

&lt;h3 id="設計のポイント"&gt;設計のポイント&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;assets をコードリポジトリから完全分離する&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;lens は以前から assets 専用のリポジトリが分離されており、デザイナーはそこに PR を出す運用でした。lens2 では assets がコードリポジトリに直接コミットされていたため、専用の assets リポジトリを新設してデザイナーの PR 先を切り替えました。&lt;/p&gt;

&lt;p&gt;どちらも assets リポジトリへのマージ時に CI が &lt;code&gt;aws s3 sync&lt;/code&gt; を実行し S3 に同期します。S3 Files 経由で Pod への反映はほぼリアルタイムです。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ゼロダウンタイムの段階的移行&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/05/13/s3-files-suzuri-lens/migration-phases.png" alt="図4: 段階的移行フェーズ。Phase 2a で既存イメージと S3 Files を並存させ、安全を確認してから切り替える" /&gt;&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Phase 1:  S3 バケット作成 + S3 Files 有効化
Phase 2a: 既存 Pod に S3 Files を追加マウントして動作確認
Phase 2b: assets の参照先を S3 Files に切り替え
Phase 2c: Dockerfile から assets を除外 → イメージ軽量化
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Phase 2a がこの設計の安全弁です。既存イメージを稼働させたまま別パスに S3 Files をマウントし、ファイルの内容とレイテンシを確認します。マウントに失敗しても Kubernetes の rolling update により旧 ReplicaSet がそのまま動き続けるため、本番トラフィックへの影響はゼロです。なお Phase 2c 以降はイメージから assets が除去されるため、fat image への即時ロールバックは現実的ではありません。S3 Files の可用性に依存した設計となる点は後述します。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id="実装-lens2rustで先行検証"&gt;実装: lens2（Rust）で先行検証&lt;/h2&gt;

&lt;h3 id="eks-への-s3-files-マウント"&gt;EKS への S3 Files マウント&lt;/h3&gt;

&lt;p&gt;EFS CSI Driver v3.0 以降が S3 Files に対応しています。通常の EFS ボリュームと同じ CSI ドライバーを使いますが、設定にいくつか注意点があります（後述の「ハマりどころ」を参照）。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# PersistentVolume&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolume&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lens2-assets&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100Gi&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteMany&lt;/span&gt;
  &lt;span class="na"&gt;mountOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;iam&lt;/span&gt;
  &lt;span class="na"&gt;csi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;efs.csi.aws.com&lt;/span&gt;
    &lt;span class="na"&gt;volumeHandle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3files:fs-xxxxxxxxxxxxxxxxx"&lt;/span&gt;  &lt;span class="c1"&gt;# s3files: プレフィックス必須&lt;/span&gt;
    &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Deployment への追加&lt;/span&gt;
&lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3-assets&lt;/span&gt;
    &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/mnt/s3-assets&lt;/span&gt;
    &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3-assets&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lens2-assets&lt;/span&gt;
      &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id="phase-2a-動作確認並列マウント"&gt;Phase 2a: 動作確認（並列マウント）&lt;/h3&gt;

&lt;p&gt;Phase 2a では &lt;code&gt;mountPath: /mnt/s3-assets&lt;/code&gt; に S3 Files を追加マウントするだけです。アプリの参照先（&lt;code&gt;ASSETS_DIR&lt;/code&gt;）はまだ変更しません。Pod に入って &lt;code&gt;find /mnt/s3-assets | wc -l&lt;/code&gt; でファイル数を確認し、イメージ内の assets とファイル数が一致することを確かめます。&lt;/p&gt;

&lt;h3 id="phase-2b-assets_dir-切り替え"&gt;Phase 2b: ASSETS_DIR 切り替え&lt;/h3&gt;

&lt;p&gt;動作確認が取れたら、ConfigMap で &lt;code&gt;ASSETS_DIR=/mnt/s3-assets&lt;/code&gt; に切り替えます。staging → production の順に適用し、各ステップで Datadog APM のレイテンシに異常がないことを確認します。&lt;/p&gt;

&lt;h3 id="phase-2c-dockerfile-から-assets-を除外"&gt;Phase 2c: Dockerfile から assets を除外&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ASSETS_DIR&lt;/code&gt; の参照先が S3 Files に切り替わったことを確認してから、Dockerfile を変更します。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before（削除する行）&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; ./assets ./assets&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /lens2/assets /lens2/assets&lt;/span&gt;

&lt;span class="c"&gt;# After: 上記 2 行を削除するだけ&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;これでイメージから assets が消え、ビルドと pull が劇的に速くなります。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id="ハマりどころ-efs-csi-driver-を動かしてわかったこと"&gt;ハマりどころ: EFS CSI Driver を動かしてわかったこと&lt;/h2&gt;

&lt;p&gt;ドキュメントに記載のない制約が複数ありました。同じ問題に直面した方の参考になれば幸いです。&lt;/p&gt;

&lt;h3 id="1-inline-ephemeral-csi-volume-は非対応"&gt;1. inline (Ephemeral) CSI volume は非対応&lt;/h3&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: volume mode 'Ephemeral' not supported by driver efs.csi.aws.com
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;S3 Files のマウントには &lt;strong&gt;PersistentVolume / PersistentVolumeClaim&lt;/strong&gt; が必要です。Pod spec へのインライン定義（Ephemeral volume）はサポートされていません。&lt;/p&gt;

&lt;h3 id="2-accessmodes-readonlymany-が非対応"&gt;2. &lt;code&gt;accessModes: ReadOnlyMany&lt;/code&gt; が非対応&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ReadOnlyMany&lt;/code&gt; と書くとマウントに失敗します。&lt;code&gt;ReadWriteMany&lt;/code&gt; で宣言し、&lt;strong&gt;PV の &lt;code&gt;csi&lt;/code&gt; セクション・PVC 参照（&lt;code&gt;volumes&lt;/code&gt; セクション）・&lt;code&gt;volumeMount&lt;/code&gt; の3か所すべてに &lt;code&gt;readOnly: true&lt;/code&gt; を設定する&lt;/strong&gt;のが実際に動作した方法です。上のコード例ではすべての箇所に記載されています。&lt;/p&gt;

&lt;h3 id="3-volumehandle-に-s3files-プレフィックスが必須"&gt;3. &lt;code&gt;volumeHandle&lt;/code&gt; に &lt;code&gt;s3files:&lt;/code&gt; プレフィックスが必須&lt;/h3&gt;

&lt;p&gt;通常の EFS では &lt;code&gt;fs-xxxxxxxxxxxxxxxxx&lt;/code&gt; のみ指定しますが、S3 Files では必ず &lt;code&gt;s3files:fs-xxxxxxxxxxxxxxxxx&lt;/code&gt; の形式にしなければなりません。プレフィックスがないと通常の EFS ボリュームとして解釈されてマウントに失敗します。&lt;/p&gt;

&lt;h3 id="4-mountoptions-iam-の明示指定が必要"&gt;4. &lt;code&gt;mountOptions: [iam]&lt;/code&gt; の明示指定が必要&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;mountOptions&lt;/code&gt; への &lt;code&gt;iam&lt;/code&gt; の明示指定は省略できません。&lt;/strong&gt; ドキュメント（&lt;a href="https://github.com/kubernetes-sigs/aws-efs-csi-driver/blob/master/docs/parameters.md"&gt;&lt;code&gt;docs/parameters.md&lt;/code&gt;&lt;/a&gt;）では optional な mountOption の一例として列挙されているだけで、デフォルト適用はされません。省略すると mount 時に &lt;code&gt;access denied by server&lt;/code&gt; が返ります。&lt;/p&gt;

&lt;h3 id="5-controller-sa-と-node-sa-に別々の-iam-ロールが必要"&gt;5. controller SA と node SA に別々の IAM ロールが必要&lt;/h3&gt;

&lt;p&gt;EFS CSI Driver の controller pod と node plugin pod はそれぞれ異なる ServiceAccount を使います。&lt;strong&gt;両方に S3 Files へのアクセス権を持つ IAM ロールを割り当てる&lt;/strong&gt;必要があります。Pod Identity を使う場合も同様です。片方だけ設定してもマウントが通りません。&lt;/p&gt;

&lt;h3 id="補足-s3-files-障害時の挙動と監視"&gt;補足: S3 Files 障害時の挙動と監視&lt;/h3&gt;

&lt;p&gt;Phase 2c 以降、コンテナイメージから assets が除去されるため、S3 Files が停止すると lens/lens2 の画像合成機能が利用不可になります。fat image への即時ロールバックは assets をリポジトリから削除済みのため現実的ではなく、S3 Files の可用性に依存した設計です。&lt;/p&gt;

&lt;p&gt;この点への対策を整理します。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;可用性&lt;/strong&gt;: S3 Files は EFS の基盤上に構築されており、データは S3 に保存されます。S3 の SLA は月次稼働率 99.9% で、複数 AZ に冗長化されています。S3 Files が停止しても S3 バケット内のデータは保全されるため、S3 Files の復旧後に自動復旧します&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;監視&lt;/strong&gt;: Datadog APM・外形監視・レイテンシ監視により異常を検知できる体制を整えています&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;NFS オプション&lt;/strong&gt;: soft マウントやタイムアウト設定で NFS I/O ブロック時の挙動を調整することもできますが、lens2 での本番運用で pod 起動やレイテンシに支障がなかったため、今回はデフォルト設定のまま運用しています。詳細は &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files-mounting-eks.html"&gt;AWS 公式ドキュメント&lt;/a&gt; を参照してください&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id="計測結果"&gt;計測結果&lt;/h2&gt;

&lt;h3 id="lens2rust本番実測"&gt;lens2（Rust）本番実測&lt;/h3&gt;

&lt;p&gt;Before の値は assets をイメージに焼き込んでいた移行前の実測値です。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;指標&lt;/th&gt;
      &lt;th&gt;Before&lt;/th&gt;
      &lt;th&gt;After&lt;/th&gt;
      &lt;th&gt;改善&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;ECR image size（圧縮後）&lt;/td&gt;
      &lt;td&gt;1.75 GB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;86 MB&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;約 1/20&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Build wall-clock&lt;/td&gt;
      &lt;td&gt;19m 19s&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;3m 43s&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;約 80% 削減&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Pod Scheduled→Ready（cold）&lt;/td&gt;
      &lt;td&gt;86〜102 s&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~39s&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;約 60% 削減&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;レンダリングレイテンシ（avg）&lt;/td&gt;
      &lt;td&gt;158.4 ms&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;153.8 ms&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;劣化なし（微改善）&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id="lensjavascript本番実測"&gt;lens（JavaScript）本番実測&lt;/h3&gt;

&lt;p&gt;Before の値は S3 Files 導入前の実測値です。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;指標&lt;/th&gt;
      &lt;th&gt;Before&lt;/th&gt;
      &lt;th&gt;After&lt;/th&gt;
      &lt;th&gt;改善&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;ECR image size（圧縮後）&lt;/td&gt;
      &lt;td&gt;10.3 GB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;507 MB&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;約 1/20&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Build wall-clock&lt;/td&gt;
      &lt;td&gt;14m 31s&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;1m 48s&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;約 88% 削減&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Image pull（cold）&lt;/td&gt;
      &lt;td&gt;5m 03s&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;11〜34s&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;約 1/9〜1/27&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Pod Scheduled→Ready（cold）&lt;/td&gt;
      &lt;td&gt;5〜5.5 分&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~45s&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;約 85% 削減&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;レンダリングレイテンシ（avg）&lt;/td&gt;
      &lt;td&gt;~5.1s&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~4.9s&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;劣化なし&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id="s3-files-の読み取りレイテンシlens-本番実測"&gt;S3 Files の読み取りレイテンシ（lens 本番実測）&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;ファイル&lt;/th&gt;
      &lt;th&gt;サイズ&lt;/th&gt;
      &lt;th&gt;初回アクセス&lt;/th&gt;
      &lt;th&gt;warm（2 回目以降）&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;NotoSansJP-Medium.otf&lt;/td&gt;
      &lt;td&gt;4.6 MB&lt;/td&gt;
      &lt;td&gt;272 ms&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~2.7 ms&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;watermark.png&lt;/td&gt;
      &lt;td&gt;15 KB&lt;/td&gt;
      &lt;td&gt;3.7 ms&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~1.9 ms&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;checkerboard.png&lt;/td&gt;
      &lt;td&gt;14 KB&lt;/td&gt;
      &lt;td&gt;10.4 ms&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~2.0 ms&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;item 画像（1.2 MB）&lt;/td&gt;
      &lt;td&gt;1.2 MB&lt;/td&gt;
      &lt;td&gt;247 ms&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~2.4 ms&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;item 画像（15.4 MB）&lt;/td&gt;
      &lt;td&gt;15.4 MB&lt;/td&gt;
      &lt;td&gt;343 ms&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~3.4 ms&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;初回アクセスはファイルサイズに比例して数 ms〜数百 ms かかります。2 回目以降はキャッシュに乗り 2〜3 ms 台に収まります。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;アプリレイヤーへの影響はほぼゼロです。&lt;/strong&gt; lens のレンダリング処理は ImageMagick の CPU 処理がボトルネックで 1 リクエストあたり平均 5 秒程度かかります。warm 状態での 2〜3 ms という読み取りレイテンシは、処理時間全体に対して誤差の範囲です。Datadog APM でも本番レンダリングレイテンシに劣化は確認されませんでした。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id="lensjavascriptへのロールアウト"&gt;lens（JavaScript）へのロールアウト&lt;/h2&gt;

&lt;p&gt;lens2 での実装・検証を経て、lens への展開は設計の差分を埋めるだけで済みました。&lt;/p&gt;

&lt;h3 id="lens-ならではの差異-上書きマウント方式"&gt;lens ならではの差異: 上書きマウント方式&lt;/h3&gt;

&lt;p&gt;lens2 では &lt;code&gt;ASSETS_DIR&lt;/code&gt; 環境変数で assets パスを切り替えられましたが、lens は &lt;code&gt;path.resolve('assets/...')&lt;/code&gt; + PM2 の &lt;code&gt;cwd: '/suzuri-lens'&lt;/code&gt; でパスがハードコードされているため、環境変数による切り替えができません。&lt;/p&gt;

&lt;p&gt;そこで S3 Files を &lt;strong&gt;&lt;code&gt;/suzuri-lens/assets&lt;/code&gt; に直接マウント（既存パスを shadow するボリュームマウント）&lt;/strong&gt; する方式を採用しました。アプリ側のコード変更は一切不要で、Kubernetes マニフェストの &lt;code&gt;mountPath&lt;/code&gt; をアプリの参照パスに合わせるだけです。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lens の場合: アプリの参照パスに直接マウントして shadow&lt;/span&gt;
&lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3-assets&lt;/span&gt;
    &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/suzuri-lens/assets&lt;/span&gt;   &lt;span class="c1"&gt;# アプリの参照先パスと一致させる&lt;/span&gt;
    &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Phase 2b では「既存イメージ（assets 焼き込み）+ S3 Files の上書きマウント」の並存状態になります。どちらのソースから読んでも内容は同一なので、切り替えは透過的です。Phase 2c で軽量イメージに更新すると、S3 Files が唯一の assets ソースになります。&lt;/p&gt;

&lt;h3 id="lens2-のノウハウがそのまま活きた"&gt;lens2 のノウハウがそのまま活きた&lt;/h3&gt;

&lt;p&gt;EFS CSI Driver の制約はすべて lens2 の試行錯誤で解決済みでした。PV/PVC 設定・IAM ロール構成・マウントオプションを lens2 のマニフェストからほぼそのまま流用できたため、lens の展開は短期間で完了しました。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id="まとめ"&gt;まとめ&lt;/h2&gt;

&lt;h3 id="成果サマリ"&gt;成果サマリ&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;指標&lt;/th&gt;
      &lt;th&gt;lens2&lt;/th&gt;
      &lt;th&gt;lens&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;コンテナイメージ（ECR 圧縮後）&lt;/td&gt;
      &lt;td&gt;1.75 GB → &lt;strong&gt;86 MB&lt;/strong&gt;（約 1/20）&lt;/td&gt;
      &lt;td&gt;10.3 GB → &lt;strong&gt;507 MB&lt;/strong&gt;（約 1/20）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Build time&lt;/td&gt;
      &lt;td&gt;19m 19s → &lt;strong&gt;3m 43s&lt;/strong&gt;（80% 削減）&lt;/td&gt;
      &lt;td&gt;14m 31s → &lt;strong&gt;1m 48s&lt;/strong&gt;（88% 削減）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Pod 起動（cold）&lt;/td&gt;
      &lt;td&gt;86〜102s → &lt;strong&gt;~39s&lt;/strong&gt;（60% 削減）&lt;/td&gt;
      &lt;td&gt;5〜5.5 分 → &lt;strong&gt;~45s&lt;/strong&gt;（85% 削減）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;レンダリングレイテンシ&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;劣化なし&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;劣化なし&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;上記はすべて &lt;strong&gt;月間 1.1 億リクエストを処理する本番サービスでの実測値&lt;/strong&gt;です。&lt;/p&gt;

&lt;h3 id="s3-files-を選んでよかった点"&gt;S3 Files を選んでよかった点&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;DataSync が不要&lt;/strong&gt;: S3 が source of truth を維持したまま NFS マウントができる。同期ラグの問題が根本的に存在しない&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;assets が増えてもイメージサイズが変わらない&lt;/strong&gt;: 今後 lens2 の assets が増えても コンテナイメージは変わらない&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;EKS / ECS / Lambda に横展開できる&lt;/strong&gt;: 将来の Lambda 化へのパスが確保されている&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;GA 直後でも実用レベルに達している&lt;/strong&gt;: 2026 年 4 月 7 日 GA、5 月 7 日には lens/lens2 両方で本番稼働中&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id="さいごに"&gt;さいごに&lt;/h3&gt;

&lt;p&gt;正直、GA直後のサービスを本番に入れることにはリスクがありました。GA当日は2026年4月7日で、lens/lens2両方がそろったのがゴールデンウィークを挟んだ5月7日です。実質3週間ほどでここまでできたのは、lens2でスモールスタートしてからlensへ展開するという段階的な設計が効いていたと思います。lens2のほうがトラフィックが少ないため、仮に問題が起きてもロールバックできる状態で実績を積み、ゴールデンウィーク中にレイテンシの計測実績を蓄積してからlensへ投入するという順序があったからこそ、自信を持って進められました。「新しいものを本番に入れることにリスクがある」のは、裏を返せば計測と段階的な設計で解決できる問題でした。この取り組みがどなたかの背中を押せたら嬉しいです。&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>GitHub Actionsの実行遅延をCloud SchedulerとCloud Workflowsで解消する</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/05/11/cloud-workflows-github-actions-trigger/"/>
    <id>https://tech.pepabo.com/2026/05/11/cloud-workflows-github-actions-trigger/</id>
    <published>2026-05-11T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>zaimy</name>
    </author>
    <content type="html">&lt;p&gt;こんにちは、技術部データ基盤チームの &lt;a href="https://x.com/hirokazaitsu"&gt;zaimy&lt;/a&gt; です。&lt;/p&gt;

&lt;p&gt;GitHub Actionsの &lt;code&gt;schedule:&lt;/code&gt; トリガーが大幅に遅延する問題を、Cloud SchedulerとCloud Workflowsで解消した話を書きます。最終的に採用した構成だけでなく、検討して棄却した構成と棄却理由も合わせて紹介します。&lt;/p&gt;

&lt;ol id="markdown-toc"&gt;
  &lt;li&gt;&lt;a href="#背景" id="markdown-toc-背景"&gt;背景&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#検討した構成" id="markdown-toc-検討した構成"&gt;検討した構成&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#案a-cloud-schedulerからgithub-apiを直接叩く" id="markdown-toc-案a-cloud-schedulerからgithub-apiを直接叩く"&gt;案A: Cloud SchedulerからGitHub APIを直接叩く&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#案b-cloud-schedulerから起動するcloud-workflowsでgithub-actionsを置き換える" id="markdown-toc-案b-cloud-schedulerから起動するcloud-workflowsでgithub-actionsを置き換える"&gt;案B: Cloud Schedulerから起動するCloud WorkflowsでGitHub Actionsを置き換える&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#案c-採用-cloud-schedulerから起動するcloud-workflowsがgithub-actionsをdispatchする" id="markdown-toc-案c-採用-cloud-schedulerから起動するcloud-workflowsがgithub-actionsをdispatchする"&gt;案C (採用): Cloud Schedulerから起動するCloud WorkflowsがGitHub Actionsをdispatchする&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#なぜcloud-kmsが必要なのか" id="markdown-toc-なぜcloud-kmsが必要なのか"&gt;なぜCloud KMSが必要なのか&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#実装" id="markdown-toc-実装"&gt;実装&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#cloud-kms-asymmetric-keyの作成-terraform" id="markdown-toc-cloud-kms-asymmetric-keyの作成-terraform"&gt;Cloud KMS asymmetric keyの作成 (terraform)&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#github-appのprivate-keyをcloud-kmsにimport" id="markdown-toc-github-appのprivate-keyをcloud-kmsにimport"&gt;GitHub Appのprivate keyをCloud KMSにimport&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#cloud-workflowsのyaml" id="markdown-toc-cloud-workflowsのyaml"&gt;Cloud WorkflowsのYAML&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#ハマりどころ" id="markdown-toc-ハマりどころ"&gt;ハマりどころ&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#import-methodの選択--aes-256-の有無" id="markdown-toc-import-methodの選択--aes-256-の有無"&gt;Import methodの選択 (&lt;code&gt;-aes-256&lt;/code&gt; の有無)&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#pem--pkcs8-derへの変換" id="markdown-toc-pem--pkcs8-derへの変換"&gt;PEM → PKCS#8 DERへの変換&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#結果" id="markdown-toc-結果"&gt;結果&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#その他の構成案" id="markdown-toc-その他の構成案"&gt;その他の構成案&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#まとめ" id="markdown-toc-まとめ"&gt;まとめ&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="背景"&gt;背景&lt;/h2&gt;

&lt;p&gt;データ基盤チームでは、毎日の昼会で「前日のチーム内アップデート」を共有しています。GitHub Projectsのカードを更新者やステータスに基づいてアップデート種別 (例: 話題にすべき / 軽く触れる / ステータス移動) を自動分類してラベル付けするGitHub Actionsを、昼会の少し前に走らせる運用にしていました。このAction自体はラベル付与にClaudeを使う仕組みになっていて中身も面白いのですが、本記事の本題はそこではなく、起動タイミングの話です。&lt;/p&gt;

&lt;p&gt;このActionは &lt;code&gt;schedule:&lt;/code&gt; トリガー (cron) で平日11:55に実行するよう設定していましたが、実際の実行時刻は以下の通り、毎日60分以上の遅延がある状態でした。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;日付&lt;/th&gt;
      &lt;th&gt;cron 設定 (UTC)&lt;/th&gt;
      &lt;th&gt;実際の実行時刻 (UTC)&lt;/th&gt;
      &lt;th&gt;遅延&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;4/22 Wed&lt;/td&gt;
      &lt;td&gt;02:55&lt;/td&gt;
      &lt;td&gt;03:55&lt;/td&gt;
      &lt;td&gt;+60min&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4/21 Tue&lt;/td&gt;
      &lt;td&gt;02:55&lt;/td&gt;
      &lt;td&gt;03:57&lt;/td&gt;
      &lt;td&gt;+62min&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4/20 Mon&lt;/td&gt;
      &lt;td&gt;02:55&lt;/td&gt;
      &lt;td&gt;04:02&lt;/td&gt;
      &lt;td&gt;+67min&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;GitHub Actionsの &lt;code&gt;schedule:&lt;/code&gt; はベストエフォートで、混雑時は遅延・スキップされる仕様です。これは &lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule"&gt;GitHub公式ドキュメント&lt;/a&gt; にも明記されています。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Note: The &lt;code&gt;schedule&lt;/code&gt; event can be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour. If the load is sufficiently high enough, some queued jobs may be dropped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;そこで定刻で実行できるよう、スケジューラを別の仕組みに移すことにしました。&lt;/p&gt;

&lt;h2 id="検討した構成"&gt;検討した構成&lt;/h2&gt;

&lt;h3 id="案a-cloud-schedulerからgithub-apiを直接叩く"&gt;案A: Cloud SchedulerからGitHub APIを直接叩く&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://cloud.google.com/scheduler"&gt;Cloud Scheduler&lt;/a&gt; は秒単位で定刻実行する信頼性の高いサービスです。HTTPターゲットを設定できるので、&lt;code&gt;POST https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches&lt;/code&gt; を直接叩けばよいのでは、というアイデアです。&lt;/p&gt;

&lt;p&gt;棄却理由: Cloud SchedulerのHTTPターゲットの認証オプションは、&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;なし (公開エンドポイント向け)&lt;/li&gt;
  &lt;li&gt;OAuth2トークン (Google APIs専用)&lt;/li&gt;
  &lt;li&gt;OIDCトークン (Cloud Run / Cloud Functions向け)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;の3つだけで、&lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; のような任意ヘッダの値をSecret Manager等から動的に解決する仕組みがありません。カスタムヘッダ機能はあるものの、ヘッダ値はジョブ定義に平文ハードコードになります。つまりこの構成だと認証に必要な情報をterraform stateに平文で書くことになるため、棄却しました。&lt;/p&gt;

&lt;h3 id="案b-cloud-schedulerから起動するcloud-workflowsでgithub-actionsを置き換える"&gt;案B: Cloud Schedulerから起動するCloud WorkflowsでGitHub Actionsを置き換える&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://cloud.google.com/workflows"&gt;Cloud Workflows&lt;/a&gt; はYAMLで処理を記述するワークフローエンジンで、HTTPコール、Secret Manager / KMS / BigQueryなどのGoogle Cloudサービス連携、リトライ、分岐、ループを書けます。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tech.pepabo.com/2026/05/11/cloud-composer-retirement/"&gt;データ基盤のワークフロー構成変更によるコスト84%削減とCI 34倍高速化&lt;/a&gt; で紹介している通り、ペパボのデータ基盤ではすでにCloud SchedulerとCloud Workflowsの利用実績があるため、Cloud Workflowsで既存のActionを置き換えられるのでは、というアイデアです。&lt;/p&gt;

&lt;p&gt;棄却理由: 既存のラベル付けスクリプトは500行程度のPythonで、&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;GitHub GraphQL APIのページネーション&lt;/li&gt;
  &lt;li&gt;Vertex AI ClaudeでのJSON分類&lt;/li&gt;
  &lt;li&gt;Issue / PRの差分計算 (前回のラベルと今回の分類差分だけを更新)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;を含みます。これらをCloud Workflows YAMLで書き直すと、可読性と保守性が大きく落ちます。スクリプトをコンテナに入れて、Cloud Workflowsから呼び出すCloud Runで実行する構成もあり得ますが、本件の本質は定刻実行なので、ラベル付け本体の実装はGitHub Actions側に残すのが妥当と判断しました。&lt;/p&gt;

&lt;h3 id="案c-採用-cloud-schedulerから起動するcloud-workflowsがgithub-actionsをdispatchする"&gt;案C (採用): Cloud Schedulerから起動するCloud WorkflowsがGitHub Actionsをdispatchする&lt;/h3&gt;

&lt;p&gt;最終的に採用したのはこの構成です。Cloud WorkflowsからGitHub AppのJWT署名を行い、installation tokenを取得して &lt;code&gt;dispatches&lt;/code&gt; を叩きます。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cloud Scheduler (Asia/Tokyo, 55 11 * * 1-5)
  └─ Cloud Workflows
       ├─ Secret ManagerからGitHub App ID / Installation IDを取得
       ├─ Cloud KMS asymmetricSignでJWT (RS256) を署名
       │    └─ GitHub App private keyはCloud KMSにasymmetric keyとしてimport済み
       ├─ POST https://api.github.com/app/installations/{id}/access_tokens
       │    └─ installation tokenを取得
       └─ POST https://api.github.com/repos/{org}/{repo}/actions/workflows/{workflow}.yml/dispatches
             └─ GitHub Actionsを起動 (既存のラベル付けスクリプトが走る)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;各レイヤの責務がきれいに分かれます。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;レイヤ&lt;/th&gt;
      &lt;th&gt;責務&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Scheduler&lt;/td&gt;
      &lt;td&gt;定刻実行&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Workflows&lt;/td&gt;
      &lt;td&gt;GitHub認証 + dispatch (JWT署名 + token取得 + API呼び出し)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GitHub Actions&lt;/td&gt;
      &lt;td&gt;ラベル付け本体 (Vertex AI Claude / GraphQL / 差分計算)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id="なぜcloud-kmsが必要なのか"&gt;なぜCloud KMSが必要なのか&lt;/h2&gt;

&lt;p&gt;GitHub Appのトークン取得フローはこうです。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;App ID + private keyでJWT (RS256) を作る&lt;/li&gt;
  &lt;li&gt;JWTをBearerに付けて &lt;code&gt;POST /app/installations/{id}/access_tokens&lt;/code&gt; → installation token (1時間有効) を取得&lt;/li&gt;
  &lt;li&gt;installation tokenをBearerに付けて &lt;code&gt;dispatches&lt;/code&gt; を叩く&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;問題は、1.のRS256署名です。Cloud Workflowsの標準ライブラリ (&lt;code&gt;base64&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt; など) には暗号系のライブラリが含まれておらず、RSA秘密鍵で署名する関数が無いため、秘密鍵をSecret Managerから取り出してもJWTを組み立てられません。&lt;/p&gt;

&lt;p&gt;回避策は以下の通りで、今回はCloud KMSを利用しました。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;案&lt;/th&gt;
      &lt;th&gt;署名する場所&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud KMS asymmetricSign&lt;/td&gt;
      &lt;td&gt;KMS (鍵をimportして署名APIを呼ぶ)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloud Run や Cloud Run Functionsを挟む&lt;/td&gt;
      &lt;td&gt;Python &lt;code&gt;pyjwt&lt;/code&gt; 等で署名&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id="実装"&gt;実装&lt;/h2&gt;

&lt;h3 id="cloud-kms-asymmetric-keyの作成-terraform"&gt;Cloud KMS asymmetric keyの作成 (terraform)&lt;/h3&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_kms_key_ring"&lt;/span&gt; &lt;span class="s2"&gt;"workflows"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"workflows"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_kms_crypto_key"&lt;/span&gt; &lt;span class="s2"&gt;"github_app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"github-app-private-key"&lt;/span&gt;
  &lt;span class="nx"&gt;key_ring&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_kms_key_ring&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workflows&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;purpose&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ASYMMETRIC_SIGN"&lt;/span&gt;

  &lt;span class="c1"&gt;# 既存の GitHub App private key を後から import するため初期バージョンは作らない&lt;/span&gt;
  &lt;span class="nx"&gt;skip_initial_version_creation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;version_template&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;algorithm&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RSA_SIGN_PKCS1_2048_SHA256"&lt;/span&gt;
    &lt;span class="nx"&gt;protection_level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SOFTWARE"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_kms_crypto_key_iam_member"&lt;/span&gt; &lt;span class="s2"&gt;"github_app_signer"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"prod-serviceaccount@{project}.iam.gserviceaccount.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"test-serviceaccount@{project}.iam.gserviceaccount.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;crypto_key_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google_kms_crypto_key&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_app&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"roles/cloudkms.signerVerifier"&lt;/span&gt;
  &lt;span class="nx"&gt;member&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"serviceAccount:${each.value}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id="github-appのprivate-keyをcloud-kmsにimport"&gt;GitHub Appのprivate keyをCloud KMSにimport&lt;/h3&gt;

&lt;p&gt;GitHub Appは外部公開鍵の登録を許さない (常にGitHub側で鍵を生成する) ので、既にSecret Manager上に登録してあるprivate keyをCloud KMSにimport jobで取り込みます。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. import job を作成する。AES wrapping を含む -aes-256 つきを使う。&lt;/span&gt;
gcloud kms import-jobs create import-github-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1 &lt;span class="nt"&gt;--keyring&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;workflows &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--import-method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rsa-oaep-3072-sha256-aes-256 &lt;span class="nt"&gt;--protection-level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;software

&lt;span class="c"&gt;# 2. import job が ACTIVE になるまで数分待つ。&lt;/span&gt;
gcloud kms import-jobs describe import-github-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1 &lt;span class="nt"&gt;--keyring&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;workflows &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'value(state)'&lt;/span&gt;

&lt;span class="c"&gt;# 3. private key を Secret Manager から取得して PKCS#8 DER に変換する。&lt;/span&gt;
&lt;span class="c"&gt;#    Secret Manager 上は GitHub から download した PKCS#1 PEM 形式だが、&lt;/span&gt;
&lt;span class="c"&gt;#    --target-key-file は PKCS#8 DER binary を要求するため変換が必要。&lt;/span&gt;
gcloud secrets versions access latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;github-app-private-key-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--out-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/gh_app.pem
openssl pkcs8 &lt;span class="nt"&gt;-topk8&lt;/span&gt; &lt;span class="nt"&gt;-nocrypt&lt;/span&gt; &lt;span class="nt"&gt;-inform&lt;/span&gt; PEM &lt;span class="nt"&gt;-in&lt;/span&gt; /tmp/gh_app.pem &lt;span class="nt"&gt;-outform&lt;/span&gt; DER &lt;span class="nt"&gt;-out&lt;/span&gt; /tmp/gh_app.der

&lt;span class="c"&gt;# 4. private key を import する。&lt;/span&gt;
gcloud kms keys versions import &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--import-job&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;import-github-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1 &lt;span class="nt"&gt;--keyring&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;workflows &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;github-app-private-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rsa-sign-pkcs1-2048-sha256 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-key-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/gh_app.der

&lt;span class="c"&gt;# 5. 一時ファイルを削除する。&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/gh_app.pem /tmp/gh_app.der
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id="cloud-workflowsのyaml"&gt;Cloud WorkflowsのYAML&lt;/h3&gt;

&lt;p&gt;JWT構築 → Cloud KMS署名 → installation token取得 → dispatchの流れをそのまま書き下します。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;init&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;assign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;project_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;github_app_id_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${args.github_app_id_secret}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;github_app_installation_id_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${args.github_app_installation_id_secret}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kms_key_version_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${args.kms_key_version_name}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target_repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${args.target_repo}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target_workflow_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${args.target_workflow_file}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${args.target_ref}&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;get_github_app_id_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;googleapis.secretmanager.v1.projects.secrets.versions.access&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${"projects/" + project_id + "/secrets/" + github_app_id_secret + "/versions/latest"}&lt;/span&gt;
        &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_id_secret_response&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;decode_github_app_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;assign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;github_app_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${int(text.decode(base64.decode(app_id_secret_response.payload.data)))}&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;get_github_app_installation_id_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;googleapis.secretmanager.v1.projects.secrets.versions.access&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${"projects/" + project_id + "/secrets/" + github_app_installation_id_secret + "/versions/latest"}&lt;/span&gt;
        &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;installation_id_secret_response&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;decode_github_app_installation_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;assign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;github_app_installation_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${text.decode(base64.decode(installation_id_secret_response.payload.data))}&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;build_jwt_header_and_payload&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;assign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;now_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${int(sys.now())}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_header_obj&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;alg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256"&lt;/span&gt;
              &lt;span class="na"&gt;typ&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT"&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_payload_obj&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;iat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${now_seconds - 60}&lt;/span&gt;
              &lt;span class="na"&gt;exp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${now_seconds + 480}&lt;/span&gt;
              &lt;span class="na"&gt;iss&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${github_app_id}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_header_json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${json.encode_to_string(jwt_header_obj)}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_payload_json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${json.encode_to_string(jwt_payload_obj)}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_header_b64_std&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${base64.encode(text.encode(jwt_header_json))}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_payload_b64_std&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${base64.encode(text.encode(jwt_payload_json))}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_header_b64&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${text.replace_all(text.replace_all(text.replace_all(jwt_header_b64_std, "+", "-"), "/", "_"), "=", "")}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_payload_b64&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${text.replace_all(text.replace_all(text.replace_all(jwt_payload_b64_std, "+", "-"), "/", "_"), "=", "")}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_signing_input&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${jwt_header_b64 + "." + jwt_payload_b64}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_signing_input_b64&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${base64.encode(text.encode(jwt_signing_input))}&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;sign_jwt_with_kms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http.post&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${"https://cloudkms.googleapis.com/v1/"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;kms_key_version_name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;":asymmetricSign"}'&lt;/span&gt;
          &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OAuth2&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${jwt_signing_input_b64}&lt;/span&gt;
        &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kms_sign_response&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;assemble_jwt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;assign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_signature_b64_std&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${kms_sign_response.body.signature}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt_signature_b64&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${text.replace_all(text.replace_all(text.replace_all(jwt_signature_b64_std, "+", "-"), "/", "_"), "=", "")}&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;jwt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${jwt_signing_input + "." + jwt_signature_b64}&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;exchange_jwt_for_installation_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http.post&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${"https://api.github.com/app/installations/"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github_app_installation_id&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/access_tokens"}'&lt;/span&gt;
          &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${"Bearer " + jwt}&lt;/span&gt;
            &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/vnd.github+json"&lt;/span&gt;
            &lt;span class="na"&gt;X-GitHub-Api-Version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2022-11-28"&lt;/span&gt;
            &lt;span class="na"&gt;User-Agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;google-cloud-workflows"&lt;/span&gt;
        &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;installation_token_response&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;extract_installation_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;assign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;installation_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${installation_token_response.body.token}&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dispatch_workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http.post&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${"https://api.github.com/repos/"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target_repo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/actions/workflows/"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target_workflow_file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/dispatches"}'&lt;/span&gt;
          &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${"Bearer " + installation_token}&lt;/span&gt;
            &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/vnd.github+json"&lt;/span&gt;
            &lt;span class="na"&gt;X-GitHub-Api-Version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2022-11-28"&lt;/span&gt;
            &lt;span class="na"&gt;User-Agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;google-cloud-workflows"&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${target_ref}&lt;/span&gt;
        &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dispatch_response&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;log_dispatched&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sys.log&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${"Dispatched&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;workflow&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target_repo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target_workflow_file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ref="&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target_ref}'&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INFO&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;return_result&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;return&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;${"dispatched:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target_repo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target_workflow_file}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;ポイントを補足します。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;JWTのbase64url変換: Cloud Workflowsの &lt;code&gt;base64.encode&lt;/code&gt; は標準base64 (&lt;code&gt;+&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;=&lt;/code&gt;) を返すので、&lt;code&gt;text.replace_all&lt;/code&gt; を3段ネストしてbase64url (&lt;code&gt;-&lt;/code&gt;, &lt;code&gt;_&lt;/code&gt;, パディングなし) に変換します。&lt;/li&gt;
  &lt;li&gt;Cloud KMS asymmetricSignの呼び出し: &lt;code&gt;RSA_SIGN_PKCS1_2048_SHA256&lt;/code&gt; は &lt;code&gt;data&lt;/code&gt; フィールドに署名対象を渡せばKMS側でハッシュ計算と署名をやってくれます。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="ハマりどころ"&gt;ハマりどころ&lt;/h2&gt;

&lt;h3 id="import-methodの選択--aes-256-の有無"&gt;Import methodの選択 (&lt;code&gt;-aes-256&lt;/code&gt; の有無)&lt;/h3&gt;

&lt;p&gt;最初 &lt;code&gt;--import-method=rsa-oaep-3072-sha256&lt;/code&gt; で &lt;code&gt;import-job&lt;/code&gt; を作ったところ、&lt;code&gt;gcloud kms keys versions import&lt;/code&gt; で&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: ('target-key-file', "The file is larger than the import method's maximum size of 318 bytes.")
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;と弾かれました。素の &lt;code&gt;rsa-oaep-3072-sha256&lt;/code&gt; はRSA-OAEPで直接wrapできる最大サイズが318バイトしかなく、PKCS#8 DERのRSA 2048 private key (1200バイト超) は入りません。正解はAES wrappingを組み合わせる &lt;code&gt;rsa-oaep-3072-sha256-aes-256&lt;/code&gt; です。これだとAESでkey materialをwrapし、AESキーをRSA-OAEPでwrapする2段構成になり、大きな鍵もimportできます。&lt;/p&gt;

&lt;p&gt;なお、&lt;code&gt;import-job&lt;/code&gt; はimmutableで明示削除APIがありません (作成から3日でexpireする)。method指定を間違えると同名でやり直せないので、suffix変更 (&lt;code&gt;-v2&lt;/code&gt; 等) で逃げる必要があります。&lt;/p&gt;

&lt;h3 id="pem--pkcs8-derへの変換"&gt;PEM → PKCS#8 DERへの変換&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--target-key-file&lt;/code&gt; にはPKCS#8形式のDER (binary) でエンコードされた鍵を渡す必要があります。Secret Managerに保存していたGitHub Appのprivate keyはPEM形式 (PKCS#1) だったので、&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl pkcs8 &lt;span class="nt"&gt;-topk8&lt;/span&gt; &lt;span class="nt"&gt;-nocrypt&lt;/span&gt; &lt;span class="nt"&gt;-inform&lt;/span&gt; PEM &lt;span class="nt"&gt;-in&lt;/span&gt; /tmp/gh_app.pem &lt;span class="nt"&gt;-outform&lt;/span&gt; DER &lt;span class="nt"&gt;-out&lt;/span&gt; /tmp/gh_app.der
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;で変換してから渡しました。最初これを忘れて &lt;code&gt;state: IMPORT_FAILED&lt;/code&gt; になり、&lt;code&gt;importFailureReason: The key material in the import request couldn't be unwrapped or wasn't formatted correctly&lt;/code&gt; で落ちました。&lt;code&gt;reimportEligible: true&lt;/code&gt; だったので同じversion番号に対して再importできました。&lt;/p&gt;

&lt;h2 id="結果"&gt;結果&lt;/h2&gt;

&lt;p&gt;定刻に「Cloud Scheduler実行 → Cloud Workflows実行 → Actions起動」まで一連の流れが安定動作するようになりました。Cloud Workflowsの実行は数秒で完了しています。&lt;/p&gt;

&lt;p&gt;また、副次的な収穫として、Cloud KMS asymmetricSign経由でGitHub App JWTを署名するパターンが手元に残ったので、今後Cloud WorkflowsからGitHub APIを叩きたい別ユースケースがあれば容易に再利用できます。&lt;/p&gt;

&lt;h2 id="その他の構成案"&gt;その他の構成案&lt;/h2&gt;

&lt;p&gt;データ基盤チームでは既存資産としてCloud SchedulerとCloud Workflowsの組み合わせにおけるアラート実装や障害対応フローが存在するためCloud Workflowsを採用しましたが、制約がない場合はCloud SchedulerとCloud Run Functionsによる実装も十分選択できそうです。&lt;/p&gt;

&lt;h2 id="まとめ"&gt;まとめ&lt;/h2&gt;

&lt;p&gt;GitHub Actionsの &lt;code&gt;schedule:&lt;/code&gt; トリガーは便利ですが、定刻実行が要件のジョブには使えません。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Cloud Schedulerで定刻実行させる&lt;/li&gt;
  &lt;li&gt;Cloud Workflowsで「GitHub認証 + dispatch」を担当する&lt;/li&gt;
  &lt;li&gt;Cloud KMSにGitHub App private keyを取り込んでJWT署名する&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;の3レイヤ構成にしたことで、ベストエフォートの実行から定刻実行に切り替えられました。&lt;/p&gt;

&lt;p&gt;「Cloud SchedulerからGitHubを直接叩く」「Cloud Workflowsで全部やる」など、いくつかの代替案を比較検討した結果、責務がきれいに分かれて既存の運用基盤に乗るこの構成に落ち着きました。&lt;/p&gt;

&lt;p&gt;Cloud KMSに鍵をimportする手順 (import-method、AES wrapping、PEM→PKCS#8 DER変換、IMPORT_FAILED時の再import) には地味なハマりどころが多いので、同じ構成を組む方の参考になれば嬉しいです。&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>データ基盤のワークフロー構成変更によるコスト84%削減とCI 34倍高速化</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/05/11/cloud-composer-retirement/"/>
    <id>https://tech.pepabo.com/2026/05/11/cloud-composer-retirement/</id>
    <published>2026-05-11T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>zaimy</name>
    </author>
    <content type="html">&lt;p&gt;技術部データ基盤チームの&lt;a href="https://x.com/hirokazaitsu"&gt;@zaimy&lt;/a&gt;です。&lt;/p&gt;

&lt;p&gt;ペパボの社内データ基盤「Bigfoot」では、&lt;a href="https://rand.pepabo.com/article/2020/06/16/bigfoot-migration/#section-4"&gt;2020年にDigdagから移設して以来&lt;/a&gt;約5年間にわたりCloud Composer（Managed Apache Airflow）をワークフローエンジンとして運用してきましたが、2026年1Qをもって別構成に移行しました。本記事では、Cloud Composerから3つのマネージドサービスへ移行した経緯と、その効果について紹介します。&lt;/p&gt;

&lt;ol id="markdown-toc"&gt;
  &lt;li&gt;&lt;a href="#cloud-composerで動いていた3つのワークロード" id="markdown-toc-cloud-composerで動いていた3つのワークロード"&gt;Cloud Composerで動いていた3つのワークロード&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#なぜ撤退したのか" id="markdown-toc-なぜ撤退したのか"&gt;なぜ撤退したのか&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#1-agentic-ai-readyになるためにdbtを身軽にしたい" id="markdown-toc-1-agentic-ai-readyになるためにdbtを身軽にしたい"&gt;1. Agentic AI readyになるためにdbtを身軽にしたい&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#2-コストの膨張" id="markdown-toc-2-コストの膨張"&gt;2. コストの膨張&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#3-運用負荷の高さ" id="markdown-toc-3-運用負荷の高さ"&gt;3. 運用負荷の高さ&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#移行先コンポーネントの紹介" id="markdown-toc-移行先コンポーネントの紹介"&gt;移行先コンポーネントの紹介&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#dbt-cloud" id="markdown-toc-dbt-cloud"&gt;dbt Cloud&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#bigquery-data-transfer-service-dts" id="markdown-toc-bigquery-data-transfer-service-dts"&gt;BigQuery Data Transfer Service (DTS)&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="#cloud-workflows" id="markdown-toc-cloud-workflows"&gt;Cloud Workflows&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#コスト比較" id="markdown-toc-コスト比較"&gt;コスト比較&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#開発効率比較" id="markdown-toc-開発効率比較"&gt;開発効率比較&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#運用面の比較" id="markdown-toc-運用面の比較"&gt;運用面の比較&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#エラーレート比較" id="markdown-toc-エラーレート比較"&gt;エラーレート比較&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#まとめ" id="markdown-toc-まとめ"&gt;まとめ&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="cloud-composerで動いていた3つのワークロード"&gt;Cloud Composerで動いていた3つのワークロード&lt;/h2&gt;

&lt;p&gt;データ基盤ではCloud Composer上で主に以下の3つのワークロードを動かしていました。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;dbtによるデータ変換（Cosmos経由でAirflow DAG上で実行）&lt;/li&gt;
  &lt;li&gt;事業DBからBigQueryへの同期&lt;/li&gt;
  &lt;li&gt;dbt以外のデータ変換や機械学習パイプラインなどその他のオーケストレーション&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="なぜ撤退したのか"&gt;なぜ撤退したのか&lt;/h2&gt;

&lt;p&gt;3つの課題が重なったため、2026年1Qでの撤退を決定しました。&lt;/p&gt;

&lt;h3 id="1-agentic-ai-readyになるためにdbtを身軽にしたい"&gt;1. Agentic AI readyになるためにdbtを身軽にしたい&lt;/h3&gt;

&lt;p&gt;技術部の方針「&lt;a href="https://tech.pepabo.com/2026/02/19/tech-division-agent-ready-2026/"&gt;Agent Ready&lt;/a&gt;」に基づき、AIエージェント前提の技術基盤づくりを進めるにあたって、dbtでSSoTなデータマートを作った上でAgentic AIによる分析を実現したいと考えています。そのためにdbtをより身軽に使いたいのですが、Airflow上でdbtを実行する構成はオーバーヘッドが大きく、AIを用いた開発運用においてフィードバックループが遅い状態でした。&lt;/p&gt;

&lt;h3 id="2-コストの膨張"&gt;2. コストの膨張&lt;/h3&gt;

&lt;p&gt;Cloud Composer 2からCloud Composer 3への移行や、使用量の増加により、月額コストが数十万円に膨張していました。&lt;/p&gt;

&lt;h3 id="3-運用負荷の高さ"&gt;3. 運用負荷の高さ&lt;/h3&gt;

&lt;p&gt;AirflowはPythonで作り込めるがゆえに、5年間の運用を経てコードベースが複雑になっていました。さらにCloud Composer/Airflowのバージョンアップ対応が必要であったり、Cloud Composerはリソース量をプリセットから選ぶ形でフルマネージドとも言えなかったりと、マネージド度合いを高めたいという課題がありました。&lt;/p&gt;

&lt;p&gt;なお、Cloud Composerのバージョンアップにまつわる運用課題については、以前「&lt;a href="https://tech.pepabo.com/2024/12/12/airflow-bg-deployment/"&gt;社内データ基盤のワークフローエンジンをBlue-green deploymentで無停止バージョンアップする&lt;/a&gt;」で紹介しています。&lt;/p&gt;

&lt;h2 id="移行先コンポーネントの紹介"&gt;移行先コンポーネントの紹介&lt;/h2&gt;

&lt;p&gt;Cloud Composerが担っていた3つのワークロードを、それぞれ専用のマネージドサービスに分離しました。&lt;/p&gt;

&lt;h3 id="dbt-cloud"&gt;dbt Cloud&lt;/h3&gt;

&lt;p&gt;dbtの実行基盤をAirflow + Cosmosからdbt Cloudに移行しました。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;メリット&lt;/th&gt;
      &lt;th&gt;デメリット&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;開発体験&lt;/td&gt;
      &lt;td&gt;CI実行時間が劇的に短縮（後述）。dbtに特化したIDE・ドキュメント生成・リネージが標準で使える&lt;/td&gt;
      &lt;td&gt;開発者シート数に上限あり&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;運用&lt;/td&gt;
      &lt;td&gt;バージョン管理・デプロイがSaaS側で完結。Airflowの運用が不要に&lt;/td&gt;
      &lt;td&gt;障害時の対応手段が限定的&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;拡張性&lt;/td&gt;
      &lt;td&gt;Webhookで後続処理をトリガー可能（Eventarc連携）&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id="bigquery-data-transfer-service-dts"&gt;BigQuery Data Transfer Service (DTS)&lt;/h3&gt;

&lt;p&gt;事業DBの同期をDTSに移行しました。元は&lt;a href="https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/USER_ExportSnapshot.html"&gt;Amazon RDSのAmazon S3へのDBスナップショットデータのエクスポート&lt;/a&gt;をAirflow DAGから実行し、さらにBigQueryのLOADジョブをAirflow DAGから発行してロードする構成でしたが、移行後はMySQLやPostgreSQLなどのコネクタを使ってRDSから直接BigQueryにデータ転送します。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;メリット&lt;/th&gt;
      &lt;th&gt;デメリット&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;コスト&lt;/td&gt;
      &lt;td&gt;Google公式コネクタを使うことで現時点で転送コストなし&lt;/td&gt;
      &lt;td&gt;RDSがプライベートVPC内のためCloud VPNが必要&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;運用&lt;/td&gt;
      &lt;td&gt;フルマネージド。スケジュール実行・モニタリングが組み込み&lt;/td&gt;
      &lt;td&gt;細かいカスタマイズが難しい&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;信頼性&lt;/td&gt;
      &lt;td&gt;ジョブ品質をGoogleに任せられる&lt;/td&gt;
      &lt;td&gt;1ジョブにテーブルを詰め込みすぎるとレコード欠損が生じるケースがある。運用面からもジョブ分散が有用&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id="cloud-workflows"&gt;Cloud Workflows&lt;/h3&gt;

&lt;p&gt;MLパイプラインやデータ変換のオーケストレーションをAirflow DAGからCloud Workflowsに移行しました。
また、dbt Cloudとの連携のため、dbt modelのビルド完了をトリガーにEventarcを介してCloud Workflowsを起動する仕組みも構築しました。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;メリット&lt;/th&gt;
      &lt;th&gt;デメリット&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;コスト&lt;/td&gt;
      &lt;td&gt;従量課金で月額数百円程度。無料枠も大きい&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;開発体験&lt;/td&gt;
      &lt;td&gt;YAMLベースでシンプル。Terraform統合でCI/CDが容易&lt;/td&gt;
      &lt;td&gt;Airflowと比べ柔軟性が低い&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;運用&lt;/td&gt;
      &lt;td&gt;サーバーレス。ワークロードごとに独立。Cloud Logging/Monitoringに統合&lt;/td&gt;
      &lt;td&gt;Airflow UIのような統合的な実行管理画面はない&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id="コスト比較"&gt;コスト比較&lt;/h2&gt;

&lt;p&gt;構成変更前後の月額コストを比較すると、約84%の削減となりました。&lt;/p&gt;

&lt;h2 id="開発効率比較"&gt;開発効率比較&lt;/h2&gt;

&lt;p&gt;開発効率の比較としてdbt modelの開発を例に、構成変更前後のCI実行時間を比較しました。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;指標&lt;/th&gt;
      &lt;th&gt;Cloud Composer（n=622）&lt;/th&gt;
      &lt;th&gt;dbt Cloud（n=476）&lt;/th&gt;
      &lt;th&gt;改善&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;中央値&lt;/td&gt;
      &lt;td&gt;約17分&lt;/td&gt;
      &lt;td&gt;約30秒&lt;/td&gt;
      &lt;td&gt;34倍高速化&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;平均値&lt;/td&gt;
      &lt;td&gt;約42分&lt;/td&gt;
      &lt;td&gt;約2.5分&lt;/td&gt;
      &lt;td&gt;17倍高速化&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;CIの高速化により、dbt modelの変更に対するフィードバックループが劇的に短縮されました。これはAgentic AIがdbt modelを自律的に変更する際にも重要な基盤です。&lt;/p&gt;

&lt;h2 id="運用面の比較"&gt;運用面の比較&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;観点&lt;/th&gt;
      &lt;th&gt;Cloud Composer&lt;/th&gt;
      &lt;th&gt;移行後&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;バージョンアップ&lt;/td&gt;
      &lt;td&gt;Cloud Composer/Airflowの大型アップグレード対応が必要（Cloud Composer 2→3でコスト増）&lt;/td&gt;
      &lt;td&gt;マネージドサービスで自動。EOL対応が不要&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;障害時の影響範囲&lt;/td&gt;
      &lt;td&gt;全DAGが1環境に依存。障害で全ワークロード停止&lt;/td&gt;
      &lt;td&gt;ワークロードごとに独立。障害の影響を局所化&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;スケーリング&lt;/td&gt;
      &lt;td&gt;環境サイズの変更が必要（Worker 2台〜を常時確保するためコスト負荷もあり）&lt;/td&gt;
      &lt;td&gt;サーバーレスで自動スケール&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;デプロイ&lt;/td&gt;
      &lt;td&gt;時間がかかる（GCS同期→パース待ち）&lt;/td&gt;
      &lt;td&gt;個別に高速にデプロイ可能&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;可観測性&lt;/td&gt;
      &lt;td&gt;Airflow UIで一元管理&lt;/td&gt;
      &lt;td&gt;Cloud Logging/Monitoringに統合&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;アラート・障害対応&lt;/td&gt;
      &lt;td&gt;Airflowのpost hookでSlack通知 + GitHub Issueを作成&lt;/td&gt;
      &lt;td&gt;Cloud MonitoringとCloud FunctionsでSlack通知 + GitHub Issueを作成&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id="エラーレート比較"&gt;エラーレート比較&lt;/h2&gt;

&lt;p&gt;Cloud Monitoringのメトリクスから、本番環境のDAG/Workflow実行の失敗率を比較しました。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;Cloud Composer（1〜3月）&lt;/th&gt;
      &lt;th&gt;Cloud Workflows（4月）&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;成功&lt;/td&gt;
      &lt;td&gt;32,494&lt;/td&gt;
      &lt;td&gt;1,849&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;失敗&lt;/td&gt;
      &lt;td&gt;286&lt;/td&gt;
      &lt;td&gt;15&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;合計&lt;/td&gt;
      &lt;td&gt;32,780&lt;/td&gt;
      &lt;td&gt;1,864&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;エラーレート&lt;/td&gt;
      &lt;td&gt;0.87%&lt;/td&gt;
      &lt;td&gt;0.80%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;期間の違いによりサンプル数の差はありますが、移行後の安定性が改善傾向であることを確認できます。&lt;/p&gt;

&lt;h2 id="まとめ"&gt;まとめ&lt;/h2&gt;

&lt;p&gt;Cloud Composerをdbt Cloud / BigQuery Data Transfer Service / Cloud Workflowsの3つのマネージドサービスに分離することで、以下の効果を得ました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;コスト: 月額コスト約84%削減&lt;/li&gt;
  &lt;li&gt;開発効率: CI中央値17分→30秒（34倍高速化）&lt;/li&gt;
  &lt;li&gt;運用: サーバーレス化によりEOL対応・スケーリング・障害影響範囲が改善&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;「マネージドなAirflow」という一枚岩のアーキテクチャから、ワークロードの特性に合った専用サービスに分離するアプローチは、同様の構成で運用コストに課題を感じている方の参考になれば幸いです。&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>ORDER BY id DESC が招くインデックス誤選択 — 直近のレコードを N 件取り出すクエリを実測 約 658 倍に高速化</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/05/01/minne-order-by-limit-pitfall/"/>
    <id>https://tech.pepabo.com/2026/05/01/minne-order-by-limit-pitfall/</id>
    <published>2026-05-01T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>kazu</name>
    </author>
    <content type="html">&lt;h2 id="はじめに"&gt;はじめに&lt;/h2&gt;

&lt;p&gt;「&lt;strong&gt;ある所有者の直近のレコード（注文・投稿・取引など）から、最新の N 件を取りたい&lt;/strong&gt;」というユースケースは、Web アプリケーションを書いていれば頻出するパターンです。Rails 風に書けば次のような形になります。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orders&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="vi"&gt;@order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# 自分自身は除外する&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: :desc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 「直近」を id 降順で表現&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;このパターンが、minne では特定のユーザーで安定的に MySQL のクエリタイムアウトを起こしていました。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mysql2::Error: Query execution was interrupted, maximum statement execution time exceeded
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;調査の結果、原因は &lt;strong&gt;&lt;code&gt;ORDER BY id DESC&lt;/code&gt; + &lt;code&gt;LIMIT N&lt;/code&gt; の組み合わせによる MySQL optimizer のインデックス誤選択&lt;/strong&gt; で、worst case で &lt;strong&gt;2,300 万行をスキャン&lt;/strong&gt; して &lt;code&gt;max_statement_time&lt;/code&gt; を踏み抜いていることが判明しました。&lt;code&gt;ORDER BY&lt;/code&gt; のカラムを既存インデックスに合わせて変えるだけで、&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;スキャン推定行数: &lt;strong&gt;23,297,383 行 → 65,024 行（約 358 倍削減）&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;実測実行時間: &lt;strong&gt;56.6ms → 0.086ms（約 658 倍高速化）&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;まで縮みました。本記事では、その原因分析と修正アプローチを EXPLAIN ANALYZE とあわせて紹介します。「直近のレコードを N 件取り出す」クエリを書くすべての人に還元できる知見となることを願っています。&lt;/p&gt;

&lt;ol id="markdown-toc"&gt;
  &lt;li&gt;&lt;a href="#はじめに" id="markdown-toc-はじめに"&gt;はじめに&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#何が起きていたか" id="markdown-toc-何が起きていたか"&gt;何が起きていたか&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#原因--order-by-id-desc--limit-n-で-optimizer-が-primary-を選ぶ" id="markdown-toc-原因--order-by-id-desc--limit-n-で-optimizer-が-primary-を選ぶ"&gt;原因 — &lt;code&gt;ORDER BY id DESC&lt;/code&gt; + &lt;code&gt;LIMIT N&lt;/code&gt; で optimizer が PRIMARY を選ぶ&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#limit-を外せば直るのではない" id="markdown-toc-limit-を外せば直るのではない"&gt;「LIMIT を外せば直る」のではない&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#修正--order-by-を既存インデックスのソート順に揃える" id="markdown-toc-修正--order-by-を既存インデックスのソート順に揃える"&gt;修正 — &lt;code&gt;ORDER BY&lt;/code&gt; を既存インデックスのソート順に揃える&lt;/a&gt;    &lt;ol&gt;
      &lt;li&gt;&lt;a href="#補足-id-desc-と-ordered_at-desc-の意味的な差" id="markdown-toc-補足-id-desc-と-ordered_at-desc-の意味的な差"&gt;補足: &lt;code&gt;id DESC&lt;/code&gt; と &lt;code&gt;ordered_at DESC&lt;/code&gt; の意味的な差&lt;/a&gt;&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;a href="#比較サマリ" id="markdown-toc-比較サマリ"&gt;比較サマリ&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#学び" id="markdown-toc-学び"&gt;学び&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#参考" id="markdown-toc-参考"&gt;参考&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="何が起きていたか"&gt;何が起きていたか&lt;/h2&gt;

&lt;p&gt;問題のあったコードは、ある作家の &lt;strong&gt;直近の注文を N 件取り出す&lt;/strong&gt; ためのクエリでした。本筋に関係しない部分を削ぎ落とすと、概形は次のようになります。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orders&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="vi"&gt;@order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# 自分自身は除外する&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: :desc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 「直近」を id 降順で表現&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;current_user&lt;/code&gt;（作家）の注文のうち、現在のレコード自身を除いて &lt;code&gt;id&lt;/code&gt; 降順に N 件取るだけ。発行される SQL もざっくり次のような形です。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creator_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;一見、&lt;code&gt;creator_id&lt;/code&gt; で絞って &lt;code&gt;id&lt;/code&gt; 降順に N 件取るだけのシンプルなクエリです。&lt;strong&gt;注文数が少ない作家では即座に返ります。&lt;/strong&gt; ところが累計数万件規模の注文を持つような作家では、このクエリが安定してタイムアウトしていました。&lt;/p&gt;

&lt;h2 id="原因--order-by-id-desc--limit-n-で-optimizer-が-primary-を選ぶ"&gt;原因 — &lt;code&gt;ORDER BY id DESC&lt;/code&gt; + &lt;code&gt;LIMIT N&lt;/code&gt; で optimizer が PRIMARY を選ぶ&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sales&lt;/code&gt; テーブルには &lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; の複合インデックス &lt;code&gt;index_sales_on_creator_id_ordered_at&lt;/code&gt; が張ってあります。&lt;strong&gt;&lt;code&gt;(creator_id, id)&lt;/code&gt; のインデックスはありません。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;実際の作家（sale 約 6.5 万件保有）で EXPLAIN ANALYZE を取った結果が次のとおりです。なお &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; は通常の &lt;code&gt;EXPLAIN&lt;/code&gt; と違って &lt;strong&gt;クエリを実際に実行した上で&lt;/strong&gt; 実測値を返すため、本番 DB に対して使う場合は負荷影響が伴います。本記事の計測も、実行タイミングや対象クエリを選ぶなど &lt;strong&gt;影響範囲が最小となるよう注意した上で&lt;/strong&gt; 取得しています。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPLAIN ANALYZE SELECT sales.* FROM sales
WHERE sales.creator_id = xxxxx AND sales.id != yyyyy
ORDER BY sales.id DESC
LIMIT 10;

-&amp;gt; Limit: 10 row(s)  (cost=66636 rows=10) (actual time=6.26..56.6 rows=10 loops=1)
   -&amp;gt; Filter: ((sales.creator_id = xxxxx) and (sales.id &amp;lt;&amp;gt; yyyyy))  (cost=66636 rows=34158) (actual time=6.26..56.6 rows=10 loops=1)
       -&amp;gt; Index range scan on sales using PRIMARY over (id &amp;lt; yyyyy) OR (yyyyy &amp;lt; id) (reverse)
          (cost=66636 rows=23.3e+6) (actual time=0.0213..54.1 rows=27907 loops=1)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;possible_keys&lt;/code&gt; には &lt;code&gt;PRIMARY&lt;/code&gt; と &lt;code&gt;index_sales_on_creator_id_ordered_at&lt;/code&gt; の両方が挙がっていましたが、optimizer は &lt;strong&gt;PRIMARY 逆順スキャン&lt;/strong&gt; を選択しました。&lt;code&gt;ORDER BY id DESC LIMIT 10&lt;/code&gt; を満たすのに「PRIMARY を逆順に舐めて、&lt;code&gt;creator_id&lt;/code&gt; にヒットした行が 10 件揃ったところで打ち切る」プランを最も低コストと見積もったわけです。&lt;/p&gt;

&lt;p&gt;これは MySQL が &lt;code&gt;ORDER BY ... LIMIT&lt;/code&gt; の組み合わせに対して行う最適化（書籍『&lt;a href="https://gihyo.jp/book/2024/978-4-297-14184-4"&gt;MySQL運用・管理［実践］入門&lt;/a&gt;』では &lt;strong&gt;ORDER BY LIMIT 最適化&lt;/strong&gt; と呼ばれています）が裏目に出たケースです。同書第 4 章「ロックとクエリ実行計画」では、&lt;code&gt;ORDER BY Population DESC LIMIT 10&lt;/code&gt; のような単純なケースについてこう説明されています。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;idx_population によって Population はすでに昇順（暗黙のソート順は ASC）にソートされており、B+Tree 形式をしているためこのインデックスを昇順にたどるのと逆順にたどるのはほぼ等価に可能です（よってインデックスを使って逆順に処理したことを示す Extra: Backward index scan が追加されています）。&lt;/p&gt;

  &lt;p&gt;——『MySQL運用・管理［実践］入門』p.93&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;MySQL 8.0 で導入された &lt;strong&gt;Backward index scan&lt;/strong&gt; によって、&lt;code&gt;ORDER BY id DESC&lt;/code&gt; は PRIMARY を逆順に辿ればファイルソート無しに満たせます。さらに &lt;code&gt;LIMIT N&lt;/code&gt; が付くと「途中で N 件揃った時点で打ち切れる」という早期終了が見込めるため、optimizer の目には PRIMARY 逆順スキャンが極めて魅力的なプランに映る、というわけです。&lt;/p&gt;

&lt;p&gt;なぜこれが罠になるかは、&lt;code&gt;sales&lt;/code&gt; の PRIMARY が &lt;strong&gt;全作家の注文が時系列にミックスされた 1 本の列&lt;/strong&gt; だと考えると見えてきます。「PRIMARY を逆順に少し舐めれば N 件揃う」という見立ては、暗黙のうちに &lt;strong&gt;対象作家の注文行が PRIMARY の最新側に十分密集している&lt;/strong&gt; ことを前提にしています。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;直近で注文を捌いている作家: 最新側の id 帯に自分の注文行が頻繁に現れる → 数千行のスキャンで N 件揃う&lt;/li&gt;
  &lt;li&gt;直近で注文を出していない作家: 最新側にはほぼ存在せず、自分の注文行が出てくる id 帯まで遡る必要がある → スキャン量が膨大に&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;これは『MySQL運用・管理［実践］入門』が示す ORDER BY LIMIT 最適化の &lt;strong&gt;最悪ケース&lt;/strong&gt; に相当します。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;最悪ケースは Continent=’Asia’ を満たす行が確定する前にインデックスフルスキャンが終わるケース（すべて Continent にヒットしない）。&lt;/p&gt;

  &lt;p&gt;——『MySQL運用・管理［実践］入門』p.94&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;書籍は &lt;code&gt;Continent='Asia'&lt;/code&gt; でフィルタにヒットせずインデックスを最後まで舐めきるケースを挙げていますが、本記事の事例は &lt;code&gt;creator_id = xxxxx&lt;/code&gt; でヒットする行が PRIMARY 末尾に十分量現れない、という述語の差があるだけで構造は同じです。&lt;code&gt;WHERE&lt;/code&gt; 列と &lt;code&gt;ORDER BY&lt;/code&gt; 列のインデックスが分離している限り、optimizer は filtered の見積もりに基づいて「フィルタは早期に効くだろう」と楽観視し、それが外れた瞬間にスキャン量がテーブル全体まで線形に伸びていきます。&lt;/p&gt;

&lt;p&gt;23.3M はあくまで rows 推定の上限値で、上のテスト作家では actual 27,907 行で済んでいます。一方で本番では、最新側に行が少ない作家のケースで &lt;code&gt;max_statement_time&lt;/code&gt; を実際に踏み抜いていました。テーブルが伸びるほど worst case のスキャン量も線形に増えていく、という構造になっていたわけです。&lt;/p&gt;

&lt;h3 id="limit-を外せば直るのではない"&gt;「LIMIT を外せば直る」のではない&lt;/h3&gt;

&lt;p&gt;ここで自然な疑問は「じゃあ &lt;code&gt;LIMIT&lt;/code&gt; を外せば PRIMARY 誘導は回避できるのでは？」というものです。実際 &lt;code&gt;LIMIT&lt;/code&gt; を外すと optimizer の選択は変わります。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPLAIN ANALYZE SELECT sales.* FROM sales
WHERE sales.creator_id = xxxxx AND sales.id != yyyyy
ORDER BY sales.id DESC;

-&amp;gt; Sort: sales.id DESC  (cost=63195 rows=65024) (actual time=257..270 rows=35353 loops=1)
   -&amp;gt; Index lookup on sales using index_sales_on_creator_id_ordered_at (creator_id=xxxxx),
      with index condition: (sales.id &amp;lt;&amp;gt; yyyyy)  (cost=63195 rows=65024) (actual time=0.0305..209 rows=35353 loops=1)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; インデックスが採用されて creator スコープに閉じる代わりに、&lt;strong&gt;&lt;code&gt;id DESC&lt;/code&gt; で並べ直すための filesort（&lt;code&gt;Sort&lt;/code&gt; ノード）が必要&lt;/strong&gt;になります。実測 270ms。LIMIT 有りでの 56.6ms より遅く、テーブルが伸びれば線形に悪化します。&lt;/p&gt;

&lt;p&gt;つまり罠の核心は &lt;code&gt;LIMIT&lt;/code&gt; ではなく、&lt;strong&gt;&lt;code&gt;WHERE&lt;/code&gt; 列と &lt;code&gt;ORDER BY&lt;/code&gt; 列が別インデックスにまたがるクエリの組み立て方&lt;/strong&gt; そのものです。&lt;code&gt;LIMIT&lt;/code&gt; の有無で optimizer は別の悪い選択肢に逃げるだけです。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;LIMIT N + &lt;code&gt;id DESC&lt;/code&gt;&lt;/strong&gt;: PRIMARY 逆順スキャン誘導 → テーブル全体に対する worst case&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;LIMIT 無し + &lt;code&gt;id DESC&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; + filesort → creator の sale 件数に対する線形コスト&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;『MySQL運用・管理［実践］入門』は次のように結論しています。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;なお、WHERE と ORDER BY LIMIT 最適化を同時に満たすインデックスは、INDEX(Continent, Population) でこれが最速になります。&lt;/p&gt;

  &lt;p&gt;——『MySQL運用・管理［実践］入門』p.95&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;本件に当てはめれば &lt;code&gt;(creator_id, id)&lt;/code&gt; の複合インデックスを足すのが理論的な最速解で、両方の問題を一気に解消できます。ただし数千万行規模の &lt;code&gt;sales&lt;/code&gt; テーブルへの index 追加は運用コストが大きく、なるべく避けたいところです。本記事の修正は &lt;strong&gt;既存の &lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; を活かす形にアプリ側のクエリを寄せる&lt;/strong&gt; というアプローチを取りました。&lt;/p&gt;

&lt;h2 id="修正--order-by-を既存インデックスのソート順に揃える"&gt;修正 — &lt;code&gt;ORDER BY&lt;/code&gt; を既存インデックスのソート順に揃える&lt;/h2&gt;

&lt;p&gt;修正の本丸は &lt;code&gt;ORDER BY id DESC&lt;/code&gt; を &lt;code&gt;ORDER BY ordered_at DESC&lt;/code&gt; に切り替えることです。これだけで &lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; インデックスが &lt;strong&gt;WHERE と ORDER BY の両方&lt;/strong&gt; で使えるようになり、optimizer の選択が劇的に変わります。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creator_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ordered_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;同じ作家で EXPLAIN ANALYZE を取り直したのが次の結果です。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPLAIN ANALYZE SELECT sales.* FROM sales
WHERE sales.creator_id = xxxxx AND sales.id != yyyyy
ORDER BY sales.ordered_at DESC
LIMIT 10;

-&amp;gt; Limit: 10 row(s)  (cost=63494 rows=10) (actual time=0.0296..0.0863 rows=10 loops=1)
   -&amp;gt; Filter: (sales.id &amp;lt;&amp;gt; yyyyy)  (cost=63494 rows=32512) (actual time=0.0288..0.0848 rows=10 loops=1)
       -&amp;gt; Index lookup on sales using index_sales_on_creator_id_ordered_at (creator_id=xxxxx) (reverse)
          (cost=63494 rows=65024) (actual time=0.0277..0.0827 rows=10 loops=1)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; を逆順に index lookup し、&lt;code&gt;LIMIT 10&lt;/code&gt; で &lt;strong&gt;本当に 10 行だけ読む&lt;/strong&gt; 計画になりました。filesort も無く、actual rows が 10。実測 0.086ms です。&lt;/p&gt;

&lt;p&gt;参考までに、&lt;code&gt;LIMIT&lt;/code&gt; を外した場合の EXPLAIN ANALYZE も載せておきます。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPLAIN ANALYZE SELECT sales.* FROM sales
WHERE sales.creator_id = xxxxx AND sales.id != yyyyy
ORDER BY sales.ordered_at DESC;

-&amp;gt; Filter: (sales.id &amp;lt;&amp;gt; yyyyy)  (cost=60713 rows=32512) (actual time=0.0286..213 rows=35353 loops=1)
   -&amp;gt; Index lookup on sales using index_sales_on_creator_id_ordered_at (creator_id=xxxxx) (reverse)
      (cost=60713 rows=65024) (actual time=0.0273..209 rows=35353 loops=1)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;LIMIT&lt;/code&gt; を外しても &lt;code&gt;Sort&lt;/code&gt; ノードは現れず、&lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; を順に舐めるだけのプランです。actual rows は creator スコープの 35,353 行で、実測 213ms。これは「対象 creator の sale を一通り読む」コストそのものであり、旧クエリのようにテーブル全体の 23M 行を舐めにいくのとは性質がまったく違います。&lt;/p&gt;

&lt;p&gt;修正前後の違いを 4 通りの実験で見える化すると次のようになります（同じ作家・同じ条件で測定）。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Query&lt;/th&gt;
      &lt;th&gt;採用 key&lt;/th&gt;
      &lt;th&gt;filesort&lt;/th&gt;
      &lt;th&gt;actual rows&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;actual time&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;id DESC&lt;/code&gt; + &lt;code&gt;LIMIT 10&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;no&lt;/td&gt;
      &lt;td&gt;27,907&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;56.6 ms&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;id DESC&lt;/code&gt;（LIMIT なし）&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;..._ordered_at&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;YES&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;35,353&lt;/td&gt;
      &lt;td&gt;270 ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;&lt;code&gt;ordered_at DESC&lt;/code&gt; + &lt;code&gt;LIMIT 10&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;..._ordered_at&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;no&lt;/td&gt;
      &lt;td&gt;10&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;0.086 ms&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;ordered_at DESC&lt;/code&gt;（LIMIT なし）&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;..._ordered_at&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;no&lt;/td&gt;
      &lt;td&gt;35,353&lt;/td&gt;
      &lt;td&gt;213 ms&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code&gt;ordered_at DESC&lt;/code&gt; 系は &lt;strong&gt;LIMIT 有無に関わらず&lt;/strong&gt; filesort 無しで &lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; 一本で完結しています。LIMIT 有りの場合は早期打ち切りも効いて &lt;code&gt;actual rows = 10&lt;/code&gt;、文字どおり「N 件だけ読む」状態になっています。&lt;/p&gt;

&lt;h3 id="補足-id-desc-と-ordered_at-desc-の意味的な差"&gt;補足: &lt;code&gt;id DESC&lt;/code&gt; と &lt;code&gt;ordered_at DESC&lt;/code&gt; の意味的な差&lt;/h3&gt;

&lt;p&gt;旧実装は &lt;code&gt;id&lt;/code&gt; 降順、新実装は &lt;code&gt;ordered_at&lt;/code&gt; 降順なので、厳密には「並び順」の意味が変わります。&lt;code&gt;sales.ordered_at&lt;/code&gt; が NULL なレコードや、バックフィルなどで &lt;code&gt;ordered_at&lt;/code&gt; と &lt;code&gt;id&lt;/code&gt; が逆転するレコードでは、選ばれる注文が変わる可能性があります。&lt;/p&gt;

&lt;p&gt;今回のユースケースでは「直近の傾向に近いものが取れていれば十分」だったため許容しましたが、&lt;strong&gt;厳密に &lt;code&gt;id&lt;/code&gt; の最大を取りたい場合は単純な置き換えはできない&lt;/strong&gt; 点には注意が必要です。&lt;code&gt;(creator_id, id)&lt;/code&gt; のインデックスを足すか、&lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; の上で取った後に再度 &lt;code&gt;id&lt;/code&gt; で並べ替える、といった選択肢があります。&lt;/p&gt;

&lt;h2 id="比較サマリ"&gt;比較サマリ&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;項目&lt;/th&gt;
      &lt;th&gt;旧 (&lt;code&gt;ORDER BY id DESC&lt;/code&gt; + &lt;code&gt;LIMIT 10&lt;/code&gt;)&lt;/th&gt;
      &lt;th&gt;新 (&lt;code&gt;ORDER BY ordered_at DESC&lt;/code&gt; + &lt;code&gt;LIMIT 10&lt;/code&gt;)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;採用 index&lt;/td&gt;
      &lt;td&gt;PRIMARY&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;index_sales_on_creator_id_ordered_at&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;スキャン推定行数&lt;/td&gt;
      &lt;td&gt;23,297,383&lt;/td&gt;
      &lt;td&gt;65,024（&lt;strong&gt;約 358 倍削減&lt;/strong&gt;）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;実 scan 行数&lt;/td&gt;
      &lt;td&gt;27,907&lt;/td&gt;
      &lt;td&gt;10&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;filesort&lt;/td&gt;
      &lt;td&gt;無し（PRIMARY backward）&lt;/td&gt;
      &lt;td&gt;無し（index backward）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;実測実行時間&lt;/td&gt;
      &lt;td&gt;56.6 ms&lt;/td&gt;
      &lt;td&gt;0.086 ms（&lt;strong&gt;約 658 倍高速化&lt;/strong&gt;）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;追加 index&lt;/td&gt;
      &lt;td&gt;–&lt;/td&gt;
      &lt;td&gt;–&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;既存 index の活用に閉じているため、&lt;code&gt;sales&lt;/code&gt; テーブルに追加負担は発生せず、巨大テーブルへの DDL を一切避けられました。&lt;/p&gt;

&lt;h2 id="学び"&gt;学び&lt;/h2&gt;

&lt;p&gt;今回の改善から得た学びを 3 つに整理します。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;罠は LIMIT 単独でも &lt;code&gt;id DESC&lt;/code&gt; 単独でもなく、&lt;code&gt;WHERE 列&lt;/code&gt; と &lt;code&gt;ORDER BY 列&lt;/code&gt; が別インデックスにまたがるクエリの組み立て方&lt;/strong&gt;: LIMIT 有り → 別インデックス（PRIMARY）誘導、LIMIT 無し → filesort、どちらにしても遅くなる。fix は &lt;strong&gt;&lt;code&gt;ORDER BY&lt;/code&gt; を &lt;code&gt;WHERE&lt;/code&gt; 列と組になっている既存インデックスのソート順に揃える&lt;/strong&gt; こと&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;EXPLAIN を取るまで「速いはず」は信用しない&lt;/strong&gt;: &lt;code&gt;creator_id&lt;/code&gt; で絞っているから速いだろう、ではなく、&lt;code&gt;possible_keys&lt;/code&gt; と &lt;code&gt;key&lt;/code&gt; を見て optimizer の実際の選択を確認する。本件も &lt;code&gt;possible_keys&lt;/code&gt; には正解の index が出ていたが、&lt;code&gt;key&lt;/code&gt; は PRIMARY だった。&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; まで取ると actual rows / actual time も見えて、見積もりとの乖離も追える（ただし &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; はクエリを実際に走らせるため、本番で取るなら影響範囲が最小となるよう注意するのが基本）&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;巨大テーブルへの index 追加に頼らず、まずクエリ形を既存インデックスに寄せる&lt;/strong&gt;: 数千万行規模のテーブルへの index 追加は運用コストが大きい。&lt;code&gt;(creator_id, ordered_at)&lt;/code&gt; のような既存複合インデックスを活かす形にアプリ側のクエリを寄せるだけで、追加コストゼロで大幅改善が得られるケースは意外と多い&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;これらの観点は、minne のように長く運用されるサービスで巨大テーブルと付き合う上で、繰り返し効いてくる考え方だと感じています。同じような「特定ユーザーで突然タイムアウトする」現象に出会った方の参考になれば幸いです。&lt;/p&gt;

&lt;h2 id="参考"&gt;参考&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;yoku0825・北川健太郎・tom__bo・坂井恵 著『&lt;a href="https://gihyo.jp/book/2024/978-4-297-14184-4"&gt;MySQL運用・管理［実践］入門&lt;/a&gt;』技術評論社, 2024 — 第 4 章「ロックとクエリ実行計画」が ORDER BY LIMIT 最適化と Backward index scan の理論的背景を丁寧に扱っており、本記事の現象を語彙化する上で大きく参考にしました&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.0/en/order-by-optimization.html"&gt;MySQL :: MySQL 8.0 Reference Manual :: 8.2.1.16 ORDER BY Optimization&lt;/a&gt; — MySQL 公式リファレンス&lt;/li&gt;
&lt;/ul&gt;

</content>
  </entry>
  <entry>
    <title>生成AIの従量課金とどう付き合うか？AIサイトエージェント開発で実践した段階的コスト見積もり</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/05/01/ai-app-cost-estimate/"/>
    <id>https://tech.pepabo.com/2026/05/01/ai-app-cost-estimate/</id>
    <published>2026-05-01T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>kinosuke01</name>
    </author>
    <content type="html">&lt;h2 id="はじめに"&gt;はじめに&lt;/h2&gt;

&lt;p&gt;こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています &lt;a href="https://x.com/kinosuke01"&gt;kinosuke01&lt;/a&gt; といいます。&lt;/p&gt;

&lt;p&gt;生成AIを組み込んだアプリケーションを事業として提供するとき、避けて通れないテーマが &lt;strong&gt;コスト&lt;/strong&gt; です。&lt;/p&gt;

&lt;p&gt;事業としてやっている以上、売上・利益・粗利率には当然ながら目標値があります。開発者としても「技術的に動けばOK」ではなく、その数字を前提にしたうえで、お金のことも勘案して設計や実装を進める必要があります。&lt;/p&gt;

&lt;p&gt;ところが生成AIをAPI経由で使う場合、消費トークン数による従量課金となるため、事前にどの程度のコストになるのかを見積もるのが一筋縄ではいきません。&lt;/p&gt;

&lt;p&gt;とくに生成AIが自律的に判断・分岐するワークフローだと、入力も出力もユーザーの指示内容に大きく依存するため、「このケースで n トークン」と簡単には言い切れません。&lt;/p&gt;

&lt;p&gt;本記事では、AIサイトエージェント開発プロジェクトで実施した、&lt;strong&gt;段階的にコスト試算の精度を上げていくアプローチ&lt;/strong&gt;を紹介します。完璧な見積もりを最初から作ろうとするのではなく、プロジェクトのフェーズごとに見積もり方法を変えていく話です。&lt;/p&gt;

&lt;h2 id="前提ai-サイトエージェントというサービス"&gt;前提：AI サイトエージェントというサービス&lt;/h2&gt;

&lt;p&gt;私たちが開発している &lt;a href="https://lolipop.jp/ai/site-agent/"&gt;AI サイトエージェント&lt;/a&gt; は、「カフェのサイトを作りたい」「フリーランス向けのポートフォリオが欲しい」といった自然言語の指示を投げると、ページ構成・デザインテーマ・コンテンツまでを一括で生成してくれる Web サイト制作サービスです。生成したあとも、チャット越しに「トップのキャッチを変えて」「このセクションの写真を差し替えて」と伝えれば、AI が編集を代行してくれます。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/05/01/ai-app-cost-estimate/img01.png" alt="img01" /&gt;
&lt;img src="/blog/2026/05/01/ai-app-cost-estimate/img02.png" alt="img02" /&gt;&lt;/p&gt;

&lt;p&gt;内部のワークフローは、&lt;strong&gt;決定論的なワークフローをベースに、一部のステップで生成AIが自律的に判断・分岐する&lt;/strong&gt; 構造になっています。全部を生成AIに任せるのではなく、「ここは構造が決まっている」「ここは自由度を持たせたい」をフェーズごとに使い分けることで、品質と予測可能性を両立させる狙いです。&lt;/p&gt;

&lt;p&gt;ざっくり図にすると、このような流れです。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/05/01/ai-app-cost-estimate/flow.png" alt="flow" /&gt;&lt;/p&gt;

&lt;p&gt;ラベルに「生成AI：」と書かれているブロックが生成AI呼び出しで、それ以外は決定論的な処理です。たとえば &lt;code&gt;PageAndSectionPlanner&lt;/code&gt; は「ページ何枚構成にするか」「各ページにどのセクションを置くか」を、ユーザーの指示に応じて柔軟に決めます。一方で、決まったフォーマットの JSON が返ってこないと後続の処理が動かないので、Structured Output を使って構造を縛っています。&lt;/p&gt;

&lt;p&gt;このような構造だと、単に「1 回のサイト生成で何トークン？」と問われても、ページ数・セクション数・モデルの揺らぎで大きく変わってきます。見積もりが難しいわけです。&lt;/p&gt;

&lt;h2 id="対応方針段階的に精度を上げる"&gt;対応方針：段階的に精度を上げる&lt;/h2&gt;

&lt;p&gt;難しいのでやらない、というわけにはいきません。かといって最初から正確な数字を出すこともできません。そこでビジネス職と以下について合意しました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;従量課金であり、実績に応じてコストが変動すること&lt;/li&gt;
  &lt;li&gt;開発の進行に合わせて、段階的に試算の精度を上げていくこと&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;具体的には 3 つのフェーズに分けて見積もりを更新していきます。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;フェーズ&lt;/th&gt;
      &lt;th&gt;何を使って見積もるか&lt;/th&gt;
      &lt;th&gt;精度&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1. 設計直後&lt;/td&gt;
      &lt;td&gt;ワークフロー骨組み + 各ステップの想定トークン数&lt;/td&gt;
      &lt;td&gt;概算&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2. 実装後（検証環境）&lt;/td&gt;
      &lt;td&gt;実際の呼び出しログから集計した実績値&lt;/td&gt;
      &lt;td&gt;中精度&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3. ローンチ後&lt;/td&gt;
      &lt;td&gt;本番の実トラフィックに基づくモニタリング&lt;/td&gt;
      &lt;td&gt;高精度&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;以下、それぞれのフェーズで何をやったかを見ていきます。&lt;/p&gt;

&lt;h2 id="フェーズ-1設計段階でざっくり見積もる"&gt;フェーズ 1：設計段階でざっくり見積もる&lt;/h2&gt;

&lt;p&gt;まず最初にやるべきは、ワークフロー全体の骨組みを設計することです。このとき、細部の実装よりも「どのステップで、どんなモデルを、どのくらいのトークン量で呼ぶか」を決めることに集中します。&lt;/p&gt;

&lt;p&gt;たとえば &lt;code&gt;PageAndSectionPlanner&lt;/code&gt; であれば、ざっくりこんな見積もりになります。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;モデル：Gemini 2.5 Flash&lt;/li&gt;
  &lt;li&gt;入力トークン：プロンプト（1,500）+ ユーザー指示（200）= 1,700&lt;/li&gt;
  &lt;li&gt;出力トークン：3 ページ分の JSON ≒ 800&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;（※ 上記のトークン数は説明用の仮の値です。実際の値はプロンプトの内容や出力サイズによって変わります）&lt;/p&gt;

&lt;p&gt;これを全ステップで行い、1 サイト生成あたりの概算を出します。そこに想定利用数（例：月間 n ユーザー × 平均 m 回/人）を掛けて、ざっくりとした月額コストを算出し、ビジネス職に第一報として共有します。&lt;/p&gt;

&lt;p&gt;この段階では精度は荒いものの、&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;「桁が合っているか」の感覚を関係者で揃える&lt;/li&gt;
  &lt;li&gt;「粗利率が破綻するオーダーではないか」の早期チェック&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;という観点で十分に価値があります。桁が想定を超えていれば、そもそもワークフロー設計を見直すべきというシグナルになります。&lt;/p&gt;

&lt;h2 id="フェーズ-2実装してから実データで見積もる"&gt;フェーズ 2：実装してから実データで見積もる&lt;/h2&gt;

&lt;p&gt;2 回目の見積もりに入ります。このフェーズの流れは大きく 3 ステップです。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;エッジケースは後回しにして、まず正常系が一通り動く状態まで実装する&lt;/li&gt;
  &lt;li&gt;生成AI呼び出しのトークン数を自動で DB に記録できるようにする&lt;/li&gt;
  &lt;li&gt;検証環境で実データを溜め、集計結果をもとに再見積もりする&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;順に見ていきます。なお 2 の実装詳細は、記事末尾の「付録：トークン数を DB に記録する仕組み」に切り出しています。&lt;/p&gt;

&lt;h3 id="正常系を一通り動かす"&gt;正常系を一通り動かす&lt;/h3&gt;

&lt;p&gt;このフェーズで大事なのは、&lt;strong&gt;エッジケースの対応は後回しにして、まず「とにかく一通り動く」ものを作ること&lt;/strong&gt; です。&lt;/p&gt;

&lt;p&gt;ここで言うエッジケース対応とは、たとえば以下のようなものです。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;生成AIが Structured Output のスキーマを逸脱した JSON を返したときのサニタイズや再試行&lt;/li&gt;
  &lt;li&gt;タイムアウトやレート制限にかかったときのリトライ制御&lt;/li&gt;
  &lt;li&gt;出力内容が明らかに破綻している（空文字、文字化け等）ときのフォールバック&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;これらは本番運用では欠かせませんが、最初から詰め始めると、実データが溜まるのがずるずる先延ばしになります。正常系さえ動けば検証環境で大半のケースは実測できるので、まずはそちらを優先します。&lt;/p&gt;

&lt;h3 id="トークン数を自動で-db-に記録する"&gt;トークン数を自動で DB に記録する&lt;/h3&gt;

&lt;p&gt;次に、生成AI呼び出し時の「使用モデル」「入力/出力トークン数」「所要時間」などを自動で DB に記録する仕組みを入れます。&lt;code&gt;PageAndSectionPlanner&lt;/code&gt;、&lt;code&gt;ThemeDeterminer&lt;/code&gt;、&lt;code&gt;SectionValueGenerator&lt;/code&gt; 等のすべてのステップで、呼び出すたびに 1 レコードが残る状態を作ります。&lt;/p&gt;

&lt;p&gt;ポイントは、アプリケーションコード側に計測ロジックを足さずに済む形で組み込むことと、プロンプト・レスポンス本文も併せて保存しておくこと（後からプロンプト改善の材料になるため）です。具体的な実装は付録を参照してください。&lt;/p&gt;

&lt;h3 id="実績データから見積もる"&gt;実績データから見積もる&lt;/h3&gt;

&lt;p&gt;ここまで仕込めたら、あとは実データを溜めるフェーズです。検証環境にデプロイし、動作検証がてらサイト生成をひたすら回します。同じ指示文でも、モデルの揺らぎで消費トークン数は変わるため、数を回せば回すほどばらつきが見えてきます。&lt;/p&gt;

&lt;p&gt;実績データが溜まったら、stepType ごと・モデルごとの平均トークン数を集計し、それに想定利用数を掛けて月額コストを再計算します。実際の運用では、検証環境のデータに安全係数（今回は 2 倍程度）を掛けておきました。本番では、検証環境では出なかった長いユーザー入力や、エッジケースの追加プロンプトなどが入り込むことを想定しています。&lt;/p&gt;

&lt;p&gt;この段階で出てきた数字と粗利率の目標値を突き合わせ、ビジネス職と「このまま進められるか」「料金プランの調整が必要か」を議論します。&lt;/p&gt;

&lt;h2 id="フェーズ-3ローンチ後のモニタリング"&gt;フェーズ 3：ローンチ後のモニタリング&lt;/h2&gt;

&lt;p&gt;ここまで来ると、ローンチして実際のユーザーの利用状況を見るだけです。フェーズ 2 で仕込んだトークン数のログを定期的に集計し、&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;1 サイト生成あたりの平均トークン数は想定どおりか&lt;/li&gt;
  &lt;li&gt;粗利率の目標値を脅かす動きはないか&lt;/li&gt;
  &lt;li&gt;特定のステップだけ異常に消費量が大きくなっていないか&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;をモニタリングします。想定から外れた動きがあれば、プロンプトの見直し、モデルの選定変更、もしくは料金プラン側での調整を検討します。&lt;/p&gt;

&lt;p&gt;本番の実データという最も精度の高い情報源を持っている状態なので、このフェーズの見積もりは「見積もり」というより「実測値のトラッキング」です。ここに至って初めて、生成AIのコストは見積もりの対象ではなく、&lt;strong&gt;モニタリングし続ける運用指標&lt;/strong&gt; に変わります。&lt;/p&gt;

&lt;h2 id="まとめ"&gt;まとめ&lt;/h2&gt;

&lt;p&gt;生成AI のコストは、事前に一発で正確に見積もることは困難です。だからといって「わかりません」では済まないので、&lt;strong&gt;段階的に精度を上げていく&lt;/strong&gt; という方針で進めるのが現実的でした。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;フェーズ 1（設計直後）&lt;/strong&gt;：ワークフローの骨組みとざっくりトークン数で桁を合わせる&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;フェーズ 2（実装後）&lt;/strong&gt;：生成AI呼び出しをラップして実データを集め、安全係数を掛けて再見積もり&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;フェーズ 3（ローンチ後）&lt;/strong&gt;：本番データで継続モニタリング、粗利率への影響をチェック&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;このうち効果が大きかったのは、フェーズ 2 で踏んだ次の 2 点です。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;エッジケース対応を後回しにして、一通り動くものを先に作る&lt;/strong&gt;：実データを早く集めるには、検証環境で回せる状態に到達するのが最優先です。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;生成AI呼び出しをラッパー化して、全ステップ自動でログ化する&lt;/strong&gt;：計測を仕組み化しておけば、フェーズ 2 以降の意思決定が実データベースでできるようになります。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;どちらか片方だけでは成立しません。動くものがあってもログが取れなければ実績はわからないし、ログの仕組みだけ整えても呼び出されなければデータは溜まらない。この 2 つを揃えることで、実データを武器に意思決定できる状態に早く到達できました。&lt;/p&gt;

&lt;p&gt;生成AI を使うプロダクトを作る方にとって、同じ悩みを抱えている方の参考になれば幸いです。&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id="付録トークン数を-db-に記録する仕組み"&gt;付録：トークン数を DB に記録する仕組み&lt;/h2&gt;

&lt;p&gt;フェーズ 2 で触れた「生成AI呼び出しのトークン数を自動で DB に記録する」の実装詳細です。本筋を読み進める上では読み飛ばしても構いません。&lt;/p&gt;

&lt;h3 id="どこに永続化するかrdb-を選んだ理由"&gt;どこに永続化するか：RDB を選んだ理由&lt;/h3&gt;

&lt;p&gt;トークン数の永続化先は、いくつか選択肢がありました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;生成AI 向けの Observability SaaS（ &lt;a href="https://smith.langchain.com/"&gt;LangSmith&lt;/a&gt; など）&lt;/strong&gt; を使う&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href="https://langfuse.com/"&gt;Langfuse&lt;/a&gt; の OSS 版&lt;/strong&gt; をセルフホストする&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;自前の RDB テーブル&lt;/strong&gt; に保存する&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;最終的には RDB に保存する方針を選びました。理由は以下です。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;SaaS は便利だが、&lt;strong&gt;ベンダーロックイン&lt;/strong&gt; を避けたい&lt;/li&gt;
  &lt;li&gt;Langfuse OSS は魅力的だが、この時点では&lt;strong&gt;運用対象を増やしたくない&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;RDB なら既に運用しているので追加コストが相対的に低い&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;将来的に実データが溜まってきたら BigQuery 等のデータウェアハウスに流す、という拡張余地は残しています。「あとで捨てられる」「あとで移行できる」選択肢を選ぶことで、意思決定のコストを下げています。&lt;/p&gt;

&lt;h3 id="prisma-スキーマ"&gt;Prisma スキーマ&lt;/h3&gt;

&lt;p&gt;スキーマは必要最小限。どのステップで、どのモデルを、何トークン使ったかが後から辿れれば十分です。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model AiGenerationLog {
  id           String   @id @default(uuid())
  userId       String?  @map("user_id")
  websiteId    String?  @map("website_id")
  stepType     String   @map("step_type")  // "page_and_section_plan" 等
  model        String   @map("model")      // "gemini-2.5-flash"
  prompt       String   @db.MediumText @map("prompt")
  response     String   @db.MediumText @map("response")
  inputTokens  Int?     @map("input_tokens")
  outputTokens Int?     @map("output_tokens")
  durationMs   Int?     @map("duration_ms")
  isError      Boolean  @default(false) @map("is_error")
  errorMessage String?  @db.Text @map("error_message")
  createdAt    DateTime @default(now()) @map("created_at")

  @@map("ai_generation_log")
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;プロンプトとレスポンスも併せて保存しています。これは、後から「このトークン数のときはこういう出力が返っていた」と振り返って、プロンプトをチューニングする材料にできるためです。&lt;/p&gt;

&lt;h3 id="ログの書き込み処理"&gt;ログの書き込み処理&lt;/h3&gt;

&lt;p&gt;ログの書き込みは、ビジネスロジックに影響させたくありません。そのため、保存失敗はエラーログに残すだけで呼び出し元には伝播させない、という方針にしています。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ai-generation-logger.ts（抜粋）&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;saveLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AiGenerationLogCreateInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed to save AI generation log&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;logGeneration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;getAiLogContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;saveLog&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;siteId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stepType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stepType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;outputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;durationMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;errorMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;ログの保存が原因でサイト生成が失敗したら本末転倒なので、ここは割り切ります。&lt;/p&gt;

&lt;h3 id="生成ai呼び出しをラップする"&gt;生成AI呼び出しをラップする&lt;/h3&gt;

&lt;p&gt;あとは、生成AIクライアント側から上記のLoggerを呼ぶだけです。モデルの呼び出し箇所に毎回 &lt;code&gt;await insertLog(...)&lt;/code&gt; を書くのは現実的ではないので、生成AIクライアントをラップする層を 1 枚挟みます。ラッパー側で &lt;code&gt;usageMetadata&lt;/code&gt; からトークン数を取り出し、DB 保存用のLoggerに流します。成功時だけでなくエラー時も記録したいので、try/catch の両方でログ化します。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// db-logging-model.ts（抜粋）&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nx"&gt;DbLoggingGenerativeModel&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;GenerativeModel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="nx"&gt;modelName&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GenerativeModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;stepType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;genLogger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AiGenerationLogger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;genLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logGeneration&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;stepType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stepType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;inputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;promptTokenCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;outputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;candidatesTokenCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;durationMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;genLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logGeneration&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;stepType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stepType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;durationMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;errorMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;このデコレータを挟むことで、すべてのステップ（&lt;code&gt;PageAndSectionPlanner&lt;/code&gt;、&lt;code&gt;ThemeDeterminer&lt;/code&gt;、&lt;code&gt;SectionValueGenerator&lt;/code&gt; 等）のアプリケーションコード側に特別な計測ロジックを足さずに、全呼び出しのトークン数を DB に記録できます。&lt;code&gt;stepType&lt;/code&gt; をコンストラクタで受け取っているのは、「どのステップの呼び出しか」をあとから集計できるようにするためです。&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Claude Code Skillでメール障害対応を実施</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/04/30/claude-code-skills-mail-incident/"/>
    <id>https://tech.pepabo.com/2026/04/30/claude-code-skills-mail-incident/</id>
    <published>2026-04-30T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>kmsn</name>
    </author>
    <content type="html">&lt;p&gt;こんにちは、技術部 技術基盤グループのkmsnです。&lt;/p&gt;

&lt;p&gt;GMOペパボが運営するECサイト構築サービス「&lt;a href="https://shop-pro.jp/"&gt;カラーミーショップ&lt;/a&gt;」で、Outlook/Hotmail/Live宛メールがブロックされる障害が発生しました。この記事では、Claude Code の Skill を活用して障害の初動対応を効率化した事例を紹介します。&lt;/p&gt;

&lt;h2 id="結論skill-で障害対応の初動が変わった"&gt;結論：Skill で障害対応の初動が変わった&lt;/h2&gt;

&lt;p&gt;先に結論をお伝えします。今回の障害対応で最も効果的だったのは、&lt;strong&gt;Claude Code の Skill によって状況把握のスピードが大幅に上がった&lt;/strong&gt;ことです。&lt;/p&gt;

&lt;p&gt;カラーミーショップのメールサーバーは数十台あります。従来であれば、障害発生時に1台ずつ SSH して &lt;code&gt;sudo postqueue -p&lt;/code&gt; を叩き、キューの状態を目視で確認していく必要がありました。台数が多いため状況把握だけで時間がかかり、その間もメールは滞留し続けます。&lt;/p&gt;

&lt;p&gt;今回は &lt;code&gt;colorme-mailq&lt;/code&gt; Skill を使い、「メールキュー確認して」の一言で全台の状態を一括取得しました。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;サーバー&lt;/th&gt;
      &lt;th&gt;active&lt;/th&gt;
      &lt;th&gt;deferred&lt;/th&gt;
      &lt;th&gt;合計&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;server-1&lt;/td&gt;
      &lt;td&gt;0&lt;/td&gt;
      &lt;td&gt;3,842&lt;/td&gt;
      &lt;td&gt;3,842&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;server-2&lt;/td&gt;
      &lt;td&gt;12&lt;/td&gt;
      &lt;td&gt;0&lt;/td&gt;
      &lt;td&gt;12&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;※ 上記は全数十台のうち抜粋&lt;/p&gt;

&lt;p&gt;特定サーバーの deferred が突出して積み上がっていることが一目でわかり、対応する方針をすぐに決められました。&lt;strong&gt;従来の手動確認では状況把握に時間を要していた工程が、Skill によって数秒で完了し、対応方針の決定までの時間を大幅に短縮できました。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;さらに、この Skill は社内のプライベートリポジトリに登録されているため、自分だけでなくチームの誰でも同じように使えます。個人のスクリプトではなく &lt;strong&gt;チーム共有の Skill として整備しておくことで、次に同様の障害が起きたときにも誰でも素早く初動に入れる&lt;/strong&gt;という点が大きな価値です。&lt;/p&gt;

&lt;h2 id="何が起きたか"&gt;何が起きたか&lt;/h2&gt;

&lt;p&gt;カラーミーショップのユーザーが送信したメールが、Outlook/Hotmail/Live宛に届かずブロックされる事象が発生しました。Microsoft（Outlook.com）のような大手プロバイダーは、スパム判定によるブロック時に 5xx 系（主に 550）のエラーを返すことがあります。ただし、Postfix 側の設定や一時的なレスポンスにより deferred キューに滞留するケースもあり、今回はキューの滞留として顕在化しました。&lt;/p&gt;

&lt;p&gt;ログを確認したところ、特定のショップドメインからの大量送信が引き金となり、送信を担うサーバーの IP が Microsoft（Outlook.com）側にブロックされたと判断しました。&lt;/p&gt;

&lt;h2 id="postfix-transport-で-outlook-宛の配送経路を変更する"&gt;Postfix transport で Outlook 宛の配送経路を変更する&lt;/h2&gt;

&lt;p&gt;Skill で状況を把握した後、ブロックされたサーバーを迂回する対応を取りました。これには Postfix の &lt;strong&gt;transport テーブル&lt;/strong&gt;を使います。&lt;/p&gt;

&lt;p&gt;transport テーブルは「特定ドメイン宛の配送を、どの経路で行うか」を定義するファイルです。Outlook 系ドメイン宛のメールを別の経路に切り替える設定を追加しました。&lt;/p&gt;

&lt;p&gt;また &lt;code&gt;/etc/postfix/transport&lt;/code&gt; を確認すると、以前のインシデント時に手動で投入された送信レート制御の設定が残っていました。今回は別の経路への切り替えで対処するため不要と判断し、削除した上で新しい設定を追加しました。&lt;/p&gt;

&lt;h2 id="手動設定を-puppet-でコード化する"&gt;手動設定を Puppet でコード化する&lt;/h2&gt;

&lt;p&gt;ここで課題に当たりました。対象サーバーの &lt;code&gt;/etc/postfix/transport&lt;/code&gt; は &lt;strong&gt;Puppet で管理されておらず、手動設定のみの状態&lt;/strong&gt;だったのです。&lt;/p&gt;

&lt;p&gt;先ほどのレート制御の設定がまさにその典型で、何のためのものか、誰が投入したのかがすぐに追えない状況でした。手動設定が蓄積すると、こうした「経緯のわからない設定」が増えていきます。&lt;/p&gt;

&lt;p&gt;別のメールサーバーでは &lt;code&gt;hieradata/nodes/&lt;/code&gt; 配下にノード専用の yaml が存在し、transport 設定が Puppet で管理されていました。一方、対象サーバーにはその yaml が存在しません。チームメンバーにアドバイスをもらいながら、以下の2択で検討しました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;A: 対象サーバーのノード yaml だけに書く&lt;/strong&gt; → そのサーバーのみに適用。他サーバーへの影響なし&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;B: ロール共通の yaml に書く&lt;/strong&gt; → 全メールサーバーに一律適用&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;既存実装を参考に、今回は &lt;strong&gt;A&lt;/strong&gt; を選択。対象サーバー専用のノード yaml を新規作成し、このサーバーとしては初めて transport 設定を Puppet 管理に取り込みました。&lt;/p&gt;

&lt;p&gt;チームメンバーのレビューを受けてマージし、&lt;code&gt;--noop&lt;/code&gt; での dry-run で差分を確認してから本適用しました。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;puppet agent &lt;span class="nt"&gt;--test&lt;/span&gt; &lt;span class="nt"&gt;--noop&lt;/span&gt;  &lt;span class="c"&gt;# dry-run で変更内容を事前確認&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;puppet agent &lt;span class="nt"&gt;--test&lt;/span&gt;         &lt;span class="c"&gt;# 本適用&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;経路の切り替えが正常に動作していることを確認し、復旧しました。&lt;/p&gt;

&lt;p&gt;なお、transport による経路変更はあくまで暫定的な緩和措置であり、根本対処ではありません。これを恒久的な対処としてしまうと、迂回先のサーバーにも負荷が偏るなど新たな問題を招く可能性があります。並行して Microsoft への delist 申請や送信元ショップへの対応など、根本原因の解消に向けた対処を実施し、解消後に transport の設定を元に戻すところまでが一連の対応です。&lt;/p&gt;

&lt;h2 id="その他の学び"&gt;その他の学び&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;カスタマーサポート（CS）への連携は「確定情報だけ」を素早く。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;技術的な調査の完了を待たず、判明した情報（発生時刻・影響範囲・復旧時刻）をその都度 CS に共有するほうがユーザー影響を最小化できます。調査の過程をすべて流すのではなく、確定した情報だけをタイムリーに伝えるというシンプルな判断が、慣れないうちは意外と難しいと感じました。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;手動設定は「なぜ入っているか」が追えなくなる。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;今回 Puppet に設定を取り込んだことで、次の担当者が経緯を追いやすくなりました。障害のたびに手動で設定を足していくのではなく、コードに残す習慣が大切だと改めて感じました。&lt;/p&gt;

&lt;h2 id="おわりに"&gt;おわりに&lt;/h2&gt;

&lt;p&gt;今回の対応を通じて、&lt;strong&gt;障害対応の定型作業を Skill として整備しておくことの効果&lt;/strong&gt;を実感しました。状況把握の速さは、そのまま復旧の速さにつながります。&lt;/p&gt;

&lt;p&gt;この考え方はメール障害に限りません。たとえば Web サーバーのエラーレート急増時にログを集約する、データベースのスロークエリを一覧する、といった「障害発生直後にまず確認すること」は、どの領域にもあるはずです。こうした初動の定型作業を Skill として整備しておくことで、経験の浅いメンバーでも素早く状況把握に入れるようになり、経験豊富なメンバーは情報収集の手間を省いて判断や対応方針の策定に集中できます。&lt;/p&gt;

&lt;p&gt;同様の取り組みを検討されている方の参考になれば幸いです。&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>後付け可能な認証を NextAuth で設計する — ログイン機構が決まらないまま、ログイン前提のプロダクトを作った話</title>
    <link rel="alternate" href="https://tech.pepabo.com/2026/04/24/nextauth-mock-auth-design/"/>
    <id>https://tech.pepabo.com/2026/04/24/nextauth-mock-auth-design/</id>
    <published>2026-04-24T00:00:00+09:00</published>
    <updated>2026-08-07T03:05:40+00:00</updated>
    <author>
      <name>kinosuke01</name>
    </author>
    <content type="html">&lt;h2 id="はじめに"&gt;はじめに&lt;/h2&gt;

&lt;p&gt;こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています &lt;a href="https://x.com/kinosuke01"&gt;kinosuke01&lt;/a&gt; といいます。&lt;/p&gt;

&lt;p&gt;「この機能はログインしたユーザーのものとして扱いたい」というのは、ほとんどのプロダクトで当たり前の要件となります。ところが、プロダクト本体の開発を進めたいタイミングで、&lt;strong&gt;ログインの仕組みがまだ決まっていない&lt;/strong&gt;という状況に直面することがあります。&lt;/p&gt;

&lt;p&gt;後から差し替え可能にしておくというのは一つの手です。しかし「あとで差し替え」を甘く見ていると、いざ差し替えるときに思いのほか大がかりな書き換えが発生してしまう場合もあるのではないでしょうか。&lt;/p&gt;

&lt;p&gt;この記事では、&lt;a href="https://lolipop.jp/ai/site-agent/"&gt;AIサイトエージェント&lt;/a&gt; というプロダクトの開発で実際に直面したこの状況と、そこで取った方針について紹介していきます。&lt;/p&gt;

&lt;p&gt;要点を先にまとめると、以下の一点になります。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;決まっていない領域を、差し替え可能なレイヤーに封じ込める。そのレイヤーだけを「本物と同じ形の偽物」で置き、他のコードからは本物と区別できない状態で先に作り切る。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;具体的には、&lt;a href="https://authjs.dev/"&gt;NextAuth.js&lt;/a&gt; を土台にした「本物と同じ形の偽物ログイン」を用意することで、後から本番のログイン機構（OIDC）に NextAuth インスタンスの差し替えだけで移行できるようになりました。以降の節で、この構造を順に分解していきます。&lt;/p&gt;

&lt;h2 id="前提aiサイトエージェントとは"&gt;前提：AIサイトエージェントとは&lt;/h2&gt;

&lt;p&gt;本題に入る前に、舞台となるプロダクトの輪郭を簡単に共有しておきます。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://lolipop.jp/ai/site-agent/"&gt;AIサイトエージェント&lt;/a&gt;は、「カフェのサイトを作りたい」「フリーランスのポートフォリオが欲しい」といった自然言語の指示を投げると、ページ構成・デザインテーマ・コンテンツまでを一括で生成してくれる Web サイト制作サービスです。生成したあとも、チャット越しに「トップのキャッチを変えて」「このセクションの写真を差し替えて」と伝えれば、AI が編集を代行してくれます。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/04/24/nextauth-mock-auth-design/img01.png" alt="img01" /&gt;
&lt;img src="/blog/2026/04/24/nextauth-mock-auth-design/img02.png" alt="img02" /&gt;&lt;/p&gt;

&lt;p&gt;このサービスは、&lt;a href="https://lolipop.jp/"&gt;ロリポップ！レンタルサーバー&lt;/a&gt; と &lt;a href="https://muumuu-domain.com/"&gt;ムームードメイン&lt;/a&gt; のどちらからも利用できるようになっています。ロリポップのユーザーとムームードメインのユーザー、それぞれが AIサイトエージェントのコンパネに入ってサイトを作れる、というのが本番のユースケースとなります。&lt;/p&gt;

&lt;p&gt;&lt;img src="/blog/2026/04/24/nextauth-mock-auth-design/oidc.png" alt="ロリポップ／ムームードメインのアカウントでOIDCログインする構成" /&gt;&lt;/p&gt;

&lt;h3 id="技術スタック"&gt;技術スタック&lt;/h3&gt;

&lt;p&gt;この記事のコード例を読む前提として、プロダクトの技術スタックにも軽く触れておきます。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;フレームワーク&lt;/strong&gt;: &lt;a href="https://nextjs.org/"&gt;Next.js&lt;/a&gt;（App Router）&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;認証&lt;/strong&gt;: &lt;a href="https://authjs.dev/"&gt;NextAuth.js（Auth.js v5）&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;言語&lt;/strong&gt;: TypeScript&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;以降の本文では NextAuth の &lt;code&gt;authorize&lt;/code&gt; / &lt;code&gt;jwt&lt;/code&gt; / &lt;code&gt;session&lt;/code&gt; といったコールバック関数がコード例に登場します。NextAuth に馴染みのない方は、「ログイン処理の各ステップで呼ばれるフック関数」程度のざっくりした理解で読み進めていただければ大丈夫です。&lt;/p&gt;

&lt;h2 id="課題の整理ログインが決まっていない中で何を作るか"&gt;課題の整理：ログインが決まっていない中で何を作るか&lt;/h2&gt;

&lt;p&gt;AIサイトエージェントは、最終的にはロリポップとムームードメインの2つのサービスのアカウントでログインできる形に落ち着きました。しかし、はじめからそう決まっていたわけではなく、アカウント基盤をどうするか・どう認証するかは、ビジネス・技術の両面から議論が続いている状況でした。&lt;/p&gt;

&lt;p&gt;一方で、サイトを生成・編集するというプロダクトの中核機能の開発を止めて待つ、という選択肢はありませんでした。Webサイトの生成、ユーザーによる編集、自分のサイトにだけアクセスできる仕組みといった機能は、どれも「ログイン済みユーザーがいる」ことを前提にしています。つまり、&lt;strong&gt;ログイン機構が決まっていないまま、ログイン前提の機能を作り進める必要がある&lt;/strong&gt;という状況になっていました。&lt;/p&gt;

&lt;p&gt;この状況から、解くべき課題は次の2つに分解できます。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;今、ログイン前提の機能をどう作るか&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;本番のログイン機構が決まったとき、どうシームレスに繋ぎ込むか&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;1.だけなら、サーバー側関数で固定の userId を返すような簡易なモックで十分です。しかし 2. を同時に成立させようとすると、それだけでは足りません。本物のログインが乗ったときに、セッションの持ち方もユーザーのテーブル構造もごっそり変わるとなると、結局そのタイミングで広範囲の書き換えが発生してしまいます。&lt;/p&gt;

&lt;p&gt;加えて、設計上もうひとつ大きな制約がありました。それが次の &lt;strong&gt;独立開発の要件&lt;/strong&gt; です。&lt;/p&gt;

&lt;h3 id="開発環境を外部サービスから独立させたい"&gt;開発環境を外部サービスから独立させたい&lt;/h3&gt;

&lt;p&gt;この時点では本番のログイン機構がまだ決まっていない、というのは先に述べたとおりです。ただし、アカウント基盤の候補として議論されていたロリポップやムームードメインといった既存サービスは、どちらも長く運用されている巨大なコードベースを持つサービスとなります。&lt;strong&gt;仮にこうした既存サービスのアカウント基盤と繋ぎ込むことが決まった場合&lt;/strong&gt;、それら本番と同等のログイン基盤をまるごとローカル開発に繋ぎ込むというのは現実的ではありません。セットアップの手間もさることながら、外部依存が増えるほど開発体験は悪くなっていきます。&lt;/p&gt;

&lt;p&gt;そのため、本番のログイン機構が最終的に何になっても困らないよう、&lt;strong&gt;認証の外部依存ゼロでプロダクト本体を動かせる&lt;/strong&gt; ことも考慮したいと考えていました。&lt;/p&gt;

&lt;h3 id="方針"&gt;方針&lt;/h3&gt;

&lt;p&gt;ここまでを踏まえて、方針は次のように定めました。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;「本物が来たときに差し替えるレイヤー」だけを偽物にする。それ以外は、本番運用で使うものと同じ構造で作り込む。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;具体的には、以下の3点を本物と同じ形で作り込むことにしました。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;セッション管理の仕組み&lt;/strong&gt;: JWT ベースのセッション、コールバックの流れ&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ユーザーのテーブル構造&lt;/strong&gt;: 外部認証プロバイダーとの紐付けを前提にしたスキーマ&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;JIT プロビジョニング&lt;/strong&gt;: 初回ログイン時にユーザーと所属組織を作成する流れ&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;偽物にするのは「認証のやり方そのもの」だけとなります（以降、この偽物の認証を &lt;strong&gt;モック認証&lt;/strong&gt; と呼びます）。これなら、認証のやり方が決まったときに、そこだけ差し替えれば済むようになります。&lt;/p&gt;

&lt;h2 id="モック認証に求める振る舞い"&gt;モック認証に求める振る舞い&lt;/h2&gt;

&lt;p&gt;実装の話に入る前に、このモック認証にどんな挙動をさせたいのかを具体化しておきます。「本物と同じ形」と言っても曖昧ですので、期待する振る舞いをあらかじめ言語化しておくと、以降の実装がなぜそうなっているのかが見えやすくなります。&lt;/p&gt;

&lt;p&gt;ここで &lt;code&gt;puid&lt;/code&gt;（provider user id の略。プロバイダー側のユーザー識別子に相当する値）という言葉が出てきます。本番では OIDC プロバイダーから渡ってくる &lt;code&gt;sub&lt;/code&gt; クレームですが、モックでは開発者が自由に指定できる文字列として扱います。以降の節でも繰り返し登場する語となります。&lt;/p&gt;

&lt;p&gt;求める振る舞いは、次のように整理できます。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;モック特有の部分&lt;/strong&gt;（偽物としての振る舞い）&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;任意のIDでログインできる&lt;/strong&gt;: ログイン画面で puid を自由に入力でき、未指定の場合は固定のデフォルトユーザーとしてログインできる&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;外部サービスへの問い合わせは発生しない&lt;/strong&gt;: OIDC の認可エンドポイントや userinfo を呼ばない。入力された puid を信頼してログイン完了とする（前節の独立開発要件に対応）&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;初回ログイン時にユーザーを自動生成する&lt;/strong&gt;: その puid に対応する DB レコードが無ければ、ユーザー・組織・&lt;code&gt;ExternalIdentity&lt;/code&gt; を自動で作る（JIT プロビジョニング）&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;本番と揃えたい部分&lt;/strong&gt;（アプリから見て本物と同じ形）&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;セッションから得られる情報は本番と同じ&lt;/strong&gt;: &lt;code&gt;appUserId&lt;/code&gt;, &lt;code&gt;providerUserId&lt;/code&gt;, &lt;code&gt;provider&lt;/code&gt; がセッションに揃い、&lt;strong&gt;アプリケーションコードから見ると&lt;/strong&gt; 本番認証と区別がつかない状態となる&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;開発体験の要件&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;puid を変えればユーザーを切り替えられる&lt;/strong&gt;: 権限・所有権など複数ユーザーが絡む検証を、開発中も素直に試せる&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src="/blog/2026/04/24/nextauth-mock-auth-design/login-form.png" alt="モック認証のログインフォーム" /&gt;&lt;/p&gt;
&lt;p class="pager__caption"&gt;任意のユーザー名を入力すると、そのユーザーでログインできる&lt;/p&gt;

&lt;p&gt;1〜3 が偽物として置く部分、4 がアプリに向けてそろえる部分、5 が開発者が使うときの体験です。では、この3つの層をどう実装していくか、順に見ていきましょう。&lt;/p&gt;

&lt;h2 id="土台として-nextauth-を選ぶ"&gt;土台として NextAuth を選ぶ&lt;/h2&gt;

&lt;p&gt;この設計を支える土台として NextAuth を採用しました。NextAuth は Provider という概念を中心に、Credentials（任意の独自認証）、OAuth、OIDC など複数の認証方式を同じ抽象のもとに扱えるフレームワークです。&lt;/p&gt;

&lt;p&gt;「モック認証も一つの Provider として扱い、本番の OIDC 認証も同じく Provider として差し替える」という構造が、そのまま課題にフィットしました。セッション管理やコールバックの組み立て方は Provider に依存しないので、モック時に書いたコールバック処理は OIDC 導入後もそのまま使い回せます。このことが、後述する差し替え時の手数の少なさに直結しました。&lt;/p&gt;

&lt;h2 id="テーブル構造は外部連携前提にしておく"&gt;テーブル構造は「外部連携前提」にしておく&lt;/h2&gt;

&lt;p&gt;ログインがモックであっても、テーブル構造はあとで使う本物のスキーマを想定して設計しました。ポイントは、ユーザー本体（&lt;code&gt;User&lt;/code&gt;）と外部認証プロバイダーとの結び付け情報（&lt;code&gt;ExternalIdentity&lt;/code&gt;）をテーブル分離し、プロバイダー種別をユニーク制約に含めたところとなります。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model ExternalIdentity {
  userId         String       @unique
  provider       AuthProvider
  providerUserId String
  // ... 他のカラム省略

  @@unique([provider, providerUserId])
}

enum AuthProvider {
  MUUMUU
  LOLIPOP
  MOCK
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;モック認証もれっきとした「プロバイダーの一つ」として &lt;code&gt;AuthProvider.MOCK&lt;/code&gt; を割り当てることで、本番のプロバイダーと同じ経路でユーザーを特定できるようになります。本番のプロバイダーが後から増えても、enum に値を足して &lt;code&gt;ExternalIdentity&lt;/code&gt; を作るだけで対応可能です。モックと本番を同じ構造で受け止めるスキーマとなっています。&lt;/p&gt;

&lt;h2 id="モック認証を-nextauth-の上に載せる"&gt;モック認証を NextAuth の上に載せる&lt;/h2&gt;

&lt;p&gt;モック認証は NextAuth の &lt;strong&gt;Credentials Provider&lt;/strong&gt; で実装しました。本物の認証ではなく、画面で入力された puid をそのまま通すだけのダミーです。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 入力された puid を簡易バリデーションするための正規表現&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PUID_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;createMockAuth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;credentials&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mock Login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;puid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ProviderUserId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;puid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;puid&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;
              &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;puid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
              &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mock-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

          &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;PUID_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;puid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;

          &lt;span class="c1"&gt;// jwt コールバックで user として受け取る値&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;puid&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;trustHost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;mockJwtCallback&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;commonSessionCallback&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 本番と共通&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;コードの中に &lt;code&gt;authorize&lt;/code&gt; / &lt;code&gt;jwt&lt;/code&gt; / &lt;code&gt;session&lt;/code&gt; という3つのコールバックが出てきます。NextAuth に馴染みのない方向けに、それぞれの役割と、&lt;code&gt;mock-user&lt;/code&gt; でログインボタンを押したときの呼び出し順を軽く整理しておきます。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;authorize&lt;/code&gt;&lt;/strong&gt;: ログインフォームから送られた値を受け取り、認証の可否を判断するコールバックです（Credentials Provider 特有のもの）。OK ならユーザーを表すオブジェクトを返し、NG なら &lt;code&gt;null&lt;/code&gt; を返します。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;jwt&lt;/code&gt;&lt;/strong&gt;: セッションの元となる JWT を組み立てるコールバックです。&lt;code&gt;authorize&lt;/code&gt; が返した情報をベースに、JWT へ積みたい情報（ここでは &lt;code&gt;appUserId&lt;/code&gt; や &lt;code&gt;providerUserId&lt;/code&gt; など）を追加できます。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;session&lt;/code&gt;&lt;/strong&gt;: アプリケーション側で &lt;code&gt;auth()&lt;/code&gt; から取り出すセッションオブジェクトを組み立てるコールバックです。JWT の中身を、アプリに見せたい形へ整えるのが役割です。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ログイン画面で puid に &lt;code&gt;mock-user&lt;/code&gt; を入力してログインボタンを押した場合、これらは次の順で呼ばれます。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;authorize({ puid: "mock-user" })&lt;/code&gt;&lt;/strong&gt;: puid を検証し、&lt;code&gt;{ id: "mock-user" }&lt;/code&gt; を返す&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;jwt&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;authorize&lt;/code&gt; が返した情報をもとに、JIT プロビジョニングで DB からユーザーを取得（なければ作成）し、&lt;code&gt;appUserId&lt;/code&gt; などを JWT に積む&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code&gt;session&lt;/code&gt;&lt;/strong&gt;: JWT の値をセッションに詰め替え、アプリから参照できる形に整える&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;この流れを頭に入れておくと、以降のコールバックの中身が読みやすくなります。では、&lt;code&gt;authorize&lt;/code&gt; は任意の puid を受け取ってそのまま通すだけなので、キモとなるのは残りの2つです。&lt;code&gt;session&lt;/code&gt; コールバックは本番と共通のものを使っており、&lt;code&gt;jwt&lt;/code&gt; コールバックもモックと本番で中の処理（後述する JIT プロビジョニングの有無）こそ違うものの、JWT に積む情報の形（&lt;code&gt;providerUserId&lt;/code&gt;, &lt;code&gt;appUserId&lt;/code&gt;, &lt;code&gt;provider&lt;/code&gt;）は揃えてあります。&lt;/p&gt;

&lt;h3 id="jit-プロビジョニングで複数ユーザーに対応する"&gt;JIT プロビジョニングで複数ユーザーに対応する&lt;/h3&gt;

&lt;p&gt;開発中は「ユーザーAとしてログインして確認」「ユーザーBとしてログインして権限を確認」といったシナリオが頻繁に発生します。モックだからといって単一ユーザー固定にしてしまうと、そうした検証がやりにくくなってしまいます。&lt;/p&gt;

&lt;p&gt;そこで、JWT コールバックで &lt;strong&gt;JIT（Just-In-Time）プロビジョニング&lt;/strong&gt; を行うようにしました。ログイン時に指定された puid がまだ DB に存在しなければ、そのタイミングでユーザーと所属組織を作成します。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;mockJwtCallback&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;providerUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// (provider, providerUserId) で既存ユーザーを探し、なければ&lt;/span&gt;
  &lt;span class="c1"&gt;// ユーザー・組織・組織メンバー・ExternalIdentity をトランザクション内で一括作成する&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getOrCreateUserByExternalIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;AuthProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MOCK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;providerUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;providerUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;appUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;appUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuthProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MOCK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id="セッションコールバックを本番と共通化する"&gt;セッションコールバックを本番と共通化する&lt;/h3&gt;

&lt;p&gt;さて、この記事のキモとなるのが次の &lt;code&gt;commonSessionCallback&lt;/code&gt; です。モックと本番で &lt;strong&gt;完全に同じ関数&lt;/strong&gt; を使い回すことで、「セッションから取り出せる値の形」がモードに依らず一定です。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;commonSessionCallback&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// jwt コールバックで積んだ error をそのまま伝搬&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// DB 側でユーザーが削除・無効化されていないかを確認&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;validateUserExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appUserId&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ActiveUserNotFound&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;providerUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;providerUserId&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appUserId&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;AuthProvider&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;中身はほぼ JWT からセッションへの値の詰め替えだけで、モック・本番の差は一切ありません。「ログイン済みユーザーを DB で再確認する」というプロダクト側の要件だけを淡々と満たす形となっています。この関数がモードに依存しない形で書けていることが、後の差し替えコストを最小にしてくれます。&lt;/p&gt;

&lt;p&gt;セッション型も同じ形で固定しておきます。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kr"&gt;module&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;providerUserId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;appUserId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;AuthProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;アプリケーションのコードは、このセッションから &lt;code&gt;session.appUserId&lt;/code&gt; を取り出して使うだけです。&lt;strong&gt;モックか本番かを意識する必要がありません&lt;/strong&gt;。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;verifyWebsiteOwnership&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;appUserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;UnauthorizedError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;website&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;websiteService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;findByIdForUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;NotFoundError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Website not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;このガード関数が、モック時代も本番移行後もそのまま動いているというのが、この設計のポイントとなります。&lt;/p&gt;

&lt;h2 id="本番ログインが決まった後の差し替え"&gt;本番ログインが決まった後の差し替え&lt;/h2&gt;

&lt;p&gt;その後、「ロリポップとムームードメインのアカウントで OIDC ログインする」という方針が確定しました。OIDC は NextAuth が標準でサポートしているので、Provider 設定を書くだけで基本的には動くようになっています。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;createOidcAuth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;providers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;createOidcProviderConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;muumuu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Muumuu Domain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MUUMUU&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;createOidcProviderConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lolipop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Lolipop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOLIPOP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;trustHost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jwt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;maxAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;updateAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oidcSignInCallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oidcJwtCallbackWithProviderGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;commonSessionCallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// モック時代と同じもの&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;重要なのは、&lt;strong&gt;&lt;code&gt;session&lt;/code&gt; コールバックはモックと共通のものを使っている&lt;/strong&gt;ところです。セッションから取り出せる値（&lt;code&gt;appUserId&lt;/code&gt;, &lt;code&gt;provider&lt;/code&gt;, &lt;code&gt;providerUserId&lt;/code&gt;）の形は変わっていないので、アプリ本体のコードはほぼそのままで動きました。OIDC 導入に伴う変更は、認証レイヤー内のファイル（新設した OIDC 用のコールバック群と起動時の分岐）にとどまり、ガード関数や Server Action などアプリ側の呼び出しコードは変更不要となっています。&lt;/p&gt;

&lt;p&gt;JWT コールバックだけは OIDC 用に差し替わります。ムームー／ロリポップの OIDC では「契約 API 側で事前に作成済みのユーザーに対してログインを許可する」というルールとなっているため、&lt;code&gt;getOrCreateUserByExternalIdentity&lt;/code&gt;（なければ作る）ではなく &lt;code&gt;getUserByExternalIdentity&lt;/code&gt;（見つからなければログイン拒否）を使う点がモックとの違いとなります。&lt;/p&gt;

&lt;p&gt;モードの切り替えは、どちらのファクトリ関数を呼ぶかを差し替えるだけとなっています。&lt;/p&gt;

&lt;div class="highlight"&gt;&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// OIDC モードで起動する場合&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextAuth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createOidcAuth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// モック認証モードで起動する場合&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextAuth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createMockAuth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signOut&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextAuth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;本番コードの書き換えはほぼこの一カ所で済み、シームレスな差し替えが実現できました。&lt;/p&gt;

&lt;h2 id="副産物モック認証が開発環境に残り続けている"&gt;副産物：モック認証が開発環境に残り続けている&lt;/h2&gt;

&lt;p&gt;最初から狙っていたこととはいえ、本番ログインが決まったあとも開発環境向けにモック認証モードを残せているというのは、実際に開発を回すうえで効いています。ローカル開発で puid を切り替えながら複数ユーザーのシナリオを試せるので、「差し替えのためのモック」がそのまま「開発体験のためのモック」として現役で動き続けている状態となります。&lt;/p&gt;

&lt;h2 id="まとめ"&gt;まとめ&lt;/h2&gt;

&lt;p&gt;決まっていない領域があるとき、「あとで差し替える」を本当にシームレスにやるには、&lt;strong&gt;本物と同じ形の偽物&lt;/strong&gt;を用意するというのが有効なアプローチになります。ポイントを振り返ります。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;差し替えるレイヤーを明確にする&lt;/strong&gt;: 認証のやり方だけを偽物にし、セッション・テーブル構造・JIT プロビジョニングは本番と同じ形で作り込む&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;抽象の提供者を借りる&lt;/strong&gt;: NextAuth のように「複数の認証方式を同じ抽象で扱う」ことが前提のフレームワークを土台にすると、差し替えが自然な操作となる&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;モックも本番と同列に扱う&lt;/strong&gt;: &lt;code&gt;AuthProvider.MOCK&lt;/code&gt; のように、モックを本番プロバイダーと同じ型・経路の上に載せることで、設計がぶれない&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ログイン機構に限らず、要件が決まりきっていない依存を抱えたままプロダクトを前に進めたい場面は、開発の現場には少なくありません。そういうときは、決まっていない部分を特定のレイヤーに封じ込めつつ、それ以外は本番の設計で作り込む、という方針が有効です。どこまでを偽物で受け、どこから先は本番と同じ形で作るか、その線引きの設計こそが、不確実性を抱えたまま前に進むための要となります。&lt;/p&gt;
</content>
  </entry>
</feed>
