TECH PLAY

株式会社エブリー

株式会社エブリー の技術ブログ

430

はじめに こんにちは。開発本部 開発1部 デリッシュリサーチチームの江﨑です。 デリッシュリサーチは、デリッシュキッチンに蓄積された検索ログやレシピへの反応をもとに食トレンドを分析できるサービスです。 本記事では、社内用にデリッシュリサーチのデータを Claude から自然言語で問い合わせられるようにする MCP サーバーを自作した話を紹介します。FastMCP と Databricks Apps で実装した構成、運用上のノウハウ、そしてリリース後に社内で広がった活用事例をまとめます。 はじめに 背景:なぜ自前の MCP サーバーを作ったか システム全体像 使用技術 MCP サーバーの実装 ツール一覧 ツールの基本パターン シノニムの代表語正規化 Databricks Apps での運用ノウハウ app.yaml で起動コマンドを定義 Resource 宣言の上限 20 個 ツール呼び出し履歴を Unity Catalog に蓄積 実際の呼び出しの流れ 試してみた事例 ダッシュボードでは出せない切り口に対応 MCP × Web 検索を組み合わせたトレンド分析 商材タイアップ提案の素材集め まとめ 参考リンク 背景:なぜ自前の MCP サーバーを作ったか デリッシュリサーチには各種分析機能があり、検索トレンド・レビュー・お気に入りレシピなど、マーケティングに使えるデータが一通り揃っています。ただ、実際に業務で使おうとすると、いくつかの壁がありました。 分析の手間 :デリッシュリサーチを開いて、目当ての機能を探して、フィルタを設定して、結果から示唆を得る、という手順が必要です。気になったことをちょっと確認する用途には、やや手数が多くなります。 分析の仕方に一定のスキルが必要 :どの切り口で見るか、どのフィルタを組むか、数字をどう読み解くかは訓練が要る作業で、誰でもすぐに使いこなせるとはいきません。 画面を横断した分析がしづらい :それぞれの機能はタブごとに分かれており、「検索データ × 気温 × 都道府県」のような掛け合わせの問いには答えづらい構造でした。 実際のデリッシュリサーチの画面は次のようになっています。 デリッシュリサーチの画面 デリッシュリサーチは社外のクライアント企業に提供しているサービスですが、社内のメンバーも利用しており、同じ壁にぶつかっていました。一方で、ちょうど社内には Claude が広く配布され始めており、 「Claude で何ができるか」の具体例を作って社内全体での活用を推進したい という思いもありました。デリッシュリサーチのデータを Claude のチャットから自然言語で聞ける MCP を作れば、上の 3 つの壁を越えつつ、社内向けの Claude 活用事例にもなります。 加えて、Databricks には Managed MCP サーバーという仕組みがあります。これは Unity Catalog の汎用ツール(Vector Search、Genie Space、Databricks SQL、Unity Catalog Functions)を MCP として AI クライアントに提供するものです。ただし、シノニム正規化や複数テーブル結合を伴うドメイン特有の集計を Managed MCP に組み込むのは難しく、求めている精度のデータを提供するには物足りませんでした。 そこで、リサーチデータの集計操作そのものをツールとして公開する 自前の MCP サーバー を作ることにしました。 システム全体像 構成は以下のとおりです。 システム全体像 チームの admin が Custom Connector に MCP サーバーを一度登録すれば、利用者は Claude.ai や Claude Desktop から Databricks の SSO でログインするだけで接続できます。MCP サーバー本体は Databricks Apps 上で稼働しており、Databricks SDK を経由して Unity Catalog のテーブルにアクセスします。 Databricks Apps は、Databricks ワークスペース上で Web アプリケーションをホストできる機能です。Unity Catalog と同じワークスペース上で動かせるため、アプリに自動で割り当てられるサービスプリンシパルからそのまま Unity Catalog のテーブルにアクセスできます。 認証は Databricks ワークスペースの OAuth で行います。利用者が Claude から MCP に接続すると、最初に Databricks のログイン画面に飛ばされてログインします。以降の MCP リクエストには発行されたアクセストークンが付与されます。Databricks Apps の手前にあるリバースプロキシがそれを検証したうえで、認証済みのエンドユーザー情報を x-forwarded-email などのヘッダーに載せてアプリに転送してくれます。MCP サーバーのコード側ではこのヘッダーを読むだけで「誰が呼び出しているか」が分かります。 使用技術 本 MCP サーバーで使用している主な技術は以下のとおりです。 項目 技術 言語 Python 3.11 以上 MCP フレームワーク FastMCP 2.x Web フレームワーク FastAPI + uvicorn ホスティング Databricks Apps データ層 Unity Catalog(Delta テーブル) 観測 OpenTelemetry パッケージ管理 uv FastMCP は Python で MCP サーバーを書くためのフレームワークです。MCP の HTTP ベースの通信方式(Streamable HTTP)に対応しており、Databricks Apps 上で動かせます。 MCP サーバーの実装 ツール一覧 現在、MCP サーバーには次のようなツールが登録されています。 キーワードの検索数を期間・粒度を指定して取得 検索ワードランキング 組み合わせ検索ランキング 主ワードに対する副ワードの傾向分析 都道府県別の検索ワードランキング 都道府県別の組み合わせ検索ランキング お気に入りに追加されたレシピのランキング レシピ単位のレビューと平均評価 気温と検索数の相関 食材の物価データ 食材の物価の前年同月比ランキング ツールの基本パターン FastMCP では、Python の関数に @mcp_server.tool デコレータを付けるだけでツールとして登録できます。本 MCP では、関数の docstring・引数のバリデーション・SQL 実行・整形して返却、という流れを全ツールで揃えています。 def register (mcp_server): @ mcp_server.tool # FastMCP にツールとして登録 def get_search_trends ( search_word: str = "" , start_date: str = "" , end_date: str = "" , granularity: str = "monthly" , ) -> dict : """指定ワードの検索数推移を期間・粒度を指定して取得する。 Args: search_word: 検索ワード(例: "キャベツ")。シノニムは自動で代表語に正規化される。 start_date: 開始日(YYYY-MM-DD、省略時は前年同月の月初) end_date: 終了日(YYYY-MM-DD、省略時は前日) granularity: 粒度(daily / weekly / monthly / quarterly / yearly) """ # 引数のバリデーション search_word = search_word.strip() if not search_word: return { "error" : "search_word is required" } # 表記ゆれを代表語に正規化(後述のシノニム正規化) main_word = resolve_to_main(search_word) # SQL を実行し、結果を整形して返却 try : rows = _fetch_basic_trends(main_word, start_date, end_date, granularity) return { "search_word" : main_word, "data_points" : len (rows), "data" : rows} except Exception : # エラーは Unity Catalog の観測テーブルに記録される logger.exception( "get_search_trends failed" , extra={ "tool" : "get_search_trends" }) return { "error" : "Internal error" } ツールの docstring は AI クライアントがそのままツールの説明として参照します。AI 側がどのツールをどんな引数で呼ぶかは、この docstring の質に強く依存します。 Args: の各引数に「何を渡してよいか」「省略時の挙動」を必ず明記する運用にしています。 シノニムの代表語正規化 集計テーブルの search_word は、ETL の段階で表記ゆれや言い換えが代表語に寄せられた状態で保存されています。たとえば「キャベツ」「きゃべつ」のような表記の違いは代表語「キャベツ」に集約されています。MCP のツール側でも、入力されたワードを resolve_to_main() で代表語に変換してから集計テーブルを参照するようにしました。これにより、ユーザーがどの表記で問い合わせても同じ結果を返せます。 Databricks Apps での運用ノウハウ app.yaml で起動コマンドを定義 Databricks Apps では、リポジトリのルートに app.yaml を置いたうえで、Workspace 上の管理画面から GitHub リポジトリを連携してデプロイしています。ソースを更新した後は Workspace 側で再デプロイを実行するだけで反映されます。 command : [ "uv" , "run" , "opentelemetry-instrument" , "custom-mcp-server" ] env : - name : WAREHOUSE_ID value : "<warehouse-id>" opentelemetry-instrument を経由して起動することで、アプリのログ・トレース・メトリクスが自動で Unity Catalog 上の観測テーブルに流れます。 Resource 宣言の上限 20 個 Databricks Apps では、参照したいテーブルや SQL Warehouse を UI上で宣言できます。宣言したリソースはデプロイ時にアプリのサービスプリンシパルへ自動で GRANT され、削除時には自動で REVOKE されます。 運用していて詰まったのが、 1 アプリあたりに宣言できる resource の数は 20 個まで という制約です。これは 2026 年 5 月時点で公式ドキュメントには明記されておらず、実際に 21 個目を追加しようとしてエラーで気付きました。今回の MCP では参照するテーブルが 20 個を超えており、すぐに上限に到達してしまいました。 そのため、UIからではなく Unity Catalog 側で 手動 GRANT を運用する方式 に切り替えました。 GRANT USE CATALOG ON CATALOG external_marketing_research TO `<sp-uuid>`; GRANT USE SCHEMA ON SCHEMA external_marketing_research.search TO `<sp-uuid>`; GRANT SELECT ON TABLE external_marketing_research.search.search_count TO `<sp-uuid>`; ツール呼び出し履歴を Unity Catalog に蓄積 社内向けに公開している以上、誰がどのツールをどの引数で呼んだかを追えるようにしておきたい要件がありました。 FastMCP には Middleware クラスがあり、ツール呼び出しの前後にフックを差し込めます。これを使い、呼び出し履歴を Unity Catalog のテーブルに INSERT する仕組みを入れました。MCP が参照している他の集計テーブルと同じカタログに置くことで一元化できます。 class HistoryMiddleware (Middleware): async def on_call_tool (self, context, call_next): tool_name = context.message.name user = get_request_user() # x-forwarded-email から取得 start = time.perf_counter() is_error = False try : return await call_next(context) except Exception : is_error = True raise # 例外は握りつぶさず外側に伝播させる finally : # 成功/失敗どちらの場合も履歴を書く duration_ms = (time.perf_counter() - start) * 1000 self._schedule_write( tool_name=tool_name, user_email=user[ "email" ] if user else None , arguments_json=_serialize_arguments(context.message.arguments), duration_ms=duration_ms, is_error=is_error, ) Databricks Apps では、リクエストヘッダーの x-forwarded-email にエンドユーザーのメールアドレスが入ります。これを contextvars で受け取り、INSERT 時に user_email として記録しています。 このテーブルを使うと、誰が何を呼んだかを追える監査記録としてだけでなく、次のような切り口で利用状況を集計できます。 ツール別の呼び出し頻度 :どのツールが実際によく使われているかを把握し、機能改善の優先度を判断する ツール別のエラー率と所要時間 :例外発生率と平均レスポンス時間からツールの健全性を確認する ユーザー別の利用状況 :誰がどれくらい使っているかを見て、活用事例のヒアリング相手を決める 実際の呼び出しの流れ たとえば「リサーチ MCP を使用して、キャベツのトレンドを分析してください」と Claude に投げかけると、必要なツールを Claude が自分で判断して順番に呼び出し、結果を分析・可視化して回答してくれます。 MCPの呼び出し例 この例では、次の流れで動いています。 まず利用可能なツールを把握する 過去 2 年分の検索トレンドと組み合わせワードのツールを呼び出す 続けて物価データのツールも呼び出して相関を確認する 取得したデータをもとに、要約(前年同期比、価格との相関係数、ピーク月など)とグラフを生成する 利用者は自然言語で問いを投げるだけで、その背後で複数のツールが呼ばれ、検索データ・物価データ・組み合わせワードを横断したアウトプットが返ってきます。 試してみた事例 社内リリース後、エンジニアからビジネス職まで、さまざまなメンバーに触ってもらいました。まだ実業務での運用に組み込んでいるわけではなく、各自で試しながら可能性を探っている段階ですが、いくつか興味深い使い方が出てきたので紹介します。 ダッシュボードでは出せない切り口に対応 「平日と比較して休日に検索頻度が増加する料理を教えてください」のように、デリッシュリサーチの管理画面上のプリセットでは答えられない切り口の問いにも対応できます。AI 側で日次データを取得するツールを呼び出し、平日/休日の比率を計算する手順を自分で組み立てて回答します。 MCP × Web 検索を組み合わせたトレンド分析 「リサーチ MCP の検索データ」と「Web 検索」を併用して、特定の食材・調理法のブームを背景情報込みで分析するような使い方も試されています。たとえば、せいろブームの背景に Web 上のどんな話題があり、検索数とどう連動しているかをまとめる、といった分析です。 商材タイアップ提案の素材集め 「ビール商材向けにおつまみレシピのタイアップ提案を考えて」のような依頼でも、MCP からレビュー数の多いレシピや時期別の検索傾向を取得し、提案スライドまで作るところまで行われました。 数値や因果関係の最終確認は必要ですし、プロンプトや精度にもまだ改善の余地があります。とはいえ、提案のドラフトを素早く試作する用途として手応えを感じています。 まとめ 今回は社内向けのデリッシュリサーチ MCP サーバーを自作した話を紹介しました。 リリース後、エンジニアからビジネス職までいろいろなメンバーに触ってもらっています。デリッシュリサーチ単体では難しい横断的な分析や、分析からスライド作成までを一気通貫でやれる点に可能性を感じています。SNS など外部のデータソースも組み合わせて、活用の幅を伸ばしていきたいです。 最後まで読んでいただきありがとうございました! 参考リンク Model Context Protocol FastMCP Databricks Apps Databricks マネージド MCP サーバーを使用する Unity Catalog 権限リファレンス
目次 はじめに イベントの様子 スポンサーブース ブース企画 1. アンケートボード「あなたのAI活用、どこまで来てる?」 2. 食クイズ&くじ引き エントランス企画ゾーン セッション紹介 ビジネスモデルから紐解く、AI+型駆動開発 型の「重心」はビジネスモデルで決まる 型の Origin と AI への渡し方 SaaS 型 — フォーム状態を Discriminated Union で渡す マーケットプレイス型 — 中央スキーマを Origin として渡す 所感 TS 7: How We Got There なぜ Go だったのか Snapshot テストと Differential Fuzzing 所感 制約と時代から読み解く TypeScript コンパイラ設計史 TypeScript Compiler の「独特さ」 設計を決めた時代背景と JS の制約 Go port による改善 所感 いつテストを書くか?―ソフトウェア開発における安心と不安について考える 保守性の本質は「変更容易性」である 変更容易性の 2 層モデルと、開放閉鎖原則の再解釈 テストは「不安」と「構造」にフィードバックを与える いつテストを書くか? 各社スポンサーブース レバレジーズさん プレイドさん ZOZOさん まとめ アフターイベントのご案内 最後に はじめに 2026年5月22日(金)、23日(土)に開催された TSKaigi 2026 に、弊社の開発本部から 6 名のエンジニアが参加してきましたので、イベントの様子や印象に残ったセッションをご紹介します。 各セッションのアーカイブも公開予定とのことですので、ぜひ公式サイト・YouTube チャンネルなどをチェックしてみてください。 2026.tskaigi.org www.youtube.com イベントの様子 スポンサーブース エブリーは今回、ゴールドスポンサーとしてブースを出展させていただきました! 足を運んでいただいた皆様、本当にありがとうございました! ブース企画 1. アンケートボード「あなたのAI活用、どこまで来てる?」 ブースでは、エンジニアの皆さんのAI活用状況を伺うアンケートを実施しました。 回答いただいた多くの皆様、ありがとうございました!最終結果はこちらです……! キャリア0〜5年の若手から15年以上の大ベテランまで、非常に幅広い層のエンジニアの皆様に回答いただきました! 結果としては、「指示は出すが都度レビュー」「並列で動かしている」という回答が多く、「AIに全任せするのはまだ難しい...」「並列で回すとコンテキストスイッチが大変...!」といった声も寄せられ、興味深い結果となりました! 2. 食クイズ&くじ引き さらに、エブリーとしては初の試みとなる「クイズ企画」も行いました! クイズに参加していただくことで、キッチングッズ(まな板、計量スプーン、しゃもじ、お箸)が当たるくじを引けるという形式です。 クイズは大盛況で、「これ見たことある!答えなんだっけ!」「簡単じゃん〜……ってうわ!間違えた!」と、皆さん真剣かつ楽しそうに頭を悩ませてくださる姿が印象的でした。ブース担当メンバーも、皆さんのリアクションを見ながら一緒に盛り上がることができ、企画して本当に良かったと感じています! エントランス企画ゾーン Day1にネイル企画がありプロのネイリストによるネイル体験をしてきました! メニューは「ネイルアート」か「ネイルケア」のいずれか好きな方を選ぶことができました。 綺麗にケアされた自分の手元が視界に入るたびにテンションが上がり、大満足の体験でした!キーボードを叩くモチベーションも爆上がりです!! セッション紹介 ビジネスモデルから紐解く、AI+型駆動開発 2026.tskaigi.org 発表者: omote (株式会社 estie) レポート: 江﨑 estie の omote さんによるセッション「ビジネスモデルから紐解く、AI+型駆動開発」について紹介します。スライドは以下で公開されています。 speakerdeck.com 本セッションは「AI 時代における開発のスタート地点はどこか」という問いから始まり、開発体験やフロントエンドのベストプラクティスではなく ビジネスモデルこそが設計の起点になる という仮説のもと、ビジネスモデル別に「型設計を AI とどう組むか」を整理していく構成でした。 型の「重心」はビジネスモデルで決まる 型はクライアント・API・データのどこにでも書けますが、設計エネルギーを最も注ぐ場所はプロダクトごとに違います。omote さんはその場所を 型の重心 と呼び、技術スタックではなく事業構造で分類すべきだと整理していました。具体的には、プロダクトを次の 4 つに分けています。 分類 価値の源泉 型の重心 型の Origin インタラクション中心 SaaS 型 ユーザー入力・編集体験 UI 状態・フォーム・権限 TypeScript 自身 データ依存型 外部データ・ドメイン知識 API レスポンス・ドメインモデル DB / GraphQL スキーマ API 中心マイクロサービス型 API 契約そのもの コントラクト・スキーマ定義 .proto / OpenAPI マーケットプレイス型 マッチング・取引の成立 取引データのスキーマ 中央ドメインモデル 価値の源泉と重心、Origin がフラットに 4 分類で並ぶことで、自分の関わっているプロダクトをそのまま当てはめながら聞ける構成になっていました。 型の Origin と AI への渡し方 もう一段下の問いとして「型はどこから来るのか(Origin)」も提示されていました。SaaS 型・マーケットプレイス型では人間が Origin を定義する側になり、データ依存型・API 中心型では既存スキーマや契約から型を受け取る側になります。つまり TypeScript の役割はビジネスモデルによって 定義する側 と 受け取る側 の 2 つに分かれる、という整理です。 ここを踏まえると、AI に渡す「文脈の深さ」によって出力の質も変わってきます。 Level 1: 自然言語だけ Level 2: TypeScript の派生型を渡す Level 3: Origin 自体、または Origin に近い型情報を渡す Level 3 まで踏み込むと、データソースの制約や整合性まで型として運ばれるため、Claude の出力が事業文脈と整合した実装になりやすい、という話でした。実例として、SaaS 型とマーケットプレイス型の 2 つが、Claude への入力と出力の対比で示されていました。 SaaS 型 — フォーム状態を Discriminated Union で渡す 人間側では、フォームの状態と送信エラーを Discriminated Union で定義した型を Claude に渡します。 type InviteMemberFormState = | { status: "editing"; email: string; role: Role; errors: ValidationError[] } | { status: "confirming"; email: string; role: Role } | { status: "submitting"; email: string; role: Role } | { status: "succeeded"; invitedEmail: string } | { status: "failed"; email: string; role: Role; error: SubmitError }; type SubmitError = | { code: "already_invited"; existingMemberEmail: string } | { code: "quota_exceeded"; currentCount: number; limit: number } | { code: "network_error" }; type Role = "admin" | "editor" | "viewer"; この型を添えて「この InviteMemberFormState を満たす React コンポーネントを実装してください」と依頼すると、Claude は失敗時の表示を次のような switch で出力します。 function FailedView ( { error , onRetry , onCancel } : FailedViewProps ) { switch (error.code) { case "already_invited" : return < p > { error.existingMemberEmail } はすでにメンバーです </ p > ; case "quota_exceeded" : return ( < p > 招待上限( { error.limit } )に達しています(現在 { error.currentCount }{ " " } 名) </ p > ); case "network_error" : return < p > ネットワークエラーが発生しました。再度お試しください </ p > ; } // ↑ 'code' の網羅性が型レベルで保証される。新エラー追加時もここでコンパイルエラー } 各エラーコード固有のプロパティ( existingMemberEmail ・ limit ・ currentCount )に型安全にアクセスできており、 switch の網羅性チェックが型レベルで効いている点が示されていました。新しいエラーコードを追加した際にも同じ箇所でコンパイルエラーになるため、AI 出力に対するガードレールが型で担保されているのがよく分かります。 マーケットプレイス型 — 中央スキーマを Origin として渡す マーケットプレイス型では、売り手・買い手・運営・取引整合性といったあらゆる方向の関心が、中央スキーマ Transaction に集約されます。これを Origin として Claude に渡す形になります。 // あらゆる方向からの依存が、ここに集約される type Transaction = { id: TransactionId; sellerId: UserId; // 売り手の関心 buyerId: UserId; // 買い手の関心 price: number; platformFee: number; // 売り手・運営の関心 shippingAddress: Address; // 買い手のみ trackingNumber: string | null; status: TransactionStatus; // Discriminated Union(全方向の関心) // ... タイムスタンプ群、双方向の評価 }; 「この中央スキーマから SellerTransactionView と BuyerTransactionView を派生させてください」と依頼すると、Claude は Pick で必要なフィールドだけを切り出し、 netRevenue や totalPaid といった派生プロパティを足した型を出力します。 // 売り手向け type SellerTransactionView = Pick< Transaction, | "id" | "itemId" | "buyerId" | "price" | "platformFee" | "shippingFee" | "trackingNumber" | "status" | "buyerRating" > & { netRevenue: number; }; // 除外: shippingAddress, sellerRating // 買い手向け type BuyerTransactionView = Pick< Transaction, | "id" | "itemId" | "sellerId" | "price" | "shippingFee" | "shippingAddress" | "trackingNumber" | "status" | "sellerRating" > & { totalPaid: number; }; // 除外: platformFee, buyerRating Transaction 自体には手を加えず、 Pick で必要なフィールドだけを切り出して派生型を機械的に生成している点がポイントで、「何を売り手に見せ、何を買い手に見せるか」という事業判断は中央スキーマの段階で人間が決めておかなければならない、という対比になっていました。 所感 プロダクト開発の設計と言われると、つい開発体験やフロントエンドのベストプラクティス側から話が始まりがちです。それに対し、本セッションでは 起点をビジネスモデルに置き、型の重心と Origin で具体まで落とす という流れが一貫していて、ビジネスモデルと型の関係性が非常に丁寧に整理されていたのが印象的でした。 実装が AI に委ねられるからこそ、人間が残すべき仕事は「型をどこに置くか」「Origin をどう定義するか」という意思決定だ、というメッセージも明確で、AI と分担して開発を進める際に人間側に残すべき判断をはっきりさせてくれる内容でした。自分が関わっているプロダクトについても、まずは「型の重心はどこにあるのか」を棚卸しするところから取り入れてみたいと感じました。 TS 7: How We Got There 2026.tskaigi.org 発表者: Jake Bailey (Microsoft) レポート: パンダム/rymiyamoto 私からは Microsoft の Jake Bailey さんによるキーノート「TS 7: How We Got There」について紹介します。スライドは以下で公開されています。 jakebailey.dev 本セッションでは、TypeScript の処理系を JavaScript から Go へ移植する取り組みについて、その動機・ポートを支えた手法・段階的なリリース戦略が紹介されました。 なぜ Go だったのか tsc / tsserver は数千万行規模のコードベースや 2,000 個以上の tsconfig.json を扱うユーザーが現れる中で、JavaScript ランタイムの制約(スレッド間でのオブジェクト共有不可、async/await の関数色分け、4GB のメモリ上限)からパフォーマンス改善が限界に近づいていたとのことです。 「rewrite ではなく port」を前提に Rust / C# / Zig などと比較したうえで Go が選ばれた理由として、以下が挙げられていました。 TypeScript(class より data + 関数 + 構造的 interface 寄り)と構造が近く、コードを 1:1 で移し替えやすい GC があるため AST の循環参照を意識せずに書ける goroutine による並行性を、関数の色分けなしに導入できる 学習コストが低く、チームの 10 名ほどが数日で生産的になれた ライブラリ単体ではなく「チーム全員が短期間で動ける」観点で言語選定している点は印象的でした。 Snapshot テストと Differential Fuzzing ポートを支えた手法として、特に Snapshot(ベースライン)テストと Differential Fuzzing が紹介されていました。 Snapshot テストは、出力をファイルとしてリポジトリにコミットし、PR の diff で挙動変化を確認する手法です。TypeScript では新旧コンパイラの出力差分そのものを baseline として扱い、その差分ファイルが消えること自体をポート完了の指標にしているとのことでした。 Differential Fuzzing は、新旧の実装に同じ入力を fuzzer から流し込み、結果が一致しない入力を自動で発見させる手法です。Go では組み込みの fuzzer で以下のように記述できます。 func FuzzToFileNameLowerCase(f *testing.F) { f.Add( "foo/bar/baz.ts" ) f.Add( "C:/foo/bar/baz.ts" ) f.Fuzz( func (t *testing.T, p string ) { want := oldToFileNameLowerCase(p) got := ToFileNameLowerCase(p) assert.Equal(t, want, got) }) } ユニットテストで取りこぼしがちなエッジケースを fuzzer に探させる発想は、性能改善で挙動を保ったまま実装を差し替える場面で特に有効で、自身の業務にも持ち帰りたい手法でした。 所感 会場のライブデモでは、 tsc が数分単位で進まない遅さを見せたあと、 tsgo に切り替わった瞬間に 10 秒足らずで型チェックが終わり、スライドの「VS Code(約 2.3M LoC)で tsc 比 約 10 倍・クラッシュ率約 1/20」という数字をその場で体感することができました。また発表中には「昨年の TSKaigi 2025 のタイミングでは tsgo のクラッシュが多かった」という話に触れられて会場が湧いていましたが、その状態から 1 年でテレメトリと継続的な改善でクラッシュをほぼ潰し、ここまで仕上げている開発スピードには、純粋に驚かされました! 特に Differential Fuzzing は、自分が普段触っている Go バックエンドで「速度改善のために旧実装と挙動を変えずに置き換えたい」場面と相性が良さそうで、まずは小さな関数で testing.F を使った新旧比較から試したいと考えています。一方で、ベースライン駆動のレビューは出力ファイルを丸ごとコミットする運用になるため、レビュー差分の見やすさと引き換えにリポジトリ容量や CI の負荷が膨らみそうで、テスト数を踏まえてどこから採用するかは見極めが必要そうです。フロントエンドの TypeScript 側でも、 tsgo が 7.0 として安定すれば CI の型チェック時間が大きく改善する余地があるので、ベータの様子は引き続き追っていこうと思います。 制約と時代から読み解く TypeScript コンパイラ設計史 2026.tskaigi.org 発表者: Yoshiaki Togami (株式会社メルペイ) レポート: 庄司 私からは Yoshiaki Togami さんによるセッション「制約と時代から読み解く TypeScript コンパイラ設計史」について紹介します。 スライドは以下で公開されています。 www.docswell.com 本セッションでは、TypeScript Compiler がなぜ「独特な内部設計」を持つに至ったのかを、 当時の時代背景と JavaScript の制約を交えつつ、Go port による改善までを整理する構成となっていました。 TypeScript Compiler の「独特さ」 冒頭で TypeScript Compiler のパイプライン(Scanner → Parser → Binder → Checker)と、 識別子から宣言へたどる Symbol が紹介され、Compiler の構造的な「独特さ」として 2 つの特徴が挙げられました。 Binder が AST に semantic を後付けするため、意味情報が構文木と同居する AST の親子や Symbol 間でアクセスできるようにするために循環参照が発生する C# コンパイラで採用されている Red-Green Tree というデータ構造を採用することで、immutability と semantic へのアクセス可能性を両立できないかというアイディアもありましたが、 当時の JS では Worker API などもなく immutability を活用しきれない点やメモリ消費が大きくなってしまうという点から見送られました。 設計を決めた時代背景と JS の制約 TypeScript の設計が決まった 2010 年前後には以下のような技術的背景がありました。 2004〜2006 年の Ajax 革命(Gmail / Google Maps / Google Docs)で「JS で本格的なアプリ」が現実味を帯びる 2008 年に V8 が公開される。(しかし、ES5 段階では Worker API がなく最適化も未熟) JS ツーリングが貧弱で開発体験が悪い これらを踏まえて、以下のような3つのアプローチが取られました。 JS を遠ざける(Script# 等、別言語で書いて JS にトランスパイルする) JS 自体を置き換える(Dart 等) JS のスーパーセットとして型を追加する(Strada -> TypeScript) これは「ターゲット言語からそこまで離れるくらいなら JS 自体を直すべきでは」という発想から出たアイディア 結果的には、SharedArrayBuffer 等の共有メモリ機構が未整備で、オブジェクトヘッダのメモリオーバーヘッドも大きいという背景から、 immutability やメモリ消費を犠牲にして、現在の「AST が意味情報を背負う・循環参照あり・単一ツリー」という設計になった、という整理でした。 Go port による改善 最後には、ここまで触れてきた制約が Go port でどう解消されるのかが紹介されました。 tsc → tsgo の高速化の背景として、大きく 2 つのポイントが挙げられました。 単一の Go バイナリへのネイティブ化によるウォームアップリードタイムの削減 共有メモリによるキャッシュヒット率改善やマルチスレッド並列化 所感 TypeScript Compiler の内部設計の独特さについて、当時の時代背景込みで聞ける機会は少なく、とても面白かったです。 当時の JS 特有の仕様的制約から現実解を模索していった流れは技術選定全般への向き合い方としても興味深いものでした。 利用している技術がどんな課題を解決するために生まれたソリューションなのかを知ることが、 ツールを使いこなす上でも重要であることを改めて感じることができました。 いつテストを書くか?―ソフトウェア開発における安心と不安について考える 2026.tskaigi.org 発表者: lacolaco (株式会社TwoGate) レポート: 黒髙 私からは TwoGate の lacolaco さんによるセッション「いつテストを書くか?―ソフトウェア開発における安心と不安について考える」について紹介します。スライドは以下で公開されています。 bit.ly 私自身、lacolaco さんがパーソナリティを務めるポッドキャスト「 リファラジ|リファクタリングとともに生きるラジオ 」のリスナーだったこともあり、本セッションは聞く前から楽しみにしていました。 加えて最近は AI にテストを書かせる場面が増えたことで「そもそもテストを書く意義はなんだったか」が曖昧になってきており、自分がどう向き合うべきかを改めてはっきりさせたい、というのが個人的な動機でした。 本セッションは、Agentic Coding が広がる中であらためて「テストを書く意義」を問い直すために、 ソフトウェアの本質的な性質である「変更容易性」 を補助線として、テスト駆動開発と開放閉鎖原則の関係を整理しなおしていく構成でした。 保守性の本質は「変更容易性」である このセッションは、AI によって開発速度が上がったからこそ「保守性の限界」がすぐ目に見える形で訪れるようになっている、という提起から始まります。保守性の本質は 変更容易性 であるという前提のもと、「変更容易性はソフトウェアに内在する性質ではなく、人間とソフトウェアとの関係として現れる」と説明されていました。 変更容易性の 2 層モデルと、開放閉鎖原則の再解釈 変更容易性は次の2つの独自定義で捉え直されていました。 予期的変更容易性 : 変更を行う「前」に開発者が抱く「変更のしやすさ」の感覚。必要な作業量・影響範囲・成功確率・失敗時のリスクへの予期。要するに、 開発者の不安や恐怖の程度 そのものである。 経験的変更容易性 : 変更を行っている「最中」に開発者が経験する「変更のしやすさ」の感覚。実際にかかった労力や、影響した範囲、引き起こした副作用。 ソフトウェア側から返ってくる「変更への抵抗」の程度 である。 そして、ソフトウェアが「ソフト」である状態は この 2 つの変更容易性が両立している状態 として定義しなおされます。「容易そうだと思える」ことで変更の機会そのものが増え、「実際に容易である」ことで変更の合理性が高まる。アーキテクチャはこの両立を実現する構造を目指すものだ、という整理です。 同時に、ご存知の方も多いでしょう、 開放閉鎖原則 (OCP) がアーキテクチャの根本原理として登場します。 修正に対して閉じている : 既存の要求を満たす振る舞いが変わらないこと(=安定性) 拡張に対して開いている : 既存の要求を満たしたまま新しい要求に応えられること(=柔軟性) この 2 つが両立することで、変更への不安は解消し、労力も軽減される。つまり OCP の達成は予期的変更容易性と経験的変更容易性の両方を高めることに直結する、という形で、 OCP を「変更容易性そのもの」と等価に位置づけ直す再解釈 が行われました。 「変更容易性」を変更の 前 と 最中 という時間軸で分けながら、その 2 層の両立を 開放閉鎖原則 という一本軸に落とし込んでいくことで、自分の中にあった「テストを書く理由」の曖昧さがそのまま整理されていくような感覚がありました。 テストは「不安」と「構造」にフィードバックを与える さらに、「変更容易性とはコードベースではなく、それを変更しようとしている人間が抱く感覚に依存する」という視点のもと、テスト駆動開発が変更容易性に対して 2 つの方向からフィードバックを与える と整理されていました。 ひとつ目は 予期的変更容易性へのフィードバック 、つまり「開発者の不安を取り除く」役割です。テストケースは「既存の要求と期待される振る舞いの定義」であり、変更しても既存テストが壊れないなら、その範囲では「閉じている」ことが保証されているということです。 ふたつ目は 経験的変更容易性へのフィードバック 、すなわち「構造上の問題を教える」役割です。機能追加が既存のテストに影響するならそのモジュールは閉じていないし、新しい機能のテストを書くのが大変ならそのモジュールは開いていないということになります。 そして開放閉鎖原則そのものは、設計のループの中で 徐々に 満たされていくものとして描かれます。 ここで特に印象的だったのは、開放閉鎖原則を 「達成すべきゴールではなく、漸近していく理想状態」 として位置づけていた点です。OCP は完全には満たせないし、変更容易性は変更してみないとわからない。だからこそ、テストと TDD は OCP を徐々に満たしていくためのワークフローとして必要になる、という説明の流れにはとても納得感がありました。 いつテストを書くか? ここまで積み上げてきた整理を踏まえて、本セッションのタイトルに立ち戻ります。テストを書くべきタイミングは、 変更容易性のどちらが阻害されているか で見え方が変わる、というのが lacolaco さんの答えでした。 予期的変更容易性が低いとき (=変更するのが怖いとき)は、 不安を取り除くためにテストを書く 。実装を支えるテスト。逆に、不安がないなら書く必要はないし、書いても不安が変わらないなら書く意味もない。 経験的変更容易性が低いとき (=構造に問題を感じているとき)は、 構造を学習するためにテストを書く 。設計(リファクタリング)を支えるテスト。OCP が十分に満たされ構造を熟知しているなら書く必要はない。 結合度の高いテスト(結合テスト)は不安の解消に、結合度の低いテスト(ユニットテスト)は構造のフィードバックに、という区別がテストピラミッドの図解で整理されておりとても分かりやすかったです。 このセッションが個人的にとても良かったのは、「いつテストを書くか?」という極めて実践的な問いに対する答えが、 自分がコードの変更に対してどう向き合っているのか(不安なのか、構造に違和感があるのか)への感度を高めること に着地していた点です。テストを書くべきかどうかを「自分の感覚を起点に考える、という視点は、AI と分担して開発する時代だからこそ、改めて言語化して持っておきたい感覚だと感じました。 総じて、ソフトウェアとは何か、保守性とは何かという基礎的な問いから始め、それを「開放閉鎖原則」という古典的な原理で言語化しなおすことで、「自分がどのタイミングでテストに向き合うか」という実践的なところまでイメージができる、学びの深いセッションでした。保守性の限界がこれだけ早く訪れるようになった今こそ、 『テスト駆動開発』 や 『Clean Architecture』 といった書籍をもう一度しっかり読み直して咀嚼する価値があると感じています。 各社スポンサーブース 他社さんのスポンサーブースにもたくさん訪問させていただきました! 各社趣向を凝らしたブースや様々な企画が展開され、会場全体がとても賑わっていました。 いくつかご紹介させていただきます。 レバレジーズさん レバレジーズさんのブースでは、アジャイル開発の業務効率化支援 SaaS「agile effect」について解説していただきました! タスク管理ツールと連携することで進捗把握や調整がしやすくなり、開発プロセス自体を可視化することで感覚に頼らない改善が進められるとのことでした。AI によって開発効率が上がる中で、ボトルネックがどこにあるかを把握する重要性は増していると感じる場面が多く、興味深いサービスでした! プレイドさん プレイドさんのブースでは、弊社でも利用させていただいている CX プラットフォーム「KARTE」について、実際のユースケースを交えて解説していただきました! 設定方法まで丁寧に教えていただき、とてもありがたかったです。ブース内ではおみくじを引かせていただく企画もあり、結果はハズレでしたが、他のブースにはない体験で印象に残りました! ZOZOさん ZOZOさんのブースでは、テックリードお手製の TypeScript クイズに挑戦しました!全問正解者は数人とのことで、なかなか歯ごたえのある内容でした。 型推論の挙動や演算子の細かな仕様など、自分の理解が曖昧だった部分がクイズを通して炙り出され、TypeScript の言語仕様を学び直すきっかけになりました! まとめ TSKaigi 2026 は、TypeScript の最新動向や活用事例から、AI とどう組み合わせて開発を進めるかという最近のトピックまで幅広く語られ、TypeScript コミュニティの盛り上がりを改めて感じられる、とても素敵なイベントでした。 特に今年は、 tsgo / TypeScript 7 のキーノートや TypeScript Compiler の設計史といった 言語処理系そのものの節目 に関するセッションと、AI 時代の型駆動開発や Agentic Coding 時代におけるテストの意義といった AI との向き合い方 を問うセッションが両軸で並んでおり、TypeScript 自身の進化と AI との関わり方の両方を同時に追えるラインナップでした。ブースで実施したアンケートでも「指示は出すが都度レビュー」「並列で動かしている」といった回答が並んでいたことから、参加者の AI 活用が着実に進んでいることも伺えました。 今後も TypeScript コミュニティ、TSKaigi がより一層発展していくことを期待しています。 今回の参加レポートが、TypeScript を学びたい・活用していきたい方の参考になれば幸いです。 運営の皆さん、カンファレンスを開催していただきありがとうございました!! アフターイベントのご案内 TSKaigi 2026 にスポンサーや登壇者で参加した ウェルスナビ・PeopleX・弁護士ドットコム・スリーシェイク・エブリー の 5 社で、2026年6月12日(金)に TSKaigi 2026 のアフターイベントを開催いたします! セッションや公募LTなどのコンテンツを用意しておりますので、みんなでTypeScriptで盛り上がりましょう! every.connpass.com 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
目次 はじめに TSKaigiとは? イベント当日について アフターイベントのご案内 はじめに この度、株式会社エブリーは、2026年5月22日(金)、23日(土)に開催される「TSKaigi 2026」に、ゴールドスポンサーとして協賛することになりました! TSKaigiとは? 2026.tskaigi.org TSKaigiは「学び、繋がり、"型"を破ろう」をミッションに掲げ、2024年に第1回が開催された、TypeScriptに関するあらゆるテーマを扱う国内最大級の技術カンファレンスです。2026年はベルサール羽田空港に会場を移し、2日間にわたって開催されます。 今年の開催概要は以下のとおりです。 開催日時 Day 1: 2026年5月22日(金) Day 2: 2026年5月23日(土) 開催場所 ベルサール羽田空港(東京都大田区羽田空港2-7-1) 開催形態 ハイブリッド開催(現地・オンライン) コンテンツ ・基調講演 ・公募セッション(30分 / 10分) ・スポンサーセッション ・懇親会 セッションは、TypeScriptの基礎から実務に近い内容まで様々なテーマが見られます。TypeScriptの内部実装がGoに移植されたこともあり、TypeScriptの実装に関するセッションも注目したいところです! イベント当日について エブリーのブースでは、料理に関するクイズや、ノベルティ・キッチングッズなどが当たるくじ引き、アンケートボードを設置します。ぜひお立ち寄りください! TSKaigi 2026 で皆さんと良い学び・思い出を作れることを楽しみにしています! アフターイベントのご案内 ウェルスナビ・PeopleX・弁護士ドットコム・スリーシェイク・エブリーの5社共催によるアフターイベントを開催いたします! 弊社からは、 黒髙 が登壇します!セッションや公募LTなどのコンテンツを用意しておりますので、みんなでTypeScriptで盛り上がりましょう! 開催日時 2026年6月12日(金) 開催場所 東京都港区六本木3-2-1 六本木グランドタワー 38階 株式会社エブリー イベントスペース 開催形態 オフライン コンテンツ ・各社のTypeScriptに関するセッション ・公募LT ・交流会 お申し込みは以下で行っております。みなさんのご参加をお待ちしています! every.connpass.com 最後までお読みいただき、ありがとうございました!
はじめに こんにちは、リテールハブ開発部でバックエンドエンジニアをしているホシと申します。 現在、Laravel などを利用しながら小売アプリ開発に取り組んでいます。 少し前になりますが、先日3月17日にLaravel13がリリースされました。 ( https://laravel.com/docs/13.x/releases ) 昨年にもLaravel12について、バージョンアップ内容などを記事にしたのですが、 今年も新バージョンについてお話しできればと思います。 前回同様にまずはサポート期限の一覧を記載します。 1年サイクルでリリースしているのと、サポート期間はあっという間に過ぎてしまうことがわかります。 毎年のバージョンアップは大変でも2年ごとくらいには実施した方が良さそうです。 Laravel13の機能の紹介やバージョンアップ方法などはすでにたくさんあるとは思いますが、 本記事では、Laraveをバージョンアップする際にどんなことが必要かを調べてみたのと、 今回のLaravel13の大きな特徴として「破壊的変更を抑えつつ、AI時代向けにLaravelを進化させたバージョン」のようなので、 AI関連の気になる機能についても、あまり詳しくない私を含め、なるべくわかるような内容にできればと思います。 Laravel13のバージョンアップよりも、PHPバージョンアップが大変? 今回もLaravelのバージョンアップ自体は破壊的変更も少なく、比較的低コストでできるようなのですが、 今回のバージョンでは、PHPが8.3からになりました。 PHP8.2以下の場合、ここが実は一番手間がかかる対応なのかもしれません。 まずはPHP8.3に変更することが問題なくできるかを調べるのが先になります。 また、今回のタイミングでできれば8.4まで上げることができれば、 今後のバージョンアップもスムーズにしやすくなるので、なるべく最新にしたいところです。 ただ、8.5まで上げてしまうとまだサポートしていないものなどでうまく行かない可能性も高くなるので、 8.4で試してみるのがバランスが良いのかなと思いました。 バージョンアップする際の対応項目 先ほどはPHPを上げる方が大変かもと記載していますが、 laravelの変更点もカバーすることを考えるとどちらもそれなりに対応は必要そうです・・・。 以下に現環境で必要な対応項目を整理してみました。 1. ランタイム / 環境の確認 PHP を最低 8.3(推奨 8.4)に統一 composer.json Dockerfile PHP 拡張・PECL(Swoole / OpenSwoole)が PHP 8.3/8.4 対応バージョンか確認 ECS タスク定義側で参照している base image / runtime も同期 2. 現在使用しているLaravelは全て13に対応しているか laravel/sanctum laravel/octane laravel/tinker laravel/boost laravel/pint など、使用しているもの全てが「13」に対応しているか確認しておく。 (上記は一部最新の情報ではないかもしれませんので参考程度にしてもらえると幸いです。) 3. 周辺ライブラリ(互換確認・場合によりバージョンアップ) sentry/sentry-laravel aws/aws-sdk-php など、周辺ライブラリがまだ未対応などがありそうなので、調べておく必要あり。 4. テスト / 静的解析(バージョンアップ対応) PHPUnit: 12.x(Pest 3 が要求) Pest: 3.0 へ移行 tests/Pest.php の uses() / プラグイン構成見直し Larastan phpstan.neon のルール再調整必須 mockery/mockery fakerphp/faker など、テストに関する変更は調べる限り、いろいろ見直しも必要そうです。 ちょっとここは詳しくないので、実際には試行錯誤しながらやらないと詰まりそうな雰囲気も感じています。 5. コード内の修正・確認が必要そうなところ(一部抜粋) Carbon 3 への移行(Laravel 12 から Carbon3.x を使用する必要あり) → now(), Carbon::parse() を使う全箇所を回帰テスト Eloquent / Query Builder → クエリの修正は不要の見込み Container / Service Provider → bootstrap/providers.php に記載すること推奨されていて、 Validation → カスタムルールがある場合、問題ないか確認など Authentication / Authorization → Hash::make の確認、Sanctum トークン形式の互換性確認など Octane → State leak / メモリリークなどが起きないかなど あまり特殊な処理などはしていないのですが、Carbon系は特にたくさん使っているのでよくみた方が良さそうでした。 テスト実行して問題ないか全体を確認は必須ですが、 基本的には利便性向上、追加機能系なので動かなくなるなどほぼほぼなさそうです。 メモリリークとかそういったものがすぐにはわかりにくいのでここもしっかり確認はした方が安全かなと思います。 6. CI / Docker / デプロイ バージョンを変えるなどは必須だと思いますが、 あとは環境やもともと記載している内容に合わせての変更になると思います。 ここは実際に動かしながら修正・動作確認して間違いの無いようにしたいです。 7. アップデート手順 もし複数バージョンアップの場合は、アップデート手順として推奨されているのは、 Laravelでも段階アップデートが安全なようなので、12 → 13 のように段階アップデートにするか、 一気に上げる方法にするかは変更、確認コストを見て決めていけると良いかなと思います。 Laravel 13の気になる機能:AIサポート ここからは、Laravel 13で一番気になったAI関連機能についてお話しできればと思います。 特に注目したいのは以下です。 Laravel AI SDK Embeddings Vector Search Semantic Search 今までは、様々なライブラリや外部サービスを組み合わせてAI検索などを実現する必要がありましたが、 Laravel13では、Laravel標準に近い形で実装できるようになっています。 ただ、Laravel13自体がAIモデルを持っているわけではなく、実際には外部のAIサービスを利用してEmbedding生成や意味検索を行います。 Laravel13では、それらをLaravelらしいAPIで扱いやすくなった、というイメージです。 また、Laravel側でAIサービスの差異をある程度吸収できるため、将来的に利用するAIモデルやサービスを変更しやすくなる可能性があります。 従来のように各AIサービスごとに個別実装するよりも、Laravelアプリへ組み込みやすくなったのかなと思いました。 Embeddingsとは? そもそも、上記の各AIに関連するワードについてもしっかりと理解できていなかったので調べてみました。 Embeddingsとは、文章をAIが扱いやすい数値データに変換する仕組みです。 例えば、 「胃に優しい料理」 という文章を、AIが意味を比較できるベクトルデータに変換します。 内部的には: [0.183, -0.929, 0.442, ...] みたいな大量の数値になります。 この数値化はすでに大量の文章、単語関係、文脈を学習済みのモデルを利用することで実現できています。 use Illuminate\Support\Str; // 検索したい文章をEmbedding化する $embedding = Str::of('胃に優しい料理')->toEmbeddings(); 以下のwhereVectorSimilarTo() は、内部で検索文をEmbedding化し、保存済みEmbeddingとの意味距離を比較するイメージです。 use Illuminate\Support\Facades\DB; $recipes = DB::table('recipe_embeddings') ->whereVectorSimilarTo('embedding', '胃に優しい料理') ->limit(10) ->get(); このようなEmbedding生成やVector SearchをLaravelらしいAPIで扱いやすくなりました。 また、DB保存はPostgreSQLのpgvectorを利用してデータを保存したりします。 他にもいろいろあるようなのですが、ただMySQLだと、すでにVector Search関連機能が追加され、Embeddingを利用した類似検索自体は可能なのですが、 最適化やパフォーマンス面ではpgvectorなどを利用する方が現状は良いようです。 そのため、もしすでにMySQLを使用している場合は使い分けが一番コスト面、メリットの点で良いのかなと思います。 これにより、単なる文字一致ではなく、 数値の近似値の比較をすることができるようになり、 胃に優しい ↓ 雑炊、豆腐、うどん、茶碗蒸し のように、意味が近いものを検索しやすくなります。 Vector Searchとは? Vector Search(ベクトル検索)は、 ベクトル同士の距離を比較して、近いものを探す技術です。 つまり、 検索文 ↓ Embedding(数値化) ↓ DB内のベクトルと距離比較 ↓ 近いものを取得 という流れになります。 意味が近い文章ほど、数値的にも近い位置に配置されるため、Semantic Search「意味検索」をしているようになります。 LIKE検索と意味検索の違い 従来のLIKE検索は、文字が一致するものを探します。 WHERE name LIKE '%豆腐%' これは商品名やコード検索には強いです。 一方、意味検索は、 疲れている時に食べたい 夜でも重くない 夏バテでも食べやすい のような曖昧な検索に向いています。 これにより、レシピや記事など様々な情報を持っているものから キーワード検索のような文字列一致ではなく、意味検索をできることから キーワード検索では拾えないような曖昧な検索にも対応できるようになります。 ハイブリッドがバランス良い 意味検索は強力な検索ですが、上記の商品名やコード検索には弱く、 どちらにもメリット、デメリットがあるため、 LIKE検索から切り替えるのではなく、組み合わせるのが一番バランスが良いかなと思います。 まずLIKE検索 結果が少なければVector Search 重複を除いて結果を返す 商品名検索ならLIKE検索で十分なことも多いです。 一方、レシピ検索や記事検索のように「意味」や「状況」で探したいものは、Vector Searchと相性が良いです。 上記は例になりますが、ここは工夫の余地の大きいところでもあり、 仕様や検索内容、実施頻度、パフォーマンスなどを考慮して実現できるようにしたいです。 精度を上げるポイント ここは私は勘違いしていましたが、 Vector Searchは、データ件数よりも説明文や属性情報が重要です。 確かにいくら大量にデータがあっても、1つ1つの情報が少ないと意味を持たせられないと思いました。 悪い例: 麻婆豆腐 良い例: 辛味が強く、ご飯が進む中華料理。 豆腐とひき肉を使った定番メニュー。 満足感があり、夕食向き。 このように、AIが理解しやすい説明を持たせることで検索精度が上がります。 精度検証の進め方 ここまででとりあえず試してみたいと思った際は、最初から大規模に導入する必要はありません。 検証の際も大量のデータというよりは、1つ1つのデータ量が揃っているかが重要で、 その上で、まずは小さく試すのが良さそうです。 500〜3000件程度のデータを用意 検索クエリを30〜100個作る LIKE検索とVector Searchを比較する 上位5件に納得できる結果があるか確認する 最初は厳密な評価指標より、人間が見て「使えそうか」を確認するだけでも十分です。 最後に いかがでしたでしょうか。 今回のバージョンアップはAI関連の進化が大きな特徴だったことがよくわかりました。 私でも実際に簡単なAI検索であれば、比較的低コストで実装もできそうなので、導入も気軽にできます。 もちろん精度アップや検証面はどうしても最初は大変かと思いますが、少しずつ試しながらノウハウも蓄積しつつ 少しずつ規模を大きくしつつ、応用していくのが大事かなと思いました。 ただ肝心のバージョンアップはそこまでコード改修は不要なものの、 全体を見るとやることは多いので、しっかり時間を確保して対応が必要かなと思います。 今後のLaravelバージョンアップの際にぜひ少しでも参考にしていただければ幸いです。 最後までお読みいただき、ありがとうございました。
Goのtime.Nowとは? 〜synctestを添えて〜 はじめに エブリーでエンジニアをやっております、 赤川 です。食事管理アプリ ヘルシカ の開発を通じてGoを嗜んでいます。 ダイエット・食事管理・体重管理・カロリー計算 - ヘルシカ every, Inc. ヘルスケア/フィットネス 無料 ふと、以下のコードを見て、「Goにおける現在時刻ってなんなんだ…?」となりました。 now := time.Now() OSから取って来ているのは既知とした上で、Goのコードでそれをどのような形で扱っているのか、synctestの仮想時刻を返す挙動がどのように実装されているのかなど、いろいろ気になったのでコードを追っていこうと思います。 本記事で話すこと Goの時刻の扱い方 Goの現在時刻の取得の実装(ある程度高レイヤーの部分のみ) 本記事で話さないこと 他プログラミング言語の現在時刻の取得方法との違い プラットフォーム別実装など低レイヤーの詳細 time.Now の戻り値の作られ方 ウォールクロックとモノトニッククロック まず、OSが提供する時刻ソースには、大きく2種類あります。 種類 内容 特徴 ウォールクロック 現実で扱われている時刻。OSはUNIXエポック(1970-01-01 UTC)からの経過秒数として返すことが多い NTP補正・サマータイム・手動変更で 過去に巻き戻ることがある モノトニッククロック マシン起動などを起点とした、単調増加するカウンタ 必ず単調増加。日付としての意味は持たない これらの扱いについて、 time パッケージの公式ドキュメント に方針が書かれています。 Operating systems provide both a “wall clock,” which is subject to changes for clock synchronization, and a “monotonic clock,” which is not. The general rule is that the wall clock is for telling time and the monotonic clock is for measuring time. Rather than split the API, in this package the Time returned by time.Now contains both a wall clock reading and a monotonic clock reading; later time-telling operations use the wall clock reading, but later time-measuring operations, specifically comparisons and subtractions, use the monotonic clock reading. OSは「ウォールクロック」と「モノトニッククロック」の2つを提供している。ウォールクロックはクロック同期のために変更されうるが、モノトニッククロックは変更されない。一般的なルールとして、ウォールクロックは時刻を知るため(telling time)に、モノトニッククロックは時間を測るため(measuring time)に使う。本パッケージではAPIを分けるのではなく、 time.Now が返す Time にウォールクロックとモノトニッククロックの両方の読み取り値を含めることにしている。以降の「時刻を知る」操作はウォールクロックの値を、「時間を測る」操作(具体的には比較と差分)はモノトニッククロックの値を使う。 time.Timeの構造 「ウォール/モノトニックの両方を1つの Time で扱う」という方針を踏まえて、 src/time/time.go#L140-L161 で定義されている time.Time の構造を見てみましょう。 type Time struct { // wall and ext encode the wall time seconds, wall time nanoseconds, // and optional monotonic clock reading in nanoseconds. // // From high to low bit position, wall encodes a 1-bit flag (hasMonotonic), // a 33-bit seconds field, and a 30-bit wall time nanoseconds field. // The nanoseconds field is in the range [0, 999999999]. // If the hasMonotonic bit is 0, then the 33-bit field must be zero // and the full signed 64-bit wall seconds since Jan 1 year 1 is stored in ext. // If the hasMonotonic bit is 1, then the 33-bit field holds a 33-bit // unsigned wall seconds since Jan 1 year 1885, and ext holds a // signed 64-bit monotonic clock reading, nanoseconds since process start. wall uint64 ext int64 // loc specifies the Location that should be used to // determine the minute, hour, month, day, and year // that correspond to this Time. // The nil location means UTC. // All UTC times are represented with loc==nil, never loc==&utcLoc. loc *Location } 中身は wall 内に持っている hasMonotonic フラグによって2パターンに切り替わります。 hasMonotonic = 1 (ウォール+モノトニック) hasMonotonic = 0 (ウォールのみ) wall フラグ(1) + ウォール秒(33bit, 1885年起点) + ウォールナノ秒(30bit) フラグ(0) + 33bit秒は未使用(0) + ウォールナノ秒(30bit) ext モノトニッククロック値 (プロセス起動からのナノ秒) ウォール秒 (西暦1年起点の符号付き64bit) loc タイムゾーン タイムゾーン ext が hasMonotonic で意味を切り替えるようになっているのは、 wall の33bit秒(1885年起点)だと 1885〜2157年の約272年分 しか表現できないからです。 time.Date(1500, ...) のような範囲外の時刻を扱う hasMonotonic = 0 のケースでは、ウォール秒を wall の33bitから ext (int64, 西暦1年起点) に移してより広い範囲をカバーします。これは Time のサイズを増やさずに「モノトニック付き」と「広い時刻範囲」を両立させるための工夫です。次節の time.Now 実装の中にも、以下のように33bit上限に言及するコメントが出てきます。 // This will be true after March 16, 2157. time.Now 本体の実装 ここまで把握した上で、 src/time/time.go#L1347-L1361 にある time.Now の実装を見ていきます(Go 1.26.3 現在)。 // Now returns the current local time. func Now() Time { sec, nsec, mono := runtimeNow() if mono == 0 { return Time{ uint64 (nsec), sec + unixToInternal, Local} } mono -= startNano sec += unixToInternal - minWall if uint64 (sec)>> 33 != 0 { // Seconds field overflowed the 33 bits available when // storing a monotonic time. This will be true after // March 16, 2157. return Time{ uint64 (nsec), sec + minWall, Local} } return Time{hasMonotonic | uint64 (sec)<<nsecShift | uint64 (nsec), mono, Local} } コードに出てくる定数は同じく src/time/time.go の L163-L169 ( hasMonotonic など)と L535-L568 ( unixToInternal など)、および L1341 ( startNano )に以下のように定義されています。(簡単のため一部省略して記述しています) const ( secondsPerDay = 24 * 60 * 60 // 西暦1年1月1日 〜 UNIXエポック(1970-01-01) の秒数 unixToInternal int64 = ( 1969 * 365 + 1969 / 4 - 1969 / 100 + 1969 / 400 ) * secondsPerDay // 西暦1年1月1日 〜 1885年1月1日 の秒数 wallToInternal int64 = ( 1884 * 365 + 1884 / 4 - 1884 / 100 + 1884 / 400 ) * secondsPerDay ) const ( hasMonotonic = 1 << 63 // wall の最上位bitに立てるフラグ minWall = wallToInternal // wall の33bit秒の起点(= 1885年) nsecShift = 30 // wall に秒を詰めるときのシフト量 ) // プロセス起動時点のモノトニック値(runtime 初期化時にセットされる) var startNano int64 (1969*365 + 1969/4 - 1969/100 + 1969/400) の式は閏年を考慮してその年までの日数を計算しています。これに secondsPerDay を掛けることで「西暦1年1月1日からその年までの 秒数 」になります。 unixToInternal は1970年、 wallToInternal は1885年までのそれにあたります。 役割を整理すると以下のとおりです。 定数 役割 unixToInternal OSが返すUNIX秒を 「西暦1年起点」 に変換するオフセット minWall (= wallToInternal ) 西暦1年起点を 「1885年起点」 にずらすオフセット( wall の33bit秒の基準合わせ) nsecShift wall に秒を詰めるとき左に30bitシフトしてナノ秒の場所を空ける hasMonotonic モノトニックが入っているかのフラグ( wall の最上位bit) startNano プロセス起動時点のモノトニック値。これを引くことで ext を 「プロセス起動からの経過ns」 に正規化する Local loc に入れるデフォルトのタイムゾーン( *Location ) これらを踏まえてもう一度 time.Now を読み直すと、3パターンで Time を組み立てていることがわかります。 func Now() Time { // OSから現在のウォール秒・ナノ秒・モノトニック値を取得 sec, nsec, mono := runtimeNow() // 【パターン1】モノトニッククロックが取れなかった環境 // → ウォールクロックだけを ext に入れて返す(hasMonotonic = 0 のレイアウト) if mono == 0 { // sec(UNIX秒) + unixToInternal で「西暦1年起点の秒」に変換し、ext に詰める return Time{ uint64 (nsec), sec + unixToInternal, Local} } // 以降は mono あり mono -= startNano // モノトニックを「プロセス起動からの経過ns」に正規化 sec += unixToInternal - minWall // sec を「1885年起点の秒」に変換(33bit領域に詰める準備) // 【パターン2】33bitに収まらない(= 2157年3月16日以降) // → モノトニックを諦めて、ウォール秒は ext の方に置く(hasMonotonic = 0 のレイアウト) if uint64 (sec)>> 33 != 0 { // sec はいま1885年起点。minWall を足し戻して「西暦1年起点」に戻してから ext へ return Time{ uint64 (nsec), sec + minWall, Local} } // 【パターン3】通常パス // → hasMonotonic フラグを立て、ウォール・モノトニック両方を wall / ext に詰める // - sec は1885年起点のまま wall の33bit領域へ(<< nsecShift でナノ秒の場所を空けて | で合成) // - mono は正規化済みの値をそのまま ext へ return Time{hasMonotonic | uint64 (sec)<<nsecShift | uint64 (nsec), mono, Local} } runtimeNow() の中身 runtimeNow() は time パッケージ側ではシグネチャだけ書かれており、実体は src/runtime/time.go#L16-L31 にあります。 //go:linkname time_runtimeNow time.runtimeNow func time_runtimeNow() (sec int64 , nsec int32 , mono int64 ) { if bubble := getg().bubble; bubble != nil { sec = bubble.now / ( 1000 * 1000 * 1000 ) nsec = int32 (bubble.now % ( 1000 * 1000 * 1000 )) // Don't return a monotonic time inside a synctest bubble. // If we return a monotonic time based on the fake clock, // arithmetic on times created inside/outside bubbles is confusing. // If we return a monotonic time based on the real monotonic clock, // arithmetic on times created in the same bubble is confusing. // Simplest is to omit the monotonic time within a bubble. return sec, nsec, 0 } return time_now() } 分岐は2つあります。 synctest bubble の分岐 : 実行中のゴルーチンが synctest のバブル内にいる場合、バブルの仮想時刻 bubble.now を ウォール秒・ナノ秒として返し 、モノトニックは 0 を返します。 通常の分岐 : time_now() を呼び出します。これの実体はプラットフォーム別に実装されており、最終的にはどれもOSが提供する時刻取得APIを叩いています。 time_now() の実装は低レイヤーに近い話になるので今回は触れません。 bubble.now は、 src/runtime/synctest.go#L186-L187 の synctestRun で初期化されます。 const synctestBaseTime = 946684800000000000 // midnight UTC 2000-01-01 bubble.now = synctestBaseTime モノトニックを使わない理由については、コメントに書かれています。以下、和訳です。 synctestバブル内ではモノトニック時刻を返さないようにする。 フェイククロックに基づいたモノトニック時刻を返してしまうと、バブル内で作った時刻とバブル外で作った時刻のあいだでの計算結果が紛らわしくなる。 一方で実モノトニッククロックに基づいたモノトニック時刻を返しても、同じバブル内で作った時刻同士の計算が紛らわしくなる。 もっともシンプルな解は、バブル内ではモノトニック時刻を省略することだ。 モノトニッククロックはマシン起動を起点とするものなので、ウォールクロックだけ仮想時刻を進めても両者の値が食い違ってしまいます。一方で synctestBaseTime を起点にしたモノトニック値を別途用意するという選択肢もありますが、その場合もバブル外で取得した Time との差分計算で実時間とバブル内仮想時間が混在してしまいます。これらを避けるために、バブル内ではウォールクロックのみを扱う実装になっている、ということですね。 まとめ time.Now() の経路は以下のようになっていることがわかりました。 time.Now() │ │ ① (sec, nsec, mono) を取得 └── time.runtimeNow() ── linkname ──→ runtime.time_runtimeNow() │ ├── synctest bubble 内 │ sec = bubble.now / 1e9 (バブル仮想時刻) │ nsec = bubble.now % 1e9 │ mono = 0 (バブル内ではmonoを返さない) │ └── 通常経路 runtime.time_now() (プラットフォーム別実装) └─ OSが提供する時刻取得APIを呼ぶ │ │ ② 受け取った値を Time{wall, ext, loc} に組み立てて返す │ ├── mono == 0 → hasMonotonic=0, ext に「西暦1年起点ウォール秒」 ├── 通常 (33bit以内) → hasMonotonic=1, wall=フラグ|33bit秒(1885年起点)|30bitナノ秒, ext=mono(プロセス起動からのns) └── 33bit溢れ (2157年以降) → hasMonotonic=0, ext に「西暦1年起点ウォール秒」 Goが扱う時刻の構造とその設計理由、synctestの分岐実装、そしてウォールとモノトニックを巧みに組み合わせる工夫を知ることができ、とても満足しています。プラットフォームごとの実装は、まずは自分が使っているarm64から見てみたいと思います。 最後までお読みいただきありがとうございました! 参考 Package time - pkg.go.dev Package testing/synctest - pkg.go.dev Go: src/time/time.go Go: src/runtime/time.go Go: src/runtime/synctest.go Go 1.9 Release Notes - Monotonic Clocks Proposal: Monotonic Elapsed Time Measurements in Go
はじめに こんにちは!デリッシュキッチンで主にバックエンドの開発を担当している秋山です。 私たちのチームでは Gemini API を使った機能を運用しており、利用料金をいかに抑えるかは継続的に向き合うべきテーマになっています。 この記事では、Gemini API のコスト削減の選択肢を一通り整理したうえで、私自身が実際に試した中での学びを共有します。 料金体系 まずはGemini APIの料金に対して説明します。 Gemini API の料金は、ざっくり以下の3要素で決まります。 入力トークン : プロンプトやシステム指示、画像・動画・音声などの入力 出力トークン : モデルが返したテキスト 思考(thinking)トークン : 推論モデルが回答前に内部で考えるトークン。※ 出力料金として課金される また、各トークンの料金は使用するモデルによって大きく異なります。 2026年5月時点でGemini3系の100万トークンあたりの標準価格を比較すると、以下のようになります。 モデル 入力(テキスト) 出力 Gemini 3.1 Flash-Lite $0.25 $1.50 Gemini 3 Flash (Preview) $0.50 $3.00 Gemini 3.1 Pro (Preview) $2.00 $12.00 (※入力データの種別(テキスト/画像/動画/音声)やプロンプト長によって単価が変動するため、本表では「テキスト入力・200Kトークン以下」の条件に揃えて比較しています。) ご覧のとおり、FlashとProで出力料金は4倍、Flash-LiteとProでは8倍の差があります。 定期的な価格変動があるため、各モデルのコストについて詳しくは公式ドキュメントをご覧ください。 ai.google.dev コスト削減の選択肢 主なコスト削減手法として次のようなものがあると考えています。 手法 削減効果 モデルダウングレード 数十%以上 Context Caching 入力トークン最大90% Batch API 50% Flex推論 50% Thinking Budget / Level の縮小 思考トークン分 それぞれ簡単に補足していきます。 モデルダウングレード 最も基本的かつ強力です。 料金体系のところで示したとおりモデルによって大きく料金が異なります。 基本的には最新のモデルや推論に適したモデルの料金が高いです。 そのため、Geminiに任せるタスクにおいて、各モデルで精度を検証し、精度と料金を踏まえて使用するモデルを決定することが重要です。 例えば、最新のモデルの方が推論性能が高いことが多いですが、タスクによっては古いモデルでも十分な精度の回答を得られる場合があるため、どのモデル以上であれば要件を満たすことができるかの検証が必要です。 Context Caching 同じシステム指示やドキュメントを繰り返し使用する場合に効きます。 キャッシュ済みの入力トークン単価が大幅に下がります。 Gemini 2.5 以降のモデルではデフォルトで有効になっていますが、コスト削減を保証したい場合は明示的な設定が必要です。 ai.google.dev Batch API Batch APIは、非同期で大量リクエストを処理する仕組みで、料金が標準の半額になります。 24時間以内の完了が目標値で、即応性が不要なバックグラウンド処理向きです。 リクエストの提出方式は、API呼び出しにそのまま埋め込む インライン方式 と、JSONL形式のファイルをアップロードする 入力ファイル方式 の2種類があります。 ジョブとして実行されるので、結果の取得はポーリング( batches.get() )か、 batch.succeeded / batch.failed イベントのWebhook購読で行います。 ジョブには 48時間の有効期限 があり、その間に処理が完了しない場合は JOB_STATE_EXPIRED 扱いとなって失効します。 Batch APIを使用するメリットとして 標準料金の半額で利用できる 1つのバッチで大量のリクエストを行うことができる という点があります。 一方で、 非同期処理のため即時応答ができない ジョブ管理(投入・状態取得・結果回収)のクライアント実装が必要になる というデメリットがあります。 ai.google.dev Flex推論 Flex推論は、レイテンシや信頼性が変動する代わりに、標準料金の50%でAPIを叩ける仕組みです。 2026年5月現在はプレビュー機能です。 Gemini 2.5 系以降の主要モデル(2.5 Pro / Flash / Flash-Lite、3.1 Pro / Flash-Lite、3 Flash など)に対応しており、リクエスト時に service_tier: "flex" を指定するだけで切り替えられます。 Flex推論を使用するメリットとして 標準料金の半額で利用できる 同期APIとして扱えるため、Batch APIのように非同期化(ジョブ投入 → 結果取得)する手間がかからない 「即時応答までは不要だが、同じセッション内で結果を受け取りたい」というユースケースに合う という点があります。 一方で、 標準APIよりも信頼性が低い レイテンシが標準APIより遅く、公式の目安は 1〜15 分 。 Flex推論が枯渇しても自動で標準APIには昇格しないため、クライアント側で 指数バックオフを含むリトライやフォールバックを自前で実装する必要がある というデメリットがあるため、ユースケースに適している場合のみ使用するのが良さそうです。 ai.google.dev Thinking Budget / Level の縮小 Gemini 2.5 / 3 系の推論モデルでは、思考トークン量をパラメーターで制御できます。 デフォルトのままだと簡単なタスクでもモデルが過剰に考え込んでしまい、出力料金とレイテンシが余計にかかってしまうケースがあります。 Gemini 2.5 系では ThinkingBudget (トークン数の上限を数値指定)、Gemini 3 系では ThinkingLevel ( MINIMAL / LOW / MEDIUM / HIGH の段階指定)でこれを制御できます。 Geminiに渡すタスクごとに、このパラメータを調整することでコストやレイテンシーの最適化を行うことができます。 ai.google.dev 実際にFlex推論を試しました ここからは、上で紹介した手法の中から、私が実際に本番運用で試したFlex推論について共有します。 私たちがGeminiに任せているタスクとして、リアルタイム性は必要ないが数分~数十分以内には完了したいタスクがありました。 料金を下げるためにBatch APIも考えましたが、Batch APIだとレイテンシに最大24時間かかるのが懸念でした。 一方Flex推論は目標レイテンシーが1分~15分のため、このタスクには最適だと判断しました。 Batch APIと違いジョブ管理の考慮が不要で、Flex推論のパラメータを指定してリクエストするだけなので、気軽に試すことができて開発体験的にも良かったです。 ただし、Flex推論の欠点として信頼性の低下があるため、私の場合は、 指数バックオフでリトライする処理 リトライに失敗する場合標準APIにフォールバックする処理 を入れました。 リトライ処理については公式でも推奨されています。 フォールバックを入れておくと、平常時はFlex推論の割引を享受しつつ、Flex推論が使えない時も影響を出さずに処理を続けられます。フォールバック時のコストは標準料金に戻りますが、「常に標準APIで叩く」ケースと比べれば全体としては安く済みます。 使ってみての感想ですが、Flex推論は実際の本番運用以外にも、信頼性が求められない日々のモデル検証で使用できそうだと思いました。 まとめ Gemini API のコスト削減には複数の選択肢があります。 そのため、タスクの性質と運用要件を踏まえ、複数の手法を組み合わせて使うことになります。 まだ自分で触れていない Context Caching と Batch API も、ユースケース次第で大きな効果が見込める手段なので、引き続き検証していきたいと考えています。 同じように Gemini API のコストを気にし始めた方にとって、選択肢を整理するきっかけになれば嬉しいです。
エブリー開発本部の塚田です。 バックエンドやデータ基盤をメインに担当しています。 2026年4月に Amazon S3 の新機能として Amazon S3 Files が GA となり、続けて4月後半には Lambda からの利用にも対応 しました。 データエンジニア視点で見ると、「Lambda で並列データ処理を書くときに毎回悩んでいた、状態の持ち回り」がやりやすくなるんじゃないかと感じました。 本記事では、Lambda 上のデータ前処理パイプラインを S3 Files で組み直すとどう変わるか、を検討しました。 なぜ Lambda 前処理で「状態」が悩みの種だったのか Lambda はスケールが効き、起動コストも安いので、データ前処理を分散させる用途には便利です。一方で、Lambda が「ステートレスな関数」であるという前提と、データ処理に必要な「ある程度大きな共有データ・中間成果物のやり取り」が噛み合わないことが多く、設計時に悩む箇所でもあります。 これまで取りうる選択肢と課題感は以下があると思います。 各 Lambda が S3 から個別に GET/PUT : 起動ごとに DL コストがかかり、並列度を上げてもスループットが頭打ちになり、リクエスト課金も比例して膨らむ DynamoDB / ElastiCache を共有ストアにする : 大きめのオブジェクトには不向き、別サービスの運用も乗ってくる Lambda + EFS マウント : ファイル共有はできるが、S3 と二重管理になり、外部から覗きにくい EFS をマウントすれば「並列 Lambda が共有のファイルシステムを持つ」構成自体は以前から作れました。ただし EFS と S3 はそれぞれ別ストレージなので、「分析やバックアップは S3 側のオブジェクトでやるが、パイプラインの共有だけ EFS」というように二重管理になりがちで、ファイルとオブジェクトの間を行き来する同期スクリプトが必ずどこかで生えていました。 S3 Files が変えるもの S3 Files は内部的には Amazon EFS をベースにした NFS v4.2 のファイルシステム で、EC2 / Lambda / ECS / EKS から mount でき、書いたデータはマウント側からは即時に見え、S3 バケット側にも非同期で反映されます。 S3 Files は Mountpoint for Amazon S3 のような機能とは別物です。Mountpoint は S3 の API の上にファイルシステムの振る舞いを「見せる」アプローチなので、たとえばファイルの一部を上書きする操作が原理的にサポートされません。一方 S3 Files はファイルシステム側から見えるのは本物の NFS セマンティクスで、S3 側から見えるのは本物の S3 オブジェクトです。「両者は別物だが、その間の同期レイヤーを AWS が引き受けてくれている」という設計になっています。 ここまでが前提です。データ基盤側にとってこの仕様が嬉しいのは、次の2点あると考えています。 複数 Lambda が同時マウントできる :並列ジョブ間の共有として使える 同じデータをファイル経由でも S3 API 経由でも読める :パイプラインの内部処理はファイル世界で書いて、運用や監査は S3 世界で済ませられる 検証: 並列特徴量生成パイプラインを組んでみる 具体的なシナリオで Before / After を比較します。 シナリオ 入力: 新規レシピ 1000 件(メタデータと本文) 処理: 10 並列の Lambda が分担して特徴量を抽出する 共有して使うマスターデータ: 食材辞書 JSON(数十〜数百 MB) 既存レシピの埋め込みベクトル(数百 MB〜1 GB) 出力: 各 Lambda が抽出した特徴量を後段ジョブが集約 Before: 各 Lambda が S3 から個別 DL する構成 import boto3, json import numpy as np s3 = boto3.client( "s3" ) def handler (event, context): # コールドスタートのたびに、数百MB級のマスターを /tmp に取得 s3.download_file( "recipes-bucket" , "master/ingredients.json" , "/tmp/ingredients.json" ) s3.download_file( "recipes-bucket" , "master/embeddings.bin" , "/tmp/embeddings.bin" ) ingredients = json.load( open ( "/tmp/ingredients.json" )) embeddings = np.fromfile( "/tmp/embeddings.bin" , dtype=np.float32) features = build_features(event[ "recipes" ], ingredients, embeddings) # 結果は S3 に書き戻す body = serialize(features) s3.put_object( Bucket= "recipes-bucket" , Key=f "features/{event['job_id']}/{event['shard']}.parquet" , Body=body, ) この場合、以下のような問題点が考えられます Lambda コールドスタートのたびに数百 MB の DL が走る /tmp の 10 GB 制限に当たりかけることがあり、マスターを増やす際の考慮事項が発生 並列度を上げると、結局 S3 → Lambda の転送スループットがボトルネックになる マスターを更新したときに「全 Lambda がそれを見ている」状態を担保しづらい After: S3 Files をマウントして共有領域として使う /mnt/s3files に recipes-bucket をマウント済みとします。 import json, os import numpy as np # モジュールトップで一度だけ読み、コンテナ再利用時はそのまま使う INGREDIENTS = json.load( open ( "/mnt/s3files/master/ingredients.json" )) EMBEDDINGS = np.fromfile( "/mnt/s3files/master/embeddings.bin" , dtype=np.float32) def handler (event, context): out_dir = f "/mnt/s3files/features/{event['job_id']}" os.makedirs(out_dir, exist_ok= True ) out_path = f "{out_dir}/{event['shard']}.parquet" features = build_features(event[ "recipes" ], INGREDIENTS, EMBEDDINGS) write_parquet(out_path, features) boto3 の呼び出しが消え、純粋に「ファイルを読み、ファイルを書く」コードになっています。Lambda 関数内で s3.put_object を発行する必要がないので、リトライやマルチパートアップロードの考慮もパイプライン側のコードからは消えます。 書き出した特徴量は、後段の集約ジョブからは S3 API で s3://recipes-bucket/features/{job_id}/ を aws s3 ls するだけで一覧でき、運用者から見えるバケットの世界も自然なままです。 Before / After で何が変わるか 指標 Before(個別 DL) After(S3 Files) コールドスタート時の初期化 約 1 GB の DL が発生する マウント越しの mmap / page cache に乗る 並列実行時のスループット S3 → Lambda の DL 帯域がボトルネックになりやすい 各 Lambda が同じファイルをキャッシュ越しに参照 S3 GET リクエスト数 コールドスタート × オブジェクト数 ぶん発生 マスターは初回のみ、出力 PUT は不要 /tmp 使用量 DL したマスターぶん消費 マウント領域は /tmp を消費しない 特に効きそうなのは コールドスタート時の DL コスト と S3 リクエスト数 です。前者はマスターサイズに比例し、後者はそのまま月次コストに跳ねるため、並列度が高いシナリオほど差が広がります。 チェックポイント付きジョブとしての応用 並列処理の共有だけでなく、もう一つ価値が出やすいユースケースが 長時間ジョブのチェックポイント だと考えています。 Lambda は最大実行時間の制限がありますが、それを超える処理は分割して Step Functions で繋ぐ、というのがよくある構成かと思います。ステップ間で「どこまで処理が進んでいるか」を引き渡すのに様々な方法で考慮を入れることが発生します。S3 Files を使うと、これがそのままファイルとして書け、しかも S3 API から覗ける という性質を活かせます。 import json, os WORKSPACE = "/mnt/s3files/agents/recipe-tagger" def handler (event, context): job_id = event[ "job_id" ] state_dir = f "{WORKSPACE}/{job_id}" state_path = f "{state_dir}/state.json" os.makedirs(state_dir, exist_ok= True ) state = json.load( open (state_path)) if os.path.exists(state_path) else { "processed" : 0 , "results" : [], } # 中断された場合は、processed の続きから再開 for item in event[ "items" ][state[ "processed" ]:]: state[ "results" ].append(process(item)) state[ "processed" ] += 1 # 100件ごとにチェックポイントを永続化 if state[ "processed" ] % 100 == 0 : tmp_path = f "{state_path}.tmp" with open (tmp_path, "w" ) as f: json.dump(state, f) os.replace(tmp_path, state_path) # アトミックに置き換える with open (state_path, "w" ) as f: json.dump(state, f) return { "job_id" : job_id, "processed" : state[ "processed" ]} ポイントは、 チェックポイントが S3 API 側からも見える という点です。運用中に「いまどのジョブがどこまで進んでいるか」を aws s3 cp s3://recipes-bucket/agents/recipe-tagger/{job_id}/state.json で確認できます。 向く用途・向かない用途 検証を通じて見えた、自分なりの線引きです。 S3 Files が向く用途 並列ジョブが共通参照する大きめのマスターデータの配置先 並列ジョブの中間成果物を集約する 長時間ジョブのチェックポイント 既存のファイル前提コードを最小改修で S3 へ移すリフトアンドシフト 向かない用途 強整合性が必要なメタデータ管理 大量の小ファイル rename を伴うワークフロー ミリ秒レイテンシが要求されるクリティカルパス ファイル経由と S3 API 経由の両方から同じファイルに書き込む運用 おわりに すべての処理を S3 Files 経由にする必要はなく、並列ジョブの共有、チェックポイント、共通マスターの配布など、「これまでファイルシステムが欲しかったがゆえに EFS を別建てしていた / 自前で同期していた」箇所だけをピンポイントで置き換えるのが、効きの良い使いどころだと現状では考えています。
はじめに こんにちは。リテールハブ開発部の清水です。 私たちのチームでは、外部システムと深夜帯にCSVをやり取りするバッチシステムを開発・運用しています。 これらのバッチ群は適切な順番で適切な設定で実行することが求められるのですが、 新メンバーがジョインしたとき、これをローカル環境で実際に動かして確かめるのはハードルが高いと感じていました。 本記事ではこのようなバッチシステムを動作確認しやすくするために考えた点をご紹介します。 対象のバッチシステム 本番のインフラ構成イメージ ローカル開発環境 Docker Composeで FTP / S3互換ストレージ / MySQL を立てて、Goバッチがそれらに対して動作する形です。 動作確認が大変な理由 外部システム連携であること 外部システム側のフォーマット仕様書は手元にあるのですが、仕様書を読むだけだとピンとこない箇所がそれなりにあります。 さらに本物のCSVは非常にカラム数が多い上、センシティブな情報も含まれるので、軽い気持ちで実物を見るのはためらわれるものです。 こういった状況から気軽にローカルで試すためのテストデータを作成することのハードルがかなり高いです。 バッチ処理を複数に分けていること リトライしやすさを優先して、CSV取得・ETL変換・計算処理・CSVエクスポート・FTP送信・ファイル送信履歴更新とバッチを6つに分けています。 途中まで処理したファイルは都度S3に設置する形を取っています。 初見だと「どの順番でどの環境変数で動かせばいいんだっけ?」と混乱しやすいです。 一度ローカルで一通り実行してみるのがいちばん早いのですが、その「一度通す」までのお膳立てが意外に重い、というのが課題でした。 工夫1: テストデータ作成用コマンドを実装 JSONからテストデータを生成するmakeコマンドを用意しました。 make gen-testdata CONFIG=test_20260507.json { " a ": " 2026-05-07 ", " b ": [ { " c ": " TEST_001 ", " d ": [ { " e ": 1 , " f ": " 10:30:00 ", " g ": [ { " h ": 1 , " i ": 2 , " j ": 500 } ] } ] } ] } JSONを差し替えるだけでさまざまなテストケースを切り替えられます。 本物のCSVに触らずにテストできるようになり、テストデータ作成のハードルがだいぶ下がりました。 実際に使用するときは人間がJSONを用意するのではなく、Claude Codeに「こういうテストケースのデータを作って」と指示を出すとこちらのコマンドが使われる形になります。 工夫2: Claude Codeでオンボーディングスキルを作成 スキルのディレクトリ .claude/skills/onboarding/ ├── SKILL.md ├── references/ │ ├── architecture.md │ └── batch-pipeline.md └── scripts/ ├── check_env.sh └── check_step.sh SKILL.md がスキル本体で、 references/ 以下にアーキテクチャ説明やパイプラインの全体像を、 scripts/ 以下に通過判定用のスクリプトを置いています。 大まかな内容 Phase 1: 環境チェック Docker / docker compose / make / コンテナの起動・healthy 状態をスクリプトで判定する Phase 2: アーキテクチャ説明 + 動作確認 references/ 以下のドキュメントで全体像を伝えてから、6本のバッチを1本ずつ手で動かしてもらう こだわったポイント 実際に手で動かせるようにする 私はどうしてもコードを読むだけでは理解できないと感じることが多いので、ローカル環境で立ち上げて一通り動作させることにこだわりました 表示されたコマンドをコピーして、別タブのターミナルで実行して進める形にしました 通過判定をスクリプトで行う 最初は「ステップごとに Claude Code がユーザーに確認して、その回答を信じて進める」くらいの素朴な作りで考えていました 実際にはまだ前のステップが完了していない (例: DB初期化を忘れている) のに気づかず進んでしまい、後段でエラーになってからようやく手戻りが発生する、ということが起こりました おわりに 新メンバーが触れたときに迷子になりがちな部分を、テストデータ作成コマンドとオンボーディングスキルでだいぶ楽にできた手応えがあります。 特にスキル側では、ステップごとの通過判定をスクリプトに寄せたことで、Claude Codeが「分かったつもり」で先に進んでしまう問題を防げました。 同じように複雑なシステムの動作確認に悩まれている方の参考になれば嬉しいです。
開発2部の内原です。 シェルで >file 、 2>&1 のような記号を使ってリダイレクト処理を行うことは多いかと思いますが、なぜこのような書き方をするのか、それが実際にカーネルやプロセスのレベルで何をやっているのか、は意外と説明しづらい、というかなんとなくふわっとした理解のままでいました。 そこでこの記事ではファイルディスクリプタとUnixシステムコールの観点から、これらの記号の意味を考えてみます。 ファイルディスクリプタ(fd)とは ファイルディスクリプタとは、プロセスごとにカーネルが管理しているファイルテーブルへのインデックス(整数)のことです。プロセスがファイルやソケットを開くと、カーネル側でテーブルにエントリが作られ、その識別子となる整数(0, 1, 2, 3, ...)がプロセスに返されます。 プロセスは以降、この整数を read(2) / write(2) などのシステムコールに渡してファイル操作を行います。 0, 1, 2 という慣習 POSIXにおいてプロセス起動時点で、以下の3つのfdが予め確保されています。 fd 名前 用途 0 stdin 標準入力 1 stdout 標準出力 2 stderr 標準エラー出力 これらのファイルディスクリプタは通常、ターミナルのデバイスファイル( /dev/ttys00N など)にOSによって関連付けられています。 $ lsof -p $$ -a -d 0-2 # 今実行しているシェル自身が開いている標準入出力(fd 0〜2)を表示 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME zsh 49106 uchihara 0u CHR 16,6 0t10 1167 /dev/ttys006 zsh 49106 uchihara 1u CHR 16,6 0t10 1167 /dev/ttys006 zsh 49106 uchihara 2u CHR 16,6 0t10 1167 /dev/ttys006 3つともターミナル端末( /dev/ttys006 )を指していることがわかります。 open(2) で fd を取得する 新しくファイルを開くと、未使用の最小番号の fd が返却されます。起動時点で 0, 1, 2 が使われているので通常は 3 になります。 #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { int fd = open ( "/tmp/hello.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); printf ( "fd = %d\n " , fd); write (fd, "hello \n " , 6 ); close (fd); return 0 ; } $ cc fd_open.c -o fd_open && ./fd_open fd = 3 $ cat /tmp/hello.txt hello 確かに fd = 3 が返ってきています。 dup(2) で fd を複製する dup(2) はオープン済みの fd を複製し、未使用の最小番号で新しい fd を返します。複製された2つの fd は同じファイルテーブルエントリを指すため、ファイル位置(オフセット)も共有されます。 #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { int fd1 = open ( "/tmp/dup.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); int fd2 = dup (fd1); printf ( "fd1= %d , fd2= %d\n " , fd1, fd2); write (fd1, "via fd1 \n " , 8 ); write (fd2, "via fd2 \n " , 8 ); close (fd1); close (fd2); return 0 ; } $ cc fd_dup.c -o fd_dup && ./fd_dup fd1=3, fd2=4 $ cat /tmp/dup.txt via fd1 via fd2 オフセットが共有されているため、 fd1 で書き込んだ続きから fd2 の書き込みが進んでいることがわかります。 dup2(2) で fd 番号を複製する dup2(oldfd, newfd) は newfd 番がすでに使われていれば一旦close してから、 oldfd の複製を newfd 番に作るシステムコールです。これがリダイレクトの実態となります。 #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { printf ( "before redirect \n " ); fflush ( stdout ); int fd = open ( "/tmp/dup2.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); dup2 (fd, 1 ); // fd 1 (stdout) を fd の指す先に差し替える close (fd); // 元の fd はもう不要なので閉じる printf ( "after redirect \n " ); return 0 ; } $ cc fd_dup2.c -o fd_dup2 && ./fd_dup2 before redirect $ cat /tmp/dup2.txt after redirect dup2(fd, 1) を境にして、 printf の出力先が端末からファイルに切り替わっていることがわかります。 なお fflush(stdout) を挟んでいるのはstdioのバッファリングを考慮するためです。バッファに残ったまま fd 1 を差し替えると、後でフラッシュされたタイミングで意図しないファイルに書き込まれてしまいます。 >file の正体 シェルがリダイレクトを行うときの手順は以下の通りです。 (新たに子プロセスでコマンドを実行する場合) fork(2) で子プロセスを生成 子プロセスで出力先ファイルを open(2) その fd を dup2(2) で 1 番(stdout)に複製 元の fd は close(2) で閉じる execve(2) で実コマンドに置き換える 自前でリダイレクトを再現してみる ./mini_redirect <出力ファイル> <コマンド> [引数...] のように実行すると、指定したコマンドの標準出力をファイルへリダイレクトします。 #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> int main ( int argc, char *argv[]) { if (argc < 3 ) { fprintf ( stderr , "usage: %s <output_file> <cmd> [args...] \n " , argv[ 0 ]); return 1 ; } const char *outfile = argv[ 1 ]; pid_t pid = fork (); if (pid < 0 ) { perror ( "fork" ); return 1 ; } if (pid == 0 ) { // 子プロセス: 出力先を open し、stdout (fd 1) に dup2 してから exec int fd = open (outfile, O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); if (fd < 0 ) { perror ( "open" ); _exit ( 1 ); } if ( dup2 (fd, 1 ) < 0 ) { perror ( "dup2" ); _exit ( 1 ); } close (fd); execvp (argv[ 2 ], &argv[ 2 ]); perror ( "execvp" ); _exit ( 1 ); } // 親プロセス: 子の終了を待つ int status; waitpid (pid, &status, 0 ); return WIFEXITED (status) ? WEXITSTATUS (status) : 1 ; } 実行してみます。 $ cc mini_redirect.c -o mini_redirect $ ./mini_redirect /tmp/mini_out.txt echo hello world from mini_redirect $ cat /tmp/mini_out.txt hello world from mini_redirect これは以下と同じ動作です。 $ echo hello world from mini_redirect >/tmp/mini_out.txt 2>&1 とはなにか 2>&1 という書き方忘れたりしません?(自分はたまに忘れます)これは実際には dup2(1, 2) の意味で、シェルの記述とシステムコールの引数が逆になっていて混乱しがちではあります。 あと、標準出力と標準エラー出力をまとめてファイルに書き出したい時に、 >file 2>&1 と 2>&1 >file どっちだっけ?みたいになることもあります。 結論 書き方 stdout の行き先 stderr の行き先 cmd >file 2>&1 file file cmd 2>&1 >file file 変化なし(通常はターミナル) 2>&1 は「fd 2 を fd 1 と同じにする」と説明されますが、これは正確には「 2>&1 を実行した時点の fd 1 の指し先を fd 2 にコピーする」という操作で、以後 fd 1 が別の場所に切り替わっても fd 2 は連動しません。(リンクしているわけではないという意味) シェルでの挙動 $ bash -c 'echo stdout-msg; echo stderr-msg >&2' >/tmp/sh_a.txt 2>&1 $ cat /tmp/sh_a.txt stdout-msg stderr-msg $ bash -c 'echo stdout-msg; echo stderr-msg >&2' 2>&1 >/tmp/sh_b.txt stderr-msg $ cat /tmp/sh_b.txt stdout-msg パターンBではなぜ stderr が端末に残ってしまうのか、再現してみます。 C で再現してみる シェルは > や 2>&1 といったリダイレクト指示を左から右に評価する仕様なので、順序が重要になります。 パターン A: >file 2>&1 #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { int fd = open ( "/tmp/order_a.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); dup2 (fd, 1 ); // ① fd 1 をファイルに差し替え close (fd); dup2 ( 1 , 2 ); // ② fd 2 を「現時点の fd 1」(=ファイル) に差し替え fprintf ( stdout , "stdout: hello \n " ); fflush ( stdout ); fprintf ( stderr , "stderr: world \n " ); fflush ( stderr ); return 0 ; } $ cc order_a.c -o order_a && ./order_a $ cat /tmp/order_a.txt stdout: hello stderr: world ①の時点で fd 1(標準出力)はファイルを指します。 ②でその「ファイルを指している fd 1」をコピーして fd 2(標準エラー出力) にセットしているので、fd 2 もファイルを指すことになります。 結果として両方ファイルに書き込まれます。 パターン B: 2>&1 >file #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { dup2 ( 1 , 2 ); // ① fd 2 を「現時点の fd 1」(=ターミナル) に差し替え int fd = open ( "/tmp/order_b.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); dup2 (fd, 1 ); // ② fd 1 をファイルに差し替え(fd 2 は連動しない) close (fd); fprintf ( stdout , "stdout: hello \n " ); fflush ( stdout ); fprintf ( stderr , "stderr: world \n " ); fflush ( stderr ); return 0 ; } $ cc order_b.c -o order_b && ./order_b stderr: world $ cat /tmp/order_b.txt stdout: hello ①の時点では fd 1(標準出力)はまだターミナルを指しており、fd 2(標準エラー出力)が同じところを指します。 ②で fd 1 はファイルに切り替わりますが、fd 2 はすでに「①でコピーされた時点のターミナル」を保持し続けています。 これはシェルの挙動と一致しています。 なぜそうなるのか カーネル内部の構造で見るとわかりやすいです。 各 fd はプロセスごとの fd テーブルから「open file table」のエントリを指している dup2(oldfd, newfd) は「 newfd の指し先を oldfd の指し先と同じにする」というポインタ複製操作 後から oldfd の指し先を変更しても、 newfd は元の指し先を指したまま つまり 2>&1 は「fd 2 と fd 1 を以後リンクする」のではなく、「実行時点の fd 1 の指し先を fd 2 にコピーする」ということになります。 覚え方 ログを全部ファイルに落としたいときは、まずファイルリダイレクト >file を先に書いて、その後 2>&1 でまとめる、と考えるのがよさそうです。 $ cmd >file 2>&1 # 両方 file $ cmd 2>&1 >file # stdout だけ file、stderr は端末 まとめ シェルでよく使う >file や 2>&1 といった記号は、 open dup2 close という Unix システムコールの組み合わせとしてみると、その挙動がそのままシステムコールによる実装になっていることが確認できました。 まあとは言え覚えづらいですよね・・・結局のところ慣れでしかないかもしれません。 あと、パイプやヒアドキュメントについてもまたいつか調べてみたいです。 fd は open 時に未使用の最小番号が返却される整数 >file は open → dup2(fd, 1) → close(fd) 2>&1 は実行時点の fd 1 の指し先を fd 2 にコピーするだけで、以後 fd 1 が変わっても fd 2 は連動しない 評価順は左から右なので、 >file 2>&1 と 2>&1 >file は別物になる
Claude Code を快適に使うための macOS デスクトップ通知セットアップ 背景 なぜ alerter を採用したのか 1. alerter のインストール 2. 通知スクリプトの作成 2-1. notify_alerter.sh(Stop / Notification hook 用) 2-2. notify_pretool.sh(PreToolUse hook 用) 3. Claude Code の hooks 設定 各 Hook の役割 4. VSCode 拡張での Notification hook の扱い 5. macOS のセキュリティ許可 6. 動作確認 通知テスト 確認項目 デバッグログ 7. alerter のプロセス管理で学んだこと 問題: プロセスのゾンビ化 対策1: --group(プロセス蓄積の防止) 対策2: --timeout(最終的なプロセス回収) 溜まったプロセスの手動クリーンアップ 8. なぜ nohup + disown が必要だったか 9. 通知のカスタマイズ 特定ツールの通知をスキップする サウンド --sender(通知アイコン) まとめ 最後に  こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️です。 背景  弊社ではClaude を非エンジニアも含めた全社に展開しており、業務のあらゆる場面で生成AI の活用を推進しています。  そんな中、我々のチーム内でも今年3月から本格的にCursor から移行してClaude Code (VSCode 拡張機能)を日常的に使うようになってから、両者の明らかな違いを実感することになりました。  それは、Cursor が標準搭載しているmacOS デスクトップ通知機能でした。Claude Code にはその機能がないためAgent にプロンプトを投げた後、私自身が他の作業を並行しているとClaude Code 側が permission_prompt のWait でタスクが一向に完了できない状態やタスク完了状態に気付くのが随分遅れてしまうということがしばしばありました(業務効率化のためのAgent ツールなのに、、)。  Claude Code には Hooks という仕組みが用意されています。これは Stop(応答終了)や Notification(許可待ち等)、PreToolUse(ツール実行直前)といったライフサイクルイベントに対して任意のシェルコマンドを実行できる公式機能で、JSON がイベント情報として標準入力から渡ってきます。  本記事ではこの Hooks と alerter というコマンドラインツールを組み合わせて、 タスク完了・許可待ち・入力待ちの デスクトップ通知を出す 通知を クリックすると、対象プロジェクトの VSCode ウィンドウが自動でアクティブになる (全画面の別アプリ上からでも切り替わる) VSCode 拡張版 でも許可待ち通知を取りこぼさない という環境を構築した内容をまとめます。macOS 26 系(Tahoe)環境で動作確認しています。 なぜ alerter を採用したのか  macOS から通知を出すだけなら選択肢は複数あります。今回の要件「通知をクリックしたら VSCode がアクティブになる」を満たせるものを比較した結果を表にまとめます。 ツール 通知表示 クリックイベントの取得 備考 terminal-notifier 環境依存 可能(旧来の定番) 公式リポジトリ の最新リリースは 2017 年 11 月(v2.0.0)で、近年の macOS での動作不具合 Issue( #307 、 #312 、 #319 ほか)が未解決のままです。私の環境(macOS 26 系)では通知が出ませんでした。 osascript ( display notification ) 動作する 不可 AppleScript 公式ドキュメント( Standard Additions: display notification )に「戻り値なし」と明記されており、クリック結果を取得する手段がありません。 alerter 動作する 可能 公式リポジトリ によれば、 terminal-notifier を Swift で書き直した後継で、macOS 13.0 以降対応。クリック時に @CONTENTCLICKED / @ACTIONCLICKED を stdout に出力するため、外部プロセスでの後処理が可能です。   alerter がクリック結果を stdout に返してくれるおかげで、「クリック → open -a "Visual Studio Code" で対象プロジェクトを開く」という連携を、標準ツールの組み合わせだけで実現できました。 1. alerter のインストール  Homebrew で導入します( 公式の導入手順 に準拠)。 brew install vjeantet/tap/alerter  インストール確認: which alerter # /opt/homebrew/bin/alerter alerter --version 2. 通知スクリプトの作成  2 つのスクリプトを ~/.claude/ に配置し、実行権限を付与します。前者は Stop / Notification hook 用、後者は VSCode 拡張向けの PreToolUse hook 用です。 chmod +x ~/.claude/notify_alerter.sh chmod +x ~/.claude/notify_pretool.sh 2-1. notify_alerter.sh (Stop / Notification hook 用)  タスク完了通知および、CLI 版 Claude Code での許可待ち通知を処理します。Hook に渡ってくる JSON の仕様は 公式リファレンスの Stop / Notification セクション に従っています。 notification_type として permission_prompt / idle_prompt が返ってくるため、これで分岐しています。 #!/bin/bash input = $( cat ) echo " $( date ' +%H:%M:%S ' ) $input " >> /tmp/claude_notify_debug.log cwd = $( echo " $input " | jq -r ' .cwd ' ) project = $( basename " $cwd " ) notification_type = $( echo " $input " | jq -r ' .notification_type ' ) # ターミナルアプリの Bundle ID を自動検出 get_terminal_bundle_id() { if [[ -n " ${__CFBundleIdentifier} " ]] ; then echo " ${__CFBundleIdentifier} " return fi case " ${TERM_PROGRAM} " in " Apple_Terminal ") echo " com.apple.Terminal " ;; " iTerm.app ") echo " com.googlecode.iterm2 " ;; " ghostty ") echo " com.mitchellh.ghostty " ;; " WarpTerminal ") echo " dev.warp.Warp-Stable " ;; * ) local pid parent comm pid = $$ while [[ " ${pid} " -ne 1 ]] 2 >/dev/null; do parent = $( ps -p " ${pid} " -o ppid = 2 > /dev/null | tr -d ' ' ) || break [[ -z " ${parent} " ]] && break comm = $( ps -p " ${parent} " -o comm = 2 > /dev/null ) case " ${comm} " in *Terminal* ) echo " com.apple.Terminal "; return ;; *iTerm* ) echo " com.googlecode.iterm2 "; return ;; *Cursor* ) echo " com.todesktop.230313mzl4w4u92 "; return ;; *Code* ) echo " com.microsoft.VSCode "; return ;; *ghostty* ) echo " com.mitchellh.ghostty "; return ;; *warp* ) echo " dev.warp.Warp-Stable "; return ;; * ) ;; esac pid = " ${parent} " done echo "" ;; esac } BUNDLE_ID = $( get_terminal_bundle_id ) send_notification() { local message =" $1 " local sound =" $2 " local group =" $3 " local args = (--title " Claude Code " --subtitle " ${project} " --message " ${message} " ) if [[ -n " ${sound} " ]] ; then args += ( --sound " ${sound} " ) fi args += ( --sender " com.microsoft.VSCode " ) # --group: 同じグループの通知は前のプロセスを自動終了して置き換える args += ( --group " ${group :- claude-default } " ) # --timeout: プロセスのゾンビ化防止(秒)。通知自体は macOS 通知センターに残る local timeout = 86400 local timeout_file =" $HOME /.claude/notify_timeout.conf " if [[ -f " ${timeout_file} " ]] ; then timeout = $( cat " ${timeout_file} " | tr -d ' [:space:] ' ) fi args += ( --timeout " ${timeout} " ) # alerter はクリック待ちでブロックするため、nohup + disown で完全にデタッチ nohup bash -c " result= \$ (alerter $( printf ' %q ' " ${args[ @ ]} " ) 2>/dev/null) if [[ \"\$ {result} \" == \" @CONTENTCLICKED \" || \"\$ {result} \" == \" @ACTIONCLICKED \" ]] && [[ -n \" ${cwd} \" ]]; then open -a \" Visual Studio Code \" \" ${cwd} \" fi " &> /dev/null & disown } case " ${notification_type} " in " permission_prompt ") send_notification " 許可待ち " " Ping " " claude-permission " ;; " idle_prompt ") send_notification " 入力待ち " " Purr " " claude-idle " ;; " stop ") send_notification " タスク完了 " " Glass " " claude-stop " ;; * ) send_notification " 通知 " " default " " claude-other " ;; esac 2-2. notify_pretool.sh (PreToolUse hook 用)  こちらは VSCode 拡張環境向けの「許可待ち通知」の代替実装です。詳細は「4. VSCode 拡張での Notification hook の扱い」で後述します。  ざっくり説明すると、次の 4 つの設定ファイルの permissions.allow リストと照合し、 自動許可されないツールの実行前にのみ 通知を送るというロジックです。 ~/.claude/settings.json (グローバル) ~/.claude/settings.local.json (グローバルローカル) $cwd/.claude/settings.json (プロジェクト) $cwd/.claude/settings.local.json (プロジェクトローカル) #!/bin/bash # PreToolUse hook: 許可が必要なツール実行前に通知を送る # settings.json の allow リストにマッチするツールはスキップする input = $( cat ) tool_name = $( echo " $input " | jq -r ' .tool_name ' ) cwd = $( echo " $input " | jq -r ' .cwd ' ) project = $( basename " $cwd " ) # 常に自動許可されるツール(通知不要) case " ${tool_name} " in Glob|Grep|TodoWrite|Agent|Skill|ToolSearch|SendMessage ) exit 0 ;; esac # ユーザー個別のスキップリスト(~/.claude/notify_skip_tools.txt) SKIP_FILE = " $HOME /.claude/notify_skip_tools.txt " if [[ -f " ${SKIP_FILE} " ]] ; then while IFS = read -r skip_tool; do [[ -z " ${skip_tool} " || " ${skip_tool} " == \# * ]] && continue if [[ " ${tool_name} " == " ${skip_tool} " ]] ; then exit 0 fi done < " ${SKIP_FILE} " fi # allow リストと照合する関数 check_allow_list() { local settings_file =" $1 " [[ -f " ${settings_file} " ]] || return # Bash ツール: コマンドプレフィックスで照合 if [[ " ${tool_name} " == " Bash " ]] ; then local command command= $( echo " $input " | jq -r ' .tool_input.command ' ) while IFS = read -r pattern; do if [[ " ${pattern} " =~ ^Bash\((.+)(:\*|\*)?\)$ ]] ; then local prefix =" ${BASH_REMATCH[ 1 ]} " prefix = " ${prefix % :* } " if [[ " ${command} " == " ${prefix} " * ]] ; then exit 0 fi fi done < < ( jq -r ' .permissions.allow[] ' " ${settings_file} " 2 > /dev/null ) fi # Read ツール: パスパターンで照合 if [[ " ${tool_name} " == " Read " ]] ; then local file_path file_path = $( echo " $input " | jq -r ' .tool_input.file_path ' ) while IFS = read -r pattern; do if [[ " ${pattern} " =~ ^Read\(//(.+)\)$ ]] ; then local path_pattern =" ${BASH_REMATCH[ 1 ]} " local path_prefix =" ${path_pattern %% /** } " if [[ " ${file_path} " == " ${path_prefix} " * ]] ; then exit 0 fi fi done < < ( jq -r ' .permissions.allow[] ' " ${settings_file} " 2 > /dev/null ) fi # MCP ツール・WebSearch 等: 完全一致で照合 while IFS = read -r pattern; do if [[ " ${pattern} " == " ${tool_name} " ]] ; then exit 0 fi done < < ( jq -r ' .permissions.allow[] ' " ${settings_file} " 2 > /dev/null ) } # グローバル設定 check_allow_list " $HOME /.claude/settings.json " check_allow_list " $HOME /.claude/settings.local.json " # プロジェクト設定 check_allow_list " $cwd /.claude/settings.json " check_allow_list " $cwd /.claude/settings.local.json " # 許可リストにマッチしない → 通知を送る echo " $( date ' +%H:%M:%S ' ) PRETOOL_NOTIFY: ${tool_name} " >> /tmp/claude_notify_debug.log nohup bash -c " timeout=86400 timeout_file= \"\$ HOME/.claude/notify_timeout.conf \" if [[ -f \"\$ {timeout_file} \" ]]; then timeout= \$ (cat \"\$ {timeout_file} \" | tr -d '[:space:]') fi result= \$ (alerter --title 'Claude Code' --subtitle ' ${project} ' --message '許可待ち: ${tool_name} ' --sound Ping --sender com.microsoft.VSCode --group claude-pretool --timeout \"\$ {timeout} \" 2>/dev/null) if [[ \"\$ {result} \" == '@CONTENTCLICKED' || \"\$ {result} \" == '@ACTIONCLICKED' ]] && [[ -n ' ${cwd} ' ]]; then open -a 'Visual Studio Code' ' ${cwd} ' fi " & > /dev/null & disown exit 0 3. Claude Code の hooks 設定   ~/.claude/settings.json の hooks セクションに以下を追加します( 公式リファレンス の書式に準拠)。 { " hooks ": { " Stop ": [ { " matcher ": "", " hooks ": [ { " type ": " command ", " command ": " echo '{ \" cwd \" : \" ' \" $(pwd) \" ' \" , \" notification_type \" : \" stop \" }' | ~/.claude/notify_alerter.sh " } ] } ] , " Notification ": [ { " matcher ": "", " hooks ": [ { " type ": " command ", " command ": " ~/.claude/notify_alerter.sh " } ] } ] , " PreToolUse ": [ { " matcher ": "", " hooks ": [ { " type ": " command ", " command ": " ~/.claude/notify_pretool.sh " } ] } ] } } 各 Hook の役割 Hook 発火タイミング 用途 VSCode 拡張 CLI Stop Claude が応答を終えて停止したタイミング 「タスク完了」通知 動作する 動作する Notification 許可待ち・入力待ちなどの通知イベント 「許可待ち」「入力待ち」通知 permission_prompt が発火しないケースあり 動作する PreToolUse ツール実行の直前 VSCode での「許可待ち」通知の代替 動作する 動作する 4. VSCode 拡張での Notification hook の扱い   公式リファレンス では、 Notification hook の notification_type として permission_prompt / idle_prompt / auth_success / elicitation_dialog の 4 種が定義されています。しかし、私の環境で動作確認したところ、 VSCode 拡張版では許可ダイアログが出ても Notification hook( permission_prompt )が発火しないケース があり、「許可待ちなのに通知が来ない」という状態になっていました。CLI 版では同じ設定で期待どおり発火しています。  そのため、VSCode 拡張で使う場合は PreToolUse hook(必ず発火する)でツール実行直前に自前で判定する という回避策を取っています。流れは以下です。 PreToolUse hook がツール実行直前に発火する notify_pretool.sh がツール名(と Bash の場合はコマンド、Read の場合はファイルパス)を受け取り、4 つの設定ファイルの permissions.allow と照合する allow リストに マッチしなかったとき だけ通知を送る(=「このあと許可ダイアログが出るはず」というタイミング)  この方式であれば、 Notification hook の発火有無にかかわらず、VSCode でも CLI でも漏れなく許可待ち通知を届けられます。CLI 版では Notification hook が正常動作するため、重複しないよう --group を claude-permission と claude-pretool で分けています(後述)。 5. macOS のセキュリティ許可   alerter + open -a の組み合わせは、macOS のアクセシビリティ・オートメーション等の追加許可なしで動作しました。初回のみ通知センター側で通知の表示許可を求められる程度で、特別な設定は不要です。 6. 動作確認 通知テスト # タスク完了通知 echo ' {"cwd": " ' $( pwd ) ' ", "notification_type": "stop"} ' | ~/.claude/notify_alerter.sh # 許可待ち通知(CLI の Notification hook 用) echo ' {"cwd": " ' $( pwd ) ' ", "notification_type": "permission_prompt"} ' | ~/.claude/notify_alerter.sh 確認項目 タスク完了通知がデスクトップに表示される 許可待ち通知が表示される(VSCode: PreToolUse / CLI: Notification) VSCode アイコンが通知に表示される( --sender com.microsoft.VSCode ) 通知をクリックすると対象プロジェクトの VSCode ウィンドウがアクティブになる 全画面の別アプリ(Chrome 等)から通知をクリックしても正しいウィンドウに切り替わる 通知後に Claude が WAIT 状態にならず即座に続行する デバッグログ  通知が来ないときはデバッグログを確認します: tail -f /tmp/claude_notify_debug.log 7. alerter のプロセス管理で学んだこと  運用してみて一番ハマったのがプロセス管理です。 問題: プロセスのゾンビ化   alerter は クリックされるまで stdout をブロックし続ける 仕様です( 公式リポジトリ の README にある @CONTENTCLICKED / @ACTIONCLICKED / @TIMEOUT / @CLOSED のいずれかが出力されるまでプロセスが生きる)。通知バッジを macOS 通知センターから消去しても alerter プロセスは終了しません。放置すると各プロセスがメモリを消費し、長時間の利用で数 GB に達するケースがありました。 対策1: --group (プロセス蓄積の防止)  同じ --group の通知が新たに発行されると、前のプロセスが自動で kill されます。グループは用途別に分けており、同時に存在するプロセスは最大 4 つになる設計です: グループ 用途 claude-stop タスク完了 claude-permission 許可待ち(CLI Notification hook) claude-pretool 許可待ち(VSCode PreToolUse hook) claude-idle 入力待ち 対策2: --timeout (最終的なプロセス回収)   --group だけでは最後の 4 プロセスが残り続けるため、 --timeout でプロセスの最大生存時間を設定して確実に回収します。 デフォルト: 86400 秒(1 日) カスタム: ~/.claude/notify_timeout.conf に秒数を書く # 例: 2 時間に変更 echo 7200 > ~/.claude/notify_timeout.conf  なお、timeout が切れてもプロセスが終了するだけで、macOS 通知センターの通知バッジは残ります。 溜まったプロセスの手動クリーンアップ # alerter プロセス数を確認 ps aux | grep alerter | grep -v grep | wc -l # 全 alerter プロセスを終了 pkill -f alerter 8. なぜ nohup + disown が必要だったか  前述のとおり alerter はクリック待ちでブロックします。単純に (...) & でバックグラウンド実行しても、 Claude Code の hook ランナーが子プロセスの終了を待ってしまい、Claude 本体が WAIT 状態のまま止まる (トークンも消費し続けてしまう)という問題がありました。   nohup ... & で SIGHUP を無視させ、さらに disown でジョブテーブルから外すことで、hook プロセスから完全に切り離せます。これにより、通知の表示・クリック待ちとは独立して Claude が動作を継続できるようになりました。 9. 通知のカスタマイズ 特定ツールの通知をスキップする  VSCode の「Edit Automatically」などセッションレベルで自動許可しているツールは settings.json に記録されないため、 ~/.claude/notify_skip_tools.txt に 1 行 1 ツール名で記載する仕組みを入れてあります: # セッションレベルで自動許可しているツール名を 1 行 1 つで記載 Edit  もしくは notify_pretool.sh の先頭付近にあるスキップリスト( Glob|Grep|TodoWrite|... )に追記する方法でも同等です。 サウンド  macOS 標準のサウンド名を指定できます: Ping , Purr , Glass , default , Basso , Blow , Bottle , Frog , Funk , Hero , Morse , Pop , Sosumi , Submarine , Tink 。 --sender (通知アイコン)   --sender に Bundle ID を指定すると通知アイコンが変わります。現在は com.microsoft.VSCode を指定して VSCode アイコンを表示しています。 アプリ Bundle ID VSCode com.microsoft.VSCode Cursor com.todesktop.230313mzl4w4u92 Terminal com.apple.Terminal iTerm2 com.googlecode.iterm2 Ghostty com.mitchellh.ghostty  ただし --sender を指定すると、そのアプリの macOS 通知設定に依存することになります。対象アプリの通知を OFF にしていると通知が表示されなくなるため注意が必要です。 まとめ  本記事では、Claude Code の Hooks 機能と alerter を組み合わせて、 タスク完了・許可待ち・入力待ちのデスクトップ通知を出す 通知クリックでプロジェクトの VSCode ウィンドウを自動でアクティブにする VSCode 拡張でも PreToolUse hook で許可待ち通知を取りこぼさない というセットアップ方法と、その過程で踏んだプロセス管理の落とし穴(ゾンビ化 → --group / --timeout / nohup + disown での回収)をご紹介しました。  Claude Code をバックグラウンドで走らせつつ他の作業を並行して進めるスタイルにおいては、「気づかずに長時間止まっていた」という時間を減らすだけで、体感の生産性が目に見えて向上します。CLI と VSCode 拡張で挙動が異なる部分は PreToolUse hook で吸収できるので、Hooks の仕様を把握したうえで自分の開発スタイルに合わせてカスタマイズしてみてください。 通知例 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
Next.js 16 のキャッシュとどう付き合うか ― 実装と運用のあいだで考えたこと 目次 Next.js 16 のキャッシュとどう付き合うか ― 実装と運用のあいだで考えたこと はじめに Next.js のキャッシュを整理する ブラウザ(Router Cache) CDN・Edge(HTTP Cache) サーバー(Data Cache = use cache) キャッシュに関する思想と変更の歴史 1. App Router 初期 — 暗黙的なキャッシュ 2. Next.js 15 — uncached by default への揺り戻し 3. Next.js 16 — Cache Components による explicit / composable 化 歴史に対してどう立ち向かうか 実装中に気づいた挙動と対策 気づき①: dynamic 判定で意図せず private / no-store が付与される 遭遇したきっかけ 検証(3つのページの比較) 気づき②: layout.tsx が TTL を持つと子ページにも伝搬する 実ビルド出力 実測で対策する A. next build のログを読む B. HTTP ヘッダを直接見る C. テストで Cache-Control を監視する D. 補足: 内部 Data Cache の hit/miss チーム開発を見据えたキャッシュ運用ルール 書き方を縛る TTLプロファイルを活用し、選択肢を増やしすぎない TTL 設定か invalidation か、どちらか統一する 書き方と場所を統一する 機械的に検知する ルールを明文化する 豊富な機能より保守性 おわりに 参考文献 はじめに こんにちは、開発本部の 黒髙 です。普段は デリッシュキッチン の開発に携わっています。 現在、運用中のWebアプリケーションをNext.jsに移行する検討を進めており、その過程で避けて通れないテーマのひとつがキャッシュでした。Next.jsの機能を調べてみると思ったよりも複雑で、理解が難しいと感じました。しかし、アプリの要件上、サーバーリソースの負荷を抑える観点ではある程度キャッシュを考慮すべきであり、完全に無視して運用することは現実的ではありません。 キャッシュの事故でよく耳にするのが、更新したはずのデータが古いままユーザーに届き続ける「stale」と呼ばれる状態です。本記事では細かいパフォーマンス調整よりも、「予期せぬ stale による事故のリスク/その原因となる実装ミスをどう減らすか」という観点を中心に考えます。 まず現状のキャッシュ機構を3層で整理したうえで、方針転換を繰り返してきた歴史と、実装時の注意点を検証も含めて述べていきます。最後に、これらを踏まえてチーム開発でどう運用するかを、コーディングエージェント(AI)との共存も含めて考察します。 Next.js のキャッシュを整理する Next.js のキャッシュは、Router Cache / Full Route Cache / Data Cache といった似た響きの用語と、use cache / cacheLife / revalidateTag など複数のAPIが絡み合っており、公式ドキュメントでも全体を掴むのは難しいと感じました。私は、キャッシュの動作場所に注目して、以下の3層で整理するのがわかりやすいのではないかと考えました。 ブラウザ(Router Cache) CDN・Edge(HTTP Cache) サーバー(Data Cache = use cache ) Webアプリのライフサイクル全体で言えば、バックエンドサーバー自身のキャッシュなども存在しますが、本記事では扱いません。とはいえ、Next.jsを取り巻くキャッシュだけでもWebアプリのライフサイクルの多くをカバーしていることがわかります。 ブラウザ(Router Cache) クライアントのブラウザ上で動作するキャッシュで、 <Link> などによるページ遷移をスムーズに見せるために内部的に保持されるものです。 staleTimes で挙動を調整できますが、基本的には値を細かく設定する層ではない印象です。 <Link> 経由で遷移する先は、ビューポート付近に入ったタイミングで裏側で prefetch され、Reactサーバコンポーネントのpayloadがブラウザ内に保持されます。 import Link from 'next/link' ; // 自動 prefetch(デフォルト) < Link href = "/recipes/123" > 生姜焼きのレシピ </ Link > // prefetch を止めたい場合 < Link href = "/recipes/123" prefetch = { false } > 生姜焼きのレシピ </ Link > 明示的に破棄したい場面では、クライアント側で router.refresh() を呼びます。 Router Cacheの詳細は、 Prefetching を参照してください。 CDN・Edge(HTTP Cache) CDN と Web アプリサーバーの間で働く、HTTPリクエストベースのキャッシュです。前提として、後述のサーバーキャッシュ( use cache , cacheLife )とは別物であり、それらが自動で同期されないことに注意が必要です。 Next.js はルートの分類( ○ Static / ◐ PPR / ƒ Dynamic )に応じて Cache-Control を自動で書き分けます。アプリ側から直接ヘッダを書くことはなく、ビルド時に上書きされます。 ○ Static → s-maxage=<revalidate>, stale-while-revalidate=<expire - revalidate> ◐ PPR → private, no-cache, no-store, max-age=0, must-revalidate ƒ Dynamic → 同上 補足: static / dynamic / PPR Next.js はルートを、ビルド時に確定できる static 、リクエストごとに描画する dynamic 、静的な shell に動的部分を後追いで差し込む PPR (Partial Prerendering) の3種類に分類します。Cache Components を有効にした Next.js 16 の主要機能です。 また、Next.js 独自のヘッダ( x-nextjs-cache , x-nextjs-prerender , x-nextjs-postponed , x-nextjs-stale-time など)も配信されますが、セルフホスティングですべてを扱おうとすると複雑性が増すため、あまり現実的ではありません。 サーバー(Data Cache = use cache ) ユーザーが意図的に管理する、サーバー内でのキャッシュです。 use cache で宣言します。Cache Components という概念自体は Next.js 16 から導入されたもので、寿命(TTL)は cacheLife 、タグによる明示 invalidation は cacheTag + revalidateTag という2系統のコントロール手段が用意されています。 関数・コンポーネント単位で 'use cache' を付けてキャッシュし、寿命は cacheLife で宣言します。 // 関数単位 import { cacheLife } from "next/cache" ; export async function fetchRecipe ( id : string ) { "use cache" ; cacheLife( "hours" ); // 組み込みプリセット: 1時間ごとに再検証 const { data } = await apiClient( `/recipes/ ${ id } ` ); return data; } // コンポーネント単位 async function RecipeList () { "use cache" ; cacheLife( "hours" ); // 1時間ごとに再検証 const recipes = await getRecipes(); return ( < ul > { recipes. map (( r ) => ( < li key = { r. id } > { r. name } </ li > )) } </ ul > ); } 時間ではなく明示的な契機で更新したい場合は、 cacheTag + revalidateTag を組み合わせます。 // 書く側: タグを打つ import { cacheTag } from "next/cache" ; export async function fetchRecipe ( id : string ) { "use cache" ; cacheLife( "days" ); // 1日ごとに再検証 cacheTag( `recipe- ${ id } ` ); const { data } = await apiClient( `/recipes/ ${ id } ` ); return data; } // 更新契機側: 無効化する(Next.js 16 は2引数必須) import { revalidateTag } from "next/cache" ; export async function POST () { revalidateTag( "recipe-1" , "days" ); return Response .json( { ok : true } ); } ただし revalidateTag が効くのはサーバ層の Data Cache のみで、CDN が前段にあれば別途キャッシュを削除する必要があります。3層のキャッシュはそれぞれ独立した寿命と無効化手段を持つため、層をまたいだ無効化には個別の対応が要ります。 なお 'use cache' には、スコープ違いの 'use cache: private' / 'use cache: remote' もあります(詳細は 公式ドキュメント: use cache を参照)。 キャッシュに関する思想と変更の歴史 Next.js のキャッシュの理解が難しいとされるもう一つの要因として、破壊的ともいえる仕様変更・方針転換がこれまで何度か行われてきた歴史も関係しています。 同じ1行の fetch が各バージョンでどう振る舞うかを整理すると、次のようになります。 バージョン const res = await fetch('/api') の挙動 明示するなら Next.js 13(初期) 暗黙にキャッシュされる (デフォルト無期限) { cache: 'no-store' } で opt-out Next.js 14 同上(+ Full Route Cache / Data Cache の概念整理) 同上 Next.js 15 毎回リクエスト(uncached) に反転 { next: { revalidate: N } } で opt-in Next.js 16 同上。ただし 'use cache' で明示宣言した関数のみキャッシュされる 関数に 'use cache' + cacheLife(...) 同じ1行が時期によって「無期限キャッシュ」「毎回リクエスト」「そもそもキャッシュされない」と意味を変えてきています。この履歴を知らずに古いサンプルコードをコピーすると、そのまま事故につながる危うさがあります。 1. App Router 初期 — 暗黙的なキャッシュ App Router 初期は、 fetch がデフォルトで暗黙にキャッシュされる挙動でした。しかもデフォルトでは TTL が設定されず、再検証を明示しない限りキャッシュされたまま残り続けるという仕様になります。 2. Next.js 15 — uncached by default への揺り戻し Next.js 15 では、デフォルトが「キャッシュ」から「uncached」へ真逆に転換されました( 公式ブログ: Next.js 15 RC )。同じ1行の fetch の意味が v14 → v15 で正反対になるため、既存コードの挙動が意図せず変わる可能性があり、移行には慎重な確認が必要だったと思われます。 3. Next.js 16 — Cache Components による explicit / composable 化 現在の中心思想であり、 'use cache' を opt-in 寄りにして明示させる方針です( 公式ブログ: Next.js 16 )。v14 の「暗黙」、v15 の「uncached デフォルト」に対して、v16 は 「 'use cache' と書いた関数だけが、cacheLife で寿命を明示したうえでキャッシュされる」という、キャッシュの有無と寿命をすべてコード上で宣言するモデルです。 歴史に対してどう立ち向かうか 単に使うだけでなく思想や背景まで知ると、キャッシュとPPR方針の関連のような縦の流れが見えて、仕様理解が深まります。とはいえ、Next.js 側が今後どういう振る舞いをしてくるかを予測するのは難しいのも事実です。 そこで、「キャッシュは明示的に書く」「デフォルト挙動に頼らない」の2点を基本にします。暗黙的なコードは移行時に予期せぬ事故を起こす可能性が高く、次に仕様が変わったときに真っ先に壊れるのも「デフォルト挙動に依存したコード」であり、そのリスクはできるだけ回避しておきたいです。 実装中に気づいた挙動と対策 本章で扱う気づきは次の2つです。 気づき① : dynamic 判定で意図せず private / no-store が付与される 気づき② : layout.tsx が cacheLife を持つと子ページにも伝搬する それぞれの遭遇経緯と検証結果を示したうえで、最後に 実践するための型(build ログ / HTTP ヘッダ / 自動テスト) を独立セクションにまとめます。 気づき①: dynamic 判定で意図せず private / no-store が付与される 遭遇したきっかけ Next.js 16 への移行を検討する中で、PPR の挙動を試していたときのことです。「TOPページの大半を 'use cache' で静的に保ちつつ、 <FavoriteInfo /> (cookie からお気に入りIDを読む小さなコンポーネント)だけ <Suspense> で分離する」という構成で実ヘッダを確認したら、 Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate が返ってきて想定外でした。このまま本番に出すと CDN のヒット率が期待どおり出ず、オリジン負荷が上がる可能性があります。 公式ドキュメント( CDN Caching )には static / dynamic それぞれの挙動は書かれているものの、 両者が混ざったルートで HTTP レイヤに返る Cache-Control は明示されていません 。移行判断の材料として、最小構成で挙動を切り分けました。 検証(3つのページの比較) 以下の3ルートをローカルの Next.js 16(Cache Components 有効)で用意して比較しました。 // /case-a : 完全 static(ビルド時に結果が決まる。動的要素なし) async function getStaticPayload () { "use cache" ; cacheLife( "hours" ); return { /* ... */ } ; } export default async function StaticOnlyPage () { const data = await getStaticPayload(); return < main > { /* ... */ } </ main > ; } // /case-b : mixed PPR(static な RecipeList と、リクエストごとに変わる FavoriteInfo が同居) export default function Page () { return ( < main > < RecipeList /> { /* 'use cache' 付き = static 扱い */ } < Suspense fallback = { < div > loading favorite list... </ div > } > < FavoriteInfo /> { " " } { /* cookies() を読む = リクエストごとに変わる動的部分 */ } </ Suspense > </ main > ); } // /case-c : ページ全体が dynamic(動的なcookies() 読み取りだけ) async function DynamicBody () { const favoriteId = ( await cookies()).get( "favoriteId" )?.value ?? "empty" ; return < p > { favoriteId } </ p > ; } export default function DynamicOnlyPage () { return ( < main > < Suspense fallback = { < div > loading... </ div > } > < DynamicBody /> </ Suspense > </ main > ); } pnpm build を実行すると、3ルートの分類は以下のようになります。 Route (app) Revalidate Expire ┌ ◐ /case-b 1h 1d ├ ◐ /case-c └ ○ /case-a 1h 1d next start を起動して実際に返る Cache-Control を確認すると次の通りです。 ルート構成 分類 Cache-Control /case-a (use cache のみ) ○ Static s-maxage=3600, stale-while-revalidate=82800 /case-b (use cache + Suspense 内 cookies) ◐ PPR private, no-cache, no-store, max-age=0, must-revalidate /case-c (shell + Suspense 内 cookies) ◐ PPR private, no-cache, no-store, max-age=0, must-revalidate ルート内に dynamicな要素(cookies / headers / connection)が1か所でも混ざると、HTTP レイヤは一律 no-store になり、 cacheLife で設定した値は効きません。 気づき②: layout.tsx が TTL を持つと子ページにも伝搬する 検証の中で、コンテンツがほぼ空のページの Cache-Control を確かめたところ、「空ページなので CDN に永続(= s-maxage=31536000 )でキャッシュできるはず」という予測が外れ、 s-maxage=3600 が返ってきました。原因は (main)/layout.tsx が cacheLife('hours') (1時間)を持つ関数を内部で呼んでいたことでした。これでは、静的に返したいページが1時間ごとに再検証される構成になってしまいます。 // app/(main)/layout.tsx import { fetchRecipes } from "@/lib/api/recipes" ; // 内部で 'use cache' + cacheLife('hours') export default async function MainLayout ( { children } ) { const recipes = await fetchRecipes(); return ( <> { /* sidebar など */ } { children } </> ); } 実ビルド出力 Route (app) Revalidate Expire ┌ ○ /recipes 1h 1d ← (main) 配下 └ ○ /about ← (static) 配下、永続 実際に返るヘッダを確認すると次の通りです。 /recipes : Cache-Control: s-maxage=3600, stale-while-revalidate=82800 ← (main) 配下 /about : Cache-Control: s-maxage=31536000 ← (static) 配下、永続 ルートの最終 Cache-Control は page + layout + 配下で呼ばれる use cache 関数の最短 cacheLife で決まるため、layout 側に短い TTL があるとそちらが優先されます。 この挙動を意識しながら、layout ごとにキャッシュ寿命が自然に分かれるように設計することと、 next build の出力で全ルートの Revalidate 列を確認する習慣を付けることが、手堅い備えになると感じました。 実測で対策する 上に挙げた気づきはいずれも実測で検知できる種類のものです。ここでは、自分が取り入れている4つの型をまとめます。 A. next build のログを読む next build の最終出力にルート一覧が出ます。これが一次資料です。 Route (app) Revalidate Expire ┌ ○ / 10m 1y ├ ○ /about ├ ○ /categories 10m 1y ├ ◐ /categories/[id] ├ ◐ /recipes/[id] └ ○ /terms ○ (Static) prerendered as static content ◐ (Partial Prerender) prerendered as static HTML + dynamic streaming ƒ (Dynamic) server-rendered on demand 読み方の要点は次の通りです。 ◐ が付いたルートは HTTP 層では必ず no-store になります。 cacheLife は内部にしか効きません。 Revalidate 列は そのルート全体で呼ばれる cacheLife のうち最も短い値 を示すので、想定より短ければ layout のキャッシュ関数が原因になっていることが多いです( 気づき② )。 B. HTTP ヘッダを直接見る Cache-Control や Next.js 独自のヘッダは、ブラウザの DevTools(Network タブ)や curl / httpie など、どの HTTP クライアントでも確認できます。 見るべきヘッダの組み合わせは例えば以下の通りです。 見えたヘッダ 結論 s-maxage=... が含まれる 完全 static、CDN で効く private, no-store が含まれる PPR か dynamic、CDN 効かない C. テストで Cache-Control を監視する Cache-Control の分類を自動テストにしておけば、意図せず分類が変わった瞬間に気付くことができます。Playwright で書くなら、例えば以下の通りです。 test ( "main routes return expected Cache-Control" , async ( { request } ) => { const table = [ { path : "/case-a" , match : /s-maxage=\d+/ } , { path : "/case-b" , match : /no-store/ } , { path : "/case-c" , match : /no-store/ } , ] ; for ( const { path , match } of table) { const res = await request. get (path); expect (res. headers () [ "cache-control" ] ).toMatch(match); } } ); D. 補足: 内部 Data Cache の hit/miss NEXT_PRIVATE_DEBUG_CACHE=1 を付けて起動すると、サーバー側のキャッシュ挙動をサーバーログから見ることもできます。 $ NEXT_PRIVATE_DEBUG_CACHE=1 pnpm start ... FileSystemCache: get /index APP_PAGE false ← 初回 miss use-cache: Resume Data Cache entry found [...] FileSystemCache: get /index APP_PAGE true ← 以降 hit チーム開発を見据えたキャッシュ運用ルール ここまでで、Next.js のキャッシュ構造と歴史、実装で出会った気づきを整理してきました。歴史からは「明示的に書く」「デフォルト挙動に頼らない」、気づきからは「局所視点では誤りやすい」という性質を引き出しました。ここからは、それらを踏まえてチーム開発で運用していくためにはどういう方針を取るべきかを考えます。 人間同士のチーム開発でも、コーディングエージェント(AI)に書かせる場合でも、同じ理由でミスをしてしまうことがあります。実際、 気づき② のケースを AI にコードから予測させてみたところ、人間と同じように外していました。キャッシュ層はファイルをまたいで合成されるため、局所視点では必然的に誤る性質を持っていると推測されます。 そのため、チーム開発でも AI が関わる場合でも、次の4点に注意して開発していきたいと考えています。 書き方を縛る: どこに何を書くかを固定し、選択肢を減らす 機械的に検知する: ESLint / build ログ / 自動テストで違反を落とす ルールを明文化する: AGENTS.md / CLAUDE.md に方針を残す 豊富な機能より保守性: 意図せぬ変更を引き起こさない選択を優先する 以下、この4つの柱を Next.js のキャッシュ運用に当てはめた具体例を示します。 書き方を縛る 選択肢を狭めることは、複雑さを避けて実装者の迷いを減らしたり、予期せぬ変更を防ぐといった保守運用面でのメリットがあります。一方で細かい制御や最適化の機会を失ってしまうため、トレードオフを要件によって見極める必要があります。 TTLプロファイルを活用し、選択肢を増やしすぎない Next.js 組み込みのプリセット( hours , days など)に加えて、 next.config.ts で自前でプロファイル定義することもできます。 これまでの本文では組み込みのプリセットを使ってきましたが、チームで運用する場合は自前のプロファイルを少数だけ許容する方針が良さそうです。 cacheLife: { 'api-default' : { revalidate: 600 } , // 10 分 'api-long' : { revalidate: 10800 } , // 3 時間 } プロファイルを絞り込むと、「期待値はこの範囲で回る」というメンタルモデルがチーム内で共有されます。選択肢を狭めることで細かい制御の機会は失いますが、複雑さを避ける観点も必要です。 TTL 設定か invalidation か、どちらか統一する 前半で触れた通り、キャッシュ更新の方針には大きく2種類あります。 TTL 型 : 全 fetch に cacheLife を付けて時間で更新する Invalidation 型 : cacheTag + revalidateTag で、CMS の webhook などの明示的な契機に合わせて無効化する こちらも同様に、両方を組み合わせてより最適化させる実装を取ることも可能です。しかし、どちらで更新されるかがコードを読むだけでは分からなくなり、判断が難しい領域が増えるといったデメリットも存在します。そのため、今回はTTL型だけに統一する方針をとっています。 // lib/api/recipes.ts export async function fetchRecipe ( id : string ) { "use cache" ; cacheLife( "api-default" ); // 10 分で background revalidate const { data } = await apiClient( `/recipes/ ${ id } ` ); return data; } 書き方と場所を統一する データ取得は lib/api/<domain>.ts に集約し、page では呼ぶだけにします。page / layout / route で 'use cache' を直接書かないようにします。 lib/api/ client.ts ← fetch 共通層 (timeout / retry / log) recipes.ts ← 全関数に 'use cache' + cacheLife categories.ts ← 同上 curations.ts ← 同上 // app/(main)/page.tsx import { fetchRecipes } from "@/lib/api/recipes" ; export default async function HomePage () { const recipes = await fetchRecipes(); // cache はデータ層が知っている return < HomeView recipes = { recipes } /> ; } page 側がキャッシュの寿命を意識しない、 lib/api だけ読めば寿命が分かる、という切り分けにします。キャッシュ関連の変更をするときも、 lib/api/ 配下だけを読めば判断できる状態にしておくのが狙いです。 機械的に検知する より厳密にしたい場合、ESLint の no-restricted-syntax で機械的に縛ることもできます。以下はキャッシュのプロファイル名を制限するコード例です。 // eslint.config.mjs の抜粋イメージ const ALLOWED = [ 'api-default' , 'api-long' ] ; // cacheLife はホワイトリスト外のプロファイル名 / custom options を禁止 { selector: `CallExpression[callee.name='cacheLife'] > Literal[value!=/^( ${ ALLOWED. join ( '|' ) } )$/]` , message: `cacheLife は ${ ALLOWED. join ( ' / ' ) } のみ使用可` , } , { selector: "CallExpression[callee.name='cacheLife'] > ObjectExpression" , message: 'cacheLife に custom options を直書きしない' , } , 機械的な制約として、前章で紹介した「確認の型」( next build のログ、 Cache-Control ヘッダ、自動テスト)をCIに組み込んで検知する仕組みを作る、という選択肢も挙げれられます。 ルールを明文化する Next.js のキャッシュ仕様は版ごとに大きく変わってきたので、AI エージェントは古いバージョンの書き方をしたり、逆に便利そうな新機能を差し込む可能性があります。そもそもそれらを提案させないために、ドキュメントで方針を明示しておくことは、基本的ではありますが重要です。 プロジェクトの CLAUDE.md では、例えば以下のように記述しています。 ### データ取得 (エージェント向け) - データ取得ロジックは ` lib/api/ ` に集約(Single Source of Truth) - SC → lib/api/ の関数を ` use cache ` 付きで直接呼び出す - ISR は使わない。キャッシュは ` use cache ` で TTL 管理 (デフォルト ` api-default ` = 10 分、一部 ` api-long ` = 3 時間) - cacheLife はプロジェクト定義のプロファイルのみ使用。preset ('hours', 'days' 等) や custom options は使わない ローカルではなくプロジェクトでファイル管理することで、これらのルールをそのままチームの共通認識として採用することが可能です。 豊富な機能より保守性 Next.js には便利な機能が豊富に用意されていますが、使うほど仕様変更の影響範囲が広がり、コードを読む際の迷いも増えます。自分自身が多くの機能を使いこなして最適化を頑張ることは魅力的に見えますが、「使わずに済むなら使わない」という決断も必要です。豊富さより簡潔さに倒すほうが、長期的には事故を減らすと感じています。 おわりに 今回の整理を振り返ると、Next.js のキャッシュと付き合う上で重要だと感じた点が自然と見えてきました。まずはブラウザ・CDN・サーバーの3層で構造を捉えること。仕様変更の振れ幅が大きい領域なので、デフォルト挙動に依存しすぎないこと。そして保守性や移植性を優先した簡潔なコードを、チームのルールとして縛ること。このあたりが、今回の整理で見えてきたことです。 振り返ると、実装時の気づきとチーム運用への落とし込みのあいだを行き来しながら、Next.js のキャッシュとどう付き合うかを考える機会になりました。層を意識して明示的に書き、ルールで縛るという地味な積み重ねが結局一番効くのだと感じています。 参考文献 Caching in Next.js | Next.js Prefetching | Next.js CDN Caching | Next.js PPR Platform Guide | Next.js Directives: use cache | Next.js Next.js 15 RC Next.js 16
govulncheckで行う脆弱性対応 はじめに 開発本部でデリッシュキッチンプレミアム会員向けの開発を担当している hond です! 先日 axiosのサプライチェーン攻撃 が話題になりました。axiosのリードメンテナのnpmアカウントがソーシャルエンジニアリング経由で侵害され、悪意のあるバージョン( 1.14.1 と 0.30.4 )が約3時間npmに公開されていたというもので、詳細はaxios公式のPost Mortemにまとまっています。広く使われているHTTPクライアントが直接狙われた事件で、エコシステムに依存する側としても他人事ではないなと感じました。 これを受けて、普段業務で利用しているGoではどのような脆弱性対策が取られているのか、また開発者としてどのような運用が推奨されているのかを改めて確認しました。結論として、Goではサプライチェーン攻撃自体は go.sum とChecksum Database( sum.golang.org )によってエコシステム側で既に対策されています。本記事ではその前提の上で、開発者側が実運用で何を行えるのか、 govulncheck と Dependabot の組み合わせによるCI運用方法をまとめます。 Goの脆弱性対応 Best Practices Goのセキュリティ対策全般については、公式の Security ページにまとめられています。このページでは脆弱性管理・Fuzzing・暗号化ライブラリ・Go自体のセキュリティポリシーなどについて確認することができ、そのひとつに Security Best Practices for Go Developers があります。 Best Practicesでは以下の6項目が推奨されています。 ソースコードおよびバイナリの脆弱性スキャン( govulncheck ) Go本体および依存関係のアップデート Fuzzingによるエッジケース脆弱性の発見 Race detectorによる競合状態の検出 go vet による疑わしい構成の検査 golang-announce メーリングリストの購読 本記事ではこのうち脆弱性スキャンに焦点を当てて、CIでの運用と Dependabot との使い分けを整理します。 govulncheck とは govulncheck はGoの脆弱性スキャナとして公式が提供するCLIツールです。一般的な依存関係スキャナとの大きな違いは、バージョン比較ではなく、脆弱性のある関数が実際に呼び出されているかを解析する点にあります。 analyzes your codebase and only surfaces vulnerabilities that actually affect you, based on which functions in your code are transitively calling vulnerable functions ( Go Vulnerability Management より) つまり、脆弱性のある関数が依存パッケージに含まれているだけでなく、自分のコードからその関数が実際に(推移的にも)呼ばれている場合にのみ検知します。これによってパッケージを取り込んでいるが該当機能は使っていないというケースでは警告が出ず、本当に対応すべき脆弱性を優先度高く扱えます。 Go Vulnerability Database govulncheck は Go Vulnerability Database( vuln.go.dev ) をAPI経由で参照して脆弱性情報を取得しています。このデータベースはGo Security Teamによって運営されており、以下のデータソースから集めた情報が登録されています。 NVD(National Vulnerability Database) GitHub Advisory Database Goパッケージメンテナからの直接報告 取り込まれた情報は OSV format に整形され、API経由で公開されます。 Severityラベル Go Vulnerability Databaseは「LOW」「CRITICAL」といったSeverityラベルを提供していません。公式では以下のように説明されています。 We believe good descriptions of vulnerabilities are more useful than severity indicators. ( Go Vulnerability Management より) 脆弱性の影響はパッケージがどのように使われているかで大きく変わります。例えばパーサーのクラッシュを引き起こす脆弱性は、外部入力を処理する箇所では深刻ですが、ローカル設定ファイルの読み込みにのみ使っている場合は影響が軽微です。普遍的なSeverity指標は誤解を招く可能性があるため、Goでは脆弱性そのものの詳細な説明に加えて、 govulncheck が実際の呼び出し経路(stack trace)と該当箇所を出力することで、利用者自身が影響を判断できるという設計が採られています。 実践 以下は脆弱性に到達可能かによって govulncheck の出力がどう変わるかを確認したものになります。どちらも golang.org/x/text@v0.3.5 ( GO-2021-0113 の対象バージョン)に依存していますが、 reachable 側は脆弱性のある language.Parse を呼び、 unreachable 側はimportするだけで呼び出しません。 reachable // go.mod module reachable-sample go 1.26 require golang.org/x/text v0. 3.5 // main.go func main() { tag, err := language.Parse( "en-US" ) if err != nil { fmt.Println( "parse error:" , err) return } fmt.Println( "parsed:" , tag) } コマンド実行 $ govulncheck ./... 出力 === Symbol Results === Vulnerability #1: GO-2021-0113 Out-of-bounds read in golang.org/x/text/language More info: https://pkg.go.dev/vuln/GO-2021-0113 Module: golang.org/x/text Found in: golang.org/x/text@v0. 3 . 5 Fixed in: golang.org/x/text@v0. 3 . 7 Example traces found: #1: main.go:10:28: reachable.main calls language.Parse Your code is affected by 1 vulnerability from 1 module. This scan also found 3 vulnerabilities in packages you import and 10 vulnerabilities in modules you require, but your code doesn ' t appear to call these vulnerabilities. main.go:10:28: reachable.main calls language.Parse という呼び出し経路と、 Found in: v0.3.5 / Fixed in: v0.3.7 という修正先バージョンが具体的に示されます。 Your code is affected by 1 vulnerability と明示されるので対応すべき箇所もすぐに分かります。 unreachable // main.go func main() { // golang.org/x/text をimportしているが、脆弱性のある language.Parse は呼ばない tag := language.English fmt.Println( "tag:" , tag) } 出力 === Symbol Results === No vulnerabilities found. Your code is affected by 0 vulnerabilities. This scan also found 4 vulnerabilities in packages you import and 10 vulnerabilities in modules you require, but your code doesn ' t appear to call these vulnerabilities. 同じ脆弱なバージョンに依存していても、 language.Parse を呼び出していないので No vulnerabilities found となります。一方で This scan also found 4 vulnerabilities in packages you import and 10 vulnerabilities in modules you require とあり、「importはしている/moduleには含まれている」レベルの脆弱性がそれぞれ何件あるかも合わせて確認できます。関数が実際に呼び出されているかまで見ることで、本当に対応すべき脆弱性の優先度がつけやすくなっています。 実行環境 govulncheck は下記の方法で利用することが可能です。 CLI : go install golang.org/x/vuln/cmd/govulncheck@latest でインストールして govulncheck ./... を実行する VS Code拡張 : Go公式拡張( golang.go )の設定で "go.diagnostic.vulncheck": "Imports" を有効にすると、編集中のファイルに対して診断が表示される pkg.go.dev : 各パッケージページの Vulnerabilities タブからそのモジュールに紐づくアドバイザリを確認できる CI(GitHub Actions): golang-govulncheck-action を使ってPull Requestごとにスキャンを回せる ローカルや拡張機能でも早い段階で脆弱性を検知できますが、継続的にブランチ全体をカバーするにはやはりCIに組み込むのが確実です。以降はCIでの利用方法について説明します。 CIでの利用 golang-govulncheck-action の利用 Goチームが公式に提供している golang/govulncheck-action を導入することでCI上で govulncheck が利用できます。 READMEにも記載されている通り、このアクションは現時点では experimental ステータスで提供されています。 最小構成は以下のようになります。 # .github/workflows/govulncheck.yml name : govulncheck on : pull_request : push : branches : [ main ] jobs : govulncheck : runs-on : ubuntu-latest steps : - uses : actions/checkout@v4 - uses : golang/govulncheck-action@v1 with : go-version-input : '1.26' これだけでPull Requestごとに govulncheck が走り、到達可能な脆弱性が見つかるとジョブが失敗します。 出力フォーマット CLI版の govulncheck には -format オプションがあり、出力形式を text , json , sarif から選べます。 golang/govulncheck-action でも同様に output-format オプションでフォーマットを切り替えることができます。このフォーマットの選択に応じてExit Codeも変わるので、CIのジョブ成否にもそのまま影響します。 output-format Exit Code ジョブの挙動 出力 text (デフォルト) 脆弱性検知時に 3 脆弱性が見つかるとジョブが失敗する Actionsのログに直接出力 json 常に 0 脆弱性が見つかってもジョブは成功する Actionsのログに直接出力(JSON) sarif 常に 0 脆弱性が見つかってもジョブは成功する output-file で指定したファイルに出力 json と sarif で常にExit Code 0 になるのは、SARIFアップロード、PRコメントなどにパイプで渡すことを想定しているためです。脆弱性が検知されたらCIを落としたいのか、検知結果を別の場所に集約したいのかで使い分けることになります。 GitHub Securityタブの利用 検知結果をGitHubのSecurityタブ(Code scanning alerts)に集約したい場合は、 sarif 出力を github/codeql-action/upload-sarif でアップロードする構成が使えます。この構成はリポジトリでCode scanning alertsが有効になっていることが前提になります(Settings > Code security and analysis)。有効になっていない場合、SARIFのアップロード自体はできてもSecurityタブにalertとして表示されません。 permissions : contents : read security-events : write actions : read steps : - uses : golang/govulncheck-action@v1 with : go-version-input : '1.26' output-format : sarif output-file : govulncheck.sarif - uses : github/codeql-action/upload-sarif@v4 with : sarif_file : govulncheck.sarif permissions を明示しているのは、 upload-sarif がSecurityタブへの書き込みに security-events: write を必要とするためです。 actions: read もプライベートリポジトリでのSARIF取り込みに必要になります。このようにすると、検知された到達可能な脆弱性がSecurityタブにalertとして積まれ、過去の検知履歴やステータスもそこから追跡できます。 govulncheck と Dependabot の使い分け ここまで govulncheck をCIで実行する話をしてきましたが、最後に Dependabot との違いについて簡単にまとめます。 Go Vulnerability Database( vuln.go.dev )に登録された脆弱性は、 GitHub Advisory Database にも取り込まれる Dependabot はGitHub Advisory Databaseを参照して、依存関係が脆弱性のあるバージョンに該当する場合にPRやアラートを生成する つまり govulncheck と Dependabot は、参照している脆弱性情報は実質同じで、検知のアプローチと役割が違うツールになっています。 Dependabot はバージョン比較なので、 golang.org/x/text@v0.3.5 に依存していれば関数を呼んでいなくてもアラートが飛びます。一方 govulncheck は関数が実際に呼び出されているかまで見て、本当に対応が必要なケースだけを affected として出力します。この性質の違いから、 Dependabot には日常的な依存アップデートの自動化を任せ、 govulncheck で実コードに影響する脆弱性を絞り込む、という役割分担で組み合わせるのが良いのかなと考えています。 まとめ コードを書く中で他のpackageに依存しない実装はほぼ不可能なのでversion管理を行っていくのは大切だと思いますが、優先度を上げにくい部分だと感じています。その中でも govulncheck を用いることで実際に到達可能でプロダクトに影響を与えるか明確にできるのは調査や対応優先度を他の人に伝える際のコストを下げることができるのでとても有用だと感じました。VSCodeの拡張などで開発時点で検証可能ですが、エージェントでのコーディングが増えIDEを開く機会も減ったのでpre-commitでCLIを実行する必要もあるのかなと思っています。チーム内でもDependabotの導入でPRは作成できているが詳細は追えておらず放置されるみたいな状態も度々あるので、GitHub Securityタブへ出力される設定を展開していく予定です。 ここまで読んでいただきありがとうございます!Goで脆弱性対策を行おうとしている人の助けになったら幸いです。
はじめに こんにちは。開発部でiOSエンジニアをしている野口です。 ヘルシカ - ダイエット・食事管理のための簡単カロリー計算 every, Inc. ヘルスケア/フィットネス 無料 ヘルシカのiOSアプリではXcode Cloudを使用して開発環境・本番環境への配布を行っています。本記事では、配布にかかっていた実行時間を約50%削減した方法を紹介します。 背景と課題 削減前のXcode Cloudの実行時間は約30分かかっていました。これを削減できれば、開発スピードの向上やQAから修正へのサイクルが回しやすくなり、品質の向上が期待できると考えました。 各ステップの実行時間はApp Store Connectのダッシュボードから確認できます。調査したところ、 ci_post_clone.sh の実行が全体の約62%を占めており、ここがボトルネックであることがわかりました。 ステップ 時間 割合 Run ci_post_clone.sh script 16分46.9秒 62.34% Run xcodebuild archive 6分17.9秒 23.39% Resolve package dependencies 2分2秒 7.55% その他(環境設定・取得・Export・後処理など) 1分48.5秒 6.72% Build Archive 合計 26分55.3秒 100.00% Prepare Build for App Store Connect 44.8秒 — 総合計 約27分40秒 — ※主要なステップ以外は「その他」にまとめています。 原因の分析 ci_post_clone.shとは ci_post_clone.sh は、Xcode Cloudがリポジトリをクローンした直後に自動で実行されるシェルスクリプトです。ビルドに必要な追加ツールのインストールや設定ファイルの書き換えなど、ビルド前の準備処理を記述します。以下の画像のPost-cloneと記述されている箇所で ci_post_clone.sh が動きます。 引用元: 実行していた処理 ci_post_clone.sh では、以下の処理を行っていました。 # ci_post_clone.sh #!/bin/sh brew install mint mint bootstrap -m ../Mintfile --overwrite y # Mintfile realm/SwiftLint@0.52.4 mono0926/LicensePlist@3.24.11 kiliankoe/swift-outdated@0.8.0 nicklockwood/SwiftFormat@0.53.3 Mintを使用して、以下のツールをインストールしていました。 SwiftLint : コードの静的解析 SwiftFormat : コードのフォーマット LicensePlist : ライセンスの管理 swift-outdated : 依存関係の更新確認 改善のアプローチ これらのツールはいずれも開発時に使用するものであり、Xcode Cloudでの配布時には実行する必要がありません。Xcode Cloudの役割はCDであり、配布作業のみ行えれば十分だからです。 そこで、「不要なツールのインストールをやめる」ことで実行時間を削減する方針としました。 キャッシュによる高速化を採用しなかった理由 「Mintのインストールをキャッシュすれば速くなるのでは?」と考えるかもしれませんが、Xcode Cloudのキャッシュ機能には制約があります。 GitHub ActionsやCircleCIなどのCI/CDツールでは、任意のパスやフォルダを指定してキャッシュできます。例えば ~/.mint をキャッシュしておけば、2回目以降のインストールを高速化できます。 一方、Xcode Cloudのキャッシュ対象は DerivedData配下のみ に限定されています。具体的にキャッシュされるのは以下の2つです。 Xcodeのビルドキャッシュ (インクリメンタルビルド用の中間成果物) Swift Package Managerで取得・ビルドされたライブラリ 任意のパスを指定する機能は提供されていないため、Homebrew経由でインストールしたMintなどはキャッシュの対象外です。つまり、 ci_post_clone.sh でのインストール処理は毎回フルで実行されることになります。 To reduce the amount of time it takes to perform a build, Xcode Cloud stores each build's derived data and other cached information for reuse in a secure and private way. — Xcode Cloud workflow reference | Apple Developer Documentation このキャッシュの制約があるからこそ、キャッシュで高速化するのではなく、そもそも不要な処理をXcode Cloudから取り除くアプローチが有効になります。 具体的には、 ci_post_clone.sh を削除してXcode CloudでのMintインストール自体をやめ、各ツールの実行はGitHub Actionsやローカル環境に移行しました。 各ツールの対応内容 SwiftLint・SwiftFormat ローカル環境でのビルド(Build Phase)とGitHub Actionsでのみ実行するようにしました。 GitHub Actionsの設定 # .github/workflows/ci.yml name : CI on : pull_request : branches : - develop jobs : swift_format : name : SwiftFormat runs-on : macos-latest steps : - uses : actions/checkout@v4 - name : Cache Mint packages uses : actions/cache@v4 with : path : ~/.mint key : ${{ runner.os }}-mint-${{ hashFiles('Mintfile') }} restore-keys : | ${{ runner.os }}-mint- - name : Install Mint run : brew install mint - name : Run SwiftFormat lint run : mint run swiftformat healthcare Packages --lint swift_lint : name : SwiftLint runs-on : macos-latest steps : - uses : actions/checkout@v4 - name : Cache Mint packages uses : actions/cache@v4 with : path : ~/.mint key : ${{ runner.os }}-mint-${{ hashFiles('Mintfile') }} restore-keys : | ${{ runner.os }}-mint- - name : Install Mint run : brew install mint - name : Run SwiftLint run : mint run swiftlint lint ローカル環境(Xcode Build Phase)の設定 XcodeのRun Script(Build Phase)でSwiftFormatとSwiftLintを実行します。Run ScriptはXcode Cloud上のビルドでも実行されるため、環境変数 CI が TRUE のときはスキップするようにしています(Xcode Cloudでは CI=TRUE が設定されます)。 if [ " $CI " = "TRUE" ]; then exit 0 fi if [ -d " /opt/homebrew/bin " ]; then export PATH =" $PATH :/opt/homebrew/bin " fi mint run swiftformat healthcare Packages mint run swiftlint lint LicensePlist もともとBuild PhasesのRun Scriptでビルドのたびにライセンス情報を自動生成していたため、生成物をGit管理していませんでした。今回の対応で生成物をGit管理に含め、Build PhasesからRun Scriptを削除しました。パッケージを変更した際にはローカルでライセンス情報を再生成する運用としています。 swift-outdated もともとXcode Cloudでは実行していなかったため、対応は不要でした。 結果 これらの対応により、Xcode Cloudの実行時間を約30分から約15分へ、約50%削減することができました。ビルドやパッケージ解決の設定を変えたわけではなく、 ci_post_clone.sh の処理を見直しただけでこれだけの効果が得られました。 まとめ Xcode Cloudの実行時間を削減するために、CDとして不要なツールのインストール処理を見直しました。Xcode CloudのキャッシュはDerivedData配下のみという制約があるため、キャッシュで高速化するのではなく、そもそも不要な処理をXcode Cloudから取り除くアプローチを採用しています。各ツールの実行はGitHub Actionsやローカル環境に移行し、CI/CDの役割を明確に分離しました。 Xcode Cloudの実行時間に課題を感じている方の参考になれば幸いです。
はじめに 今回は AgentCore CLI を使ったエージェント開発を本番運用できるかを検討した際に、複数環境のデプロイについて詰まったポイントがあったので、ご紹介させていただきます。 AgentCore CLIは2026年4月17日現在では、GA前段階のため、本記事で紹介する内容が今後変更される可能性があります。 検証に使用したエージェント構成 今回検証のために使用したエージェントの構成を簡単に紹介します。 今回はAgentCore CLIの使い方の説明が主題ではないため、使い方についての詳細は省かせていただきます。 AgentCore CLIの agentcore create コマンドで以下のようなエージェントを作成したという前提で話を進めさせていただきます。 - Project name : MyProject - Agent name : analysis - Type : Create new agent - Language : Python - Build : Direct Code Deploy - Protocol : HTTP - Framework : OpenAI Agents - Advanced : defaults コマンドを実行すると以下のような構成でファイルが生成されます。 主要なものに絞って記載していますが、実際には CDK の設定ファイルや LLM コンテキストファイルなども生成されます。 MyProject/ # プロジェクトルート ├── AGENTS.md # エージェントの概要・設計ドキュメント ├── README.md # プロジェクトのREADME ├── agentcore/ │ ├── agentcore.json # エージェント定義(ランタイム、Gateway、Credential等) │ ├── aws-targets.json # デプロイ先のAWSアカウント・リージョン │ ├── tool-schema.json # Gatewayターゲットのツール定義 │ ├── .env.local # APIキー等のシークレット │ ├── .cli/ │ │ └── deployed-state.json # デプロイ済みリソースの状態 │ └── cdk/ │ ├── bin/cdk.ts # CDKエントリポイント │ └── lib/cdk-stack.ts # CDKスタック定義 └── app/ # エージェントのアプリケーションコード └── analysis/ ├── main.py └── pyproject.toml また、上記構成に含まれていませんが、今回の構成では、AgentCore CLIで作成したエージェントが Gateway 経由で、 lambroll でデプロイした Lambda 関数をツールとして呼び出します。 デプロイの仕組み agentcore deploy を実行すると、内部では以下が行われます: デプロイターゲット( aws-targets.json )の読み込み agentcore.json のバリデーション CDKプロジェクトのビルド Credential(APIキー等)のセットアップ CloudFormationテンプレートの合成(synth) CloudFormationスタックのデプロイ CloudFormationスタックには、ランタイム、Gateway、IAMロール等のリソースがまとめて含まれます。 詰まったポイント 1. --target オプションでデプロイ先が絞り込めない 問題 AgentCore CLIでは、デプロイ先のAWSアカウント・リージョンを aws-targets.json に定義します。 dev/prodを分離するために、以下のように2つのターゲットを定義しました。 // aws-targets.json [ { " name ": " dev ", " account ": " 111111111111 ", " region ": " ap-northeast-1 " } , { " name ": " prod ", " account ": " 999999999999 ", " region ": " ap-northeast-1 " } ] agentcore deploy コマンドには --target オプションがあり、デプロイ先を指定できます。 --target dev を指定すればdev環境のみにデプロイされると期待しましたが、実際には以下のようにprodのCloudFormationスタックもdevアカウントに作成されてしまいました。 # targetをdevに指定してデプロイ AWS_PROFILE=dev-profile agentcore deploy --target dev 実際にはdevアカウントに以下の2つのスタックが作成される - AgentCore-MyProject-dev (意図通り) - AgentCore-MyProject-prod (意図しない) 原因 この問題は、CLIとCDKの間でターゲット情報が連携されていないことが原因のようです。 CLIの --target オプションは、 aws-targets.json からターゲット情報(account, region)を取得してIdentityのセットアップやデプロイ後の状態記録に使われますが、CDKのsynth(CloudFormationテンプレートの合成)やdeploy処理にはターゲット名が伝わりません。 agentcore create で生成される cdk.ts のデフォルトコードでは、 aws-targets.json に定義された 全ターゲット に対してスタックを生成するforループになっています。 // cdk.ts(デフォルト生成コード) for ( const target of targets) { // --target の指定に関係なく、全ターゲット分のスタックが生成される new AgentCoreStack(app, stackName, { ... } ); } CDKのsynthはローカルで実行されるため、アカウントIDが異なっていてもテンプレート生成自体は成功します。その結果、devアカウントのクレデンシャルで実行しているにもかかわらず、prod用のスタック定義もdevアカウントにデプロイされてしまいます。 対策 対策1: cdk.ts を修正して環境変数でフィルタ cdk.ts に環境変数 AGENTCORE_TARGET でターゲットをフィルタするコードを追加しました。 // cdk.ts const app = new App (); // 環境変数でターゲットをフィルタ const targetFilter = process .env.AGENTCORE_TARGET; const filteredTargets = targetFilter ? targets. filter ( t => t. name === targetFilter) : targets; for ( const target of filteredTargets) { // フィルタされたターゲットのみスタックを生成 new AgentCoreStack(app, stackName, { ... } ); } デプロイ時に環境変数を指定して実行します: # dev環境のみデプロイ AGENTCORE_TARGET=dev AWS_PROFILE=dev-profile agentcore deploy --target dev # prod環境のみデプロイ AGENTCORE_TARGET=prod AWS_PROFILE=prod-profile agentcore deploy --target prod 注意点 : agentcore create で新規プロジェクトを作成するたびに cdk.ts が初期状態で生成されるため、毎回この修正を適用する必要があります。 CLIの --target オプションの値はCDKプロセスに自動的に引き渡されないため、環境変数 AGENTCORE_TARGET として別途指定する必要があります。 --target はCLI内部でのターゲット情報取得に、 AGENTCORE_TARGET はCDKのsynthでのスタック絞り込みに使われるため、両方に同じ値を指定する必要があり、冗長になってしまいます。将来のCLIバージョンで改善される可能性はありますが、現時点(v0.8.0)ではこの対応が必要です。 対策2: aws-targets.json を毎回リセットしてプロファイルから自動検出 aws-targets.json を空( [] )にしてからデプロイすると、CLIが AWS_PROFILE からアカウントIDとリージョンを自動検出し、 "default" という名前のターゲットを自動生成します。 # dev環境(aws-targets.json が空の状態で実行) AWS_PROFILE=dev-profile agentcore deploy # prod環境(aws-targets.json をリセットしてから実行) echo '[]' > agentcore/aws-targets.json AWS_PROFILE=prod-profile agentcore deploy 一見シンプルですが、実用上は問題があります。devデプロイ後に aws-targets.json にはdevターゲットが追加された状態になっています。この状態でリセットせずにprodをデプロイすると、 aws-targets.json に2つのターゲットが登録され、対策1で述べたのと同じ問題(全ターゲット分のスタックがsynthされる)が発生してしまいます。 そのため、デプロイのたびに aws-targets.json をリセットする運用が必要になりますが、CI/CDを使い、 echo '[]' > agentcore/aws-targets.json を実行してからデプロイする形にすれば、毎回クリーンなワークスペースから始まるためリセット忘れは防げると思います。 対策1は agentcore create で自動生成される cdk.ts を書き換える必要があり、CLIのバージョンアップで生成内容が変わった際に手動マージが必要になったり、修正漏れで予期せぬ挙動を起こすリスクがあります。そのため、基本的には自動生成ファイルには手を入れず、対策2をCI/CDで運用するのが望ましいと考えています。 2. Lambda ARNのハードコーディング 問題 GatewayにLambda関数をターゲットとして追加するには、以下のようにコマンドを実行します。 agentcore add gateway-target \ --gateway Gateway \ --name DataFetcher \ --type lambda-function-arn \ --lambda-arn arn:aws:lambda:ap-northeast-1:111111111111:function:get-data \ --tool-schema-file ./agentcore/tool-schema.json ./agentcore/tool-schema.json にはLambda関数が提供するツールの定義を記述したJSONファイルを指定します。 Lambda ARNターゲットの場合、Gatewayがどのツールを公開しているか知る手段がないため、このファイルを自分で用意する必要があります。 // tool-schema.json の例 { " tools ": { " get-data ": { " name ": " get-data ", " description ": " 分析用のデータを取得する ", " inputSchema ": { " type ": " object ", " properties ": { " date ": { " type ": " string ", " description ": " 取得対象の日付 " } } , " required ": [ " date " ] } } } } このコマンドを実行すると、 agentcore.json に以下のようなGatewayターゲットが追加されます。 // agentcore.json " agentCoreGateways ": [ { " name ": " Gateway ", " targets ": [ { " name ": " DataFetcher ", " targetType ": " lambdaFunctionArn ", " lambdaFunctionArn ": { " lambdaArn ": " arn:aws:lambda:ap-northeast-1:111111111111:function:get-data ", " toolSchemaFile ": " ./agentcore/tool-schema.json " } } ] } ] ここで問題になるのが lambdaArn の値です。Lambda ARNにはAWSアカウントIDが含まれるため、dev/prodでアカウントが異なる場合、デプロイ前に毎回この値を対象環境のARNに書き換える必要があります。 devにデプロイする場合: arn:aws:lambda:ap-northeast-1:111111111111:function:get-data prodにデプロイする場合: arn:aws:lambda:ap-northeast-1:999999999999:function:get-data agentcore.json はgit管理されるファイルのため、デプロイのたびにARNを書き換えてコミットするのは手間がかかりますし、書き換え忘れにより誤った環境のARNでデプロイしてしまうリスクもあります。 対策 agentcore.json にはdev用のARNを登録しておき、 cdk.ts 側で関数名だけを取り出して、ターゲットのアカウント・リージョンからARNを動的に再構築するようにしました。 // agentcore.json(dev用のARNで登録しておく) { " lambdaArn ": " arn:aws:lambda:ap-northeast-1:111111111111:function:get-data ", " toolSchemaFile ": " ./agentcore/tool-schema.json " } // cdk.ts const resolvedMcpSpec = mcpSpec ? JSON . parse ( JSON . stringify (mcpSpec)) : undefined ; if (resolvedMcpSpec?.agentCoreGateways) { for ( const gw of resolvedMcpSpec.agentCoreGateways) { for ( const t of gw.targets ?? [] ) { if (t.lambdaFunctionArn?.lambdaArn) { // 元のARNから関数名を抽出し、ターゲットのアカウント・リージョンで再構築 const functionName = t.lambdaFunctionArn.lambdaArn. split ( ':' ). pop (); t.lambdaFunctionArn.lambdaArn = `arn:aws:lambda: ${ target. region} : ${ target.account } :function: ${ functionName } ` ; } } } } 注意点 : cdk.ts は agentcore create で新規プロジェクトを作成するたびに初期状態で生成されるため、毎回この修正を適用する必要があります。 また、前述の通り自動生成ファイルを書き換えるのはCLIのバージョンアップ等でバグを生みやすいので、CI/CDのデプロイジョブ内で agentcore.json のARNを対象環境のアカウントIDに置換してからデプロイする方が安全かなと思います。 3. APIキーをdev/prodで分けたい 問題 エージェントが外部API(OpenAI等)を利用する場合、APIキーをCredentialとして登録します。登録されたAPIキーはAgentCore Identityサービスの アウトバウンド認証 (エージェントから外部サービスへの認証情報)として管理されます。 agentcore add credential --name OpenAIApiKey --api-key sk-xxxxx このコマンドを実行すると、以下の2箇所に情報が書き込まれます。 agentcore/agentcore.json — Credentialのメタ情報(名前・タイプ) agentcore/.env.local — APIキーの実際の値 // agentcore.json " credentials ": [ { " authorizerType ": " ApiKeyCredentialProvider ", " name ": " OpenAIApiKey " } ] agentcore/.env.local AGENTCORE_CREDENTIAL_OPENAIAPIKEY=sk-xxxxx 環境変数名は Credential名から AGENTCORE_CREDENTIAL_{NAME} の形式で自動生成されます。デプロイ時にこの値が読み取られ、AWS側の Token Vault (AgentCore Identityサービスのシークレットストア)に登録されます。 dev/prodで同じAPIキーを使う場合は、 .env.local の値をそのまま使えるので問題ありません。しかし、セキュリティや課金管理の観点からdev/prodでAPIキーを分けたい場合、 .env.local は1ファイルしかないため、デプロイのたびに値を書き換える必要があります。 対策 デプロイ時に環境変数でAPIキーを上書きします。環境変数が設定されていれば .env.local の値より優先されます。 # dev環境 AGENTCORE_CREDENTIAL_OPENAIAPIKEY=sk-dev-xxxxx \ AGENTCORE_TARGET=dev \ AWS_PROFILE=dev-profile \ agentcore deploy --target dev # prod環境 AGENTCORE_CREDENTIAL_OPENAIAPIKEY=sk-prod-xxxxx \ AGENTCORE_TARGET=prod \ AWS_PROFILE=prod-profile \ agentcore deploy --target prod ただし、毎回デプロイコマンドにAPIキーを環境変数として渡すのであれば、 .env.local を直接書き換える運用と手間は変わりません。今回はCI/CDを使わずローカルからデプロイする運用のため、デプロイ前に .env.local の値を対象環境のAPIキーに書き換える方法を採用しました。 別のアプローチ:dev/prodでプロジェクトを分ける ここまで紹介した課題は、いずれも 1つのプロジェクトでdev/prodを共有する ことに起因しています。 これらをすべて解消するシンプルなアプローチとして、dev用とprod用でそれぞれ別のAgentCoreプロジェクトを作成する方法があります。 MyAgent-dev/ ├── agentcore/ │ ├── agentcore.json ← dev用のLambda ARN、dev用のAPIキー │ ├── aws-targets.json ← devアカウントのみ │ └── cdk/ └── app/ └── analysis/ MyAgent-prod/ ├── agentcore/ │ ├── agentcore.json ← prod用のLambda ARN、prod用のAPIキー │ ├── aws-targets.json ← prodアカウントのみ │ └── cdk/ └── app/ └── analysis/ この方法なら cdk.ts のカスタマイズは不要で、 aws-targets.json にはターゲットが1つだけなのでsynthの問題も発生せず、 .env.local も環境ごとに独立しています。 ただし、 app/ 配下のエージェントコードが2つのプロジェクトで重複するため、ロジックを変更するたびに両方を更新する必要があります。コードの同期忘れによる環境差異が生まれるリスクもあるため、この方法を積極的に採用することはできないなと思いました。 まとめ AgentCore CLIを使用してみて、実際に本番運用できるのかを検討しました。 CLIで必要なリソースを簡単に素早く作成できるというメリットはありますが、環境を分離するには課題が多いという検証結果になりました。 最後まで読んでいただきありがとうございました!
はじめに 株式会社エブリーでデリッシュキッチンのiOSアプリの開発をしている成田です。 iOS 26から、Appleの新しいデザイン言語である「Liquid Glass」が導入されました。 2026年4月の現時点では設定のフラグによって適用を回避できますが、次のXcodeのメジャーアップデートではこのフラグの廃止が見込まれています。 また、2027年春頃には新しいメジャーバージョンのXcodeでのビルドが必須になると考えられ、対応は避けられない状況です。 こうした背景から、すでにLiquid Glassへの対応を進めているiOSアプリ開発者の方も多いのではないでしょうか。 デリッシュキッチンでも現在ユーザーへのリリースを目指して対応を進めています。 本記事では、以下のような流れでデリッシュキッチンにおけるLiquid Glass対応への取り組みについて紹介したいと思います。Liquid Glassの概要については他の記事でも多く紹介されているので本記事ではできるだけ割愛します。 Liquid Glass対応の進め方 大まかな対応箇所 デリッシュキッチンにおける課題 AppleのLiquid Glassワークショップへの参加 Liquid Glass対応の進め方 キックオフと開発の流れ 今年の1月にPdMとデザイナー、エンジニアが集まりキックオフを行ってプロジェクトがスタートしました。 まず最初に、アプリのプロジェクト設定のオプトアウトフラグ UIDesignRequiresCompatibility を外した状態のアプリを社内に配布し、Liquid Glassがそのまま適用された状態で各画面をデザイナーに確認してもらいました。Appleの標準アプリや他のメジャーなアプリのUIも参考にしながら、対応が必要な箇所の洗い出しと優先度付け、そして大まかな工数見積もりを行いました。 また、対応方針については単にデザイン観点だけで決めるのではなく、技術的な実現可否や実装コストも踏まえながら、エンジニアとデザイナーで議論を重ねて整理していきました。デザインと実装の両面から検討することで、現実的かつ一貫性のある方針を定められていると感じます。 さらに、初期段階では一定期間を設けて集中的に実装を進めることで、実際の対応にどの程度の工数がかかるのかを把握することもでき、おおよそのベロシティ感を掴むことができました。 なお、参考事例としてAppleが紹介している デザイン事例集 も、実際にどのようにLiquid Glassがプロダクトに取り入れられているかを把握するうえで非常に参考になりました。 専任を置かず全員で対応する このプロジェクトでは、iOSチーム内に専任を置かず、各プロジェクトごとに分担して対応を進めています。 専任を設けると知見が特定のメンバーに偏り、今後の機能開発においてプロジェクトごとに実装のばらつきが生じる可能性があるためです。Liquid Glassのようなデザイン言語の変化は一部の対応にとどまらず、プロダクト全体に継続的に影響していくものだと考えています。 また、UIはデザイナーだけで完結するものではなく、エンジニアと連携しながら作り上げていくものです。こうした背景もあり、プロダクトに関わるiOSエンジニア全員で取り組む形で進めています。 独自フラグでコードを先行リリース 現在対応を進めているコードは、まだユーザー向けには公開せず、以下のような独自のフィーチャーフラグを設けることで、コード自体は順次リリースしつつ、ユーザーにはLiquid Glassが適用されない状態を保ったままにしています。 public enum LiquidGlassAvailability { /// Liquid Glass デザインが有効かどうかを返す。 /// iOS 26 以降かつ UIDesignRequiresCompatibility が設定されていない(または false)場合に true。 public static let isEnabled : Bool = { guard #available(iOS 26.0 , * ) else { return false } // UIDesignRequiresCompatibility が true の場合は互換モードなので Liquid Glass 無効 if let requiresCompatibility = Bundle.main.object(forInfoDictionaryKey : "UIDesignRequiresCompatibility" ) as? Bool , requiresCompatibility { return false } return true }() } このような進め方にしているのは、変更をため込むことでGitHub上のPRが滞留し、コンフリクトが発生しやすくなるのを防ぐためです。対応が完了した箇所から順次マージしていくことで、開発の流れをスムーズに保っています。 ユーザー向けの初回リリース時にはプロジェクト設定のオプトアウトフラグを取り除き、Liquid Glassが適用された状態で提供する予定です。また、リリース後も優先度に応じて段階的に適用範囲を広げていく方針です。 初回リリースに向けた大まかな対応箇所 ユーザーへの初回のリリースに向けて、優先度が高いのは以下の内容です。 ナビゲーションバー・タブバー周りの対応 最も優先度が高く、Liquid Glassの効果が大きい箇所がナビゲーションバーとタブバー周りです。Liquid Glassではこれらのバーが透過されることでコンテンツへの没入感が高まりますが、デリッシュキッチンでは元々これらのバーに対して背景色やボタンのスタイルなどを独自にカスタマイズしていました。Liquid Glassに対応するにあたり、これらの独自設定を取り除いていく作業が必要になりました。 レイアウトの修正 独自設定を削除していくと、画面によってコンテンツのレイアウトが崩れるケースが発生しました。Liquid Glassではナビゲーションバーやタブバーの背面にまでコンテンツが広がるレイアウトが前提となりますが、一部の画面ではそのような構造になっていなかったためです。各画面ごとにレイアウトを見直し、コンテンツがバーの裏側まで自然に潜り込むよう修正する作業も対応範囲に含まれています。 その他の表示崩れの修正 ここでは書ききれないので紹介を省きますが、上記の対応に加え、Liquid Glassの適用によって生じる細かな表示崩れについても最低限の修正を行ったうえでユーザーに向けた初回のリリースを行う予定です。 デリッシュキッチンにおける課題 ここでは、Liquid Glass対応を進める上でのデリッシュキッチンにおける課題をいくつかピックアップして紹介します。 ナビゲーションバー直下のカスタムViewの扱い デリッシュキッチンには、ナビゲーションバーの直下にタブやカスタムViewが配置されている画面がいくつかあります。単純にナビゲーションバーを透過にするだけでは、その下に続くカスタムViewとの境界が不自然になってしまい、コンテンツの表示領域も狭まってしまいます。これはLiquid Glassが目指すコンテンツへの没入感という思想に反してしまいます。 これらのカスタムViewをコンテンツ領域の中にどう自然に溶け込ませるか、デザインと実装の両面から検討する必要があり、現在取り組んでいる課題の一つです。 幅広い環境での検証体制 デリッシュキッチンはユーザー数も多く、現在は最新から3つのメジャーバージョンのiOSをサポートしています。Liquid GlassはiOS 26以降でのみ適用されますが、それ以前のOSでもレイアウト崩れが発生しないよう、すべてのサポートバージョンで表示を確認する必要があります。そのため、単一の環境での検証にとどまらず、複数バージョンをまたいだ確認が求められる点が大きな負担となっています。 また、弊社には専任のQAチームがないため、動作検証はPdM・デザイナー・エンジニアが協力して行っています。Liquid Glass対応のように影響範囲が広い変更では、確認すべき画面やパターンも多岐にわたるため、検証の抜け漏れを防ぎつつ、いかに効率的に進めていくかが課題となっています。 並行開発による手戻りリスク また、もう一つの課題として、通常の機能開発との並行進行があります。 現在のプロダクトでは複数のプロジェクトが並行して開発を進めており、Liquid Glass対応と並行して進行しています。そのため、新規機能の開発時にLiquid Glassの考慮が十分に行われないケースも発生しがちです。 その結果、後からデザインの調整や実装の修正が必要になり、手戻りが発生してしまう可能性があります。こうした手戻りをいかに防ぎ、現状の開発の中にLiquid Glass対応を組み込んでいくかも重要な課題となっています。 AppleのLiquid Glassワークショップへの参加 Liquid Glass対応の一環として、Appleが時折に開催しているワークショップに会社で参加する機会をいただき、3月にエンジニアとデザイナー数名で参加してきました。 ワークショップは、まずLiquid Glassの概要や設計思想、もたらす効果について一通り説明いただくところから始まり、その後はAppleのデザインのエバンジェリストの方と直接やり取りできる時間が設けられており、デリッシュキッチンにおける対応方針について質問やディスカッションを行いました。 自社アプリの課題を持ち込み、その場でフィードバックをもらえる形式だったため、抽象的なガイドラインだけではイメージしづらかった部分についても、具体的な方向性を確認することができました。 せっかくなので、ワークショップに参加して特に印象に残っている学びをいくつか紹介します。 ナビゲーションバーやタブバーで特色を出さない ナビゲーションバーやタブバーといった操作周りのUIで個性を出すのではなく、コンテンツでプロダクトの特色を表現することが重要であるという考え方が印象に残りました。 透過させることが目的ではない Liquid Glassは単に透過やブラーを適用すること自体が目的ではなく、コンテンツへのフォーカスを高めるための手段であるという話がありました。見た目だけをなぞるのではなく、どういう意図で使うかが重要だと感じました。 システムとの一貫性を保つ OS全体の表現と調和することが重要で、過度に独自のスタイルを持ち込むと違和感につながるという点も印象的でした。標準の振る舞いを尊重することが結果的に良い体験につながると感じました。 おわりに 本記事では、デリッシュキッチンにおけるLiquid Glass対応の取り組み状況についてご紹介しました。 同じようにLiquid Glassへの対応を進めている方にとって、少しでも参考になれば幸いです。 デリッシュキッチンのLiquid Glass対応のリリースもぜひ楽しみにしていてください!
はじめに エブリーでデリッシュキッチンの開発をしている本丸です。 日頃の業務でClaude Codeを活用しているのですが、AWSからリリースされたAIツール群(IAM Policy Autopilot、Agent Plugins for AWS)がClaude Codeと連携できることを知り、社内勉強会を機に実際に試してみました。 本記事では、これらのツールの概要と、素のLLMに指示した場合と専用ツールを使った場合でどのような違いが出るのかを4つのシナリオで比較した結果をまとめます。 IAM Policy Autopilot 概要 IAM Policy Autopilotは、AWS re:Invent 2025で発表されたオープンソース(Apache 2.0)のツールです。ソースコードを静的解析し、最小権限のIAMポリシーを自動生成します。 対応言語はPython / TypeScript / Goで、CLI / MCPサーバーの両方で利用できます。 仕組み 特筆すべきは LLMを使用しない決定論的な静的解析 である点です。Rust製のAST解析エンジン(ast-grep)がSDK呼び出しを検出し、IAMアクションにマッピングします。同じコードからは常に同じポリシーが生成されるため、再現性があります。 ソースコード ↓ AST解析(ast-grep) SDK呼び出しを検出 ↓ IAMアクションにマッピング IAMポリシーJSON生成 主要機能 コマンド 用途 generate-policies ソースコード解析からIAMポリシー生成 fix-access-denied AccessDeniedエラーメッセージから修正ポリシー生成 Agent Plugins for AWS 概要 Agent Plugins for AWSは、2026年2月にAWS Labsからリリースされたプラグイン群です。AIエージェントにAWSの設計・構築・運用スキルを付与します。 利用可能なプラグイン プラグイン 用途 aws-serverless Lambda / API Gateway / EventBridge deploy-on-aws アーキテクチャ推奨 / コスト見積もり / IaC生成 databases-on-aws Aurora DSQL含むDB設計ガイダンス aws-amplify Amplify Gen 2 フルスタックアプリ構築 amazon-location-service マップ / ジオコーディング / ルーティング migration-to-aws GCPからAWSへの移行支援 deploy-on-aws の5段階ワークフロー deploy-on-awsプラグインは、以下の5段階のワークフローでプロジェクトのデプロイを支援します。 1. Analyze → 2. Recommend → 3. Estimate → 4. Generate → 5. Deploy (解析) (推奨) (試算) (生成) (デプロイ) 各フェーズでは、ワークフローを主導するSkillと、外部データを参照するMCPサーバー(awsknowledge, awspricing, aws-iac-mcp)、さらにIaC検証を自動実行するHooksが組み合わさって動作します。これにより、最新のAWSドキュメント・料金情報・IaCベストプラクティスを参照しながら一貫したプロセスで進行します。 素のLLMに指示する場合の課題 これらのツールを使わず、素のLLMに直接指示した場合には以下のような課題があります。 学習データの鮮度 : 知識カットオフ以降のAPI変更・新サービスに非対応 ハルシネーション : 存在しないAPIパラメータやサービス名を生成するリスク 一貫性の欠如 : 毎回異なるアプローチ・構成を提案する可能性 検証手段がない : 生成されたポリシーやIaCの正しさを確認できない 一方、ツールを利用すると以下の改善が得られます。 最新情報の参照 : MCPサーバー経由でリアルタイムにAWSドキュメント・料金を参照 構造化プロセス : 明確なワークフローにより一貫した品質を実現 最小権限の原則 : 自動的に最小権限を適用、ベストプラクティスに基づく設計 比較シナリオ ツールを使うと実際どれくらい差分が出るのかが気になったので、AWS開発でよく遭遇しそうな場面をAIに挙げてもらい、以下の4つのシナリオを用意して比較しました。各シナリオで素のLLMと専用ツール付きに対して同じプロンプトを渡し、出力を見比べています。 シナリオ1: Lambda関数のIAMポリシー作成(IAM Policy Autopilot) シナリオ2: サーバーレスREST APIの構築(aws-serverless Plugin) シナリオ3: AccessDeniedエラーの解決(IAM Policy Autopilot) シナリオ4: AWSへのデプロイ設計(deploy-on-aws Plugin) シナリオ1:Lambda関数のIAMポリシー作成 S3からファイルを読み取り、DynamoDBに書き込むLambda関数に必要な最小権限ポリシーを作成するシナリオです。 対象コード import boto3 s3 = boto3.client( 's3' ) dynamodb = boto3.resource( 'dynamodb' ) table = dynamodb.Table( 'my-data-table' ) def handler (event, context): bucket = event[ 'bucket' ] key = event[ 'key' ] response = s3.get_object(Bucket=bucket, Key=key) data = response[ 'Body' ].read().decode( 'utf-8' ) table.put_item(Item={ 'id' : key, 'content' : data, 'source_bucket' : bucket }) return { 'statusCode' : 200 , 'body' : 'Success' } ツール利用時は「IAM Policy Autopilotの generate_application_policies ツールを使って」と追加で指示しました。 結果比較 項目 素のLLM IAM Policy Autopilot S3アクション GetObject のみ GetObject + LegalHold + Retention + Tagging + Version + ObjectLambda DynamoDBアクション PutItem のみ PutItem + WriteDataForReplication KMS暗号化 なし S3・DynamoDB向け kms:Decrypt (条件付き) CloudWatch Logs 含む(推測で追加) 含まない(サービスロールに委任) IAM Policy Autopilotは暗号化・バージョニング・Access Point等、本番運用で必要になる権限を網羅的にカバーしています。素のLLMが推測ベースで生成したのに対し、IAM Policy AutopilotはAST解析により get_object() と put_item() の呼び出しを検出し、関連する権限を自動的に追加しました。 一方で、IAM Policy Autopilotの出力はKMS暗号化やAccess Pointなど実際に使っていない権限まで含まれるため、過剰な権限にならないよう利用するリソースに合わせてレビューすることは必要そうです。 シナリオ2:サーバーレスREST APIの構築 ユーザー情報のCRUD APIをLambda + API Gateway + DynamoDBで構築するシナリオです。 ツール利用時はaws-serverlessプラグインのMCPサーバーを利用しました。 結果比較(template.yaml) 項目 素のLLM aws-serverless Plugin Lambda関数数 1(ルーターパターン) 5(操作ごとに分離) IAMポリシー 全操作に DynamoDBCrudPolicy Read → ReadPolicy / Write → CrudPolicy (粒度分離) CPUアーキテクチャ x86_64(デフォルト) arm64(コスト最適化) トレーシング なし Tracing: Active(X-Ray) ツール利用時は5回のMCP呼び出しが行われました。最初の get_serverless_templates では条件が具体的すぎて失敗しましたが、エージェントが自動で条件を緩めて再試行する適応的な動作が見られました。最後に validate_cloudformation_template でテンプレートの妥当性検証も実施されています。 興味深かったのは、aws-serverless Pluginが単一のLambda関数ではなく、CRUD操作ごとに5つに分割した関数を生成した点です。これは最小権限の原則を徹底するためで、Read系の関数には DynamoDBReadPolicy 、Write系の関数には DynamoDBCrudPolicy と、操作ごとに必要最低限のIAMポリシーだけを付与できるようにするための構成だと考えられます。単一関数にするとどうしてもCRUD全ての権限を付けざるを得ないため、関数を分割することで権限分離をしっかり行うベストプラクティスが反映されているようでした。 シナリオ3:AccessDeniedエラーの解決 Policies: [] でDynamoDB権限を付け忘れたLambdaのAccessDeniedエラーを解決するシナリオです。 ツール利用時はIAM Policy Autopilotの generate_policy_for_access_denied ツールを利用しました。 注:実際にAWS上へリソースを作成して再現したわけではなく、あらかじめ用意したエラーメッセージ(JSON)とLambdaコード・SAMテンプレートを入力として渡し、修正ポリシーがどのように生成されるかを確認しています。 エラーメッセージ { " statusCode ": 500 , " body ": " { \" error \" : \" AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/scenario3-data-writer-role/scenario3-data-writer is not authorized to perform: dynamodb:PutItem on resource: arn:aws:dynamodb:ap-northeast-1:123456789012:table/scenario3-data-table \" } " } 結果比較 項目 素のLLM IAM Policy Autopilot 原因特定 正しく特定 正しく特定 ポリシーJSON 一般的な記述(アカウントIDなし) 完全修飾ARN(リージョン+アカウントID) 検証手順 なし sam build → sam deploy → テストの手順を提示 どちらも根本原因( Policies: [] )は正しく特定できました。差が出たのはポリシーの精度で、IAM Policy AutopilotはエラーメッセージからアクションとリソースARNをパースし、ピンポイントの修正ポリシーを生成しました。 シナリオ4:AWSへのデプロイ設計 Express.js(PostgreSQL / Redis / WebSocket / 画像アップロード)アプリケーションのAWS構成設計とコスト見積もりを行うシナリオです。 ツール利用時はdeploy-on-awsプラグインのMCPサーバー(awsiac + awspricing)を利用しました。 アーキテクチャ比較 項目 素のLLM deploy-on-aws Plugin NAT Gateway あり($36/月) なし(Public Subnet + Public IP) RDS構成 Multi-AZ(高可用性) Single-AZ(コスト重視) Fargate 512 CPU / 1024 MB × 2タスク 256 CPU / 512 MB × 1タスク(ARM64) セキュリティ 標準的 enforceSSL , allowAllOutbound: false 設計方針 可用性・冗長性重視 コスト効率重視(必要十分) コスト比較 サービス 素のLLM deploy-on-aws Plugin 差 ECS Fargate $29.55 $8.99 -70% ALB $22.40 $20.66 -8% RDS PostgreSQL $27.36 $20.55 -25% ElastiCache Redis $11.68 $18.25 +56% NAT Gateway $36.14 $0 -100% 合計 ≈$134/月 ≈$71/月 -47% ElastiCache Redisのようにdeploy-on-aws側の方が高くなる項目もありますが、NAT Gatewayの削除やARM64採用などのコスト最適化により全体では約半額に収まっています。 ツール利用時は14回のMCP呼び出しが行われましたが、その中で試行錯誤も発生しました。たとえばECS Fargateの料金取得でフィルタが不正だったり、ALBのサービスコード名がAPI正式名称( AWSELB )と異なるためにエラーになったりと、エージェントが get_pricing_service_codes で正しいコードを探索する過程が見られました。 /deploy Skill による実行 シナリオ4を /deploy スラッシュコマンドでも実行してみました。Skillが5段階のワークフローを主導し、各フェーズで選定理由をテーブルで明示するなど、よりプロセスの透明性が高い出力が得られました。 注: /deploy は最後にAWSへ実際にデプロイするステップまで含むワークフローですが、今回はAnalyze → Recommend → Estimate → GenerateまでのIaCコード生成フェーズで停止させ、実際のデプロイは行っていません。 3方比較 観点 素のLLM MCP直接 /deploy Skill 正確性 一般知識に基づく推測 静的解析・API参照で裏付けあり 同左 + 構造化ワークフローで漏れを防止 コスト 冗長性重視で高コスト(≈$134/月) リアルタイム料金でコスト最適化(≈$71/月) コスト最適化 + 代替案の差額も提示(≈$87/月) プロセス Read/Writeのみ MCP呼び出し多数 Skill + MCP + cdk synth 検証ループ まとめ 今回はIAM Policy AutopilotとAgent Plugins for AWSを実際に使い、素のLLMとの出力の違いを4つのシナリオで比較してみました。 全体を通して感じた共通する価値は以下の点です。 最新のAWS情報に基づいた提案 : MCPサーバー経由でリアルタイムにドキュメント・料金を参照するため、知識カットオフの影響を受けない 実コード解析による根拠ベースの出力 : 推測ではなく、AST解析やAPI参照に基づくため信頼性が高い(IAM Policy Autopilotの特徴) 構造化ワークフローによる一貫した品質 : 毎回同じプロセスで進行するため、出力のばらつきが少ない 最小権限・ベストプラクティスの自動適用 : ARM64、関数分離、権限の粒度分離などが自動で適用される 一方、ツールがあればすべて完璧というわけではなく、料金取得の試行錯誤やテンプレート検索の条件調整など、エージェントが適応的に動作する場面も多く見られました。また、静的解析ベースのIAMポリシー生成では実際に使わないリソースへの権限まで含まれる場合があるため、生成されたコード・ポリシーは必ず人間がレビューしてからデプロイすることが重要です。 今回のように素のLLMとの出力差分を実際に確認してみると、ツールがどのような前提・ベストプラクティスに基づいて出力を生成しているかを把握することの重要性も感じました。便利だからと漫然と使うのではなく、ツールを導入することで何が変わるのか・どこまで任せられるのかをきちんと理解した上で、日々の開発に取り入れていきたいと思います。 参考文献 IAM Policy Autopilot - GitHub Simplify IAM policy creation with IAM Policy Autopilot, a new open source MCP server for builders | AWS News Blog Agent Plugins for AWS - GitHub Introducing Agent Plugins for AWS | AWS Developer Tools Blog
はじめに こんにちは、リテールハブ開発部の杉森です。 Vercel Labs が開発しているローカル API エミュレータ「emulate」が面白そうだったので、実際に触りながら AWS SDK (S3) との互換性、GitHub / Google の OAuth フロー、本番 API への切り替えまでを試してみました。 emulate とは emulate は Vercel Labs が開発しているオープンソースのローカル API エミュレータです。GitHub、Google、Slack、Stripe、AWS など 12 のサービスをローカルで再現でき、単なるモック(固定レスポンスを返すだけ)ではなく、ステートフルなデータストアと OAuth フローを備えています。 npx emulate の1コマンドで 12 サービスがポート 4000〜4010 で起動します。設定ファイルは不要で、デフォルトのユーザーとトークンが自動で作成されます。 emulate v0.4.1 vercel http://localhost:4000 github http://localhost:4001 google http://localhost:4002 slack http://localhost:4003 apple http://localhost:4004 microsoft http://localhost:4005 okta http://localhost:4006 aws http://localhost:4007 resend http://localhost:4008 stripe http://localhost:4009 mongoatlas http://localhost:4010 Tokens test_token_admin -> admin 起動直後から Authorization: Bearer test_token_admin で全サービスの API を呼び出せます。 # ユーザー情報を取得 curl http://localhost:4001/user -H "Authorization: Bearer test_token_admin" # リポジトリを作成 curl -X POST http://localhost:4001/user/repos \ -H "Authorization: Bearer test_token_admin" \ -H "Content-Type: application/json" \ -d '{"name":"hello-world"}' レスポンスは本物の GitHub API と同じ JSON 構造です。ステートフルなので、上で作成したリポジトリに対して Issue や PR を追加するといった操作もできます。 実際に試してみた emulate v0.4.1 / Node.js v22 の環境で、以下の3つの観点から検証しました。 AWS SDK (S3) との互換性: SDK 経由でそのまま使えるか GitHub / Google OAuth フローの実装: OAuth を含むアプリをローカルで開発できるか 本番 API への切り替え: 2 で作ったアプリのコードを変えずに本番で動かせるか AWS SDK (S3) との互換性を検証する emulate の AWS エミュレータが、AWS SDK v3 からそのまま使えるかを検証しました。検証には @aws-sdk/client-s3 3.1028.0 を使用しています。 emulate の認証の仕組み emulate ではすべてのサービスが Bearer トークン認証に統一されています。実際のサービスではそれぞれ認証方式が異なりますが、emulate 上ではどれも同じ Authorization: Bearer でアクセスできるようになっています。 また、登録されていないトークンでリクエストしても、フォールバック機構によりデフォルトユーザー(admin)として認証が通ります。テストコードでトークンの値を気にせず書けるのは便利でした。 この仕組みが AWS SDK との互換性に直接関わってきます。 困った点と対応内容 AWS SDK v3 から emulate の S3 エミュレータを使うには、以下の2つの対応が必要でした。 1. endpoint のパスが合わない emulate のルートは /s3/:bucket/:key 形式ですが、AWS SDK は /bucket/key にリクエストを送るため、パスが一致しません。endpoint を localhost:4007/s3 にすることでパスを合わせます。 2. 末尾スラッシュでルートが一致しない AWS SDK は PUT /s3/bucket-name/ のように末尾スラッシュ付きでリクエストを送りますが、emulate のルート定義にスラッシュがないためマッチしません。SDK ミドルウェアで末尾スラッシュを除去することで対応しました。 対応後の動作結果 上記2つの対応を入れることで、CreateBucket、PutObject、GetObject、ListObjectsV2、ListBuckets、DeleteObject といった主要な S3 操作はすべて動作しました。 なお、AWS SDK は SigV4 署名を送りますが、emulate は SigV4 を解釈しません。前述のフォールバック認証によりデフォルトユーザーとして通るため、credentials の値は { accessKeyId: "dummy", secretAccessKey: "dummy" } で動きます。 ただし、Presigned URL、S3 イベント通知、SQS との連携などは未対応です。AWS SDK のより広い互換性が必要な場合は別のライブラリ等を検討した方が良さそうです。emulate の AWS エミュレータは「REST API の形状をテストする」用途向けという印象です。 補足: 本記事で触れたルートのパス不一致や Presigned URL 未対応については、修正の PR がすでに存在しております。マージされれば上記の回避策は不要になりそうです。 OAuth フローを組み込む emulate を使ってみて特に便利だと思ったのは OAuth フローのエミュレーションです。GitHub OAuth でサインインして PR 一覧を表示するアプリを作って検証しました。 シード設定で初期データを定義する emulate は YAML で初期データ(シード)を定義できます。起動時にユーザー、リポジトリ、OAuth App が自動で作成されます。 github : users : - login : admin name : Admin User email : admin@example.com repos : - owner : admin name : test-repo auto_init : true oauth_apps : - client_id : emu_github_client_id client_secret : emu_github_client_secret name : PR Viewer App redirect_uris : - http://localhost:3000/auth/callback 以下は、今回作ったアプリの OAuth フローです。 emulate にアクセスすると、以下のような認可画面が表示されます。 シードで定義したユーザーをクリックするだけで認可が完了します。トークン交換や API コールは本物の GitHub API と同じエンドポイントで動作するため、アプリ側のコードは本番と同じ実装がそのまま使えます。 なお、シード設定は宣言的なデータ定義のみに対応しており、PR のようなリソースはシードで作成できません。API 経由で投入する必要があります。 Node.js から直接起動して開発環境を自動化する emulate は CLI( npx emulate )だけでなく、Node.js のコードから直接起動する API( createEmulator )も提供しています。これを使って、emulate の起動、テストデータの投入、Web サーバーの起動を1コマンドにまとめました。 import { createEmulator } from "emulate" ; const github = await createEmulator ({ service : "github" , port : 4001 , seed : config }) ; const google = await createEmulator ({ service : "google" , port : 4002 , seed : config }) ; npm run dev だけで全部起動する体験は快適でした。 本番 API への切り替えを検証する server.js はすべてのエンドポイント URL を process.env から読み取る設計にしました。emulate と本番の切り替えは .env.local と .env の読み分けだけで行えます。 # .env.local(emulate 用) GITHUB_URL=http://localhost:4001 GITHUB_API_URL=http://localhost:4001 GITHUB_CLIENT_ID=emu_github_client_id GITHUB_CLIENT_SECRET=emu_github_client_secret GITHUB_OWNER=admin GITHUB_REPO=test-repo # .env(本番用) GITHUB_URL=https://github.com GITHUB_API_URL=https://api.github.com GITHUB_CLIENT_ID=<実際の Client ID> GITHUB_CLIENT_SECRET=<実際の Client Secret> GITHUB_OWNER=<実際のオーナー名> GITHUB_REPO=<実際のリポジトリ名> { " scripts ": { " dev ": " node --env-file=.env.local dev.js ", " start:prod ": " node --env-file=.env server.js " } } 本番の GitHub OAuth App を作成し .env に設定して npm run start:prod で起動したところ、コード変更なしで本物の GitHub 認可画面が表示され、実際の PR 一覧が取得できました。 観点 emulate 本番 GitHub 認可画面 emulate のユーザー選択画面 GitHub の実際の認可画面 認可の操作 ユーザーをクリック 「Authorize」ボタン データ テストデータ リポジトリの実際の PR API キー 不要 実際の Client ID / Secret Google OAuth + Gmail API も同じパターンで追加しました。emulate 用の環境変数は以下の通りです。 # .env.local(emulate 用) GOOGLE_URL=http://localhost:4002 GOOGLE_TOKEN_URL=http://localhost:4002/oauth2/token GOOGLE_API_URL=http://localhost:4002 GOOGLE_CLIENT_ID=emu_google_client_id GOOGLE_CLIENT_SECRET=emu_google_client_secret # .env(本番用) GOOGLE_URL=https://accounts.google.com GOOGLE_TOKEN_URL=https://oauth2.googleapis.com GOOGLE_API_URL=https://www.googleapis.com GOOGLE_CLIENT_ID=<実際の Client ID> GOOGLE_CLIENT_SECRET=<実際の Client Secret> まとめ 3つの検証観点ごとに結論を整理します。 AWS SDK (S3) との互換性: endpoint のプレフィックス追加と末尾スラッシュ除去の2つの回避策を入れれば、主要な S3 操作は動作する。ただし Presigned URL 等は未対応で、より広い互換性が必要なら 別のライブラリを検討した方が良い。 GitHub / Google OAuth フローの実装: シード設定と認可画面の自動生成により、OAuth App の登録やテストユーザーの作成なしで OAuth フローを含むアプリの開発を始められる。OAuth フローをローカルで手軽にテストできるのは便利だった。 本番 API への切り替え: エンドポイント URL を環境変数に切り出しておけば、コード変更なしで本番に切り替えられる。 興味がある方はぜひ試してみてください。 参考リンク emulate GitHub リポジトリ emulate 公式ドキュメント
目次 はじめに ECR イメージスキャンとは 構成の全体像 検知の網羅性 通知のノイズ低減 認知のスピード コスト 試算の考え方 試算例 Terraform による構築 1. ECR スキャン設定 2. EventBridge ルール 3. SNS トピック 4. AWS Chatbot(Slack 通知) 実際の通知と運用 導入してみて まとめ はじめに こんにちは、開発本部開発1部トモニテグループのエンジニアの パンダム/rymiyamoto です。 2025年末に Next.js の React Server Components に DoS(サービス拒否)とソースコード露出の脆弱性が公開 され、App Router を使用するサービスでのアップグレード対応が求められました。 このように、利用しているフレームワークやライブラリに深刻な脆弱性が見つかることは珍しくありません。 こうした脆弱性が公開中のサービスに影響していないかを素早く把握できる体制を整えるべく、弊社でも ECR のイメージスキャンを導入しました。 本記事では、その取り組みの一つとして ECR のイメージスキャンを導入した際の設計・構築・運用について紹介します。 同じように ECR のイメージスキャンをこれから導入しようとしている方の参考になれば幸いです。 ECR イメージスキャンとは Amazon ECR のイメージスキャンは、コンテナイメージに含まれるソフトウェアの脆弱性(CVE)を検出する機能です。 スキャンには Basic Scanning と Enhanced Scanning の2種類があります。 項目 Basic Scanning Enhanced Scanning スキャンエンジン Clair(オープンソース) Amazon Inspector2 検出対象 OS パッケージの脆弱性 OS パッケージ + プログラミング言語パッケージ(npm, pip, Maven 等) スキャンタイミング プッシュ時 / 手動 プッシュ時 / 継続スキャン 料金 無料 有料(スキャンしたイメージ数に応じた従量課金) 構成の全体像 導入した構成は以下の通りです。 ECR Enhanced Scanning (Inspector2) ↓ 脆弱性検知 EventBridge Rule (CRITICAL のみフィルタ) ↓ SNS Topic ↓ AWS Chatbot → Slack チャンネルに通知 設計にあたって意識したのは以下です。 検知の網羅性 OS パッケージだけでなく言語パッケージもカバーしたかったため、Enhanced Scanning を採用しました。対応言語の詳細は公式ドキュメントを参照してください。 docs.aws.amazon.com 一方で、OS パッケージの脆弱性検知だけで十分なケースや、まずは無料で始めたいケースでは Basic Scanning も有力な選択肢です。自社の要件に合わせて検討してみてください。 通知のノイズ低減 すべての severity を通知すると対応が追いつかなくなるため、まずは CRITICAL に絞って運用を開始しました。実際に HIGH まで含めて試してみたところ、本当に対応すべき通知が埋もれかねないと感じたので、まずは CRITICAL で運用を開始し、必要に応じてフィルタを広げる方針としています。 認知のスピード 脆弱性の存在に気づかないことが一番のリスクなので、Slack への即時通知を組み込みました。Slack への通知方法としては EventBridge → Lambda で通知内容をカスタマイズする方法もありますが、今回はまず検知できる状態を素早く作ることを優先し、コードを書かずに構築できる AWS Chatbot を採用しました。 コスト Enhanced Scanning は Amazon Inspector2 の料金体系に基づきます。料金は以下の2つで構成されます(2026年4月時点)。 最新の料金は公式ドキュメントをご確認ください。 aws.amazon.com 初回スキャン: イメージがプッシュされた時のスキャン、$0.09 / イメージ 再スキャン: 継続スキャンにより新しい CVE が公開された際の自動再スキャン、$0.01 / イメージ 試算の考え方 スキャン頻度によってコストの構造が異なります。 スキャン頻度 発生するコスト 計算式 プッシュ時 初回スキャンのみ 月間プッシュ数 × $0.09 継続スキャン 初回スキャン + 再スキャン 上記 + 保持イメージ数 × 再スキャン回数/月 × $0.01 弊社では本番環境は継続スキャン、開発環境はプッシュ時スキャンで運用しています。本番環境では新しい CVE が公開されたタイミングでも即座に検知したいため継続スキャン、開発環境では脆弱性を含む実装が入った時点で素早く検知しつつコストも抑えたいためプッシュ時スキャンが適しています。 試算例 例えば、5つのリポジトリに対して月間100回プッシュし、本番では各リポジトリに2イメージを保持(計10イメージ)するケースで試算します。再スキャン回数は月にどれくらいの頻度で対象の CVE が新たに公開されるかに依存しますが、ここでは月15回程度を見込みました。 項目 計算式 コスト 初回スキャン 100 push × $0.09 $9.00 再スキャン 10 images × 15回 × $0.01 $1.50 月額合計 $10.50 実際のコストはリポジトリ数・プッシュ頻度・保持イメージ数によって変わるので、自社の運用に合わせて試算してみてください。 Basic Scanning(無料)と比較するとコストはかかりますが、言語パッケージの脆弱性検知や新規 CVE の自動再スキャンが得られることを考えると、検討する価値はあると思います。 Terraform による構築 1. ECR スキャン設定 まず ECR レジストリに対して Enhanced Scanning を有効化します。 resource "aws_ecr_registry_scanning_configuration" "this" { scan_type = "ENHANCED" rule { scan_frequency = "CONTINUOUS_SCAN" repository_filter { filter = "*" filter_type = "WILDCARD" } } } filter = "*" でレジストリ内のすべてのリポジトリをスキャン対象にしています。リポジトリを個別に指定する方法もありますが、新しいリポジトリを追加した際にスキャン対象への追加を忘れるリスクがあるため、ワイルドカードで全体を対象にしています。 scan_frequency は環境によって使い分けています。本番環境では CONTINUOUS_SCAN 、開発環境では SCAN_ON_PUSH を設定しています。 2. EventBridge ルール resource "aws_cloudwatch_event_rule" "ecr_scan_finding" { name = "ecr-scan-finding-notification" event_pattern = jsonencode ( { "source" : [ "aws.inspector2" ] , "detail-type" : [ "Inspector2 Finding" ] , "detail" : { "status" : [ "ACTIVE" ] , "severity" : [ "CRITICAL" ] , "resources" : { "type" : [ "AWS_ECR_CONTAINER_IMAGE" ] } } } ) state = "ENABLED" } resource "aws_cloudwatch_event_target" "ecr_scan_finding_sns" { rule = aws_cloudwatch_event_rule.ecr_scan_finding.name arn = var.ecr_scan_finding_sns_topic_arn } Enhanced Scanning では Inspector2 がスキャンエンジンとなるため、イベントソースは aws.inspector2 になります。 Basic Scanning の場合は aws.ecr になるので注意が必要です。 3. SNS トピック EventBridge から受け取ったイベントを AWS Chatbot に渡すための SNS トピックを作成します。 resource "aws_sns_topic" "ecr_scan_finding_topic" { name = "ecr-scan-finding-topic" } resource "aws_sns_topic_policy" "ecr_scan_finding_topic_policy" { arn = aws_sns_topic.ecr_scan_finding_topic.arn policy = data.aws_iam_policy_document.sns_ecr_scan_finding_topic_policy.json } data "aws_iam_policy_document" "sns_ecr_scan_finding_topic_policy" { # EventBridge からの Publish を許可 statement { sid = "AllowEventBridgeToPublishSNS" effect = "Allow" actions = [ "sns:Publish" ] principals { type = "Service" identifiers = [ "events.amazonaws.com" ] } resources = [ aws_sns_topic.ecr_scan_finding_topic.arn ] condition { test = "StringEquals" variable = "AWS:SourceAccount" values = [ data.aws_caller_identity.current.account_id ] } condition { test = "ArnEquals" variable = "aws:SourceArn" values = [ "arn:aws:events:$ { data.aws_region.current.name } :$ { data.aws_caller_identity.current.account_id } :rule/ecr-scan-finding-notification" ] } } # Chatbot からの Subscribe を許可 statement { sid = "AllowChatbotToSubscribe" effect = "Allow" actions = [ "sns:Subscribe" ] principals { type = "Service" identifiers = [ "chatbot.amazonaws.com" ] } resources = [ aws_sns_topic.ecr_scan_finding_topic.arn ] condition { test = "StringEquals" variable = "AWS:SourceAccount" values = [ data.aws_caller_identity.current.account_id ] } condition { test = "ArnEquals" variable = "aws:SourceArn" values = [ "arn:aws:chatbot::$ { data.aws_caller_identity.current.account_id } :chat-configuration/slack-channel/alert-to-slack" ] } } } SNS トピックポリシーでは、EventBridge からの Publish と Chatbot からの Subscribe のみを許可しています。 condition で発信元を絞ることで、意図しないリソースからの操作を防いでいます。 4. AWS Chatbot(Slack 通知) 最後に、SNS トピックのメッセージを Slack に転送する Chatbot の設定です。 resource "aws_chatbot_slack_channel_configuration" "chatbot_alert_to_slack" { configuration_name = "alert-to-slack" slack_channel_id = "XXXXXXXXX" # 通知先の Slack チャンネル ID slack_team_id = "XXXXXXXXX" # Slack ワークスペース ID iam_role_arn = var.chatbot_role_arn sns_topic_arns = [ var.ecr_scan_finding_topic_arn, # 他の通知用 SNS トピックもここに追加できる ] guardrail_policy_arns = [ "arn:aws:iam::aws:policy/ReadOnlyAccess" ] logging_level = "ERROR" } これで CRITICAL な脆弱性が検知された際に、Slack チャンネルに通知が届くようになります。 なお、AWS Chatbot では同じ Slack チャンネルに対して複数の configuration を作成できません。そのため configuration_name は alert-to-slack のように汎用的な名前にしています。こうしておけば、今後 WAF のアラートなど別の通知を追加したくなっても sns_topic_arns にトピックを足すだけで済みます。 実際の通知と運用 実際に届く通知は以下のような形式です。 最初は CVE の詳細まで Slack で確認できるものだと思っていたのですが、実際に届く通知には Inspector2 Finding というイベント名と対象の ECR イメージの ARN が表示されるだけで、CVE 名もパッケージ名も表示されませんでした。 そのため、EventBridge の input_transformer を使い、Chatbot のカスタム通知で通知内容を改善しました。 resource "aws_cloudwatch_event_target" "ecr_scan_finding_sns" { rule = aws_cloudwatch_event_rule.ecr_scan_finding.name target_id = "SendToSNS" arn = var.ecr_scan_finding_sns_topic_arn input_transformer { input_paths = { "severity" = "$.detail.severity" "title" = "$.detail.title" "description" = "$.detail.description" "repository" = "$.detail.resources[0].details.awsEcrContainerImage.repositoryName" } input_template = <<TEMPLATE { "version": "1.0", "source": "custom", "content": { "textType": "client-markdown", "title": ":rotating_light: ECR <severity> 脆弱性検出 [環境名 (AWSアカウントID)]", "description": "*重要度*: <severity>\n*リポジトリ*: <repository>\n*脆弱性*: <title>\n*詳細*: <description>" } } TEMPLATE } } ポイントは input_paths でイベントから必要な項目を抽出し、カスタム通知フォーマットで整形している点です。改善後の通知は以下のような形式です。 CVE-ID やパッケージ名、リポジトリ名が表示されるようになり、Slack 上で脆弱性の概要を把握できるようになりました。詳細な対応判断が必要な場合は Inspector2 のダッシュボードを確認する運用ですが、通知を見ただけで対応要否がわかることが増えました。 さらに通知内容を自由にカスタマイズしたい場合は、EventBridge → SNS → Chatbot の経路ではなく、EventBridge → Lambda で整形する方法もあります。 導入してみて CRITICAL に絞った判断はうまくいきました。最初の通知が来たときも「これは本当に対応が必要なものだ」と落ち着いて対処できたので、狙い通りでした。 一方で、Chatbot のデフォルトの通知では CVE の詳細が出ず、正直もう少し情報が出ると思っていました。実際に使ってみて初めて気づいた部分で、 input_transformer を使ってカスタマイズできることも後から知りました。 Terraform での複数環境展開やスキャン頻度の使い分けはすんなりいきました。 まとめ 今回は、フレームワークやライブラリの脆弱性に素早く対応できる体制づくりの一環として、ECR の Enhanced Scanning を導入した事例を紹介しました。 構成としては ECR Enhanced Scanning → EventBridge → SNS → Chatbot → Slack というシンプルなパイプラインですが、Terraform でコード化することで再現性のある形で複数環境に展開できました。 まず検知できる状態を作ることが第一歩、そこさえ超えれば運用しながら精度を上げていけます。本記事がその一歩を踏み出すきっかけになれば嬉しいです。 最後まで読んでいただきありがとうございました!
はじめに こんにちは。株式会社エブリーの開発1部の村上です。 弊社ではClaudeを非エンジニアも含めた全社に展開しており、業務のあらゆる場面で生成AIの活用を推進しています。 弊社のデータ基盤は、昨年TreasureDataとDatabricksを併用していた構成からDatabricksに統一しました。(この移行の話は今週の 「第3回 Youは何しにDatabricksへ!?」 で「データ基盤をTreasureData + DatabricksからDatabricksへ統一する話」として弊社のデータエンジニアの吉田がお話しする予定なので、ぜひご参加ください。)基盤が統一されたことで、次のステップとして見据えているのが「AIを活用したデータの民主化」です。 AIの進歩によって、ずっと掲げてきたこのテーマがいよいよ現実味を帯びてきました。MCP経由で社内のデータを取得し、AIと対話しながら分析を進め、今までにないインサイトを得る。そんな世界がすぐそこまで来ています。 一方で、 「AIを使えばデータが簡単に出せる」ことと「現場で信頼して意思決定に使えるレベルの分析ができる」 こととの間には、まだまだ大きな壁があります。 AIはとても賢いですが、私たちの会社のこと、事業のこと、プロダクトのことを詳細には知りません。そのため、聞きたいことをそのまま質問してもその意図を正確に理解できず、全く違うデータを返してしまったり、生成するクエリが微妙に間違っていて正しいデータが出せなかったりします。結果として「使い物にならない」となってしまうわけです。 今回は、そんな状態からどのように進めていくことで「現場で使えるAI分析基盤」を作れるのか、Databricks環境で試行錯誤している話をします。 Databricks Genieとは こうした課題を解決するためにDatabricksが提供しているのがGenieです。Genieは自然言語でデータに対して質問すると、SQLを自動生成して結果を返してくれるインターフェースです。 docs.databricks.com ただし、これは単にDatabricks上でLLMを呼び出してSQLを書かせるだけのものではありません。 LLMの限界を理解した上で、自分たちの組織専用にチューニングできるように設計されている のがGenieの本質です。 Genie Space — 目的特化の分析空間 Genieでは「Genie Space」という単位でデータの分析空間を作ります。会社にはさまざまな部門があり、それぞれが見たいデータや使う用語は異なります。Spaceではそのそれぞれに適したコンテキストを設定できるようになっており、登録するテーブルを指定することで必要なデータだけにアクセスさせることができます。 たとえば営業チーム向けのSpaceにはSalesforceのデータを、ECチーム向けのSpaceには注文・顧客データを登録するといった具合です。1つのSpaceに登録できるテーブルは最大30件で、むやみに広げるのではなく、そのSpaceが答えるべき質問の範囲に絞ることが推奨されています。 Knowledge Store — AIのためのコンテキストを整える仕組み 各Genie Spaceには「Knowledge Store」と呼ばれるコンテキストをチューニングする機能が備わっています。これがGenieを組織専用に育てていくための中核です。Knowledge Storeには以下の要素があります。 Metadata: テーブルやカラムの説明文、同義語、不要カラムの非表示。GenieがSQLを組み立てるための基礎知識 Prompt Matching: カラムの実際のデータ値をGenieに事前認識させ、ユーザーの言葉とデータ値のマッチング精度を上げる Joins: テーブル間の結合条件を定義。Genieが複数テーブルをまたぐクエリを正しく書けるようにする SQL Expressions: Filter(条件定義)、Measure(指標の計算式)、Dimension(グルーピング定義)をSQLで直接登録 Example SQL: よくある質問に対する正解SQLをテンプレートとして登録 General Instructions: テキストでの補足指示 ここからは、実際にSpaceを作ってKnowledge Storeを育てていく過程を、実際に行なった試行錯誤とともに解説していきます。 まずは何もチューニングせずに聞いてみる まずやったのは、最小限の設定だけでGenie Spaceを作って、いきなり質問してみることです。テーブルにはカラムコメント(日本語の説明文)を付与済みで、General Instructions(テキストの指示文)にはビジネスコンテキストを3行だけ書きました。 これはECサービスのデータが格納されているスペースです。 ECカートからのトランザクションデータを元に、事業KPIを分析します。 日本語で回答してください。 この状態でいくつかの質問を投げてみた結果がこちらです。 質問 Genieの挙動 正誤 先月のGMVは? キャンセル・返品済みの注文も含めて集計 ❌ 先月の割引額は? 割引関連の3カラムのうち1つだけで集計 ❌ 先月の定期購入のGMVは? データ値を英語で推測し、0件ヒット ❌ 先月の1人あたり月間購入金額は? 分子に使うべき指標を別の指標と混同 ❌ 先月のキャリア決済のGMVは? 一部の決済方法を集計から漏らした ❌ 5問中、正解はゼロ。しかし、間違い方には共通するパターンがあります。 ビジネスルールを知らない 「KPI集計時にはキャンセル・返品を除外する」というルールをGenieは知りません。そのためフィルタなしで集計してしまいます。 言葉の定義が曖昧 「割引」と聞かれたとき、 discount というカラム名だけを見てそれだけで完結したと判断しました。実際には複数のカラムを合算する必要があるのですが、ビジネスの定義を知らなければわかりません。 データの中身を知らない 受注種別カラムには「定期受注」「通常受注」という日本語の値が入っているのに、Genieは英語の 'subscription' で推測して何もヒットしませんでした。 似た指標を区別できない 税込の総額と税抜の売上高を混同したり、決済方法のグルーピングが期待と一致しなかったり。似た概念が複数存在する領域で間違いが起きやすいことがわかりました。 AIは知らないことを推測で埋めようとします。それ自体は賢い振る舞いですが、ビジネスでは「もっともらしい間違い」が一番危険です。ここから「AIに正しく教えていく」プロセスが始まります。 AIにデータを活用できるようにするためのステップ 先述のKnowledge Storeの機能を使い、実際に設定を追加してはテストし、間違えたらまた設定を足すという繰り返しで精度を上げていった過程を紹介します。 1. メタデータ整備 — まずAIにデータの地図を渡す Genieがテーブル構造やカラムの意味を理解できなければ、そもそもSQLを正しく組み立てることさえできません。個人でClaudeを使うなら自分専用のテーブル定義書を作ってコンテキストに含めればいいですが、組織で複数人が使う場合にはスケールしません。 そこで重要になるのが、Databricksの Unity Catalog でのメタデータ管理です。 テーブル・カラムの説明文 Unity Catalogではテーブルやカラムに対してCOMMENTを付与できます。 COMMENT ON COLUMN orders.subtotal IS ' 小計(税抜商品売上)。定期割引適用済み ' ; COMMENT ON COLUMN orders.total IS ' 注文合計(税込)。GMV計算に使用 ' ; COMMENT ON COLUMN orders.revenue IS ' 売上高(subtotal + deliv_fee + charge)。税抜合計 ' ; カラムの説明は「何が入っているか」だけでなく「何に使うか」「何と違うか」まで書くと、Genieの精度が大きく変わります。特に似た概念のカラムが複数ある場合(GMV / 売上高 / 商品売上など)は、区別を明示することが重要です。 Genie Space上の同義語 ユーザーはいつも同じ言い方で質問するとは限りません。「UU」「ユニーク顧客数」「月間ユーザー数」はすべて同じ指標を指しています。Genie SpaceのMetadata設定でカラムに同義語を登録しておくことで、こうした表記揺れを吸収できます。 Prompt Matching Genieにはカラムの実際のデータ値を事前に認識させる機能があります。 Format Assistance: カラムからサンプルデータを取得して、どんな値が入っているかをGenieに学習させる Entity Matching: カテゴリカラムのユニーク値をリスト化して保存し、ユーザーの言葉と実際のデータ値をマッチさせる たとえば先ほどの「定期購入のGMV」問題。これは受注種別カラムの値が日本語であることをGenieが知らず、英語で推測してしまったことが原因でした。Prompt Matchingを有効にすることで、Genieは実際のデータ値を事前に把握した状態で質問に答えられるようになります。 ただし、Prompt Matchingは値を「見せる」機能であり、「使わせる」保証はありません。あくまで補助的な役割です。確実にビジネスロジックを定義するには、次のステップが必要です。 2. SQL Expressionでビジネスロジックを定義する 自然言語での質問には、データ上の定義とのギャップが必ず存在します。ユーザーが「売上」と言ったとき、それがGMV(税込総額)なのか売上高(税抜)なのか商品売上(商品のみ)なのかは、ビジネスの文脈を知らなければ判断できません。 Genie SpaceのKnowledge Storeでは、 SQL Expression としてこのビジネスロジックをSQLで直接定義できます。SQL Expressionには3つの種類があります。 Filter — 条件の定義 「有効注文のみで集計する」というビジネスルールをFilterとして定義します。 名前 SQL 同義語 Instructions 有効注文 orders.state NOT IN ('canceled', 'returned') 有効注文, KPI対象 GMV・売上・注文数など金額や数量を集計するクエリでは必ず適用すること。キャンセル分析時のみ適用しない Filterを設定する前は集計に不要なデータが含まれていましたが、設定後は正しい値が返るようになりました。 Measure — 指標の定義 ビジネスで使うKPIの計算式をMeasureとして定義します。 名前 SQL 同義語 Instructions 割引額 SUM(orders.subscription_discount + orders.discount + orders.point) 割引額, 割引合計, 値引き 定期割引 + クーポン割引 + ポイント利用の合計 設定前は割引に関連するカラムのうち1つだけが使われていましたが、設定後は3カラムの合算で正しい値を返すようになりました。 同様に、「1人あたり月間購入金額」もMeasureで定義することで、分子と分母に使う指標が正しく固定され、安定して正確な結果が得られるようになりました。 Dimension — グルーピングの定義 データ上は複数種類ある決済方法を、ビジネスで見たいグループにまとめるDimensionを定義します。 CASE WHEN orders.payment_method IN ( ' ドコモ払い ' , ' au決済 ' , ' ソフトバンク払い ' ) THEN ' キャリア決済 ' WHEN orders.payment_method = ' クレジットカード ' THEN ' クレジットカード ' WHEN orders.payment_method LIKE ' 後払い% ' THEN ' 後払い ' ELSE orders.payment_method END Dimensionを定義する前は、Genieが毎回自力でCASE WHENを書いていたため、聞き方によってグルーピングが変わるリスクがありました。定義後は「決済グループ別のGMVは?」と聞くだけで毎回同じロジックが適用されます。 3. Example SQLで信頼性を引き上げる SQL Expressionが「部品」だとすると、Example SQLは「完成品の見本」です。よくある質問パターンに対する正解SQLを丸ごと登録しておくことで、Genieはそのテンプレートを参考にSQLを生成します。 Example SQLの設定で重要なポイントが3つあります。 1. タイトルはユーザーが実際に聞く質問文にする Genieはタイトルとユーザーの質問をマッチングしています。「定期購入GMVクエリ」ではなく「先月の定期購入のGMVは?」と書くことで、マッチング精度が上がります。 2. Usage Guidanceでいつ使うかを明示する 「定期購入のGMV」「定期のGMV」「サブスクのGMV」と聞かれたとき、のように具体的な発動条件を書きます。 3. 全Example SQLに共通のフィルタパターンを含める これが最も効果的でした。すべてのExample SQLに有効注文フィルタを含めておいたところ、Example SQLに直接マッチしない新しい質問に対しても、Genieが同じフィルタパターンを自然に適用するようになりました。Example SQLはGenieにとって「スタイルテンプレート」としても機能するのです。 -- タイトル: 定期購入のGMVは? SELECT SUM (orders.total) AS gmv FROM orders WHERE orders.kind = ' 定期受注 ' AND orders.state NOT IN ( ' canceled ' , ' returned ' ) AND fct_orders.order_date >= :start_date AND fct_orders.order_date < :end_date Example SQLを パラメータ化 すると、そのクエリがそのまま使われた場合に応答に「Trusted」ラベルが付きます。これはGenieが検証済みのクエリをそのまま実行したことを示すもので、結果の信頼性をユーザーに保証する仕組みです。 究極的には、レビュー済みのクエリが使われるのが一番精度が高く、出力が安心できます。Trustedラベルがどんどんつくようになれば、ユーザーがデータを疑う回数は極端に減っていきます。 General Instructionsは最後の手段 ここまでの3ステップで、大半の課題は解決します。General Instructionsには何を書いたかというと、最終的にこれだけです。 これはECサービスのデータが格納されているスペースです。 ECカートからのトランザクションデータを元に、事業KPIを分析します。 日本語で回答してください。 たった3行。なぜこれだけでいいのかというと、 テキストの自然言語指示はGenieの行動を強制する力が最も弱い からです。 Genieは複合AIシステムであり、単一のLLMではありません。テーブルのメタデータ、SQL Expression、Example SQL、サンプル値、チャット履歴など、周辺のあらゆる情報を総合的に参照してSQLを生成します。多くの場合、General Instructionsに書きたいことは、SQL ExpressionやExample SQLでより堅牢に定義できます。 実際、当初はGeneral Instructionsに「KPI集計時はキャンセル・返品を除外すること」と書いていましたが、それだけでは適用されないケースがありました。SQL ExpressionのFilterとして定義し、さらにExample SQLのパターンで学習させることで、ようやく安定して適用されるようになりました。 Databricksの公式ドキュメントでも「instructionsは他の方法で対応できない場合の最終手段」と 明記 されています。 Knowledge Storeを育てた結果 ここまでの設定を積み重ねた結果、冒頭で全問不正解だった質問に対して、すべて正しい値を返せるようになりました。 対策したのは以下のようなシンプルな設定の積み重ねです。 SQL Expression: Filter、Measure、Dimensionの定義 Example SQL: よくある質問パターンの正解SQLを登録 Prompt Matching: カテゴリカラムの値を認識させる 一つひとつは小さな設定ですが、それぞれが特定の間違いパターンに対応しており、積み重なることでGenieの応答精度は着実に向上していきます。 育てたGenie Spaceを組織で活用する Genie Spaceをある程度チューニングしたら、次はそれを組織で活用して育てるフェーズです。 Genie MCP — ClaudeからGenieを直接使う DatabricksのManaged MCP Serverを使えば、Genie SpaceごとにMCPエンドポイントを作成できます。 https://<workspace>/api/2.0/mcp/genie/<genie_space_id> これをClaude.aiのConnectorに登録すると、普段使いのClaudeから直接Genieに質問できるようになります。ユーザーはDatabricksの操作を覚える必要がなく、いつも使っているClaudeで自然言語で質問するだけです。裏側でGenieがKnowledge Storeを参照しながら正確なSQLを生成し、結果を返します。 弊社ではClaudeを全社展開しているため、各部門のGenie Spaceを作ってそれぞれのMCPをClaudeに登録すれば、非エンジニアでも自分の部門のデータに自然言語でアクセスできる環境が作れます。 Monitoringで改善サイクルを回す Genie SpaceのMonitoringタブでは、ユーザーが実際に投げた質問と応答を確認できます。うまく答えられなかった質問は、Benchmarkに追加し、Knowledge Storeの設定を改善し、再度評価する。このループをチームで地道に回していくことが、Genie Spaceの精度を継続的に向上させる鍵です。 おわりに AIが自社データを"わかる"ようになるには、一度の設定では終わりません。使って、間違いを見つけて、設定を足して、テストする。その繰り返しです。 改善は地道ですが、 これをやり切った組織とそうでない組織では、プロダクト改善のスピードや事業成長のスピードに取り戻せないほどの差が生まれてくる と考えています。 AIの進化によって、データ分析の主役はSQLを書けるエンジニアから、事業を深く理解しているビジネスメンバーへとシフトしていきます。そのとき、AIが正しく答えてくれるための「土台」を整えておくことが、データ基盤をみるエンジニアの役割の一つだと思っています。私たちの組織ではこれを全社を巻き込んで主導していきたいと思っています。 エブリーでは一緒に働く仲間を募集中です! エンジニアブログをきっかけに少しでも興味も持っていただけたら、まずはカジュアルに面談しましょう!
はじめに こんにちは。 開発本部 開発1部 デリッシュリサーチでデータエンジニアをしている吉田です。 今回は、DatabricksのUnity Catalog管理下のテーブルを、自然言語で検索できるClaude Codeスキルを構築した話を紹介します。 背景 以前の記事 では、Databricks Managed MCP Serverを通してUnity Catalog Functionを実行することでテーブルのスキーマ情報を取得する方法を紹介しました。 この仕組みは便利でしたが、テーブルのパスを把握していることが前提でした。 しかし実際の運用では「あのデータはどのテーブルだっけ?」というケースが多く、テーブルがわからない状態から対象のテーブルを探したいというニーズがありました。 そこで今回は、社内で活用が進んでいるClaude Codeのスキルとして、自然言語でテーブルを検索するスキルを作成します。 スキルの概要 スキルの動作フローは以下のようになります。 ユーザが質問 : 「アプリの動画視聴ログはどこ?」 Claude CodeがSkillを起動 質問文からLLMが文脈に応じてキーワードを抽出 : アプリ/動画/視聴/app/search など scriptを利用して検索SQLを作成 Databricks DBSQL MCPを使ってsystemテーブルに対してクエリを発行 結果を受け取り解釈、イマイチな場合、キーワードを変えて再検索 Claude Codeが回答 : 「最有力候補は以下です。その他候補は ~ です」 アプローチとしてテーブル情報をVector Searchすることも考えましたが、今回は簡単な手法を選択しました。 Databricks Managed MCPの活用 今回のスキルでは、Databricksが提供するManaged MCP ServerのDBSQLを利用しています。 https://docs.databricks.com/gcp/ja/generative-ai/mcp/managed-mcp 以前の記事ではUnity Catalog FunctionsのMCP Serverを使い、事前に定義した関数を呼び出すアプローチでした。 今回は任意のクエリを実行できるDBSQLのMCP Serverを使い、SQLを直接実行するアプローチを取っています。 DBSQL MCPでは以下のツールを提供しています。 execute_sql 任意クエリの実行ツール execute_sql_read_only Select,Showなど読み取りクエリの実行ツール poll_sql_result 長時間実行されるクエリの結果を取得するツール execute_sql ツールは、 Delete , Drop などの危険なクエリを発行できるため、必要に応じて利用制限を行うのが良いです。 // settings.json { " permissions ": { " deny ": [ " mcp__<mcp_name>__execute_sql " ] } } DBSQL MCPは標準で提供されており、すぐに利用する事ができます。 systemテーブルによるメタデータ検索 Databricksの system.information_schema にはカタログ配下のオブジェクトに関するメタデータが保存されています。 https://docs.databricks.com/aws/ja/sql/language-manual/sql-ref-information-schema 主に system.information_schema.tables を利用してテーブル名やコメント名に対してLIKE検索を行うことでテーブルを探しています。 SELECT table_catalog, table_schema, table_name FROM system.information_schema.tables WHERE LOWER (table_name) LIKE ' %<keyword1>% ' OR LOWER ( comment ) LIKE ' %<keyword1>% ' Claude Codeスキルとして実装 スキルの構成 Claude Codeのスキルは SKILL.md というファイルで定義します。 今回のスキルのディレクトリ構成は以下のとおりです。 ~/.claude/skills/<skill-name>/ ├── SKILL.md # スキル定義 └── scripts/ ├── _common.py # 共通ユーティリティ ├── gen_search_table_query.py # テーブル検索(基本) ├── gen_get_columns_query.py # カラム情報取得 └── gen_get_sample_data_query.py # サンプルデータ取得 SKILL.mdのfrontmatterで、スキルが使用できるツールを制限しています。 --- name : <skill-name> description : Databricks Unity Catalogのテーブル検索スキル。 context : fork allowed-tools : - Bash - mcp__databricks-sql-mcp__execute_sql_read_only - mcp__databricks-sql-mcp__poll_sql_result --- context: fork スキルを独立したサブプロセスで実行し、メインの会話コンテキストを汚さない allowed-tools Bashに加え、読み取り専用のMCPツールのみを許可 SKILL.md の本文にはワークフロー(検索の手順)や出力ルール(最有力候補1件+その他最大4件に絞り込む等)を記述しており、Claude Codeはこの指示に従ってSQLを生成・実行します。 sqlglotによるSQL生成 SQLは sqlglot ライブラリを利用したpythonスクリプトで生成しています。 https://sqlglot.com/sqlglot.html プロンプトでSQLの生成をAgentに任せる方法と比べて、確実に目的のクエリを作成することができます。 SKILL.md中に以下のようなコマンドを指示することでSQLを作成しています。 uv run --no-project --with sqlglot scripts/<スクリプト名>.py <パラメータ> sqlglotでのクエリ生成は以下のようにして行っています。 gen_search_table_query.py,_common.pyから抜粋 # gen_search_table_query.py from sqlglot import exp, select from _common import like_or def build_query (keywords: list [ str ]) -> exp.Expression: return ( select( "table_catalog" , "table_schema" , "table_name" , "comment" ) .from_( "system.information_schema.tables" ) .where(like_or([( "table_name" , None ), ( "comment" , None )], keywords)) ) # _common.py def like_or ( columns: list [ tuple [ str , str | None ]], keywords: list [ str ] ) -> exp.Expression: """Build OR chain: LOWER(col) LIKE '%kw%' for each (column, keyword) pair.""" conditions = [ exp.Like( this=exp.Lower( this=exp.Column( this=exp.to_identifier(col), table=exp.to_identifier(tbl) if tbl else None , ) ), expression=exp.Literal.string(f "%{kw}%" ), ) for kw in keywords for col, tbl in columns ] return reduce ( lambda a, b: exp.Or(this=a, expression=b), conditions) 検索精度とデータカタログの重要性 今回のスキルの検索精度は、テーブルやスキーマに付与されたコメントの充実度に強く依存します。 テーブル名とコメントをLIKEで検索するため、コメントが空のテーブルはテーブル名のみでしかマッチしません。 十分な結果が得られなかった場合はサンプルデータの取得や別キーワードでの再検索など探索的にテーブルを探しますが、判断材料としてコメントは非常に重要です。 データカタログの育成こそが、データ活用全体の効率化につながると考えています。 まとめ Databricks Managed MCP ServerのDBSQLとsystemテーブルを組み合わせることで、Unity Catalogのテーブルを自然言語で探索できるClaude Codeスキルを構築しました。