TECH PLAY

株式会社エブリー

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

425

はじめに こんにちは!デリッシュキッチンで主にバックエンドの開発を担当している秋山です。 私たちのチームでは 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スキルを構築しました。
目次 はじめに AIの発展と開発スピードの変化 PRレビューの負荷 レビューに要する時間の増加 レビューの何が負荷なのか レビュー負荷への対処 仕組みでの対処 Claude Code plugin を活用したレビューの効率化 エージェント構成の概要 レビュー結果の出力イメージ 仕組み 課題 おわりに はじめに こんにちは。 開発本部開発1部トモニテ開発部所属の庄司( @ktanonymous )です。 最近では、AI の性能向上や開発フレームワークの進化による開発スピードの向上に伴い、 これまで以上に大量のコードをレビューしていく中で、負荷が大きくなっていると感じています。 本記事では、どういった点で負担を感じているのか、それに対してどのようなアプローチができるのか、 自分なりに考えてみたことをまとめていきたいと思います。 AIの発展と開発スピードの変化 AIの性能向上という面では、CursorやClaude Codeをはじめとするコーディングエージェントやモデル自体の進化により、 AIにコンテキストを渡して実装を依頼するだけで、実装計画の策定からコーディング、テストの追加までを自律的に行わせることが現実的になっています。 さらに、こうしたAIの性能向上に加えて、ドキュメント駆動開発/仕様駆動開発のような実装以外のフェーズにもAIを組み込むフレームワークの発展も目覚ましいです。 以前の記事ではAI駆動開発を見据えたドキュメント運用について整理してみましたが、 弊社の別チームでもエンジニアが仕様書を主導して書くことでAIによる設計・実装を加速させる取り組みが紹介されています。 tech.every.tv tech.every.tv 実装フェーズのスピードアップに加え、ドキュメント運用や仕様策定といった上流工程にもAIが入ることで、開発フロー全体が大きく加速しています。 こうした変化自体は非常にポジティブですが、一方で開発フローの中で人間が直接対応している部分がボトルネックとしてこれまで以上に顕在化しているように感じています。 PRレビューの負荷 レビューに要する時間の増加 レビュー対象には大きく分けると、以下の2種類に分類できます。 自分のアウトプット 他のメンバーのアウトプット また、個人的に、AIの出力を100%信頼できる状況にはまだなっていないと思っていて、 その他にもレビュー活動は以下のような役割を担ってくれているのが現状なのではないかと考えています。 コードの品質担保 : バグや設計上の問題を早期に発見する チーム内の知識共有 : 他のメンバーの変更を把握し、コードベースへの理解を深める機会になる コードベースの一貫性維持 : チームとしてのコーディング方針や設計思想を保つ ビジネスロジックの妥当性判断 : 仕様やドメイン知識に基づく判断は、プロジェクトの文脈を深く理解している人間でないと精度が出しにくい 実装速度の向上に伴い、PRの作成頻度は高まっていますし、適切にAIをコントロールしないと1回の変更量や粒度なども大きくなってしまいがちです。 実際に自分の業務の中でも、コーディングとレビューの比率が以前までは 7:3 程度だったのが、最近は 3:7 くらいになっているように感じています。 特に、自分のアウトプットに関しては、自分は指示役でAIがアウトプットするようになって他の人のアウトプットと近い感覚でレビューをするようになりました。 その結果、ここにかかる労力や時間も少なからず増えています。 そのような背景もあり、レビューがボトルネックになっているという感覚やレビューを通じての疲労感を以前にも増して感じています。 理想的にはPRレビューを全てAIに委譲できれば、レビュー待ちによるブロッキングはなくなります。 しかし、先ほども述べたように、現時点で人間のレビューを完全になくすのは難しいと感じています。 一方で、レビューの全てが人間にしかできないわけではないとも思っています。 「レビューをなくす」のではなく「レビューの負荷を分解して、適切に分担する」という考え方で、 機械的にチェックできる部分についてはツールやAIに委譲し、本質的なレビューに人間が集中できるようにする体制を多くの人が整備しているのではないかと思います。 レビューの何が負荷なのか そもそもレビューのどういった部分に負荷を感じているのかを考えた時、 個人的には、最近レビューをしている中で、以下のような辛みポイントがあるかなと感じています。 レビューするべきものが多い コンテキストの把握(PRの背景や仕様の確認) (以前より)レビューの優先度を考えないといけない 1つ目はシンプルで、レビューするべきものが多いほど負担が増えます。 2つ目に関しては、1つ目とも相まって、時間に対して把握する必要のあるコンテキストが多くなっているので、 これまで以上に負荷のある作業となっています。 3つ目に関しては、レビューするべきものが複数ある状況において、どれからレビューしていくのか、 その中でもどこからレビューしていくのか、優先度や効率を以前よりも意識しないといけないように感じています。 レビュー負荷への対処 ここで、仕組みの面での対処の例を挙げてみます。 仕組みでの対処 ルールやCIを整備することでレビュー負荷の軽減が見込めます。 コーディング規約・スタイル linterやformatterの導入・徹底はやはり重要で、CIでの強制など、 レビュー以前の段階で静的検査が完了していれば、レビュアーはその部分を気にする必要がなくなります。 テストの充実化 テストが要件を適切に表現できていれば、レビュアーとしても 実装が正しく動くか、要件を満たしているのかを判断しやすくなります。 AIレビューツールの活用 GitHub ActionsなどでAIによる自動レビューを組み込むことで、 人間のレビュアーが確認すべきポイントをある程度絞ることができます。 このように、ルールやCIの整備によって「本質的な判断」に集中できる状態に近づけられます。 (なお、レビュー対象の粒度や背景となる情報など、レビュアーに与えるコンテキストの調整によっても負荷の軽減は可能であり、 人間がAIをコントロールして、これらの仕組みがより効果的に働くように意識することも重要だと思います。) Claude Code plugin を活用したレビューの効率化 個人的には先に挙げたような点がレビューをしていて辛いなと感じていたので、 仕組みでの対処とは別に、Claude CodeのカスタムPluginを利用してレビューの効率化を図っています。 Claude Codeには公式のcode-reviewプラグイン 1 が提供されています。 これの構成を踏襲しつつ、個人的に以前から利用しているレビューフォーマットやドキュメントなどの外部コンテキストの取り込みを含めたプラグインを作成して利用しています。 1次レビューをAIに任せるようにしたことで、自分の作業をブロックせずにレビューの基盤を整理しておくことができるようになり、 全体感や内容の把握、優先度の判断に要する負担がある程度減らせました。 エージェント構成の概要 プラグインの処理は、情報収集・外部コンテキスト取得から、専門エージェントによる並列レビュー、信頼度の再評価を経て、レポート生成に至る流れになっています。 エージェント構成の概要 レビュー結果の出力イメージ プラグインは、レビュー結果をマークダウン形式のレポートとしてローカルに出力します。 GitHubにコメントを直接投稿しない設計にしており、レビュー結果を自分で確認・判断してからフィードバックを行うことを想定しています。 実際のレポートは以下のような構成で出力されます。 # PR Review: everytv/repo#123 — PRタイトル ## 変更の概要 ### 背景 / 変更内容 / 実現できる根拠 / メリット・デメリット ## 指摘事項 ### Critical — 信頼度 90-100 ### Important — 信頼度 80-89 ### Suggestion — 信頼度 60-79 ## 良い点 ## レビューガイド ### 推奨レビュー順序 以下では、このレポートの主要な部分がどのように生成されるかを簡単に紹介します。 仕組み コンテキストの自動収集 レビュー時の「コンテキストの把握」の負荷を軽減するために、PRの基本情報(概要・コミット履歴・変更ファイル一覧)に加え、 PR本文に含まれるissueやConfluenceなどの関連リンクを解析して外部コンテキストを取得します。 指摘事項 指摘事項は、構成図に示した3つの専門エージェント(code-reviewer、test-analyzer、design-reviewer)が並列にレビューを行い、その結果を信頼度に基づいてフィルタリングすることで生成されます。 code-reviewerはバグ検出やセキュリティ、パフォーマンスの観点で、test-analyzerはテストカバレッジや設計品質の観点で、design-reviewerは設計原則や既存コードとの整合性の観点で、それぞれレビューを行います。 公式プラグインと同様に0-100の信頼度スコアを導入し、confidence-verifierというエージェントが各指摘を独立した視点で再検証します。 レビュー結果のレポートには、信頼度スコアおよびその判定理由を記述させています。 レビューガイド レポートの「レビューガイド」では、推奨レビュー順序と各ファイルの注目ポイントを示します。 どこからレビューすればよいかの判断をツールに任せることで、優先順位付けの負荷の軽減を狙っています。 複数PRの並列レビュー レビューするべきものが多い場合に対応するため、batch-reviewスキルも用意しています。 複数のPR番号を指定すると、各PRを独立したエージェントが並列にレビューしてリポジトリごとのディレクトリに個別出力します。 まとまった数のPRをレビューする際に、事前にポイントを把握するための情報源として活用しています。 課題 プラグインはあくまでレビューの「補助」であり、完璧なレビューではありません。 そのため、ツールの出力をそのまま信頼してレビュー完了とはせず、内容を確認する必要があります。 おわりに 今回の記事では、PRレビューの負荷について自分が感じていることや、それに対して取り組んでいることを紹介しました。 AIの活用が進む中で、レビューの量やコンテキストの把握、優先順位付けといった部分に辛さを感じるようになりましたが、 仕組みの整備やプラグインの活用で少しずつ改善できている実感があります。 まだまだ試行錯誤の段階ではありますが、同じような課題感を持っている方の参考になれば幸いです。 最後まで読んでいただき、ありがとうございました。 Claude Code 公式 code-review plugin (2026年3月23日閲覧) ↩
はじめに こんにちは!トモニテで開発をしている吉田です。 2026/3/20〜2026/3/22に開催されたPHPerKaigi 2026にスタッフとして参加してきました! PHPerKaigi(ペチパーカイギ)とは以下のようなイベントです! PHPerKaigi(ペチパーカイギ)は、PHPer、つまり、 現在PHPを使用している方、過去にPHPを使用していた方、 これからPHPを使いたいと思っている方、そしてPHPが大好きな方たちが、 技術的なノウハウとPHP愛を共有するためのイベントです。 phperkaigi.jp しかしながら私自身、普段の業務でPHPは書いていません。どうして私が今回参加したのか、PHPを書いていない私が参加してどうだったのか書いていきます。 参加経緯 一番初めのきっかけは昨年開催されたiOSDC 2025で当日スタッフをしたことです。普段はGo言語やTypeScriptを書いていてiOSとは無縁でしたが、スタッフとして参加できました。 iOSDCとPHPerKaigiは実行委員長が同じです。その関係でiOSDC 2025終了後にスタッフの方からPHPerKaigiのスタッフについても話を聞いて、ぜひやってみたいと思い参加させてもらうことにしました! そんな私がどうして普段触る技術領域以外のカンファレンスでスタッフをしているのか。それは 仕事をしてるだけだと出会わない人たちと出会ってみたい!新しい世界を知りたい! と思ったからです。 ここからは実際に参加してみての感想を書いていきます。 参加してみて スタッフについて スタッフにはコアスタッフと当日スタッフの2種類があります。当日スタッフは会期前に事前の顔合わせを行い、当日の運営を担当します。コアスタッフは開催に向けて早い段階から事前準備を進めていてます。 私はコアスタッフとして参加しました。事前準備では名札を首から下げるためのストラップ制作を担当しました。 会期中はTrack Aを担当し、セッションごとに司会や演出を担当したりしていました。演出は場面に応じてスクリーンに映す内容を切り替えるといった役割です。 Track Aではオープニングや通常セッションに加えて、PHPer コードバトルやルーキーズLT、LT大会といった多様なコンテンツが行われていました。 もちろんメインは担当としての仕事ですが、シフトの合間にはスポンサーブースを回ったり、セッションも聞いていました。PHPのカンファレンスではありますが、スピーカーが話す内容はPHPを書いていないと分からないということはなく、それぞれが課題に対してどういうアプローチを取ったのかという手法の部分は、普段の技術領域にも活かせることがあるのではと感じました。 聞いたもの全て興味深かったのですが、特に面白かったものを紹介します。 PHPer コードバトル PHPer コードバトルは、指示された動作をする PHP コードをより短く書けた方が勝ちという 1 対 1 の対戦コンテンツです。予選を勝ち上がったプレイヤー6名がトーナメント形式で対決します。 スコアはコードの空白を除去したバイト数になります。 普段のサービスを動かすためのコードとはまた違うテクニック的な要素も必要になります。 ルールは分かるのですが、正直プレイヤーが具体的にどんなテクニックを使っているのかは分かりません(笑)。 ただ、会場のスクリーンにはリアルタイムにプレイヤーが書いているコードやその瞬間のプログラムサイズが表示され、解説者による解説があります! 個人的にはさながらスポーツ観戦をしているような臨場感で、プレイヤーが大きくプログラムサイズを減らすと会場がどよめくような瞬間もありました。 何より解説があるのでプレイヤーがどういう工夫をしているのか観戦者も知ることができます。 私がコードバトルで学んだのは && と and は優先順位が違うということです。いつかどこかで役立てたいと思います。 参考: PHP: 論理演算子 - Manual コードバトルはシフトが当たっていなくても会場に見に行っていたくらい面白かったです。Track Aの担当にならなければ見ることはなかったと思うので、スタッフをやったからこそ知れた面白さでした! ルーキーズLT/LT大会 ルーキーズLT大会はPHPerKaigiで初めてトークする「ルーキー」たちによる5分のショートトーク、LT大会はスピーカーを限定しないLT大会です。LT大会では参加者がペンライトを振る場面があるのですが、これが会場にとても綺麗な彩りを添えていました! 特に印象に残ったトークを2つ紹介します。まずルーキーズLTから: AI時代の脳疲れと向き合う「言語学としてのPHP」 - プロポーザル / 登壇資料 AI疲れは私自身実感していましたが、それを言語学の観点から考察しているのが新鮮で勉強になりました。ハイコンテキストな日本語話者がローコンテキストな指示を出そうとしていて、これが疲れの原因らしいです...。 「なんでこんな疲れるんだろう...」の原因を知ることができたので、これからは対策が取れそうです。 LT大会からは以下のトークです。 よし、PHPで円でも描いてみるか - プロポーザル / 登壇資料 PHPerKaigi 2024の登壇でもらった質問から「PHPで円を書いてみよう」ということになったそうです! Webやコンソールで描いてみたり、途中では電子工作をされていたり、最終的にはアニメーションする円を実現されていたりと、多種多様な円をPHPで描かれていました。実現過程も面白かったですが話術もすごくてたくさん笑わせてもらいながら聞いていました。 最後に PHPを書いていない私でも参加してみてどうだったかというと、十分に楽しめたし学びもありました。セッションで語られる課題へのアプローチは言語を問わず通じるものが多く、普段の開発にも持ち帰れる気づきがありました。また、コードバトルのようにスタッフとしてTrack Aを担当したからこそ出会えたコンテンツもあり、「仕事をしてるだけだと出会わない人たちと出会ってみたい、新しい世界を知りたい」という動機は十分に満たされました。 これからもカンファレンスのスタッフ活動を続けていきたいと思います!
はじめに こんにちは、デリッシュキッチンのバックエンドエンジニアの鈴木です。 先日、プロダクトのGoのバージョンを 1.25.4 から 1.26.0 へ アップデートしたところ、CI上の自動テストが一部落ちる(失敗する)問題に直面しました。 原因を調べてみると、テストデータの初期化で使っている TRUNCATE 処理において、これまで発生していなかった外部キー制約(Foreign Key Constraint)のエラーが頻発していることがわかりました。 コード自体はいじっていないにもかかわらず、なぜGoのバージョンを上げただけでデータベース操作が失敗するようになったのか。本記事では、このエラーの調査を通して改めて気付かされた、Goの database/sql パッケージにおけるコネクションプールの仕様と、安全なコネクション管理について共有します。 概要 Goの database/sql は内部でコネクションプーリングを行っており、 db.Exec() などのクエリ実行ごとに、プールからアイドル状態のコネクションを動的に取得・返却する仕組みになっている。 一方、MySQLの SET foreign_key_checks = 0 のような設定は、同一セッション(コネクション)内でのみ有効。 そのため、 db.Exec() を連続して呼んでも、同じコネクションで実行される保証はなく、別のコネクションが割り当てられると設定が反映されずにエラーになる。 解決策として、 db.Conn() を使ってコネクションを明示的に取得(占有)し、一連の処理が終わるまで同じコネクションを使い回す必要がある。 Go 1.26.0 へのアップデートと TRUNCATE の失敗 Goを 1.26.0 に上げたタイミングで、テストのクリーンアップ処理(テーブルのTRUNCATE)において、MySQLから以下のエラーが返るようになりました。 Error 1701: Cannot truncate a table referenced in a foreign key constraint ... MySQLで外部キー制約が張られているテーブルを TRUNCATE する場合、一時的に SET foreign_key_checks = 0 を実行して制約を無効化するのが一般的です。私たちのコードでもこの処理を入れていたはずですが、なぜか制約違反のエラーが発生していました。 問題となった実装 エラーが発生していた箇所のコードです。標準の *sql.DB を使って、3つのクエリを順番に実行していました。 // 外部キー制約を一時的に無効化してTRUNCATEを実行する実装 func (e *DBEngine) TruncateTable(tableName string ) error { // 1. 制約チェックの無効化 if _, err := e.db.Exec( "SET foreign_key_checks = 0" ); err != nil { return err } // 2. TRUNCATEの実行(ここで Error 1701 が発生) if _, err := e.db.Exec( "TRUNCATE TABLE " + tableName); err != nil { return err } // 3. 制約チェックの有効化 if _, err := e.db.Exec( "SET foreign_key_checks = 1" ); err != nil { return err } return nil } 一見すると上から順番に実行されるため問題なさそうに見えますが、この実装は各クエリが別々のコネクションで実行される可能性を考慮できていませんでした。 原因:コネクションプールとセッション変数の仕様の違い 今回の問題は、MySQLのセッション変数の仕様と、Goのコネクションプールの挙動のミスマッチが原因でした。 MySQLのセッション変数 MySQLの SET foreign_key_checks はセッション変数であり、その設定は 現在のセッション(コネクション)内でのみ 有効です。別のコネクションから繋ぎ直した場合、デフォルトの設定(通常は有効)に戻ってしまいます。 Goの database/sql の挙動 Goの db.Exec() は、実行されるたびにコネクションプールから空いているコネクションを1つ取得し、クエリを実行し終えるとすぐにプールへ返却します。 つまり、 コード上で連続して db.Exec() を書いても、同じコネクションで実行される保証はどこにもありません。 内部では以下のように、コネクションのすれ違いが発生していました。 fig.1: コネクションが切り替わることで設定が引き継がれずエラーになるフロー なぜ今までエラーにならなかったのか? これまでの環境(Go 1.25.4)では、このコードでも特にエラーは起きていませんでした。しかしこれは仕様として保証されていたわけではなく、単なる 実行タイミングの偶然 でした。 これまでは、以下の流れがたまたま成立していました。 SET foreign_key_checks = 0 を実行。 使い終わったコネクションが即座にプールへ返却される。 直後の TRUNCATE でプールからコネクションを取得する際、たった今返却されたばかりのコネクション(設定変更済み)がそのまま使い回される。 このように、他に並行して走っているクエリがない限り、実質的に同じコネクションが連続して割り当てられやすい状態になっていたに過ぎません。 なぜ Go 1.26.0 で顕在化したのか? Go 1.26.0 では、ランタイムのパフォーマンスが大きく向上しています。特に、デフォルトで有効化された新しいガベージコレクタ Green Tea GC によるスキャン待ち時間の削減や、メモリアロケーションの高速化などが含まれています。 こうしたランタイムの最適化によって、プログラム全体の実行速度やゴルーチンの切り替わりといった、マイクロ秒単位のスケジュールタイミングが微妙に変化しました。 その結果、 SET クエリを実行してコネクションが完全にプールへ戻る前に、次の TRUNCATE の処理が走り出し、プール側がいま空いている別のコネクション(設定変更されていないもの)を割り当ててしまうケースが増加したと考えられます。 つまり、Goのバージョンアップによるバグではなく、 Goのランタイムが高速化・効率化されたことで、アプリケーション側に潜んでいた実装上の不備が表面化した というのが真相です。 解決策:sql.Conn を使ってコネクションを固定する 同じコネクションを使って一連のクエリを確実に実行するには、Go 1.9から導入された db.Conn(ctx) を使用します。これを使うことで、プールから特定のコネクションを明示的に取得(占有)できます。 修正後の実装 func (e *DBEngine) TruncateTable(ctx context.Context, tableName string ) error { // 1. コネクションを明示的に取得(チェックアウト)する conn, err := e.db.Conn(ctx) if err != nil { return err } // 使い終わったら必ずプールへ返却する defer conn.Close() // 2. 確保した同一のコネクション(conn)に対してクエリを実行する if _, err := conn.ExecContext(ctx, "SET foreign_key_checks = 0" ); err != nil { return err } // 同じコネクションなので、設定が反映された状態で実行できる if _, err := conn.ExecContext(ctx, "TRUNCATE TABLE " + tableName); err != nil { return err } if _, err := conn.ExecContext(ctx, "SET foreign_key_checks = 1" ); err != nil { return err } return nil } sql.Conn オブジェクトに対してメソッドを呼び出し、処理が終わった後に Close() を呼ぶことで、セッション変数の設定を維持したまま安全にクエリを実行できるようになります。 まとめ MySQLの SET 構文のようなセッション依存の設定を行う場合、単純な db.Exec() の連続呼び出し(コネクションプール任せ)にしてはいけません。必ず sql.Conn などを使い、明示的にコネクションを占有して処理を行う必要があります。 今回のケースのように、言語やランタイムのパフォーマンスが向上した結果、これまでたまたま動いていたコードの潜在的なバグが顕在化することがあるため、仕様を正しく理解して実装することの重要性を再認識しました。
目次 はじめに 2つの課題と、目指すアーキテクチャ 手法1 — UIKit の中に SwiftUI を埋め込む 手法2 — ViewModel の Protocol と実装の分離 手法3 — UIKit 依存の画面遷移を列挙型で集約する 手法4 — SwiftUI から UIKit の画面を呼ぶ 手法5 — 本体プロジェクトに依存する View コンポーネントを外から差し込む まとめ — 制約の中で前に進む はじめに デリッシュキッチンで iOS エンジニアをしている谷口恭一です。 デリッシュキッチンは今年で10年目を迎えるアプリです。この約1年間、2つの取り組みを並行して進めています。 SwiftUI 化 — UIKit で書かれた既存画面を SwiftUI に置き換える マルチパッケージ化 — 本体プロジェクトからコードを SPM パッケージに切り出す どちらも一括でやれるものではなく、通常の機能開発と並行しながら少しずつ進めるしかありません。本記事では、この2つの取り組みを同時に進めるために実践している 5つの手法 を紹介します。 2つの課題と、目指すアーキテクチャ 具体的な手法の話に入る前に、それぞれの課題と目指す方向を整理します。 SwiftUI 化の課題 多くの既存画面は UIKit と RxSwift で構成されています。新規の画面は SwiftUI で作っていますが、既存画面の SwiftUI 化はまだ道半ばです。 SwiftUI 化を進めるうえで最大の障壁は、ナビゲーションです。アプリ全体の画面遷移は UINavigationController の push/pop で成り立っています。SwiftUI には NavigationStack がありますが、アプリ全体のナビゲーションを一気に置き換えるのは現実的ではありません。画面数が多く、各画面の遷移ロジックが UINavigationController に深く依存しているためです。 また、各画面の ViewModel は API クライアント、永続化層、広告 SDK など、本体プロジェクトの様々なサービスに依存しています。UIKit の画面を単純に SwiftUI に書き換えるだけでは済まず、こうした依存関係をどう扱うかという設計上の判断が必要になります。 マルチパッケージ化の課題 一部のパッケージ化は進んでいるものの、まだ多くのソースコードが本体プロジェクト( .xcodeproj )のメインターゲットに含まれている状態です。依存管理は CocoaPods と SPM を併用しています。 本体プロジェクトのメインターゲットにコードが集中している構成には、チーム開発で厄介な問題があります。 ファイルを追加・削除するたびに .xcodeproj 内の project.pbxproj に差分が出て、ブランチ間のコンフリクトの原因になる ことです。 Xcode 16 で導入された フォルダベースのグループ管理 を使えば、ファイルシステムとプロジェクト構造が自動同期されるため、この問題は解消できます。しかし、CocoaPods で管理されている古い依存がフォルダベースのグループに対応しておらず、現時点ではフォルダへの移行がまだ行えません。 方針:SwiftUI 化のタイミングでパッケージにも切り出す この2つの課題は別々のものですが、同時に取り組むことで互いを補い合えます。 具体的には、 UIKit の画面を SwiftUI に書き換えるタイミングで、書き換えた SwiftUI のコードを本体プロジェクトに残すのではなく、 SPM(Swift Package Manager) の Feature パッケージに切り出す という方針を取っています。 こうすることで、SwiftUI 化によってコードが新しくなると同時に、パッケージへの移動によって本体プロジェクトのメインターゲットからコードが減っていきます。SPM パッケージ内のファイル操作は .xcodeproj に影響しないため、コンフリクト問題も根本的に回避できます。 本記事ではこの2つの場所を以下の用語で呼び分けます。 本体プロジェクト ( .xcodeproj のメインアプリターゲット) - UIKit の ViewController - ViewModel の実装クラス(各種サービスに依存) - Networking / Services / Repository Feature パッケージ (SPM で管理する独立したパッケージ) - SwiftUI の View - ViewModel の Protocol - Action の列挙型 - UI コンポーネント 依存の方向は当然 本体プロジェクト → Feature パッケージ の一方向です。 理想像:本体プロジェクトをエントリーポイントにする 最終的には、UI 層だけでなく、責務ごとに適切なパッケージへコードを隠蔽し、本体プロジェクトはそれらを組み合わせるエントリーポイントとしての役割に留めるのがゴールです。今回の Feature パッケージへの UI 層切り出しは、その最初の一歩にあたります。 前提:開発リソースの制約 なお、SwiftUI 化やパッケージ化だけに専念できる時期はありません。通常の機能開発・改善と並行して、できるところから少しずつ進めるしかないのが現実です。だからこそ、1画面ずつ、1コンポーネントずつ着実に移行していける手法が必要になります。 以下、5つの考え方とそれに対応する手法を順に紹介します。 手法1 — UIKit の中に SwiftUI を埋め込む 最も基本的な手法です。アプリ全体の画面遷移を一気に NavigationStack に置き換えるのは現実的ではありません。そこで、 画面遷移は UIKit のまま諦めて、1画面ずつ中身だけを SwiftUI に置き換えていく という考え方を取ります。 Feature パッケージ側では、純粋な SwiftUI View を定義するだけです。 public struct FeatureView < VM : FeatureViewModel >: View { @ObservedObject var viewModel : VM var body : some View { ... } } 本体プロジェクト側では、この SwiftUI View を UIHostingController 経由で既存の UIKit 画面に埋め込みます。 let hosting = UIHostingController(rootView : FeatureView (viewModel : viewModel )) addChild(hosting) view.addSubview(hosting.view) hosting.didMove(toParent : self ) ナビゲーション階層には一切手を加えないため、 影響範囲がその画面だけ に限定されます。既存のナビゲーションバーの設定や画面遷移ロジックをそのまま活用できます。 なお、シートやフルスクリーンカバーによるモーダル表示は、ナビゲーションの push/pop 階層とは独立しています。したがって、ホスティングされた SwiftUI View の中で .sheet() ) や .fullScreenCover() ) を使ったモーダル遷移は、完全に SwiftUI 内で完結できます。 手法2 — ViewModel の Protocol と実装の分離 SwiftUI View を Feature パッケージに移すとき、最初にぶつかるのが ViewModel の依存関係です。ViewModel の実装クラスは API クライアントや永続化層など、本体プロジェクトの様々なサービスに依存しています。これをそのままパッケージに持っていくことはできません。 解決策は、ViewModel を Protocol(インターフェース)と実装に分離 することです。 Feature パッケージには Protocol だけを置きます。 @MainActor public protocol FeatureViewModel : ObservableObject { var items : [ Item ] { get } var isLoading : Bool { get } func fetch () async } public struct FeatureView < VM : FeatureViewModel >: View { @ObservedObject var viewModel : VM } SwiftUI View はジェネリクスで ViewModel Protocol に依存し、具象型を知りません。 実装は本体プロジェクトに残します。 final class FeatureViewModelImpl : FeatureViewModel { @Published private ( set ) var items : [ Item ] = [] @Published private ( set ) var isLoading = false private let service : ItemService func fetch () async { isLoading = true items = ( try ? await service.fetchItems()) ?? [] isLoading = false } } サービス層への依存は本体プロジェクトの実装クラスに閉じ込められ、Feature パッケージは Protocol が定義するインターフェースだけを相手にします。 そして、この2つを繋ぐのが本体プロジェクトの UIKit ViewController です。手法1と組み合わせて、実装クラスを生成し SwiftUI View に渡します。 final class FeatureViewController : UIViewController { override func viewDidLoad () { super .viewDidLoad() let viewModel = FeatureViewModelImpl(service : .shared) let hosting = UIHostingController(rootView : FeatureView (viewModel : viewModel )) addChild(hosting) view.addSubview(hosting.view) hosting.didMove(toParent : self ) } } Feature パッケージの FeatureView は FeatureViewModel Protocol しか知りませんが、本体プロジェクトが具象型 FeatureViewModelImpl を生成して渡すことで、依存関係が解決されます。 Protocol の定義はパッケージに、実装の生成は本体プロジェクトに — この役割分担がパッケージ境界を成立させます。 この分離のもう一つの利点は、 将来の拡張性 です。Service 層や Repository 層のパッケージ化が進めば、ViewModel の実装クラスもいずれ Feature パッケージに移すことができます。今の時点では Protocol と実装を分けておくことで、将来その選択肢を確保しておけるということです。 手法3 — UIKit 依存の画面遷移を列挙型で集約する UI 層が Feature パッケージに移ると、次の問題が浮上します。SwiftUI の画面からユーザーが「詳細を見る」「検索画面を開く」といった操作をしたとき、その遷移先がまだ本体プロジェクトに UIKit で実装されたままのケースです。依存の方向は本体プロジェクト → Feature パッケージの一方向なので、Feature パッケージから本体プロジェクトの画面を直接呼ぶことはできません。 このギャップを埋めるために、Feature パッケージで Action 列挙型 を定義し、本体プロジェクトのクロージャで処理します。 public enum FeatureAction { case showDetail(Item) case showSearch case showSettings } public protocol FeatureViewModel : ObservableObject { var actionHandler : (( FeatureAction ) -> Void ) ? { get set } } Feature パッケージが知っているのは「こういうアクションが起きうる」という列挙型の定義だけです。それをどう処理するかは、本体プロジェクトに委ねます。 viewModel.actionHandler = { [ weak self ] action in switch action { case .showDetail( let item ) : self? .navigationController?.pushViewController(DetailVC(item : item ), animated : true ) case .showSearch : SearchVC.present (from : self ) case .showSettings : SettingsVC.push (from : self ) } } UIKit に依存する処理はこの switch 文の中に集約されます。新しい遷移が増えたら enum にケースを追加し、 switch にハンドリングを書くだけです。遷移先が SwiftUI 化されたら、対応する case の処理を SwiftUI 内の .navigationDestination ) 等に移せばいい。enum の associated values によって遷移に必要なパラメータが型安全に保証されるため、実行時エラーのリスクも低くなります。 なお、このアクションハンドリングの処理を ViewController に直接書くのではなく、Coordinator に切り出して責務を閉じ込めるという選択肢もあります。画面遷移のパターンが多い画面では、そちらのほうが見通しが良くなるかもしれません。 手法4 — SwiftUI から UIKit の画面を呼ぶ 手法3は、主にナビゲーション(push)ベースの画面遷移で機能します。一方で、SwiftUI の .fullScreenCover() や .sheet() によるモーダル遷移は事情が異なります。 シートやフルスクリーンカバーによるモーダル遷移は、ナビゲーションの push/pop 階層から独立しています。つまり、理想的にはモーダルで表示する画面とその先をまるごと SwiftUI 化できる領域です。しかし現実には、 .fullScreenCover() の遷移先にまだ UIKit の画面が残っていることがあります。 ここでの考え方は、 遷移先が UIKit のままでも、呼び出し元の SwiftUI 化を止めない ということです。UIKit の画面を UIViewControllerRepresentable でラップして SwiftUI から呼べるようにし、UIKit → SwiftUI → UIKit という「サンドイッチ」構造を移行の過渡期として許容します。 具体的な手法は3つのステップで構成されます。 UIKit 画面を Representable でラップする まだ SwiftUI 化されていない UIKit の画面を、 UIViewControllerRepresentable で薄くラップします。 extension LegacyDetailViewController { struct Representable : UIViewControllerRepresentable { let item : Item func makeUIViewController (context : Context ) -> LegacyDetailViewController { . init (item : item ) } func updateUIViewController (_ vc : LegacyDetailViewController , context : Context ) {} } } このラッパーは本体プロジェクトに置きます。 Feature パッケージは遷移先を外から受け取る ここが最も重要なポイントです。Feature パッケージの SwiftUI View は、遷移先の具体的な画面を自分では持たず、 ジェネリクスの @ViewBuilder クロージャ として外部から受け取ります。 public struct FeatureRootView < Destination : View >: View { @ViewBuilder let detailDestination : ( Item ) -> Destination var body : some View { content .fullScreenCover(isPresented : $viewModel .showDetail) { if let item = viewModel.selectedItem { NavigationStack { detailDestination(item) } } } } } Destination: View というジェネリクス制約だけがあり、具体的にどんな View(あるいは Representable)が来るかは知りません。Feature パッケージは本体プロジェクトのレガシー画面に一切依存していません。 本体プロジェクトで Representable を注入する 組み立ては本体プロジェクトが担当します。 let rootView = FeatureRootView( viewModel : viewModel , detailDestination : { item in LegacyDetailViewController.Representable(item : item ) } ) present(UIHostingController(rootView : rootView ), animated : true ) レガシーな UIKit 画面への依存があるのは、この注入の一箇所だけです。 この構造の大きな利点は、 遷移先が SwiftUI 化されたときの変更が最小限 で済むことです。注入するクロージャの中身を差し替えるだけで、Feature パッケージのコードには一切触れる必要がありません。 detailDestination : { item in NewDetailView(item : item ) } サンドイッチ構造はあくまで移行の過渡期のものであり、最終的には UIKit の層が消えて自然な SwiftUI のコードになります。 いつこの手法を使うか この手法を使わず「遷移先もすべて SwiftUI 化してからでないと手を付けられない」と考えてしまうと、SwiftUI 化できる範囲がなかなか広がりません。たとえば画面 A から .fullScreenCover() で画面 B を表示していて、画面 B がまだ UIKit だとします。画面 A の SwiftUI 化は画面 B の完了を待つことになり、画面 B にも UIKit の遷移先があれば、さらにその先を待つ…と連鎖してしまいます。 ただし、すべてのモーダル遷移にこの手法を適用すべきというわけではありません。遷移先の UIKit 画面が少数であればラップして先に進めるメリットがありますが、遷移先の大半が UIKit であれば、ラップのコストが見合わないのでその画面の SwiftUI 化自体を後回しにする判断もありえます。ラップにかかるコストと、SwiftUI 化を先に進められるメリットを天秤にかけて判断することが重要です。 手法5 — 本体プロジェクトに依存する View コンポーネントを外から差し込む 手法1〜4で多くの UI を Feature パッケージに移せますが、もう一つ厄介なケースがあります。 画面の一部に、本体プロジェクトの依存がないと成立しないコンポーネント が含まれている場合です。 典型的な例がインフィード広告です。広告の表示には広告 SDK への依存が必要ですが、これは本体プロジェクトにしかありません。だからといって、広告を含む画面全体を Feature パッケージに移せないとなると、移行が大きく停滞してしまいます。 考え方は手法4と同じです。Feature パッケージ側では「ここに何かの View が入る」というジェネリクスの枠だけを定義し、具体的な実装は本体プロジェクトから差し込みます。 Feature パッケージの View は、広告コンポーネントの表示位置をジェネリクスの @ViewBuilder パラメータとして受け取ります。 public struct FeatureTabView < VM : FeatureTabViewModel , AdContent : View >: View { @ObservedObject var viewModel : VM private let adContent : () -> AdContent public init ( viewModel : VM , @ViewBuilder adContent : @escaping () -> AdContent ) { self .viewModel = viewModel self .adContent = adContent } public var body : some View { ScrollView { LazyVStack(spacing : 8 ) { SomeSection(viewModel : viewModel ) adContent() AnotherSection(viewModel : viewModel ) } } } } Feature パッケージはこの AdContent が何であるかを一切知りません。広告でもプレースホルダーでも EmptyView でも構わないという設計です。 本体プロジェクト側では、広告 SDK に依存する具体的な View を差し込みます。 let view = FeatureTabView(viewModel : viewModel ) { InFeedAdSectionView(adType : .infeed) } let hosting = UIHostingController(rootView : view ) InFeedAdSectionView は本体プロジェクトにあり、内部で広告 SDK を使って広告をロード・表示します。Feature パッケージにはこの View の存在も広告 SDK の存在も見えていません。 この手法のポイントは、 1つのコンポーネントが Feature パッケージに移せないからといって、画面全体のパッケージ移行を諦めない ということです。移せない部分だけを抽象化して外から差し込めば、画面の大部分は Feature パッケージに移すことができます。 まとめ — 制約の中で前に進む 本記事で紹介した5つの手法を整理します。 手法 主に解決する課題 いつ不要になるか 手法1: UIKit に SwiftUI を埋め込む SwiftUI 化 NavigationStack 全面移行時 手法2: ViewModel の Protocol / 実装分離 マルチパッケージ化 Service 層のパッケージ化完了時 手法3: UIKit 依存処理の列挙型集約 マルチパッケージ化 UIKit 画面の SwiftUI 化完了時 手法4: SwiftUI から UIKit を呼ぶ SwiftUI 化 遷移先の SwiftUI 化完了時 手法5: 依存のあるコンポーネントを外から差し込む マルチパッケージ化 依存のパッケージ化完了時 重要なのは、 すべての手法に「不要になる日」がある ということです。これらは最終的なアーキテクチャではなく、移行期を乗り越えるための手法です。 これらの手法を組み合わせることで、通常の機能開発と並行しながら少しずつ SwiftUI 化とマルチパッケージ化を進められています。一気に大きな時間を確保しなくても、1画面ずつ、1コンポーネントずつ着実に移行を進めていける実感があります。 大規模アプリの SwiftUI 移行は、短距離走ではなくマラソンです。今日の制約の中で最善の一歩を選び、長い戦略スパンで着実に前に進めていく。本記事がその一助になれば幸いです。
Go 1.26で追加されたnew(expr)はなぜこの形なのか こんにちは、開発1部の @uho-wq です。 本記事ではGo 1.26で追加された new(expr) がどのような議論の末にこの形に落ち着いたのかを説明しようと思います。 go.dev new(expr) Go 1.26で、組み込み関数 new が式(expression)を受け取れるようになりました。 p := new ( 42 ) // *int, 値は42 s := new ( "hello" ) // *string, 値は"hello" b := new ( true ) // *bool, 値はtrue とてもシンプルな構文追加に思えますが、実はこの結論に至るまで2014年から2025年までの 11年 もかかりました。 この記事では、以下の2つのissueをもとに議論の流れを追っていきます。 github.com github.com ※ この記事を作成するにあたり、これらのissueに付いたコメントすべてに目を通しました。11年分の議論は非常に膨大なため本記事では要点を絞って紹介しており、解釈の違いや抜け漏れがある可能性がありますがご了承ください。 そもそも何が問題だったのか Goではcomposite literalは直接ポインタを取得できますが、プリミティブ型は宣言時にポインタを得ることができません。 p := &Point{X: 1 , Y: 2 } // OK: composite literalは&を取れる p := & 42 // コンパイルエラー: cannot take address of 42 よって従来では以下のように一度変数に代入してポインタを得る書き方をするか、ヘルパー関数を定義するしかありませんでした。 v := 42 p := &v // ヘルパー関数 func IntPtr(v int ) * int { return &v } 例えば、AWS SDK for Goでは aws.String() 、 aws.Int64() といった ヘルパー関数 が大量に定義されています。構造体の値をaws.String()で囲むといった作業はAWS SDK for Goを使ったことがある方は経験済みなのかなと思います。 Go 1.18でGenericsが導入されたことによって、ヘルパー関数を汎用的に記述することができるようになりました。 func Ptr[T any](v T) *T { return &v } しかし、これもcomposite literalのみ直接ポインタを取れるという問題の回避策にはなりましたが、根本解決には至りませんでした。 こうした背景から、言語レベルでの解決策が長年にわたって議論されてきました。以降では、その議論がなぜ最終的に new(expr) という形に落ち着いたのかを時系列で追っていきます。 proposal: spec: add &T(v) to allocate variable of type T, set to v, and return address #9097 2014年11月にchai2010氏により最初の提案が行われました。 提案は、以下の2つの構文を追加する、というものでした。 new 関数の拡張: func new(Type, value ...Type) *Type &Type(value) 構文の追加 例: px := new ( int , 9527 ) px := & int ( 9527 ) 当初は大きな反響もなくissueは放置されていましたが、2018年にIan Lance Taylor氏が 提案に再度言及 しました。 &int(5) を許すなら new(int, 5) は不要であり、 new を完全に削除することすら検討すべき だと述べました。そして任意の式に & を適用する際の問題点を2つ指摘しています。 1つ目は任意の式 v に対して &v を取れるとした場合、論理的にはアドレスのアドレス &&v を取れるべきだが、 && は異なる意味を持つ演算子なので動作しない 2つ目は &var はループ内で呼び出しても毎回同じ値に解決されるが、 &expr は毎回新しいインスタンスを確保するので異なる値に解決される また2020年には、Ian Lance Taylor氏自身が「ジェネリクスが入れば新しい言語機能を必要としないので、ジェネリクスを得るまで待って、そのようなアプローチが十分かどうかを見たいと思う」とも 述べています 。 結局#9097は2023年8月に#45624を優先する形でクローズされました。9年間で40件のコメントが寄せられ、Ian Lance Taylor氏が提示した論点は#45624でも継続して議論されます。 spec: expression to create pointer to simple types #45624 2021年4月にRob Pike氏によってissueが立てられました。 Pike氏はissueを再オープンする代わりに、新たに2つの選択肢を提示しました。 Option 1: new に第2引数を追加する p1 := new ( int , 3 ) p2 := new ( rune , 10 ) p3 := new (Weekday, Tuesday) Option 2: 型変換の結果をアドレス可能にする p1 := & int ( 3 ) p2 := & rune ( 10 ) p3 := &Weekday(Tuesday) Pike氏は「両方入れてもいいかもしれない」とも述べています。 注目すべきは、この時点では最終形となる new(expr) はまだ提案されていなかったということです。Pike氏の提案はあくまで new(T, v) (型と値の2引数)と &T(v) の2択でした。 new(1) の提案 (2021年4月) Pike氏の提案から数日後、Go Teamの Russ Cox氏のコメント が多くの賛同を得ました。 The overloading of & for "take address of existing value" and "allocate copy of composite literal" has always been unfortunate. An alternative to expanding the overloading of & would be to overload new instead, so that it is the generic ptrTo function as well as the original new(T), as in new(1). Then &T{...} can be explained retroactively as mere syntactic sugar for new(T{...}). & 演算子は既に「既存の値のアドレスを取得する &v 」と「composite literalのコピーを割り当てる &T{...} 」という2つの異なる意味を持っています。ここにさらに意味を追加するのではなく、 new を拡張して new(1) のように書けるようにすべきではないか。そうすれば &T{...} は new(T{...}) の糖衣構文として説明できる、という主張です。 これが最終形 new(expr) の原型でした。 ジェネリクスの提案 (2021年4月) 一方でRoger Peppe氏は 言語変更そのものに異を唱えました 。 Given this possibility, I don't see that there's any need to change new or the language syntax itself to accommodate this functionality. Goのジェネリクスを使えば以下のように書けるのだから、newや言語仕様自体を変える必要はないのでは、というものです。 // ref returns a pointer to the value of t. func ref[T any](t T) *T { return &t } このジェネリクス案は、その後4年にわたって繰り返される反論の原型となりました。 膠着状態 (2021年9月) 2021年9月、Ben Hoyt氏が 議論の停滞を指摘 し、再検討を求めました。 Looks like this was last discussed in the proposal review meeting on May 5. While there's no clear consensus here, there are a number of good options. It seems like there's a fair bit of enthusiasm for Russ's simple new(1) form, and a decent amount of support for a new builtin generic function like Roger Peppe's ptr(1) suggestion. My vote would be for ptr(1) as it just uses "ordinary" generics, but I like new(1) too. Could this be discussed at the review meetings again? この時点で支持が集まっていたのはnewの拡張である new(1) とジェネリクスを使用した ptr(1) の2案でしたが、コンセンサスには至りませんでした。ジェネリクスの正式リリース(Go 1.18、2022年3月)を待つ形で、議論は一時休止に入ります。 PtrTo[T any] vs &T(v) vs new の拡張 (2023年6月) 2023年6月、Go TeamのIan Lance Taylor氏がissueに戻り、 選択肢を3つに絞りました 。 PtrTo[T any] のような標準ライブラリ関数 &T(v) 構文 new(v) / new(T, v) の拡張 そしてGo Teamの立場を明確にしました。 @griesemer, @bradfitz, and @ianlancetaylor prefer permitting both new(v) and new(T, v) . この時点では、Go Teamの主要メンバー3人が new 拡張を支持していました。 ただし new(v) と new(T, v) の 両方 を許可する案であり、 new(v) 単独ではありませんでした。 また、依然として &T(v) を支持する声はあったものの、批判的な意見も支持されるようになってきました。Ben Hoyt氏の 主張 が端的に示しています。 I slightly prefer new(v) over &T(v) because it eliminates stuttering in cases like new(time.Now()) -- that would be &time.Time(time.Now()) with the other syntax. If new(T, v) is supported in addition for clarity in certain cases, that's fine. new() is also a bit clearer that it always creates a "new" thing. new(time.Now()) のようなケースだと冗長な繰り返しがなくなりますが、&T(v)の構文だと &time.Time(time.Now()) になってしまいます。明確さが必要な場合に new(T, v) が追加でサポートされるのは問題にはならず、new() は常に「新しい」ものを作成することがより明確である、と主張しています。 さらにHoyt氏も &演算子が概念的に同等でないこと も指摘しています。 When you do &Struct{} Go creates a new value every time and returns its address, but when you do &s Go returns the address of that same variable each time. &Struct{}を行うと、Goは毎回新しい値を作成してそのアドレスを返しますが、&sを行うとGoは毎回その同じ変数のアドレスを返します。 この後も &T(v) 案は依然として支持されるものの、議論の焦点はnewの拡張方法とジェネリクスの活用に移っていきます。 new(T, v) vs new(v) (2023年7月) Goチームが支持しているnewの拡張方法は new(T, v) と new(v) の2パターンありました。 2023年7月、Ian Lance Taylor氏が 方針転換を報告 しました。Rob Pike氏とRoger Peppe氏などから「 new(v) と new(T, v) の両方ではなく、 new(T, v) のみにすべき」という意見が出ました。 また型名が長くなるケースの大半は構造体であり、構造体には既に &S{} 表記があります。単純な値 v に対して複雑な型 T を書く new(T, v) のケースはそもそもほとんど発生しないと考え、 new(T, v) でも混乱を招くことは少ないだろう、という見解を示しています。 これに対してMerovius氏が 具体例 で切り返しました。 new(int64(42)) isn't any more to type or read than new(int64, 42), but new(time.Second) is significantly better than new(time.Duration, time.Second). I don't think having the type in there really adds anything. We are already kind of used to inferring the type from a constant literal. new(int64(42)) は new(int64, 42) と比べてタイプ量も読む量も変わりませんが、 new(time.Second) は new(time.Duration, time.Second) よりもはるかに良いです、と述べています。 このコメントが賛同を集めた一方で、 new(v) を見たときに v が型なのか値なのかを読者が把握している必要があるのでnew(v)を好まない、という意見も複数ありました。 new(T, v) は書き方として冗長である一方で明確に記述でき、 new(v) は書き方として簡潔である一方で表現として曖昧であるとし、この時点ではコンセンサスには至りませんでした。 ジェネリクスの限界 (2023年 - 2024年) 「ジェネリクスで Ptr[T] が書ける」という反論は依然として主張されていました。 しかし2023年12月、Rob Pike氏が 改めてこの問題の本質を言い直しています 。 it's easier to build a pointer to a complex thing than to a simple one. 「複雑な構造体へのポインタは &T{...} で簡単に作れるのに、単純な int へのポインタは面倒」 ジェネリクスを用いたヘルパー関数を書くことは、この非対称性の問題の根本的な解決にはなっていないと言及しています。 ジェネリクスが根本の解決になっていないとするエピソードとして、perj氏の 体験談 が象徴的でした。 I appear to be writing this function about once every second month, when I need it in a new package. It's not very annoying, but does feel a bit like I'm littering my packages with this function, so not having to write it would be welcome. I do realise I can put it in a package I import, but that also seems overkill for a one-liner. 2ヶ月に1度、新しいパッケージでこのヘルパー関数を書いている パッケージをこの関数で散らかしているような感じがするので、書かなくて済むなら歓迎 importするパッケージに入れることもできるが、たった1行のコードのためにそれをするのはやりすぎな気がする このコメントは20ものGood評価を集めており、ジェネリクス案の限界を端的に示しているといえます。 new(T, v) は解決策にならない (2025年3月-8月) 2025年3月、かつて「ジェネリクスで十分」と主張していたRoger Peppe氏が、 new(T, v) 案に対して 批判 を投じました。 Replacing, for example, ref(someMap[x]) with new(SomeType, someMap[x]) would be a net loss because it makes the code more verbose and a little bit more fragile, requiring update should the type of the map's values change. ref(someMap[x]) を new(SomeType, someMap[x]) に書き換えるのはコードが冗長になるだけでなく、mapの値の型が変わるたびに修正が必要になる。 型を2回書く new(T, v) では、ジェネリクスのヘルパー関数からの移行メリットがない、という指摘です。 その後、2025年8月にGo TeamのAlan Donovan氏が 決めてとなるコメント を投じました。 it is important not to have to redundantly state the type and the value, making new(T, v) a non-solution. 型と値を冗長に並べる必要がないことが重要であり、 new(T, v) は解決策にならない、と主張し、Donovan氏は3月のPeppe氏のコメントに納得して new(value) を支持する立場を表明しました。 デフォルトの型が合わない場合は new(T(v)) とキャストを組み合わせればよく、 new(T, v) のような複雑なルールは不要だ、としています。 The proposal committeeの承認 (2025年8月) 2025年8月15日、The proposal committeeを代表してAustin Clements氏が 宣言 しました。 The proposal committee is happy with new(expr) . new(T) (型を渡す)と new(expr) (式を渡す)は動作が異なり、構文的な曖昧さを欠点として持つものの、どちらも「新しいストレージを確保して返す」点で一貫しています。 そしてDonovan氏が 収集したデータ が決め手となりました。 the data @adonovan collected indicates that, while this can be written as a generic function, there are so many instances that it seems well-worth a standardized built-in. ジェネリクスを用いた関数として記述することも可能ですが、その利用箇所が非常に多いため、標準化された組み込み関数として実装する価値は十分にある、としています。 Accepted (2025年9月) 2025年9月17日、Austin Clements氏が 正式に採択を宣言 しました。 そして2025年10月27日、実装を完了したAlan Donovan氏が issueを締めくくりました 。 All done, in only eleven years since #9097. ;-) Go 1.26での仕様 The Go Programming Language Specification では、 new は以下のように定義されています。 The built-in function new creates a new, initialized variable and returns a pointer to it. It accepts a single argument, which may be either an expression or a type. 引数が型 T の式(または、デフォルト型が T のuntyped定数式)である場合、 new(expr) は型 T の変数を確保し、 expr の値で初期化し、そのアドレス(型 *T の値)を返します。 type Config struct { Timeout *time.Duration Retries * int Verbose * bool } cfg := Config{ Timeout: new ( 30 * time.Second), Retries: new ( 3 ), Verbose: new ( true ), } 関数の戻り値も渡せます。 p := new (time.Now()) // *time.Time q := new (strconv.Itoa( 42 )) // *string 注意点: untyped constantの挙動 ただし1つ注意点があります。 new() に定数を渡した場合、default typeが使われます。 var ui uint = 10 // OK: untyped constant 10はuintに暗黙変換される // しかし... uip := new ( 10 ) // *int(10のdefault typeがint) var ui2 uint = *uip // コンパイルエラー: cannot use *uip (type int) as type uint 定数 10 がそのまま変数宣言で使われる場合はuntyped constantとして柔軟に型推論されますが、 new(10) の時点で *int に確定してしまいます。明示的な型が必要な場合は型変換を組み合わせましょう。 uip := new ( uint ( 10 )) // *uint まとめ 最後に、11年の議論で登場した各提案の結論についてまとめます。 提案 結論 &T(v) & 演算子の意味の不連続性。 &2 は毎回新しいアドレスを返すが &v は同じアドレスを返す。混乱を招く ref(v) / ptr(v) ジェネリクスで1行で書ける。だが逆に「全員が書いている」。組み込みとして標準化する方が合理的 new(T, v) 冗長。 new(time.Duration, time.Second) はジェネリクスの ref(time.Second) より後退する new(expr) 採用。 & のセマンティクスを変えず、既存の new 関数の自然な拡張 個人的には、議論全体を通して new(expr) という結論に至ったことがとても腑に落ちました。ジェネリクスの導入を見越して一度議論を止め、導入後も便利さに飛びつかず実運用の課題を吸い上げた上で、本質的な解決策に辿り着いています。 最終形の new(expr) は、2021年にRuss Cox氏が投じた new(1) の発想そのものでした。4年の間に &T(v) や new(T, v) が検討され、結局最もシンプルな案に戻ってきたのが面白いなと思いました。