TECH PLAY

株式会社エブリー

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

439

目次 はじめに 背景と課題 方式の検討 全体アーキテクチャ 認証・認可フロー クライアントが認可サーバーを発見するまで トークンの取得 ゲートウェイでの JWT 検証 ゲートウェイの実装ポイント 設定ファイルによるバックエンド管理 検証済みユーザー情報の伝搬となりすまし防止 起動時の OIDC Discovery による fail-fast モック認可サーバーによるローカル開発 インフラ構成 社内での活用事例 今後の課題 おわりに はじめに こんにちは。 開発本部開発3部トモニテ開発部所属の庄司( @ktanonymous )です。 エブリーの開発組織では、日常業務から離れて新しい技術やアイデアに挑戦する「挑戦week」という取り組みを定期的に開催しています。 先日行われた挑戦weekの中で、私たちのチームは全社共通で利用できるリモート MCP ( Model Context Protocol ) サーバー向けの認証・認可ゲートウェイを設計・実装しました。 本記事では、その全体アーキテクチャや認証・認可フロー、実装のポイントなどを紹介したいと思います。 ※ 挑戦weekの詳細については過去の記事で紹介していますので、興味のある方は以下をご覧ください。 tech.every.tv 背景と課題 弊社では非エンジニア職にも Claude が配布され、職種を問わず AI 活用が盛んになってきています。 業務の中で Claude を利用するにあたり、社内情報の取得のために社内 API へ接続したいという需要が高まってきていると感じました。 今後、その手段として各チームがリモート MCP サーバーを立てるケースが増えていくと考えられます。 リモート MCP サーバーはインターネット経由でアクセスされるため、社外の人間が利用できないように認証・認可を考慮する必要がありました。 一方で、MCP サーバーを実装するたびに各チームが認証・認可を設計・実装するのはコスト面でも効率面でも避けたいものです。 そこで、共通で利用できる認証・認可基盤を作り、各チームの MCP サーバーはそれぞれのツールの実装に専念できるようにすることを目指しました。 今回検討した要件は以下の通りです。 Claude (Web / Desktop / Code) などの各種 MCP クライアントから、社内のリモート MCP サーバーのツールを利用できる 弊社の Google Workspace アカウントでログイン済みのユーザーのみがツールを利用できる 各チームが新しく MCP サーバーを追加するとき、認証・認可の実装を不要にする 方式の検討 認証・認可の共通化にあたり、大きく分けて以下 3 つのアプローチを検討しました。 ライブラリ方式: 認証・認可処理を共通ライブラリとして実装し、各 MCP サーバーに組み込む サーバー方式: 認可サーバーを立て、各 MCP サーバーがトークンを問い合わせることで検証する ゲートウェイ方式: ゲートウェイがリクエストを一括で受けて認証・認可を行い、検証済みのリクエストだけを後段の MCP サーバーへ転送する 3 つの内、ライブラリ方式は MCP サーバーの実装言語ごとにライブラリを用意する必要があり、 サーバー方式は、責務こそ分離できるものの、各 MCP サーバー側に認可サーバーへトークンを問い合わせる実装が必要になります。 一方、ゲートウェイ方式であれば、認証・認可をゲートウェイに一元化でき、後段の MCP サーバーは実装言語を問わず認証・認可も意識せずに済みます。 近年の MCP ゲートウェイ製品の設計とも方向性が近いことから、今回はゲートウェイ方式を採用しました。 なお、 mcp-context-forge や agentgateway といった既存の OSS MCP ゲートウェイも調査しましたが、 今回の要件に対して機能・ボリュームが大きすぎたため採用を見送り、必要最小限のゲートウェイを実装することにしました。 全体アーキテクチャ 全体のアーキテクチャは以下の通りです。 アーキテクチャ概要 今回実装したゲートウェイは大きく 4 つの要素から構成されています。 MCP クライアント: Claude Code / Claude Desktop など。OAuth 2.0 のパブリッククライアントの立ち位置 ゲートウェイ: ECS Fargate 上で稼働する Go (Echo) 製のリバースプロキシ。JWT の検証と後段 MCP サーバーへのルーティングを担う。クライアントから直接見える唯一の MCP サーバー。 Amazon Cognito: 認可サーバー兼 IdP。Google Workspace アカウントへのフェデレーションを行い、アクセストークン (JWT) を発行する MCP サーバー群: 各チームが実装するリモート MCP サーバー。private subnet に配置し、ゲートウェイ経由でのみアクセス可能であり、クライアントから直接的には見えていない 意識した点として、JWT を検証するのはゲートウェイだけという点があります。 後段の MCP サーバーは JWT 検証を実装せず、ゲートウェイが注入する検証済みのユーザー情報 (後述の X-Auth-* ヘッダー) を信頼します。 その前提を成立させるため、MCP サーバーはインターネットに直接公開せず、ゲートウェイからのみ到達できるネットワーク構成としました。 認証・認可フロー MCP の認可は MCP Authorization 仕様 で定義されており、 OAuth 2.1 をベースに、PRM (RFC 9728) などの仕様を組み合わせて構成されています。 今回のゲートウェイもこの仕様に沿って実装しています。 具体的には以下のようなフローとなっています。 認証・認可フロー クライアントが認可サーバーを発見するまで MCP クライアントには MCP サーバーの接続先を登録するため、認可サーバーがどこにあるかを予め知ることはできません。 クライアントがトークンなしでアクセスすると、ゲートウェイは 401 Unauthorized を返し、 WWW-Authenticate ヘッダーの resource_metadata パラメータで Protected Resource Metadata (PRM) の URL を通知します。 HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://mcp-gateway.example.com/.well-known/oauth-protected-resource" PRM は、保護されたリソース (今回はゲートウェイ) が「どの認可サーバーに保護されているか」「どのスコープをサポートするか」といった自身のメタデータを公開するための仕様です。 クライアントはこのメタデータを参照することで、トークンの取得先を機械的に発見できます。 クライアントがこの URL にアクセスすると、PRM の仕様で定義された以下のような JSON が返ります。 { " resource ": " https://mcp-gateway.example.com/ ", " authorization_servers ": [ " https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id> " ] , " scopes_supported ": [ " openid ", " email ", " profile " ] , " bearer_methods_supported ": [ " header " ] } クライアントは authorization_servers から認可サーバー (Cognito) を発見し、 さらに Cognito の Authorization Server Metadata ( /.well-known/openid-configuration ) を取得して、 認可エンドポイントやトークンエンドポイントを把握します。 Authorization Server Metadata は、認可サーバーが「どこで認可リクエストやトークン発行を受け付けるか」「どの機能をサポートするか」といった自身の設定情報を公開するための仕様です。 Cognito の場合、以下のような JSON が返ります (主要なフィールドのみ抜粋)。 { " issuer ": " https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id> ", " authorization_endpoint ": " https://<domain>.auth.ap-northeast-1.amazoncognito.com/oauth2/authorize ", " token_endpoint ": " https://<domain>.auth.ap-northeast-1.amazoncognito.com/oauth2/token ", " jwks_uri ": " https://cognito-idp.ap-northeast-1.amazonaws.com/<user-pool-id>/.well-known/jwks.json ", " scopes_supported ": [ " openid ", " email ", " phone ", " profile " ] } この 2 段階のメタデータ取得により、クライアント側に認可サーバーの情報を事前設定することなく、OAuth フローを開始することができます。 トークンの取得 認可サーバーの発見後は、通常の OAuth 2.0 Authorization Code フロー (PKCE 付き) です。 クライアントがブラウザを開いて Cognito の Hosted UI に遷移し、Cognito は Google へフェデレーションします。 ユーザーが自社の Google Workspace アカウントでログインすると、クライアントはアクセストークン (JWT) を取得します。 Cognito 側の設定のポイントは以下の通りです。 パブリッククライアント + PKCE 必須: Claude などの MCP クライアントは利用者の手元で動くため、クライアントシークレットを保持できません。そこでパブリッククライアントとして登録し、PKCE を利用するようにします。 アプリクライアントの事前登録: Cognito では接続元のアプリケーションを「アプリクライアント」として登録します。MCP クライアントごとにユーザープールを登録し、対応するコールバック URL (認可コードの返却先) を設定することで事前に利用するクライアントを登録します。 なお、MCP Authorization 仕様では、クライアントの登録方法として事前登録のほかに、 Client ID Metadata Documents (URL を client_id として扱い、認可サーバーがその URL からクライアント情報を取得する方式) や Dynamic Client Registration (RFC 7591) による動的登録も定義されています。 今回は、Cognito がこれらに対応していないことと、社内利用ではクライアントの種類が限られる (基本的に Claude 系のみ) ことから、仕様でも正規の選択肢とされている事前登録制を採用しました。 また、Cognito のアクセストークンには標準では email クレームが含まれないため、 Pre Token Generation Lambda トリガー を利用して、トークン生成時に email クレームを注入しています。 これにより、後段の MCP サーバーが「誰からのリクエストか」をメールアドレスで判定できるようになります。 ゲートウェイでの JWT 検証 ゲートウェイは、リクエストごとに JWT を検証します。 署名検証には github.com/coreos/go-oidc (v3.18.0) を利用し、 Cognito の JWKS (JSON Web Key Set) は初回取得後にキャッシュされます。 検証項目は以下の通りで、署名・有効期限といった基本的な検証に加えて、クレームベースのチェックを重ねています。 検証項目 内容 失敗時 署名 / iss / exp JWKS による署名検証、発行者・有効期限の確認 401 token_use "access" であること (ID トークンの誤用防止) 401 client_id 事前登録したアプリクライアントの許可リストに含まれること 403 email ドメイン エブリードメインであること 403 scope 必須スコープを満たすこと (設定時のみ) 403 token_use の検証は Cognito 固有のポイントです。 Cognito は ID トークン用とアクセストークン用にそれぞれ別の署名鍵を持ちますが 1 、両方の公開鍵が同一の JWKS で公開されます。 そのため、JWKS 内のいずれかの鍵で署名が検証できることだけを条件にすると、ID トークンも検証を通過してしまいます。 クライアントが誤って ID トークンを Authorization ヘッダーに載せてきた場合に備えて、 token_use クレームが "access" であることを確認しています。 なお、検証項目に aud (audience) クレームは含まれていません。 MCP Authorization 仕様ではリソースサーバーによるトークンの audience 検証が求められていますが、 Cognito のユーザープールが発行するアクセストークンには aud クレームが含まれず、代わりに client_id クレームが含まれます。 そのため、今回の実装では client_id の許可リスト検証によって、トークンが事前登録済みのクライアントに発行されたものであることを確認する形をとっています。 ゲートウェイの実装ポイント ゲートウェイ本体は Go (1.26.3) + Echo (v4.15.2) で実装しました。 ここでは設計上のポイントについて触れます。 設定ファイルによるバックエンド管理 ゲートウェイがバイパスする MCP サーバー (バックエンド) は YAML で宣言的に管理しています。 backends : - name : server-a url : http://server-a.internal:8081 - name : server-b url : http://server-b.internal:8082 ゲートウェイは起動時にこのファイルを読み込み、 /<name>/mcp というパスを各バックエンドの /mcp にマッピングします。 たとえば POST /server-a/mcp へのリクエストは http://server-a.internal:8081/mcp に転送されます。 プロキシ部分は Go 標準ライブラリのリバースプロキシをベースに、以下のように実装しています。 標準実装は転送先のホストを書き換えるだけでパスはそのまま転送するため、転送直前に呼ばれるリクエストの書き換え処理を拡張して、パスプレフィックスの除去を加えています。 func newBackendProxy(targetURL, stripPrefix string ) (*httputil.ReverseProxy, error ) { u, err := url.Parse(targetURL) if err != nil { return nil , fmt.Errorf( "parse %q: %w" , targetURL, err) } rp := httputil.NewSingleHostReverseProxy(u) originalDirector := rp.Director rp.Director = func (req *http.Request) { // 標準の書き換え処理 (転送先を u に向ける) を実行した上で、 // ゲートウェイ側のパスプレフィックスを除去 (/server-a/mcp → /mcp) originalDirector(req) req.Host = u.Host if stripPrefix != "" { req.URL.Path = strings.TrimPrefix(req.URL.Path, stripPrefix) if req.URL.Path == "" { req.URL.Path = "/" } } } // バックエンドに到達できない場合は 502 を返す rp.ErrorHandler = func (w http.ResponseWriter, r *http.Request, err error ) { log.Printf( "[proxy] upstream error %s %s: %v" , r.Method, r.URL.Path, err) http.Error(w, "bad gateway" , http.StatusBadGateway) } return rp, nil } 新しい MCP サーバーを追加したいチームは、サーバーをデプロイしてこの YAML に 1 エントリ追記するだけで、 認証・認可付きのリモート MCP サーバーを公開できます。 検証済みユーザー情報の伝搬となりすまし防止 ゲートウェイは JWT の検証後、 Authorization ヘッダーを除去し、検証済みのクレームを X-Auth-* ヘッダーとしてバックエンドへのリクエストに注入します。 X-Auth-Sub : ユーザー識別子 (sub クレーム) X-Auth-Email : メールアドレス (Pre Token Generation Lambda で注入した email クレーム) X-Auth-Client-Id : OAuth クライアント ID X-Auth-Scope : 許可されたスコープ一覧 このとき重要なのが、クライアントから送られてきた X-Auth-* ヘッダーを必ず削除してから再注入することです。 // 受信した Authorization / X-Auth-* を全て削除(なりすまし防止) stripIncomingAuthHeaders(req.Header) // 検証済みクレームから X-Auth-* を再注入 req.Header.Set( "X-Auth-Sub" , claims.Sub) req.Header.Set( "X-Auth-Email" , claims.Email) req.Header.Set( "X-Auth-Client-Id" , claims.ClientID) req.Header.Set( "X-Auth-Scope" , strings.Join(claims.Scopes, " " )) これにより、クライアントが偽の X-Auth-Sub を付けてリクエストすることで他人になりすますのを防ぎます。 ゲートウェイがクレームヘッダーの削除と再注入を強制するため、バックエンド側は常にゲートウェイで検証済みの X-Auth-* ヘッダーの利用を保証できます。 加えて、バックエンドにはゲートウェイ経由でしか到達できないようにネットワークを構成しています (後述)。 X-Auth-* を信頼できるのは「ゲートウェイを必ず通る」ことが前提なので、アプリケーション実装とネットワーク構成をセットで設計する必要があります。 起動時の OIDC Discovery による fail-fast ゲートウェイは起動時に Cognito へ OIDC Discovery ( /.well-known/openid-configuration の取得) を行います。 issuer の設定ミスなどがあればこの時点で起動エラーになるため、リクエストを受けてから認証エラーが多発する、という事態を防げます。 モック認可サーバーによるローカル開発 ローカル開発用に、OIDC Discovery・JWKS・トークン発行だけを備えた最小限のモック認可サーバーを用意しました。 ゲートウェイから見ると issuer の URL が違うだけなので、実際の Cognito と同じコードパスで JWT 検証まで通しでテストできます。 docker compose でゲートウェイ・サンプルバックエンド・モック認可サーバーを一括起動できるようにしており、AWS 環境なしで認証フロー全体を確認できます。 インフラ構成 インフラは Terraform で管理しています。要点は以下の通りです。 ECS Fargate: ゲートウェイは private subnet に配置し、外部への通信は NAT Gateway 経由 ALB: TLS を終端し、ゲートウェイへ転送。セキュリティグループでゲートウェイへの入力は ALB からのみに制限 Cognito User Pool: Google フェデレーション、アプリクライアント、Resource Server、Pre Token Generation Lambda を Terraform で定義 デプロイ: GitHub Actions から OIDC でロールを引き受けて ECR push と ECS デプロイを実行 MCP クライアント (Claude など) は固定 IP を持たないため、ALB は HTTPS (443) を全公開とし、アクセス制御は JWT 検証と WAF に任せます。 バックエンドの MCP サーバーは private subnet 内でゲートウェイからのみ到達できるようにし、ゲートウェイを経由しない場合はネットワーク的に到達不可能にしています。 社内での活用事例 実際に社内で Redash を操作するリモート MCP サーバーをゲートウェイを利用して社内向けにリリースされました。 Claude とのチャットだけで、クエリの実行やダッシュボードの操作といった Redash 上のほとんどの操作ができます。 ローカル版の Redash MCP サーバーについて、以前の挑戦weekの記事で紹介しています。 tech.every.tv Redash MCP サーバーをリモート化し、認証・認可をゲートウェイに集約することで、個人ごとに API Key などの配布や登録が不要になり、 ビジネスサイドのメンバーであっても、Redash アカウントを持っていれば Google アカウントにログインするだけで API 経由で Redash を操作できるようになりました。 Redash MCP を社内に公開しました 今後の課題 短期間での構築だったため、以下のような課題が残っています。 きめ細かなポリシー管理: 現状は「自社ドメインの社員であること」の確認までで、厳密に権限管理をする場合には、所属チームなどの属性に応じて利用できる MCP サーバーやリソースを制限する仕組みが必要になります。 VPC 間の接続: 各チームの MCP サーバーは別の VPC や AWS アカウントで稼働するケースもあります。そのため、今後さらに MCP サーバーの利用を展開していくためには、VPC Peering などによる VPC 間接続を検討する必要があります。 おわりに 本記事では、全社共通のリモート MCP サーバー向け認証・認可ゲートウェイの設計と実装を紹介しました。 ゲートウェイ方式を採用したことで、認証・認可の実装をゲートウェイに一元化でき、 各チームは MCP サーバーのツール実装に専念して、設定ファイルへの追記だけで認証付きのリモート MCP サーバーを公開できるようになりました。 MCP の認可まわりは仕様の整備が活発に進んでいる領域なので、今後も動向を追いながら基盤を育てていきたいと思います。 この記事が、社内での AI 活用の中で同じような課題感を持っている方の参考になれば幸いです。 最後まで読んでいただき、ありがとうございました。 Understanding user pool JSON web tokens (JWTs) - Amazon Cognito (2026年6月11日閲覧) 。「Amazon Cognito generates two pairs of RSA cryptographic keys for each user pool. One private key signs access tokens, and the other signs ID tokens.」と記載されています。 ↩
はじめに こんにちは、トモニテ開発部の吉田です。 今回は、私たちの部署で実施した「AI days」という取り組みについてレポートします。2日間かけて、普段の開発業務にAIを組み込むための土台を集中的につくる社内イベントです。 そもそものきっかけは、私が「トモニテでのAI活用をもっと加速させていきたい」と声を上げたことでした。私は役職者ではなく一開発メンバーですが、その思いを伝えたところ、具体的な形として今回のAI days開催に至りました。最初から「AI daysをやりたい」と考えていたわけではなく、「なんとかしたい」という思いが先にあって、それがチームを巻き込んだ取り組みに育っていった、という流れです。 「AIを業務に活かしたい」という話はあちこちで聞くものの、実際にチームとして何から手をつければいいのかは悩ましいところだと思います。私たちも手探りで進めたので、その過程と、やってみて分かったことを正直に共有します。 なぜ「AI days」をやったのか きっかけは、いくつかの課題感が重なっていたことでした。 AI活用が「点在」していた 業界全体、そして社内の他部署でもAIを業務に組み込み始める動きが広がっていました。一方で私たちトモニテ開発部は、メンバーそれぞれがAIを使ってはいるものの、スキルやノウハウが個人に点在しているだけで、業務フローに常設できていない状態でした。 組織として一段上のフェーズへ 個人の工夫に頼るのではなく、開発部全体のAI活用レベルを底上げして、組織として次のフェーズへ移行したい、という思いがありました。今後の新たな取り組みに着手するときにも、ここで構築した環境や型をそのまま流用できる状態をつくっておきたい、という実利的な狙いもありました。 これらを一気に前進させるために、まとまった時間を確保して集中的に取り組む形として「AI days」を企画しました。 進め方 AI daysは、次のような構成で実施しました。 Day0: 何にフォーカスしてどんなものを作るのかディスカッション Day1〜2: チームでskillを作成する 実施後: 全体での振り返り Day0で方針を固め、残りの時間はひたすら手を動かす、という流れです。それぞれ、どんなことを話して何をやったのかを掘り下げて紹介します。 なお、本記事に出てくる「skill」「agent」「オーケストレーター」は、いずれも Claude Code をベースにした仕組みです。AIに実行させる手順や命令群を、Claude Code の skill / agent として書き起こしていった、という前提で読んでいただけると分かりやすいと思います。使用したモデルは、AI days を実施した2026年6月第1週時点での Claude Opus 4.8 です。 Day0:AIにどこまで任せるかを決める Day0は、作業日とは別にMTGとして実施しました。本番に入る前に、まずチームで次のことを共有・議論する時間です。 なぜやるのか:背景と大義名分の目線合わせ 他部署での活用事例の共有:たとえば、Claude Code と Redash MCP(Databricks)を組み合わせることで、非エンジニア(PMM)でも高度な統計分析や横断分析ができるようになった、といった事例 現在地のヒアリングとアクション議論:各自のAI活用の現状の持ち寄りと、次のアクションの検討 そして、この場で中心になったのが、「そもそも何にフォーカスするのか」という問いでした。 論点はいくつもありました。 日常業務の自動化か、機能開発の効率化か:効果が大きいのはどちらか。日常業務なら複数の業務をMCPでつないで回せるか、機能開発ならUIの妥当性やデザインとの接続まで担保できるか。 どこまで巻き込むか:開発部に閉じてやるか、ビジネスサイドまで巻き込める領域でやるか。 議論の中で見えてきたのは、領域によってAIに任せきれる度合いが違うということでした。たとえばサーバーサイドは、私たちの開発基盤上、DBのスキーマからモデルを自動生成したり、OpenAPIでAPIの型を定義したりと、「定義」から実装をコード生成で結びつける仕組みが整っています。そのぶん生成物が定義からずれにくく、確からしさを担保しやすい。一方でUIには、こうした「正解」を機械的に突き合わせる仕組みがなく、AIのよしなな判断に委ねるとずれが出やすく、任せきりにしにくい領域です。 こうした議論を経て、今回は「開発部に閉じて、実装フェーズの自動化にフォーカスする」と決めました。対象にしたのは、 要件 → 設計 設計 → 実装 それを受けてのレビュー という実装まわりの一連の流れです。なお、要件→設計については、すでに個人が作ったものが共有されていたため、AI daysでは設計→実装のところから着手しました。 ここで目指したのは、AIの生成物をほぼノーレビューでapproveできる状態でした(結果がどうだったかは、後半の「振り返り」であらためて触れます)。そのために、行き当たりばったりではなく「こういう順番で・こういうルールで開発する」という型(ハーネス/ガードレール)をあらかじめ決めてしまおう、という方針を立てました。 進め方そのものも合わせて決めました。モブプロ/ペアプロ形式で手を動かし、話し合った内容をその場で誰かが作業者として形にしていく。そして、毎日PRを出してマージするところまで持っていく、という進め方です。 Day1〜2:チームでskillをつくる まずは「どんなルールがあるとよいか」を出し合う Day1は、対象とするAPIサーバーのリポジトリを1つ決め、「設計→実装の仕組みづくりにおいて、どんなルールがあるとよいか」をメンバーそれぞれが発表するところから始めました。 出てきた観点は多岐にわたりました。 アーキテクチャの明示:レイヤードアーキテクチャや命名規則といった既存の実装規約 実装フローの明示:要件→設計→テスト→実装という順序、Red→Greenサイクル テスト設計方針:テストテンプレート、テストデータ作成、テーブルテストの汎用化 レビュー観点:個人情報、環境依存、命名規則、再利用性、依存パッケージのバージョンなど PR作成:レビューしやすいよう小さく分割する セキュリティ:余計な権限をつけない、依存は最新・公式の使い方に準拠 パフォーマンス:EXPLAINなどで重くなりそうな箇所に事前に気付けるように この過程で出てきたのは、明確なルールだけではありませんでした。「普段はこう書く」「ここはこう判断している」といった、これまで明文化されてこなかったチームの共通認識も、あわせて表に出てきました。各自の頭の中にあった前提を口に出して並べたことが、思わぬ副産物につながりました。この点は、後ほど「振り返り」であらためて触れます。 「AIにできること/できないこと」で仕分ける 集まったアイデアは、次の3つに分類していきました。 人間はできているが、AIがまだできていないこと 人間もAIもできていないこと これまでできていなかったが、AIを使うことで効率よくできるようになること この仕分けが効きました。(1) はすでに人間が回せていることを言語化すればよいので、効果が出やすく着手しやすい。(2)(3) は新たに型をつくる必要があるぶん難度は上がりますが、うまくいけば伸びしろが大きいというように、何にどれだけ力を割くかの優先度を、チームの認識を揃えたうえで決められたからです。 そのうえで採ったのが、「普段のワークフローをskillに落とし込む」というアプローチです。いきなりAIエージェントの大掛かりな設計に踏み込むのではなく、すでに自分たちが回している業務の手順を、AIが実行できる形(skill)として書き起こしていきました。身近な業務から手をつけられるので、メンバー全員が手を動かしやすく、成果物がそのまま日々の開発に使える、という狙いです。初日は、この設計→実装のskillを形にするところまで進めました。 動かして検証し、レビューフローまでつくる 2日目は、前日につくったskillを実際のタスクに当てて動かし、その結果をチームで共有するところから始めました。出来上がったコードだけでなく、エージェントが実際にどう動いたかのログも一緒に見ながら、「ここは気になる」というポイントを洗い出していきます。たとえば、こんな気づきが挙がりました。 既存の実装を見つけているのに使わない:月齢計算のような複雑な処理で、本来は流用してほしい既存のユーティリティ関数があるのに、AIはそれを見つけたうえで使わず、独自に実装していました。「これを使ってね」という前提を事前に渡しておくべきだった、という気づきです。 ゼロから作るものは別PRに切り出したい:テストのfactoryのように、まだベースが存在しないものを実装しながら作らせると、意図しない作り方になりがちでした。先にそれだけを用意するPRを分けた方がよさそうです。あわせて「PRをどう分割するか」自体も、判断基準として持たせる必要があるかもしれません。 手順として明示すべき付随作業がある:DBを変更したらマイグレーションを生成する、API定義を変えたらドキュメント(swagger)を更新する、といった作業は、フローの中に手順として書いておかないと抜けてしまいます。 agentとskillの責務分担:「どこまでをagentに考えさせ、どこからをskillとして固定するか」の線引きには個人差があり、ここでも議論になりました(この問いは後述の「今後の展望」にもつながっていきます)。 そして、こうして見えてきた観点も踏まえながら、2日目の後半は、初日と同じくモブプロ形式でレビューフローの作成に取り組みました。実装する側だけでなく、生成物をチェックする側の型もそろえることで、「AIに実装を任せ、別系統でレビューする」という一連の流れを形にする、という狙いです。 つくったもの 2日間で、思っていた以上にたくさんの成果物が生まれました。大きく分けると、AIに渡すための「土台」と、それを使って実際に動かしたskill群です。 土台(AIに前提を渡すためのドキュメント類) リポジトリごとの約束ごとをまとめた CLAUDE.md テスト設計ガイドライン(テスト設計の指針をドキュメント化。 docs/test_design_guideline.md ) 実装ガイドラインの更新( docs/implementation_guide.md ) 実装フローを回すskill群 全体を束ねるオーケストレーターと、そこから呼び出される工程ごとのskillをセットで作りました。タスク分割書(実装タスクを粒度ごとに分割したドキュメント。前述のとおり要件→設計の工程は既存のskillが担っており、その出力物がこれにあたります)を入力に渡すと、テストコード・実装のdiff・ドラフトPRが出力される、というイメージです。具体的には、次の流れを一気通貫で回します。 コンテキスト収集 → テストケース定義 → テスト実装(Red)→ 機能実装(Green / Refactor)→ 動作確認(curlでの実機疎通)→ ドラフトPR作成 → レビュー 工夫したのは、各工程を独立したサブエージェント(別コンテキスト)として起動し、引き継ぎは会話ではなくファイル(収集したコンテキスト、テストコード、diff など)で行うようにした点です。テスト・実装・レビューが互いの思考を引きずらないので、後述する「別系統でのレビュー」も自然に組み込めました。 イメージしやすいよう、オーケストレーターskillの構成をもう少し具体的に紹介します。オーケストレーター自身は「薄く」保ち、ループ制御・サブエージェントの起動・成果物の引き継ぎ・失敗時の停止に徹して、実装の実体は工程ごとのskillに委譲する、という役割分担です。タスク分割書の依存関係からタスクの実装順を決め、1タスクごとに次の工程を回していきます。全体像を図にすると、次のような流れになります。 工程 委譲先skill 成果物 ① コンテキスト収集 collect-context context.md(当該タスク分) ② テストケース定義 write-testcases testcases.md ③a テスト実装(Red) implement-tests テストコード+Red確認 ③b 機能実装(Green / Refactor) implement-feature 本番コード+全テストGreen ③c 動作確認 verify-endpoint curlでの疎通確認結果(※エンドポイントを含むタスクのみ) ④ ドラフトPR作成 create-draft-pr 1タスク=1ドラフトPR ⑤ レビュー 社内のレビュー用プラグイン 指摘一覧 フロー全体のルールとしては、次のようなものを明文化しています。 工程は基本①〜⑤を順に回すが、③c 動作確認はエンドポイントの追加・変更を含むタスクのときだけ実行する(repository追加やバッチなど、叩く対象がないタスクではスキップする) PRは1タスク=1PRで小さく保ち、依存タスクがある場合は依存先のブランチから分岐したスタックPRにする レビュー(⑤)でCritical / Importantな指摘が出たら、機能実装とレビューを、指摘がゼロになるまで往復する(上限あり)。このとき、修正者の思考をレビューが引き継がないよう、レビュー担当のサブエージェントは毎回新規に起動する Greenにできない・要件と矛盾するなど解消できない問題に当たったら、推測で進めずに要確認事項として記録し、そのタスクで停止する もう一歩踏み込んで、各工程のskillに実際に書いている指示も紹介します。どのskillも、「品質に気をつける」のような心構えではなく、 「〜の場合は〜する」という状況と行動のペア で指示を書いています。 テストケース定義(②)には、こんな指示を書いています。 異常系は「エラーになること」ではなく、期待するエラー文言・型まで指定する 各テストケースに信頼性ラベルを付けさせる。「🔵 要件・既存実装に基づく / 🟡 合理的な推測 / 🔴 要確認」の3段階で、AIが推測で書いた部分を確定情報と混ぜさせない テスト実装(③a)のskillには、「テストを仕様として、本番実装とは独立したコンテキストで書く」という目的と、それを守るための指示を書いています。 本番ロジックは書かない。テストのコンパイルを通すための最小限のスタブ(中身は panic("not implemented") など)までは許可する Redを確認するときは、失敗の理由が「未実装による期待どおりの失敗」なのか「テストコード自体のバグ」なのかを切り分けてから返す 機能実装(③b)には、その逆を守らせる指示を書いています。 テストを弱めない。Greenにするためにアサーションを緩めたり削ったりしない。テスト側にバグがあると判断しても自分では直さず、要確認として報告する(テストは別工程の責務) リファクタは「このタスクで自分が書いた差分」に限定する。既存コードの規約違反に気づいても勝手に直さず、別PRの領分として報告に留める 動作確認(③c)には、何をもって「確認できた」とするかを指示しています。 「200が返った」だけでは正常系の確認にしない。検証用の既知の値をDBに投入し、それがレスポンスに正しく含まれることまで突き合わせる。あわせて、他ユーザーのデータや論理削除済みデータといった「含まれてはいけないデータが含まれない」ことも確認する 結果が期待と違ったら❌のまま正直に報告し、レポートを✅に寄せない。安易に「環境のせい」にしない こうした指示の多くは、最初から書けていたわけではなく、実行ログで意図しない動作を見つけるたびに書き足していったものです。たとえば「実行が英語で進んでいってしまう」という地味な問題は、サブエージェントを起動するプロンプトの冒頭に「すべての出力・成果物・ユーザーへの質問は日本語で行うこと」を必ず明記する、という指示で潰しました。 書いたルールが実際に判断として現れた場面もありました。テスト側のフィクスチャにバグがあり(デフォルト値を持つカラムにゼロ値を入れたところ、ORMの仕様でドロップされてDB側のデフォルト値で上書きされ、「非アクティブにしたいデータ」がアクティブのまま保存されていた)、実装が正しいのにテストがGreenにならない状況が起きたときのことです。このとき機能実装のサブエージェントは「テストを弱めない・テストは別工程の責務」というルールに従って、テストを書き換えてGreenにする近道を選ばず、オーケストレーターに差し戻していました。 そのときのオーケストレーターの実行ログがこちらです(抜粋・整形。日本語化の指示を入れる前の実行なので英語のままです)。 ⏺ Agent(Write testcases TASK-0003) ⎿ Done (13 tool uses · 72.1k tokens · 1m 56s) ⏺ 14 cases. Test subagent (Red) for TASK-0003: ⏺ Agent(Write Red tests TASK-0003) ⎿ Done (22 tool uses · 103.3k tokens · 3m 12s) ⏺ Red confirmed (14 cases). Feature subagent (Green→Refactor) for TASK-0003: ⏺ Agent(Implement TASK-0003 Green) ⎿ Done (20 tool uses · 52.3k tokens · 2m 40s) ⏺ The feature SA found a genuine test-fixture bug (not an impl bug): boil.Infer() drops is_active=false (zero-value of a with-default column), so the inactive fixture V3 persists as active. The impl is correct; per the skill the feature SA correctly refused to weaken the tests and stopped. Fixing fixtures is implement-tests' responsibility. I'll spawn a focused test-fix subagent (still the implement-tests role): ⏺ Agent(Fix TASK-0003 test fixture) ⎿ Done (14 tool uses · 38.2k tokens · 1m 22s) ⏺ 14/14 green now. テストケース定義→テスト実装(Red)→機能実装(Green)と工程ごとにサブエージェントが起動されていく様子と、機能実装のサブエージェントがフィクスチャのバグを見つけながらも自分では直さず("correctly refused to weaken the tests and stopped")、オーケストレーターがテスト修正専用のサブエージェントを改めて立ち上げて、フィクスチャだけをピンポイントで直させた様子が分かります。skillに書いた「テストを弱めない・テスト側のバグは自分で直さず報告する」が、そのまま判断として現れた例です。 一方で、現実的な落としどころを選んだ部分もあります。レビュー工程は、社内ですでにプラグイン化されているものを再利用しました(このレビュー用プラグインについては 過去のブログ記事 で紹介しています。その後もアップデートを重ねています)。また動作確認は、そもそも「何をもって動作確認とするか」という議論もしました。その過程で「リモートのテスト環境(社内のtestkit)経由で実行できる仕組み」という案も出たのですが、時間の兼ね合いで断念。今回はcurlでの実機疎通に落ち着いています。 そして、これらを使って実際にコードを書き、プルリクエストを出すところまで一気に進めることができました。人間が操作したのは、走り出す前にオーケストレーターから「どこまで自律で進めるか」「push・PR作成をどう扱うか」を確認されて回答した一度だけで、あとは全自動でできました。 振り返り:やってみて分かったこと 実施後、メンバー全員で振り返りを行いました。出てきた声を、よかった点と課題に分けて整理します。 よかったこと オーケストレーター+個別skillという構成 全体を指揮するオーケストレーターと、個別のskillに分けた構成にしたのですが、これがとてもよかったです。役割が分かれているので作るときも考えやすく、あとから育てていきやすい構成になりました。 暗黙知を言語化する作業そのものに価値があった skillをつくるには、普段は頭の中にある暗黙知を明文化する必要があります。この「暗黙知を落とし込む」作業は、skill作成の上で必須であると同時に、チームの知識を整理するうえでも意味のあるプロセスでした。 振り返りでも、「チームでの理解が揃った」「自分たちのコーディングやレビュー視点の棚卸しになった」「開発のスタイルや意思決定に対する、お互いの認識を合わせられた」という声が多く挙がりました。AIに渡すための土台(コンテキスト)を整える作業が、結果としてチーム内の共通認識を言語化する場にもなった、というのが大きな収穫でした。 モブプロ/ペアプロ形式が「やり方の共有」になった 普段の開発とはあえて違う形式で進めましたが、これも結果的によかった点です。話し合った内容をその場で誰かが形にしていくので、議論したことがそのまま成果物として残っていきました。また、skillやプロンプトを書く過程を互いに見ながら進める形式だったこともあり、振り返りでは「それぞれがどんなふうにskillを使い、作っているかという話ができたのはよかった」という声が挙がりました。AI活用のノウハウが個人に点在していた私たちにとって、やり方そのものを共有する機会になりました。 「まずは作ってみる」で十分ワークした ワークフローを落とし込むだけで、ある程度使えるものが出来上がりました。最初から作り込む必要はなく、まずトライしてみる、というスタンスで進めてよかったと感じています。 課題 「ツールで仕組み化すべき層」と「AIに任せる層」が混ざっていた 一方で反省点もありました。本来は静的解析(linterや型チェックなど)で機械的に防げるはずのところを、skill側でカバーしようとしていたケースがありました。 裏を返すと、暗黙知をわざわざskillに落とし込まなくても、静的解析で担保できた領域があったということです。何でもAIに言い聞かせるのではなく、コンパイラやリンターのレベルで制約をかけられるものはそちらに寄せる、という切り分けが必要だと気づかされました。 AIのセルフレビューには構造的な盲点があった もう1つ、AI駆動開発ならではの学びがありました。生成されたコードに対してAI自身にセルフレビューをさせると「重大な問題はなし」と返ってくる一方、別のAIコードレビューに通すと、バグやテストの不備が次々と見つかる、という場面が繰り返し起きたのです。 設計判断そのものは概ね妥当でも、AIに「自分の書いたものを自分でレビューさせる」だけだと、観点に構造的な抜けが残るということです。集約したデータを取りこぼしていないか、境界条件、並列実行時のテストの安定性など、人間でも見落としやすい観点ほど抜けやすい傾向がありました。 ここから得た教訓は、AIに任せきりにせず、静的解析・別系統のレビュー・人間のレビューを重ねる「多層のガードレール」を前提に組むべき、ということです。 さらに踏み込んで議論したこと 「よかった/課題」とは別に、振り返りでは少し踏み込んだ問いについても議論しました。AI駆動開発をチームで進めるうえで、同じことを考えている方の参考になりそうなので共有します。 通常業務の時間を削ってまで、この期間を確保した価値はあったか ここはおおむね「価値はあった」で一致しました。チームの理解が揃い、各自がどんなふうにskillを使い・作っているかを共有でき、AI駆動開発を進めるための基盤ができた、という点が評価されました。一方で「実際の開発フローへの導入まではもう一段ハードルが高い」という現実的な声もありました。 作ったものを、そのまま実務に流用できそうか ここは率直に「使える、ただし手放しでは無理」という評価でした。 使うことはできるが、劇的に速くなるわけではない 出来上がるのは「本実装とプロトタイプの中間」くらいの成果物で、そのまま扱うには少し悩ましい 「どこかにダメな部分がありそう」という前提でレビューするので、認知負荷込みで考えると「爆速」にはならない レビューの往復は当面避けられず、人間の手放しは遠い ただし、「要件を満たすPRが出来上がるまでのリードタイムは減らせそう」という手応えもありました。実装そのものは人間がやらなくてよい、けれどその分の穴を見つけて直す工程が残る、というのが現在地です。「せめて、AIレビューで定番的に指摘される箇所くらいは、AI自身が自律的に直せるようになってほしい」という要望も挙がりました。 なぜ「育てながら使う」という結論に落ち着いたか 「このままバッチリ使える」ではなく「各自で使いながら育てよう」という着地になった背景も振り返りました。 成果物の数は出たものの、全体を一気に「広く」設計してしまったため、一つひとつのskillの試行錯誤や深掘り(精度向上)に時間を割ききれなかった(開発フローの一部に絞って深める、というやり方もあり得たかも) 言語化しきれていない暗黙の指定が、まだ多く残っている skillへの指定だけでは限界があり、コンパイラやリンターのような強い制約はかけられない 「使ったあとにどう育てるか」までフローに組み込めていれば、もう少し「勝手に育つ」状態に近づけられたかもしれない 印象的だったのは、「これまで『よしなに実装』していたのが、『よしなにAIを育てる』に変わった感じ」という表現でした。人間がやることの重心が、実装そのものから「AIをどう育てるかの設計」へ移りつつある、という感覚です。 継続して運用するために、最低限なにが必要か 最後に、これを一過性で終わらせないために必要な仕組みを洗い出しました。 ガードレール(静的解析やテストのファクトリなど、機械的に品質を担保する仕組み) 各skillをもっと独立させ、一部から個別に導入・チューニングできるようにすること フィードバックを貯める場所と、それを改善に回すループ(理想は、改善が自然な運用フローに乗っている状態) 「どう育てていくか」の方針 今後の展望 今回は「ワークフローをskillに落とし込む」という形で進めましたが、振り返ってみると、その一歩手前にある「これはskillにすべきか、agentにすべきか」「skillの責務は必要十分か」という「AIの設計」のレベルまで踏み込めていれば、さらによかったかもしれません。 次の一手としては、実装前の手順も含めた全体フローを可視化し、どこからどこまでがAIの役割で、どこが人間の責務なのか(エンジニアだけでなく、PdMレベルも含めて)を整理していきたいと考えています。 あわせて、このskill群が実際にどれだけ利用されているかも計測していくつもりです。また、どの工程にどのモデルを充ててトークンコストをコントロールするかも、ハーネスエンジニアリングの一部として今後組み込んでいきたいと考えています。 まずは作ってみる、はじめから作り込まない、というアプローチ自体は正解だったと思っています。「改善は使った人が各自で進めてよし、大きな変更や相談したいことは歓迎」というゆるやかな運用方針のもと、ここで構築した環境や型を、今後の開発で継続的に育てながら活かしていく予定です。 まとめ 「AI days」は、点在していたAI活用のスキルを、組織の型として束ねていくための第一歩でした。 2日間という短い期間でも、ワークフローをskillに落とし込むだけで、ある程度使えるものを形にできました。同時に、「ツールで仕組み化すべき層との切り分け」「AIのセルフレビューの盲点」「育てる仕組みの必要性」といった、次に向き合うべき課題も具体的に見えてきました。 私たちの場合は、「まずは作ってみる。はじめから作り込まない」というスタンスで始めてみました。まだ手探りですが、得られた型を実務で磨きながら、次はもっと使えるものを作れるよう、引き続き思いを馳せていきます。
こんにちは、デリッシュキッチン開発部の 鈴木 です。 概要 Data Definition Language(DDL、データ定義言語)、つまり CREATE TABLE や ALTER TABLE でスキーマを変更する操作を、トランザクションの中で実行してあとからロールバックできるかは、データベースによって違います。MySQL ではロールバックできず、PostgreSQL ではロールバックできます。 ロールバックは、1 つのトランザクションの中で行った変更を、記録しておいた変更前の状態に戻す操作です。だからロールバックするには、その操作が 1 つのトランザクションに収まっていること、そしてその変更が記録されていることの 2 つが必要です。前者はどのデータベースでも同じで、1 つのトランザクションに収まらない操作(PostgreSQL の CREATE INDEX CONCURRENTLY など)は、そもそもロールバックの対象になりません。データベースによって分かれるのは、後者です。 ロールバックできるか、つまり変更が記録されるかは、メタデータの置き場所で決まります。変更前の状態は、ストレージエンジンの内部に記録されます。そのため、スキーマの定義情報であるメタデータも、その内部になければロールバックできません。古い MySQL はメタデータをエンジンの外側のファイルに置いていたため、ロールバックできません。PostgreSQL はメタデータを通常のテーブルとして内部で管理するため、ロールバックできます。MySQL 8.0 はメタデータを内部に移しましたが、互換性のために暗黙的なコミットを残したため、ロールバックできません。MySQL と PostgreSQL の差は、この置き場所の違いから生まれます。 図1:DDL をロールバックできるかの全体像。1 つのトランザクションに収まるか(DB 共通)→ 収まればロールバックできるか(DB で違う)の順に決まる。 前提:スキーマ変更をロールバックできるかは、データベースで違う CREATE TABLE や ALTER TABLE といった DDL を、トランザクションの中で実行してあとからロールバックできるかは、データベースによって違います。 MySQL :ロールバックできません。DDL には暗黙的なコミットが伴い、 ALTER TABLE を実行した時点でそれまでの変更が確定します。あとから ROLLBACK しても戻りません。 PostgreSQL :ロールバックできます。 BEGIN; ALTER TABLE ...; ROLLBACK; で変更前の状態に戻ります。 本記事はこの差を前提とし、 この違いを生んでいるものは何か を扱います。 DDL をロールバックできるかは、収まるか → ロールバックできるか の順で決まる ロールバックは、1 つのトランザクションの中で行った変更を、記録しておいた変更前の状態に戻す操作です。だからロールバックするには、その操作が 1 つのトランザクションに収まっていること、そしてその変更が記録されていること、の 2 つが必要です。前者を満たさなければ後者には進めないので、まず収まるか、次にロールバックできるか、の順に確かめれば十分です。 1 つのトランザクションに収まるか。 実行のしかたの問題で、データベースによらず一律に効きます。 収まるとして、その変更をロールバックできるか。 メタデータ(テーブルや列などスキーマの定義情報)の置き場所で決まり、データベースによって変わります。 ロールバックできるかを問えるのは、1 つのトランザクションに収まる操作だけです。前提で見た MySQL と PostgreSQL の差は、ロールバックできるかで決まります。 1 つのトランザクションに収まるか(DB によらず一律) ほとんどの DDL は 1 つのトランザクションに収まります。ただし、収まらない操作もあります。これはデータベースによりません。 代表例は PostgreSQL の CREATE INDEX CONCURRENTLY です。通常の CREATE INDEX はテーブルをロックして一気に索引を作ります。一方 CONCURRENTLY は、書き込みを止めずに索引を作るため、複数の段階に分けて実行します。各段階はそれぞれ別のトランザクションになるので、全体を 1 つのトランザクションブロックに収められません。 DDL をロールバックできる PostgreSQL でも、 CREATE INDEX CONCURRENTLY はトランザクションの中では実行できません。ロールバックできるかどうか以前に、1 つのトランザクションに入らないからです。この操作はここで止まり、ロールバックできるかはそもそも問えません。 収まるとして、ロールバックできるか(ここで DB が分かれる) 1 つのトランザクションに収まる操作、つまり普通の DDL について、ロールバックできるかを考えます。これがデータベースによって違います。 行の更新や削除といった Data Manipulation Language(DML、データ操作言語)をロールバックできるのは、MVCC(変更前の古い行を残しておく仕組み)が古い行を保持しているからです。この記録の仕組みはストレージエンジンの内部にあります。だから DDL をロールバックするには、スキーマを記述するメタデータも、ストレージエンジンの内部、つまりこの仕組みの管理下になければなりません。 メタデータがこの管理下にあるかどうかが、データベースごとの差を生みます。 古い MySQL(5.7 以前) :メタデータは .frm というファイルにあり、ストレージエンジンの外側にありました。ロールバックの仕組みの管理下にないので、トランザクションに参加できません。だから暗黙的にコミットされ、ロールバックできません。 MySQL 8.0 :メタデータを InnoDB の内側に取り込み、データディクショナリとして管理するようにしました。これで DDL がアトミックになりました(途中で中断しても中途半端なスキーマが残りません)。ただし、この変更が狙ったのはクラッシュ時の安全性であって、ユーザーがロールバックできるようにすることではありません。従来の挙動との互換性から、暗黙的なコミットはそのまま残されました。 アトミック(壊れない)と、ユーザーが好きなときにロールバックできることは別物です。 内側に入れても、まだロールバックできません。 PostgreSQL :スキーマ情報をシステムカタログ( pg_class など)として、通常のテーブルと同じく MVCC で管理します。 CREATE TABLE はシステムカタログに 1 行追加するだけの操作になります。行の追加をロールバックするのと同じ仕組みがそのまま効くので、ロールバックできます。 3 つは別々のケースではなく、メタデータの置き場所が、外側(古い MySQL)から、内側だが暗黙コミット(MySQL 8.0)、内側で完全管理(PostgreSQL)へと段階的に変わっているだけです。前提で見たとおり、MySQL はロールバックできず、PostgreSQL はロールバックできます。これは、このメタデータの置き場所による結果です。 図2(詳細):DDL をロールバックできるかは、1 つのトランザクションに収まるか(DB 共通)→ 収まるとしてロールバックできるか(メタデータの置き場所・DB で違う)の順に決まる。 結論:DDL かどうかではなく、収まるか → ロールバックできるか で見る DDL をロールバックできるかは、収まるか、次にロールバックできるか、を順に確かめれば分かります。DDL かどうかで一律に判断すると、PostgreSQL ではロールバックできることや、PostgreSQL でも CREATE INDEX CONCURRENTLY はロールバックできないことを見落とします。この順で見れば、そうした例外も正しく判断できます。 1 つのトランザクションに収まるか。 収まらなければ( CREATE INDEX CONCURRENTLY など)、どのデータベースでもロールバックできません。 収まるとして、メタデータがロールバックの仕組みの管理下にあるか。 使っているデータベースによって、ロールバックできるかが決まります。 最後に、具体的な落とし穴を一つ挙げます。MySQL では DDL と DML を同じトランザクションに混ぜてはいけません。これはロールバックできるか(メタデータの置き場所)に関わる問題です。MySQL では DDL のメタデータがロールバックの仕組みの外側にあるため、 ALTER の暗黙的なコミットによって、直前の INSERT などが意図せず確定してしまいます。この挙動は、DDL がロールバックの仕組みの外側にあることから説明できます。 まとめ 今回は、なぜ MySQL では DDL をロールバックできず PostgreSQL ではできるのかについて考えました。MySQL は DDL をロールバックできない、PostgreSQL はできるということを覚えてもいいとは思うのですが、それではツールの使い方に閉じてしまいます。なぜそのような設計になっているのかを理解することで、MySQL や PostgreSQL という個別の知識にとどまらず、初めて触れる操作やデータベースでも、1 つのトランザクションに収まるか、そしてメタデータがロールバックの仕組みの管理下にあるか、という同じ見方で自分で判断できるようになります。実際、同じ PostgreSQL でも CREATE INDEX CONCURRENTLY はロールバックできない、といった例外にも自分で気づけるでしょう。
はじめに こんにちは、デリッシュキッチンでiOSエンジニアをしている谷口恭一です。 デリッシュキッチンの iOS アプリでは、プッシュ通知やディープリンクから特定の画面・特定のセクションへ直接遷移する導線を数多く提供しています。 delish://scroll_to_any_section このリンクの例で意図しているのは、次の一連の動作です。 アプリを起動し、下部タブバーで特定のタブに切り替え目的の画面に遷移し、その中の特定のセクションまでスクロールし、必要ならそのセクションのモーダルを提示する。 一見ただの画面遷移に見えます。ところが指示された通りに実装すると、アプリが閉じている状態や他画面にいる状態で起動したときスクロールが効かない、という現象に突き当たります。「スクロールせよ」という命令を出した瞬間には、スクロール先のセクションがまだ画面に存在しない可能性があるからです。セクションはネットワーク通信が返ってきて初めて描画されますが、その完了を待たずに命令が走ってしまいます。 なぜ難しいのか この難しさの根は、SwiftUI が宣言的 UI フレームワークであることにあります。SwiftUI では画面は状態から導出され、状態が変われば画面が再構築されます。一方「特定の瞬間に一度だけスクロールする」「モーダルを開く」といった操作は、状態とは無関係に一度だけ走らせたい 一過性の命令 です。この一過性の命令を宣言的な UI のなかで確実に実行するには、ただ命令を書くだけでは足りず、宣言的な仕組みに乗せる工夫が要ります。 しかもディープリンクの場合、命令を出す側(起動)が、命令を受け取る画面よりも先に存在します。受け手がまだ生まれていないところへ命令が飛んでくる、という時間的なずれも重なります。 この記事の目的 この記事では、この問題を 命令を直接実行しようとせず、SwiftUI が扱える「状態」に置き換えて解く というアプローチで整理します。プッシュ通知やディープリンク、外部サービスからのコールバックなど、画面のライフサイクルの外から飛んでくるトリガーを扱う場面で広く応用できる考え方だと思います。 リンク起動からのスクロールを題材に、次の順で、単純な実装が壊れるところから一つずつ組み立てていきます。それぞれの仕組みがなぜ必要になるのかが、順を追って見えてくるはずです。 なぜ単純な実装では動かないのか スクロール要求を、消えずに残る「状態」として保持する 前提条件が揃った瞬間に、何度起きても安全に一度だけ実行する 起動情報を、画面が生まれる前から保持し続ける置き場所を用意する 一つの起動情報に、複数の画面要素がそれぞれ反応できるようにする なお、ディープリンク処理は「受け取った URL を解析してどの画面へ遷移するか判定する」前半と、「遷移先の画面で要求された処理を実行する」後半に分けられます。この記事で扱うのは後半だけで、前半の URL 解析・画面判定には触れません。 1. なぜ単純な実装では動かないのか スクロールという処理を実行するには、次の前提条件がすべて揃っている必要があります。 対象画面の View 階層が構築済みであること セクションの描画に必要なデータがネットワークから取得済みであること そのデータをもとにスクロール先の View が描画済みであること 問題は、 要求が発生した時点でこれらがどこまで揃っているかが、起動状態に依存して変わる ことです。 コールドスタート (終了状態から起動):要求発生時、前提条件は一つも揃っていません。 ウォーム/ホットスタート (起動済みで対象画面も表示中):要求発生時、前提条件がすべて揃っていることもあります。 そして、揃っていく順序やタイミングは制御できず、要求側から知ることもできません。最も極端なコールドスタートでは、要求の発生時刻 t = 0 と実行可能になる時刻 t = T が大きく乖離し、 T はネットワークレイテンシ次第で毎回変動します。 要求を受けた時点で scrollTo を直接呼ぶ単純な実装を考えてみます。コールドスタートでは t = 0 に実行され、前提条件が揃う t = T まで待たないため、何も起こりません。一方ウォームスタートでは前提条件が揃っているため正しく動作します。すなわち、 同じコードが起動状態によって成功したり失敗したりする わけです。この実装は、正しさを実行時点のタイミングという制御不能な要因に依存させています。 この命令的アプローチは、暗黙に「要求時点 = 実行可能時点」を前提しています。同期的なボタンタップなら成立しますが、ディープリンク経由では成立しません。求めるのは、起動状態によらず常に正しく動く実装です。そのために、以降では次の三つを順に組み立てていきます。 要求を、実行可能になるまで失われない 状態 として保持する 前提条件が揃ったタイミングで実行する 冪等なトリガー を配置する 起動情報を、 View / ViewModel より生存期間の長いオブジェクト に保持させる 2. スクロール要求を、消えずに残る「状態」として保持する まず、要求を「即座に実行する命令」ではなく「未実行の要求」という状態として保持します。実行可能になるまで要求が失われないようにするためです。最小の表現は二値の列挙型で足ります。 enum ScrollRequest { case none case requested } @Published private ( set ) var scrollRequest : ScrollRequest = .none 要求の記録とクリアを、状態遷移として定義します。 func requestScroll () { scrollRequest = .requested } func resolveScroll () { scrollRequest = .none } 要求の記録は、いつ実行されるかを規定しません。要求側は実行すべき内容だけを記録し、いつ実行可能かの判断は次節の実行側が担います。これにより、要求の発生と実行が分離されます。 3. 前提条件が揃った瞬間に、何度起きても安全に一度だけ実行する 要求が状態として残るようになったので、次は実行側を組みます。実行してよい条件は、要求と前提条件の論理積です。 この条件は二つの変数(要求と前提条件)の論理積であり、二つが真になる順序は起動状態によって変わります。どちらが先かで二通りに分かれます。 ケース A:要求が先に立ち、前提条件が後から揃う(コールドスタートに典型的) ケース B:前提条件が先に揃っていて、要求が後から立つ(すでに対象画面が表示されている場合) どちらのケースでも、後から真になった変数が出そろった瞬間に論理積が成立します。前提条件が要求のはるか前から揃っていても、その瞬間が要求の成立と同時になるだけで、構造は変わりません。順序が一定しない以上、実行を単一のタイミングに固定することはできません。そこで、条件が満たされうる タイミングごと に、同一のガードと同一の処理を配置します。SwiftUI では次の3つが該当します。 トリガー(1) : .onAppear ── 対象セクションが View 階層に出現した瞬間 トリガー(2) : scrollRequest の .onChange ── 要求が後から立った瞬間(セクションは描画済み) トリガー(3) :データ有無の .onChange ── データが後から到着した瞬間(要求は先に立っていた) .onAppear { if scrollRequest == .requested { proxy.scrollTo(targetSection); resolveScroll(); runFollowUp() } } .onChange(of : scrollRequest ) { newValue in if newValue == .requested, data != nil { proxy.scrollTo(targetSection); resolveScroll(); runFollowUp() } } .onChange(of : data != nil ) { hasData in if hasData, scrollRequest == .requested { proxy.scrollTo(targetSection); resolveScroll(); runFollowUp() } } 3つのトリガーは、いずれも同一のガード条件と同一の処理を持ちます。どちらのケースも、少なくとも一つのトリガーで捕捉されます。 ケース 条件が満たされる瞬間 捕捉するトリガー A:要求が先、前提条件が後 前提条件が後から揃う (3) または (1) B:前提条件が先、要求が後 要求が後から立つ (2) ここでケース B が、トリガー (2) を不可欠にします。前提条件がすでに揃っているところへ要求が立つと、要求の変化を見るトリガー (2) がその瞬間に成立を検知し、遅延なく実行します。コールドスタート(ケース A)だけ想定すると (2) は冗長に見えますが、すでに対象画面が表示されている状態を正しく扱うために必要です。「遅延実行」と呼んでいますが、要求の時点で前提条件が揃っていれば即座に実行され、遅延は前提条件が未充足のときだけ発生します。 複数のトリガーを置く以上、一つが実行した後に別のトリガーが発火しえます。これを安全にするのが、実行直後の resolveScroll() による状態消費です。 最初に条件を満たしたトリガーが状態を消費するため、後続のトリガーはガードで弾かれて副作用を持ちません。結果として、 どのトリガーが何回発火しても実行は必ず一度 となります。命令的アプローチが単一の実行時点を予測して失敗するのに対し、この設計は実行可能になりうる全時点を網羅し、最初に成立した一度だけを通します。ガード条件が不変である限り、タイミングがどう変動しても正しく動きます。 これは「いつスクロールするか」という命令的なタイミング制御を、「スクロールしてよい状態か」という宣言的な条件評価へ置き換えたものにほかなりません。 .onAppear や .onChange は SwiftUI が状態変化のたびに評価する仕組みであり、一過性の命令を、UI 更新と同じ評価サイクルに乗る宣言的な判定として表現しています。 4. 起動情報を、画面が生まれる前から保持し続ける置き場所を用意する ここまでで単一画面内の遅延実行は解けました。残る課題は、ディープリンクが運んできた起動情報(遷移元やコールバック先など)をどこに保持するかです。 コールドスタートでは、ディープリンクの受信そのものがアプリ起動のきっかけになります。つまり 起動イベントが先に発生し、それを受け取るべき View / ViewModel はその後に生成される わけです。イベントの時点では対象画面の ViewModel の init すら呼ばれておらず、View / ViewModel は起動をきっかけにこれから作られます。イベントが、受け手の生成に時間的に先行しています。 この非対称性が、状態の保持先を決定します。起動情報を @State や @Published のような View / ViewModel のライフサイクルに束縛された状態として持とうとすると、それらを宣言するインスタンスがイベントより後に生成されるため、イベントを受け取れません。したがって起動情報の保持先は、 View / ViewModel より生存期間の長いオブジェクト でなければなりません。起動元に近い側でこのオブジェクトを生成し、後から生成される ViewModel へ注入します。 この保持先に Combine の CurrentValueSubject を用います。 PassthroughSubject は送出時点で購読者が存在しなければイベントを破棄しますが、起動情報確定の時点で購読者となるべき ViewModel はまだ生成されていません。 CurrentValueSubject は最新値を保持し続けるため、ViewModel が後から生成・購読しても最新の起動情報を取得できます。 実装上は、起動元に近い側で Subject を生成し、ViewModel の init に注入します。ViewModel は Subject の所有者ではなく購読者です。 final class TargetViewModel { private let intentSubject : CurrentValueSubject < LaunchIntent , Never > init (intentSubject : CurrentValueSubject < LaunchIntent , Never > ) { self .intentSubject = intentSubject } } ViewModel は注入された Subject に対し、 init 時に .value を読めば起動時に確定した起動情報を同期的に取得でき、以降の新規起動には sink で反応できます。こうして CurrentValueSubject は、 イベント駆動(送出時に反応する)と状態保持(後から参照しても残っている)の双方を、ViewModel のライフサイクルを超えて 満たします。これは第2節の「要求を状態として保持する」を、起動情報という、より広い寿命のレベルで実現したものにあたります。 なお、ここで保持する起動情報は型のない URL ではなく、型付きの構造体にしておきます。後段のコードが URL の文法を意識せず、必要な値だけを参照できるようにするためです。 struct LaunchIntent : Equatable { let from : String? let callbackURL : String? let shouldScrollToSection : Bool } 5. 一つの起動情報に、複数の画面要素がそれぞれ反応できるようにする 一つのディープリンク起動が指示する内容は、しばしば複数の画面要素にまたがります。たとえば「特定のタブに切り替える」要素と「そのタブ内である処理を実行する」要素のように、担当の異なる別々の要素が、同じ一つの起動情報を必要とする場面があります。 起動情報を Subject で配信しておくと、これらの要素が 同じ一つの起動情報を、それぞれ独立に購読できます 。重要なのは、要素のあいだに上下関係や呼び出し関係がない点です。互いの存在を知らないまま、同じ Subject から各自に必要な情報だけを取り出します。 タブ切り替えを司る要素は、次のように購読します。 intentSubject .receive(on : DispatchQueue.main ) .sink { [ weak self ] intent in if intent.callbackURL != nil { self ?.selectedTab = .targetTab } } .store( in : & cancellables) 機能処理を司る要素は、同じ Subject を別に購読します。 intentSubject .sink { [ weak self ] intent in if let url = intent.callbackURL { Task { await self ?.handleCallback(url : url ) } } } .store( in : & cancellables) 二つの受け手は対等で、互いを知りません。新たな反応を足したければ .sink を一つ加えるだけでよく、既存の購読者には影響しません。命令の連鎖(「タブを切り替えてから処理を呼ぶ」)ではなく、一つの起動情報に複数の受け手が疎結合に反応する構造になっています。これが、起動情報を共有された状態として配信することの効果です。 6. 組み上がった構造 組み立ててきた要素を統合すると、次の構造になります。 この構造は、各部分を局所的に追加するだけで拡張できます。 新たな起動挙動の追加は、 Intent へのフィールド追加と、対応する要求状態・トリガーの追加で完結します。既存フローを変更しません。 新たな反応の追加は、 Subject への .sink 追加で完結します。他の購読者に影響しません。 実行タイミングを予測せず網羅するため、レイテンシや遷移順序が変動しても、ガード条件が不変である限り正しさが保たれます。 7. おわりに ディープリンク経由フローの難しさは、ルーティングではなく、「処理を要求された時点」と「処理を実行できる時点」がずれることにあります。命令的な実装は、要求した時点ですぐ実行できると暗黙に前提します。ですが実際に実行できるのは前提条件が揃ってからで、その時点は起動状態次第で後ろにずれます。この前提が崩れたときに動かなくなります。 この記事で組み立てた実装は、「いつ実行するか」を予測する命令的な発想から、「実行してよい状態か」を評価し続ける宣言的な発想へと転換するものです。要求をクリア可能な状態として保持し、前提条件が揃いうる全タイミングに冪等なトリガーを配置し、起動情報を ViewModel より生存期間の長いオブジェクトに保持させて配信します。いずれも、一過性の命令的要求を SwiftUI が扱える宣言的な状態へ翻訳する、という同じ発想の現れです。命令を宣言的な状態に落とし込めば、その状態は SwiftUI の評価サイクルに自然に組み込まれ、UI の更新と同じ仕組みで実行タイミングが解決されます。 これらを組み合わせることで、発生元もタイミングも制御できない外部起動に対し、レイテンシや遷移順序の変動があっても正しく動作し、かつ拡張容易な実装が得られます。これはプッシュ通知・ディープリンク・外部認証コールバックといった非同期な外部トリガーを宣言的 UI の上で扱う、あらゆる場面に適用しうるパターンです。
はじめに こんにちは。開発本部でデリッシュキッチンの Android アプリ開発を担当している岡田です。 アプリを運用していると、リリースのたびに Crashlytics とにらめっこする時間が必ず発生します。クラッシュの一覧を眺めて、優先度を決めて、スタックトレースを読んで、該当コードを探して、原因を考えて、直す。やること自体は明確なのですが、ダッシュボードとエディタを行き来する手作業が地味に重く、件数が増えると後回しになりがちでした。 本記事では、公式の Firebase MCP サーバー が提供する Crashlytics ツールを Claude Code から使い、この一連の流れ(トリアージ → 原因分析 → 該当コード特定 → 修正 PR のドラフト作成)を半自動化した話を紹介します。特定の社内事例に依存しない、誰でも再現できる手順としてまとめました。Android / iOS で Crashlytics を使っていて、AI コーディングツールを業務に取り込みたい方の参考になればうれしいです。 目次 はじめに 背景:クラッシュ対応のどこが手間か Firebase Crashlytics MCP とは セットアップ 前提 Claude Code への MCP 登録 クラッシュ対応フロー:半自動化の中身 1. トリアージ:上位クラッシュを一覧する 2. 深掘り:スタックトレースと発生状況を取得 3. 突き合わせ:原因仮説と該当コードの特定 4. 修正と PR ドラフト作成 運用ノウハウ / つまづきポイント ツールは --only crashlytics で絞る CI / サービスアカウント認証では 404 に注意 プラットフォームによってツールが出ないことがある Experimental ゆえのバージョン固定 どこまで任せるか(“半” の線引き) まとめ 参考リンク 背景:クラッシュ対応のどこが手間か クラッシュ対応そのものは難しい作業ばかりではありません。むしろ「手間」の多くは、判断と判断のあいだにある移動コストでした。具体的には次のようなところです。 ツールの往復 :Crashlytics(ブラウザ)でクラッシュを確認し、エディタに戻って該当箇所を探し、また Crashlytics に戻って影響範囲を見る、という往復が多い。 スタックトレースとコードの突き合わせ :難読化されたスタックトレースから当たりをつけ、リポジトリの該当コードまで辿るのは、慣れていても地味に時間がかかる。 トリアージの属人性 :「どのクラッシュから手を付けるか」の判断が、見る人によってブレる。影響端末・OS バージョン・発生数を毎回手で確認するのも面倒。 この「往復」と「突き合わせ」を AI に任せられれば、人間は 原因の判断と最終レビュー に集中できます。そこで MCP の出番です。 Firebase Crashlytics MCP とは MCP(Model Context Protocol) は、AI クライアントに外部ツールやデータソースを接続するための共通規格です。Firebase は公式に Firebase MCP サーバー を提供しており、その中に Crashlytics 用のツール群が含まれています。 このサーバーを AI クライアントに繋ぐと、ダッシュボードを開かなくても、AI との対話の中でクラッシュデータを取得・更新できるようになります。対応クライアントは Claude Code / Claude Desktop / Gemini CLI / Cursor / VS Code Copilot など幅広く、本記事では Claude Code を使います。 公式ドキュメント時点で提供されている主な Crashlytics ツールは以下のとおりです。 ツール名 役割 crashlytics_get_report 期間や条件を指定してクラッシュレポート(上位 issue など)を取得 crashlytics_get_issue 特定の issue の詳細を取得 crashlytics_list_events issue に紐づくイベント(スタックトレース等)を一覧取得 crashlytics_batch_get_events 複数イベントをまとめて取得 crashlytics_update_issue issue の状態(クローズ等)を更新 crashlytics_list_notes / crashlytics_create_note / crashlytics_delete_note issue へのメモの参照・追加・削除 加えて、 crashlytics:connect という対話的なガイド付きワークフローも用意されており、「どのクラッシュを優先すべきか」といった相談ベースの使い方もできます。Google I/O 2026 では、これらを束ねた Crashlytics の Agent Skills も発表され、IDE から離れずにデバッグ支援を受けられる方向に進んでいます。 ⚠ 注意 :Crashlytics の MCP 機能は Experimental(実験的) です。SLA や非推奨ポリシーの対象外で、ツール名や挙動が変わる可能性があります。本記事の内容も執筆時点(2026 年 6 月)のものです。 セットアップ 前提 Node.js / npm(Firebase CLI のインストールに使用) Firebase CLI をインストールし、認証済みであること。Firebase MCP サーバーは Firebase CLI と同じ認証情報 を使います。未導入なら以下を実行します。 # Firebase CLI をインストール(未導入の場合) npm install -g firebase-tools # バージョン確認 firebase --version # ログイン(MCP サーバーはこの認証情報を使う) firebase login Claude Code への MCP 登録 インストール済みの Firebase CLI なら、MCP サーバーは次のコマンドで起動します。 firebase mcp --dir /path/to/your/project Claude Code には .mcp.json (プロジェクト直下)で登録するのが手軽です。クラッシュ対応用途であれば、後述の理由から --only crashlytics でツールを絞る のがおすすめです。 { "mcpServers": { "firebase": { "command": "firebase", "args": [ "mcp", "--only", "crashlytics", "--dir", "/path/to/your/project" ] } } } CLI から登録する場合は次の形でも同じことができます。 claude mcp add firebase -- firebase mcp --only crashlytics グローバルインストールを避けたい場合は、公式ドキュメントのとおり npx -y firebase-tools@latest mcp ... ( command を npx 、先頭の args に -y firebase-tools@latest )でも起動できます。 登録後、Claude Code で /mcp を実行し、 firebase サーバーと crashlytics_* ツールが認識されていれば準備完了です。 クラッシュ対応フロー:半自動化の中身 ここからが本題です。Claude Code に自然言語で依頼するだけで、ツール呼び出し → 分析 → コード修正 → PR ドラフトまでを通しでやってもらいます。フロー全体は次のイメージです。 1. トリアージ:上位クラッシュを一覧する まずは「今いちばん効くクラッシュ」を洗い出します。 直近 7 日間で発生数が多いクラッシュを上位 5 件、影響ユーザー数と OS バージョン分布つきで一覧にして。優先度の高い順に並べて。 Claude は crashlytics_get_report を呼び、結果を表にまとめてくれます。発生数だけでなく影響ユーザー数や端末分布まで一度に並ぶので、「まず何から直すか」をその場で判断できます。 2. 深掘り:スタックトレースと発生状況を取得 優先度の高い issue を 1 件選び、詳細を取りに行きます。 1 件目の issue の詳細を取得して、代表的なスタックトレースと、発生している端末・OS・アプリバージョンの傾向を教えて。 ここでは crashlytics_get_issue と crashlytics_list_events (必要に応じて crashlytics_batch_get_events )が呼ばれます。「特定 OS バージョンだけで起きている」「特定の画面遷移直後に多い」といった、原因の当たりをつけるための材料が揃います。 3. 突き合わせ:原因仮説と該当コードの特定 ここが手作業でいちばん面倒だった部分です。Claude Code はリポジトリのコードも読めるので、スタックトレースとソースを突き合わせてもらいます。 このスタックトレースに対応するコードをリポジトリから特定して、クラッシュの原因仮説を立てて。null 安全や lifecycle 周りの問題があれば指摘して。 スタックトレースの該当クラス・メソッドからソースの行まで辿り、「ここで nullable な値を !! で参照していて、特定条件で null になりうる」といった粒度で原因仮説を出してくれます。 4. 修正と PR ドラフト作成 原因に納得できたら、修正と PR まで一気に依頼します。 原因仮説に沿って修正案を作って。修正したらブランチを切って、変更内容と原因・対応を説明した PR をドラフトで作成して。 Claude Code がコードを修正し、 gh pr create --draft でドラフト PR まで作ってくれます。PR 本文には「どのクラッシュに対応したか」「原因」「修正内容」が整理された状態で入るので、レビュー依頼の手前まで一気に進みます。 運用ノウハウ / つまづきポイント 実際に動かすと、いくつか引っかかりやすい点があります。先に共有しておきます。 ツールは --only crashlytics で絞る Firebase MCP サーバーは Crashlytics 以外にも多くのツールを持っています。プロジェクトのデータ量が多いと、起動時のツール一覧取得( tools/list )でタイムアウトやメモリ不足が起きることが報告されています( firebase-tools #9663 )。クラッシュ対応に用途を絞るなら、最初から --only crashlytics を付けてツールを限定しておくのが安全です。動作も軽くなります。 CI / サービスアカウント認証では 404 に注意 ローカルの firebase login では問題なく動くのに、CI 環境でサービスアカウントや Workload Identity Federation(WIF)を使うと、 crashlytics_get_report が「HTTP 404, Method not found」で失敗するという報告があります( #10310 / #10004 )。同じ環境でも firebase login:ci で取得したトークンなら通る、という回避策が共有されています。CI に組み込む前提なら、認証方式の検証は早めにやっておくとよいです。 プラットフォームによってツールが出ないことがある 過去には iOS プロジェクトで Crashlytics ツールが認識されないケースも報告されています( #9495 )。ツールが見当たらないときは、 --dir で正しいプロジェクトを指していること、firebase-tools が最新であることをまず確認してください。 Experimental ゆえのバージョン固定 前述のとおり Crashlytics MCP は Experimental です。常に最新の firebase-tools を使っていると、ある日ツール名や引数が変わって手順が動かなくなる可能性があります。チームで共有するなら、 npm install -g firebase-tools@<version> のように firebase-tools のバージョンを固定して運用するほうが安定します。 どこまで任せるか(“半” の線引き) あくまで「半」自動化にとどめているのには理由があります。クラッシュの原因仮説は説得力があっても間違っていることがありますし、修正がほかの挙動に影響しないかの最終判断は人間の責任です。 今回の運用では、 Claude は提案者、人間は承認者 という役割分担を崩していません。PR をドラフトで作るところまでを AI に任せ、レビューとマージは必ず人が行います。トリアージと突き合わせという「重いけれど判断の少ない作業」を肩代わりしてもらい、人は判断に集中する——この線引きが、いまのところいちばん心地よく回っています。 まとめ 公式の Firebase MCP サーバーと Claude Code を組み合わせることで、クラッシュ対応のトリアージから原因分析、該当コードの特定、修正 PR ドラフト作成までを通しで半自動化できました。ダッシュボードとエディタの往復が減り、後回しにしがちだったクラッシュ対応の腰が軽くなったのが一番の効果です。 まだ Experimental な機能なので過信は禁物ですが、上位クラッシュの定期トリアージを仕組み化するなど、伸ばせる余地はまだまだありそうです。同じように Crashlytics 運用に手間を感じている方は、まず --only crashlytics で繋いでみるところから試してみてください。 最後まで読んでいただきありがとうございました! 参考リンク firebase.google.com firebase.google.com firebase.blog firebase.blog modelcontextprotocol.io
Goの import cycle not allowed を出しているのはコンパイラじゃない こんにちは、 uho-wq です。 早速ですが、以下のソースコードをビルドするとどのような結果になるでしょうか? # ディレクトリ構成 $ tree . . ├── a │ └── a.go ├── b │ └── b.go └── go.mod 3 directories, 3 files // ./a/a.go package a import ( "example.com/cycle/b" ) func A() string { return b.B() } // ./b/b.go package b import ( "example.com/cycle/a" ) func B() string { return a.A() } 答えは下記のようにパッケージの循環参照によるエラー ( import cycle not allowed )が出ます。 $ go build ./... package example.com/cycle/a imports example.com/cycle/b from a.go imports example.com/cycle/a from b.go: import cycle not allowed ビルドのログを見てもわかる通り、 a.go がパッケージ b を、 b.go がパッケージ a をimportしているため、依存関係が循環してしまっていることがわかると思います。 Go言語では同一パッケージ内ではファイル間の循環依存が許容されていますが、パッケージ間の循環依存は禁止されています。 では次に、Goにおけるどの処理がこのエラーを吐いていると思いますか? 答えはコンパイラ ( cmd/compile )、ではなくgoコマンド ( cmd/go )なのです。 実際にエディタで確認して見ると、LSPのdiagnosticsがエラーを吐いていたので、コンパイルする前の段階で検知されていることが確かにわかります。 LSP diagnostics 2026年3月にThe Go Blogで公開された Type Construction and Cycle Detection では、Goのコンパイル時における型チェックについて言及されていました。 ただし、この型チェックはGoコンパイラ内で行われているため今回言及したいパッケージの循環参照とは別の仕組みであるということがわかります。 この記事ではGo 1.26.0のソースコードを実際に辿りながら、 cmd/go でどのように import cycle not allowed を検出しているのかを追っていきます。 importパスを取得する パッケージが循環参照しているかを検証するために、まず import "..." の宣言内容を取得する必要があります。 このimportの宣言取得はAST (抽象構文木)によって行われます。 import 宣言は構文解析されると ast.ImportSpec という AST ノードになり、パス文字列は (*ast.ImportSpec).Path.Value に入ります。 go/build はこの AST を走査して import パスのスライスを集めます。 // src/go/build/read.go at readGoInfo info.parsed, _ = parser.ParseFile(info.fset, info.name, info.header, parser.ImportsOnly|parser.ParseComments) for _, decl := range info.parsed.Decls { d, ok := decl.(*ast.GenDecl) if !ok { continue } for _, dspec := range d.Specs { spec, ok := dspec.(*ast.ImportSpec) // ← import 宣言の AST ノード if !ok { continue } path, _ := strconv.Unquote(spec.Path.Value) info.imports = append (info.imports, fileImport{path, spec.Pos(), doc}) } } ちなみに (*ast.ImportSpec).Path.Value の importパスにはダブルクォーテーション ( " )が含まれていますが、 strconv.Unquote で削除しています。 依存を探索する importパスのスライスが取得できたので、ここからは実際にimportパスを起点に探索を行います。 コアとなるロジックは cmd/go/internal/load/pkg.go の以下の関数、メソッドです。 loadImport : import を1つ解決して、その先のパッケージを返す関数。初めて見るパッケージなら (*Package).load を呼ぶ (*Package).load : パッケージを1つ読み込む関数。そのパッケージの全 import を順に loadImport で解決し、最後に依存リストを確定する これらが再帰的に呼び合うことによって、importパスの依存関係を深さ優先探索 (Depth-First Search: DFS)で辿っていきます。 とは言ったものの、少し複雑で分かりづらいので実際のソースコードから追っておきます。 下記は (*Package).load のソースコードの抜粋です。 // src/cmd/go/internal/load/pkg.go at (*Package).load imports := make ([]*Package, 0 , len (p.Imports)) for i, path := range importPaths { p1, _ := loadImport(..., path, ...) imports = append (imports, p1) ... } p.Internal.Imports = imports importパスごとに loadImport を呼び、解決し終えたimportsのスライスを最終的に p.Internal.Imports へ格納していることがわかります。 では次に loadImport を見てみましょう。 // src/cmd/go/internal/load/pkg.go at loadImport func loadImport(..., path string , ..., stk *ImportStack, ...) (*Package, *PackageError) { bp, _, err := loadPackageData(...) importPath := bp.ImportPath p := packageCache[importPath] // キャッシュを引く if p != nil { stk.Push(...); p = reusePackage(p, stk); stk.Pop() // 循環依存判定 } else { p = new (Package); p.ImportPath = importPath packageCache[importPath] = p // キャッシュ登録 p.load(..., bp, err) // 再度 (*Package).load を呼ぶ } ... return p, nil } ここではキャッシュ ( packageCache )が用いられていることがわかります。 このキャッシュはメモ化と循環依存の判定という2つの用途を持ちます。 メモ化: ロード済みパッケージの再ロードを防ぐ 循環依存判定 ( reusePackage ): キャッシュヒットしたパスがスタックしたパスのいずれかに戻ってきたかを判定する キャッシュヒットしなかった場合には再度 (*Package).load を呼ぶことによって再帰的な探索を行っています。 循環依存判定 キャッシュヒットしたパスに対して reusePackage によって循環依存判定を行います。 // src/cmd/go/internal/load/pkg.go (reusePackage) func reusePackage(p *Package, stk *ImportStack) *Package { // We use p.Internal.Imports==nil to detect a package that // is in the midst of its own loadPackage call ... if p.Internal.Imports == nil { // この部分 if p.Error == nil { p.Error = &PackageError{ ImportStack: stk.Copy(), Err: errors.New( "import cycle not allowed" ), IsImportCycle: true , } } p.Incomplete = true } return p } p.Internal.Imports は loadImport と (*Package).load が相互再帰的に呼び出している都合上、起点となるimportパスに対して最も深い依存関係まで探索して初めて非nilになります。 今回例にあげた循環依存は a → b → a の経路を辿るので、最初の a に対しての探索が解決する前 ( p.Internal.Imports == nil )に再度 a がキャッシュヒットしたため import cycle not allowed として考えることができるわけです。 ちなみに、辿った経路はすべて ImportStack にスタックとして保持されています。 // src/cmd/go/internal/load/pkg.go type ImportStack []ImportInfo func (s *ImportStack) Push(p ImportInfo) { *s = append (*s, p) } func (s *ImportStack) Pop() { *s = (*s)[ 0 : len (*s)- 1 ] } func (s *ImportStack) Copy() ImportStack { return slices.Clone(*s) } 循環依存のエラーを返す際に、 ImportStack をPackageErrorに含めて返すことによって、ビルドのエラーログに経路を含めて表示する事が可能となっています。 ❯ go build ./... package example.com/cycle/a imports example.com/cycle/b from a.go imports example.com/cycle/a from b.go: import cycle not allowed おわりに ここまで import cycle not allowed の出どころについて追ってきました。エラーを吐いていたのはコンパイラ ( cmd/compile ) ではなく go コマンド ( cmd/go ) でした。 仕組みは、 go/build が AST から import パスを取り出し、 loadImport と (*Package).load が相互再帰で深さ優先探索し、探索中に同一パッケージがキャッシュヒットした場合に循環参照として判定していることがわかりました。
はじめに こんにちは、開発1部で食事管理アプリヘルシカの開発をしている新谷です。 ダイエット・食事管理・体重管理・カロリー計算 - ヘルシカ every, Inc. ヘルスケア/フィットネス 無料 apps.apple.com 今回は、iOSアプリの広告効果がどう計測されているのかを、アトリビューションツール Adjust を通して調べてみました。 iOS開発を始めて半年ほど経ちましたが、Adjustは広告効果を測定するためのツール程度の認識しかなく、改めて仕組みを理解してみようと思ったのがきっかけです。正直、すべてを細かく追えたわけではないのですが、調べて分かった範囲を整理してみます。 広告アトリビューションの全体像 そもそもアトリビューションとは、ユーザーがどの広告経由でアプリをインストール・利用したかを特定することです。どの広告が効果的だったかを知るために使われます。 Adjustで広告効果を計測するとき、計測は大きく2つの系統に分かれます。 Adjust自身のアトリビューション。クリックやインプレッションとインストールを突き合わせて判定します。ATT(App Tracking Transparency)の同意状況やネットワークの種類によって、判定手法が変わります。 SKAdNetworkによる計測。OSが匿名で計測する仕組みで、ユーザーの同意がなくても広告経由のインストールを計測できます。Adjustでは、自前のアトリビューションとは別のデータとして扱われます( Adjust: SKAdNetwork )。 この2つは別の経路です。前者はユーザー単位の識別子であるIDFA(Identifier for Advertisers)を使えるかどうかが鍵になり、後者はOSが匿名で計測するため同意がなくても動きます。以降、まず前提となるIDFAとATTを確認してから、それぞれの計測を見ていきます。 IDFAとATTについて iOSの広告計測の中心にあるのが IDFA です。これはデバイスごとの広告用IDで、これを使うと、どの広告を見たユーザーがインストールしたかをユーザー単位で追跡できます。 iOS 14.5以降、このIDFAへのアクセスには ATT による明示的な同意が必須になりました。ATTは、アプリがユーザーを追跡する前に同意ダイアログで許可を求めることを義務づける、Appleの仕組みです。 同意状況は ATTrackingManager.trackingAuthorizationStatus から取得でき、値は次の4つです( Apple: ATTrackingManager.AuthorizationStatus )。 notDetermined : まだ同意ダイアログを出していない状態 authorized : ユーザーが許可した状態 denied : ユーザーが拒否した状態 restricted : ユーザーが同意・拒否を選べない状態(スクリーンタイムなどで制限されている場合) 許可が得られない場合、IDFAはすべてゼロのダミー値になり、ユーザー単位の追跡はできません。 1. Adjustのアトリビューション手法 Adjustのようなアトリビューションツール(MMP: Mobile Measurement Partner)は、広告のクリックやインプレッションとインストールを突き合わせて、どの広告が効いたかを判定します。この判定の手法が、ATTの同意状況とネットワークの種類によって変わります。 1-1. ATTの同意がある場合 ATTの同意が得られていてIDFAが読める場合、Adjustは確定的アトリビューションを使います。広告のクリックやインプレッションの時点と、インストールの時点でそれぞれ一意のIDを収集し、両者が一致すればそのインストールを広告に結びつける、というデバイスマッチングの手法です。Adjustの説明では、クリックベースのものは「精度が100%で、最も信頼性の高いアトリビューション」とされています( Adjust: アトリビューション手法 )。 Adjustが使う識別子は主に次のものです。 広告ID(iOSのIDFA): ユーザーがリセット・制限できる、広告用のID デバイスID(iOSのIDFV、Identifier for Vendor): デバイスに永続的に紐づくID。アトリビューション目的でのみ使われる Adjustのリファレンスタグ: クリックやインプレッションごとに生成される一意のID SANと非SANで仕組みが違う 同じ確定的アトリビューションでも、ネットワークの種類によって、誰が突き合わせを行うかが変わります。広告ネットワークは、Google・Meta・TikTokなどの大手で自前でアトリビューションを行う SAN(Self-Attributing Network、自己アトリビューションネットワーク)と、それ以外の通常のネットワーク(非SAN)の2種類に分かれます( Adjust: Self-Attributing Network )。 SAN: クリックやインプレッションのデータを自分からは共有しません。代わりに、AdjustがSDK経由で取得した広告IDを送ってくるのを待ち、自社が持つデータと突き合わせて判定します。 非SAN(通常のネットワーク): 広告IDを含むクリックやインプレッションのデータを、Adjustのようなアトリビューションパートナーに送ります。Adjustがそれをインストールと突き合わせて判定します。 つまり、SANはAdjustからネットワークへ、非SANはネットワークからAdjustへと、データを送る向きが逆になります。突き合わせを行うのも、SANはネットワーク側、非SANはAdjustです。 図にすると、それぞれこうなります。 SAN(ネットワークが突き合わせる) 非SAN(Adjustが突き合わせる) 1-2. ATTの同意がない場合 ATTで同意が得られないとIDFAはゼロ値になるため、IDFAを使った確定的アトリビューションはできません。この場合の扱いは、ネットワークの種類で分かれます。 非SAN(通常のネットワーク)の場合 非SANでは、その代わりに確率的モデリング(デバイスIDを使わず統計的に推定する手法)やSKAdNetworkが使われると、Adjustのドキュメントで説明されています( Adjust: ATTフレームワーク )。 SANの場合(Appleを除く) SAN(Appleを除く)では、ネットワーク側がクリックなどのデータをAdjustに渡さないため、非SANのような確率的モデリングは使えません。Adjustのドキュメントでも「Apple Search Adsを除き、ユーザーの流入元としてAPI連携パートナーに紐付けることに制限がある」とされています( Adjust: ATTフレームワーク )。また、IDFAの共有にオプトインしていないiOSデバイスのインストールについては、SKAdNetworkの集計データを使って計測することになると説明されています( Adjust: Self-Attributing Network )。 1-3. Apple広告の場合 Apple広告(Apple Search Ads)だけは、他のネットワークとは別の経路で計測されます。AppleのAdServices frameworkと、サーバー側から呼ぶAPIを組み合わせた Apple Ads Attribution API という仕組みです( Apple: AdServices )。 Appleのドキュメントでは、アトリビューション取得の流れが次のように説明されています。 アプリが AdServices framework にトークンをリクエストする framework がトークンを生成して返す そのトークンを使って、APIでAppleのアトリビューションサーバーにリクエストする キャンペーンに対応するアトリビューション情報( campaignId や adGroupId 、 keywordId など)が返る Adjust SDKを使う場合は、この一連のやり取りをSDKが担います。 レスポンスは、ATTの同意状況によって2つのタイプに分かれます( Adjust: Apple Adsアトリビューションの仕組み )。 詳細(Detailed): ATTに同意したユーザー向け。エンゲージメントの時刻(分単位)まで含む 標準(Standard): 同意しなかったユーザー向け。時刻は含まれない このように、Apple広告はATTに同意がなくても標準レスポンスで計測でき、同意があれば時刻まで含む詳細なデータが得られます。 2. SKAdNetworkでの計測 ここまではAdjust自身が行う計測でした。もう1つの系統が、OSが提供するSKAdNetworkです。 SKAdNetworkはiOS 11.3(2018年)から提供されています。ユーザー個人を特定せずに広告の成果を計測できるよう、Appleがインストール計測をOS側で引き受ける仕組みとして導入しました。iOS 14.5のATTでIDFAの利用が制限されてからは、同意がなくても成果を計測できる手段として重視されるようになったようです。 SKAdNetworkの計測には、Adjust自身のアトリビューションとは違ういくつかの特徴があります( Adjust: SKAdNetwork )。 ユーザーの同意がなくても動く(OSが匿名で計測するため) Adjustが記録するデバイスレベルのデータとは別物として扱われる リアルタイムではなく、最短でも24時間の遅延がある インストール後の行動はコンバージョン値(0〜63の小さな数値)として表現される おおまかな流れは、Appleが最終的なコンバージョン値を確定し、広告ネットワークに通知し、ポストバック(成果を知らせる通知)が送られる、というものです。 なお、SKAdNetworkで計測するには、アプリ開発者が、利用する広告ネットワークのIDを Info.plist の SKAdNetworkItems に登録しておく必要があります( Apple: SKAdNetworkItems )。Adjust SDK自体にはSKAdNetworkのサポートが含まれており、コンバージョン値はAdjustの管理画面で設定したマッピングに従って処理されます( Adjust: SKAdNetwork )。 そのうえで、Adjustがこのポストバックを受け取る経路には、2つのパターンがあります。 2-1. ネットワーク経由のポストバック 1つ目は、広告ネットワークを経由する経路です。Appleが最終的なコンバージョン値を確定すると、まず広告ネットワークに通知が届きます。その通知を受けた広告ネットワークが、Adjustにデータを含むポストバックを送ります( Adjust: SKAdNetwork )。 この経路でAdjustが受け取れるデータは、広告ネットワークが提供する内容に左右されます。Adjustは複数の広告ネットワークから届いたSKAdNetworkのデータを集約してレポートします。 2-2. SKAdNetworkから直接届くポストバック 2つ目は、SKAdNetworkからAdjustに直接ポストバックが届く経路です。iOS 15以降、SKAdNetworkは獲得したポストバックを、広告ネットワークのエンドポイントに加えて、アプリ側で指定した追加のURLにも直接送れるようになりました( Adjust: SKAdNetwork ダイレクトインストール )。このURLにAdjustのエンドポイントを指定しておくと、端末のSKAdNetworkからAdjustへ直接ポストバックが届きます。 この経路を使うには、アプリ開発者が Info.plist にAdjustのコールバックURLを追加する必要があります。 この経路の利点は、コンバージョン値・トランザクションID・アトリビューション署名などを含む完全なデータセットがそのまま届くことです。一方で、FacebookやGoogle広告など一部のパートナーからはトランザクションIDや署名が得られないことがあり、その場合は詳細なデータの比較ができず、ポストバック数を比べるにとどまります。 まとめ iOSアプリの広告効果がどう計測されているのかを、Adjustを通して調べました。 調べてみると、ATTの同意状況とネットワークの種類で手法が細かく分岐していて、想像よりずっと込み入っていました。 広告計測はマーケティング寄りの領域で、普段エンジニアはあまり関わらないかもしれませんが、仕組みを知っておくと役立つ場面がありそうだと感じています。同じように広告計測まわりを調べている方の参考になればうれしいです。
目次 はじめに セッション「業務に残された良くない型で考える TypeScript の限界」 自分のプロジェクトを点検してみた まずは網羅的に数える 「どう対策するか」で分類する 実際に直してみた ユニオン配列の絞り込み 点検してわかったこと おまけ: TSKaigi 2026 を楽しんだ話 おわりに はじめに こんにちは、デリッシュキッチンでフロントエンドの開発をしている惟高です! 先日、TypeScript のカンファレンス「TSKaigi 2026」に参加してきました。 イベント全体の様子やブースの企画、各セッションの紹介は弊社の最速レポートにまとめているので、当日の雰囲気が気になる方はこちらもあわせてご覧ください。 tech.every.tv 本記事では、その中でも特に印象に残った sajikix さんのセッション「業務に残された良くない型で考える TypeScript の限界」を取り上げます。 speakerdeck.com 実務のコードに残ってしまう「良くない型」との向き合い方を扱ったセッションでした。聞きながら「自分が普段書いているコードにも、こういう型はたくさんあるな」と思い、気になったので、同じやり方で自分のプロジェクトを点検してみました。 セッション「業務に残された良くない型で考える TypeScript の限界」 セッションは、業務で書いてしまった「良くない型」や「妥協した型」を収集・分類し、パターン化・分析することで、TypeScript の難しさの一端に迫る、という内容でした。 セッションでは、「良くない型」の探し方も具体的に示されていました。AI を使って次の観点を網羅的に洗い出し、分類したうえで、分類ごとに実際にコードを見て判断する、という進め方です。 @ts-ignore / @ts-expect-error (コンパイルエラー自体を黙らせる指定) as キャスト(型アサーション。値を「この型だ」と言い切る。 as const や as unknown as は除外) 型ガード関数 value is X (値の型を絞り込む関数) セッションを聞いて、普段触っているプロジェクトに当てはめるとどんな結果になるのか気になり、実際にやってみました。 自分のプロジェクトを点検してみた 対象は、私が普段関わっているフロントエンドのコードベースです。TypeScript 4.5.5 / Next.js 12 / React 17 という構成です。 まずは網羅的に数える セッションの探し方にならい、3つの観点を網羅的に抽出しました。結果は次のとおりです。 観点 件数 @ts-ignore / @ts-expect-error 0 件 型アサーション as ( as const / as unknown as を除外) 104 件 型ガード関数 value is X 1 件 この分布を見ると、型まわりで使われている手段が as にかなり偏っているのがわかります。 @ts-ignore は使われておらず、型ガード(型述語)も1件だけでした。 こうした as は、誰か特定の人の問題ではなく、日々の開発や当時の TypeScript の制約のなかで自然に積み重なっていくものだと思います。 なお、単純に as を文字列検索すると、 export { default as Foo } のような 再エクスポート (型キャストではない構文)まで引っかかってしまいました。こうした型キャストではないものを AI に分類させて除き、「型アサーションとしての as は104件」という数字を出しました。 「どう対策するか」で分類する 次に、見つけた104件をどう分類するかです。セッションでは「どう対策するか」を起点に、次の2つの軸が紹介されていました。 境界で起きているのか / 内部で起きているのか : 外部(ライブラリ・外部データ・DOM など)との境界に由来するものか、自分たちのコード内部で起きているものか 安全圏に押し戻せるか : 型ガードやヘルパーで型安全に直せるか、それとも難しいか この2軸で104件を仕分けすると、次のようになりました。 押し戻せる 難しい 境界 約76件(外部データ・フォームライブラリ・DOM など) 1件 内部 約22件(配列の絞り込み・冗長な as など) 数件(動的なキーアクセス) Note : ライブラリの型を扱う箇所など境界と内部をまたぐパターンもあり、件数は概数です。 7割超が「境界 × 押し戻せる」に集中しました。つまり、 外部との境界で型が緩くなった箇所を as で握っていて、しかもその多くは型ガードやヘルパーで直せる 、ということです。ここからは、内部起因の as として型ガードで押し戻せる例を見ていきます。 実際に直してみた ここで紹介するコードは、社外公開のため型名や処理を一般化しています。また TypeScript 4.5.5 で型チェックが通ることを確認しています。 型ガードを追加することで TypeScript の型追跡が効くようになる例を見ていきます。 ユニオン配列の絞り込み string と number が混在しうる配列を、「全部 string の配列」として型を絞って扱いたい箇所がありました。 if (typeof answers[0] === 'string') { return (answers as string[]).every((a) => target.includes(a)); } as string[] はコンパイル時に型を string[] として扱わせる指定で、実行時には何も起きません。残りの要素に number が混ざっていても検出できず、TypeScript もこのチェックから配列全体の型を絞ることができません。 直し方は2つあります。 型述語(type predicate) : 型ガード関数を追加するだけで、既存のデータ構造を変えずに as を外せます。 const isStringArray = (a: (string | number)[]): a is string[] => a.every((x) => typeof x === 'string'); if (isStringArray(answers)) { return answers.every((a) => target.includes(a)); // a は string に絞られる } isStringArray が全要素を確認してから a is string[] を返すので、TypeScript は if ブロック内で answers が string[] だと追跡できます。 discriminated union(判別可能なユニオン) : データ構造にタグを持たせる設計変更が必要ですが、TypeScript が kind の値を見るだけで型を絞れます。 discriminated union: 共通のタグ(ここでは kind )を持たせたユニオン型。タグの値を見るだけで、どのメンバーかを型レベルで絞り込めます。 type Answers = | { kind: 'string'; values: string[] } | { kind: 'number'; values: number[] }; if (answers.kind === 'string') { return answers.values.every((v) => target.includes(v)); // v は string に絞られる } as string[] は TypeScript に型を宣言するだけで実行時の保証はありませんでしたが、型述語は全要素の確認、discriminated union はデータ構造の設計によって、TypeScript が型を正しく追跡できる状態になります。 点検してわかったこと 104件を一通り見て、いくつかのことがわかりました。 まず、セッションスライドで具体的に紹介されていたパターン(DOM Event / catch(e) / unknown / Branded型 / Array標準メソッド)は、このリポジトリにはほとんど存在しませんでした。代わりに多かったのは as string が71件で、大半はオプショナルプロパティに対して undefined の可能性を as で除いているものでした。 型ガードを追加することで押し戻せる例(union 配列の絞り込み)も見つかりました。一方、フィールド名が外部データで動的に決まるフォームのように、現在の設計では型安全にしきれない箇所も残っています。そうした as については理由と改善の見通しをコメントで残しておく、という方針が考えられます。 おまけ: TSKaigi 2026 を楽しんだ話 セッションや技術的な学び以外にも、今回は純粋にイベントを楽しめました! 各社のスポンサーブースを巡って担当者の方から直接お話を聞けたのも良かったです。エブリーのブースでは、足を運んでいただいた方々から AI の活用事例をいろいろ聞かせていただき、各現場での取り組みがとても刺激的でした。 お弁当も美味しく、ノベルティもたくさんいただいて大満足な2日間でした!TypeScript についてじっくり向き合える機会はなかなかないので、こういったカンファレンスに定期的に参加することの大切さを改めて実感しました。 ノベルティ一覧 おわりに セッションで学んだ探し方を実際に自分のコードに当てはめることで、漠然と as を使っていた箇所が可視化されました。どこに型ガードが必要なのかが具体的に見え、型アサーションを型システムと向き合うきっかけにしていきたいと思います。 最後までお読みいただき、ありがとうございました!
はじめに こんにちは。リテールハブ開発部小売アプリチームの池です。 業務で Laravel Octane のメモリが残る挙動について調査する機会がありました。 Laravel Octane は、長時間稼働するプロセス上で Laravel アプリケーションを動かして高速化するツールです。便利な一方で、プロセスが長く生きるためメモリが残り続け、書き方次第ではリクエスト間で状態が引き継がれてしまうという、従来の Nginx + PHP-FPM 構成の Laravel では発生しにくい特性を持っています。この特性を理解せずに使うと予期しない事故につながる可能性があると感じました。 そこで本記事では、Octane + Swoole の仕組みを整理した上で、サンプルプログラムで挙動を検証し、Worker プロセスが常駐することに起因して気をつけるべきポイントについて整理したいと思います。 Laravel および Octane について多少の知識がある方を前提に書いており、Laravel 本体の解説等には触れません。 なお、本記事の内容は一次情報から確認するように努めていますが、私の理解違いや Octane / Swoole のバージョン差による挙動の違いが含まれている可能性があります。誤って実装すると事故につながり得る領域でもあるため、最終的にはご自身でソースコードや公式ドキュメントをご確認の上で適用ください。 仕組みの整理 この章では、Octane + Swoole で前のリクエストの情報が次のリクエストに残る仕組みを、次の 4 つの観点から整理します。 Octane + Swoole では Worker プロセスが常駐するため、PHP プロセス内のメモリがリクエストごとに初期化されない Octane はリクエストごとに $this->app を clone して $sandbox 上で処理することで、ベースのアプリインスタンスを直接書き換えないようにしている ただし PHP の clone はシャローコピーなので、共有されたオブジェクトの内部状態はリクエスト間で残り得る Octane は RequestReceived イベントに紐づくリスナー群でフレームワーク側の状態をリセットしている 本記事に登場するアーキテクチャ図やライフサイクル図は必要な要素に絞って簡略化しています。 1. Worker プロセスが常駐するため、メモリは初期化されない Octane + Swoole は、長時間生きる PHP プロセスを立ち上げる仕組みです。 Swoole のプロセスアーキテクチャ Worker は OS プロセスです。Manager から複数生成され、一定数のリクエストを処理するか停止シグナルを受信するまで走り続けます。 Worker のライフサイクル 長時間生き続ける Worker プロセスは次のように動きます。 注目したいのは、1 つの Worker プロセスが生き続けたまま複数のリクエストを順に処理し続ける構造です。Worker 起動時に 1 回だけ Laravel アプリケーションを組み立てて $this->app に保持し、その後はリクエストのたびにこの $this->app を clone して $sandbox として使い回します。この「同じ $this->app が複数リクエストにまたがって使われる」点が、後で見る「前のリクエストの情報が次のリクエストに残ってしまう」仕組みの一部になっています。 Worker 起動時に Laravel アプリケーションを組み立てている処理 Worker::boot() の実装は以下の通りです。 <?php // vendor/laravel/octane/src/Worker.php public function boot ( array $ initialInstances = []) : void { // ベースとなる Laravel アプリインスタンスを1つ生成 // 以降、リクエストのたびにここから clone する $ this -> app = $ app = $ this -> appFactory -> createApplication ( ... ) ; $ this -> dispatchEvent ( $ app , new WorkerStarting ( $ app )) ; } なぜメモリが残るのか Swoole + Octane では以下の仕組みでメモリが残ります。 Worker プロセスが生き続けるため、リクエスト終了時に変数・オブジェクトに割り当てられたメモリが解放されない 結果として、 $this->app を含む変数・オブジェクトがリクエストをまたいでメモリに残り続ける ここから先は、この「メモリに残り続ける $this->app をリクエストごとにどう扱うか」ということに焦点を当てます。 2. Octane はリクエストごとに $this->app を clone する Worker が常駐すれば $this->app も残ります。ただ、リクエスト処理の中で $this->app を直接書き換えてしまうと、その変更が次のリクエストにそのまま残ります。Octane はこれを避けるために、リクエストのたびに $this->app を clone して $sandbox を作り、その上でリクエスト処理を回す設計になっています。 <?php // vendor/laravel/octane/src/Worker.php public function handle ( Request $ request , RequestContext $ context ) : void { // アプリインスタンスを clone してリクエスト用の sandbox を作る CurrentApplication :: set ( $ sandbox = clone $ this -> app ) ; $ gateway = new ApplicationGateway ( $ this -> app, $ sandbox ) ; try { $ response = $ gateway -> handle ( $ request ) ; // ... レスポンス返却 ... } finally { $ sandbox -> flush () ; // sandbox 側の bindings クリア unset ( $ gateway , $ sandbox , ... ) ; CurrentApplication :: set ( $ this -> app ) ; // 元のアプリインスタンスに戻す } } ここでの clone の役割は、リクエスト処理を $sandbox 側に隔離して、ベースの $this->app を直接書き換えないようにすることです。 $this->app 自体は Worker 寿命までずっと生き続けますが、毎リクエストの処理が $this->app を直接書き換えなければ、結果としてベースを変更せずに使い回せる、という設計になっています。 3. clone はシャローコピーなので、内部状態はリクエスト間で残り得る ただし、PHP の clone はシャローコピーであるため、前節の「ベースを書き換えない」が成り立つ範囲には限界があります。 Application オブジェクト自体は新規(リクエストごとの器) 配列プロパティ( bindings / instances など)はコピーされる(sandbox 側で書き換えても元には反映されない) 配列の中身(実際のオブジェクト)は元のアプリ $this->app と $sandbox で共有される これにより、以下のような挙動になります。 例えばリクエスト中に app()->instance('request', $req) のように差し替えても、 $sandbox 側にのみ反映され、ベースの $this->app には反映されない 一方で、ベースの $this->app に登録された解決済み singleton インスタンスは両者で共有されたまま 「singleton にリクエスト固有データを入れると次のリクエストにも残る」という現象は、この「clone してもオブジェクト自体は共有される」ことが要因と考えられます。 clone はコンテナの配列レベルでの隔離は提供するものの、配列の中に入っているオブジェクトの内部状態までは守ってくれない、というのがポイントです。 4. Octane の RequestReceived リスナーが一部の状態をリセットする clone だけでは配列の中に入っているオブジェクトのプロパティ書き換えを防げないため、Octane はそこを、リクエストごとに発火するイベントとそれに紐づくリスナーで、明示的に状態をリセットすることで補っています。 Worker のメインループ Worker のメインループの中では RequestReceived イベントが発火し、デフォルトで 8 個のリスナーを順に実行してから HTTP Kernel に処理を渡します。 Worker 起動 ↓ WorkerStarting イベント ↓ ┌── メインループ ─────────────────────────────────────────┐ │ RequestReceived イベント ─→ [8 listeners] │ │ ├─ FlushLocaleState │ │ ├─ FlushQueuedCookies │ │ ├─ FlushSessionState │ │ ├─ FlushAuthenticationState │ │ ├─ EnforceRequestScheme │ │ ├─ EnsureRequestServerPortMatchesScheme │ │ ├─ GiveNewRequestInstanceToApplication │ │ └─ GiveNewRequestInstanceToPaginator │ │ ↓ │ │ HTTP Kernel (Middleware → Controller) │ │ ↓ │ │ リクエスト終了 │ └─────────────────────────────────────────────────────────┘ ↓ max_requests に達したら Worker 再起動 これら 8 個のリスナーは、Locale / Cookie / Session / Auth といったフレームワーク状態のリセットや、 app('request') の差し替え、HTTPS スキームやポートの整合性チェックなどを担います。 実装の中でも、特に挙動を把握しておきたい 2 つを確認します。 まず、認証ガードを毎リクエスト破棄する FlushAuthenticationState を見てみます。 Laravel の AuthManager は内部で Guard インスタンスを $guards 配列にキャッシュし、各 Guard はさらに認証済みユーザーをプロパティに保持します。Octane でこのインスタンスをクリアしないと、 singleton で起きる現象と同じ構造で、前のリクエストの認証ユーザーが次のリクエストに引き継がれてしまいます。 FlushAuthenticationState は、このキャッシュを毎リクエスト破棄することで、前のリクエストの情報が次のリクエストに残らないようにしています。 実装は以下の通りです。 <?php // vendor/laravel/octane/src/Listeners/FlushAuthenticationState.php class FlushAuthenticationState { public function handle ( $ event ) : void { if ( $ event -> sandbox -> resolved ( 'auth.driver' )) { $ event -> sandbox -> forgetInstance ( 'auth.driver' ) ; } if ( $ event -> sandbox -> resolved ( 'auth' )) { with ( $ event -> sandbox -> make ( 'auth' ) , function ( $ auth ) use ( $ event ) { $ auth -> setApplication ( $ event -> sandbox ) ; $ auth -> forgetGuards () ; }) ; } } } auth がコンテナで解決済みの場合に forgetGuards() を呼びだし、Guards のキャッシュをクリアしていることがわかります。 次に、Request インスタンスを差し替える GiveNewRequestInstanceToApplication の実装は以下の通りです。 <?php // vendor/laravel/octane/src/Listeners/GiveNewRequestInstanceToApplication.php class GiveNewRequestInstanceToApplication { public function handle ( $ event ) : void { $ event -> app -> instance ( 'request' , $ event -> request ) ; $ event -> sandbox -> instance ( 'request' , $ event -> request ) ; } } app('request') を新しい Request インスタンスに差し替えます。 app('request') を呼ぶコードが常に最新の Request を見られるのは、このリスナーの働きによるものと理解できます。 何が残って、何が消えるのか 2 つのレイヤーに分けて整理します レイヤー 何が常駐するか リセット手段 Worker プロセス全体 Worker プロセス自体 + 関数テーブル / クラステーブル / static 変数 / グローバル変数 octane:reload / max_request 到達による Worker 再起動 Laravel Application ( $this->app ) bindings、singleton インスタンス、boot 済み ServiceProvider RequestReceived リスナー(部分的) なお、各 Worker は独立した OS プロセスであるため、Worker 間のメモリは分離されています。コルーチン無効時には同じ Worker 内のリクエストも順次処理されるため、気にすべきは「同じ Worker の中で、前のリクエストのデータが次のリクエストに引き継がれてしまわないか」という点に絞られると考えられます。 そのうえで、Laravel アプリコード上でよく使う状態保持の方法ごとに、同じ Worker・別リクエストでどう振る舞うかを整理すると次のようになります。 状態保持の方法 同 Worker・別リクエスト static 変数 残る グローバル変数 / $GLOBALS 残る Worker boot 時点などで解決済みの singleton インスタンスのプロパティ 残る 通常 bind (毎回新規) 毎回新規 scoped バインディング 次のライフサイクル開始時に flush $request->attributes Request 自体が新規生成 「残る」となっている static / グローバル変数 / singleton プロパティは、リクエスト固有のデータや、リクエストごとに増え続けるデータを置くと事故につながり得る点に注意が必要です。アプリ起動時に 1 度だけ初期化されるような不変なデータや、Worker 内で意図的に共有したいデータを置く分には問題ないと考えています。 ここまでは仕様上こうなっているはず、という整理でした。次は簡易的なプログラムで動作を検証します。 検証 仕組みの整理で示した観点を、実機で順に確認していきます。 static 変数 / グローバル変数がリクエスト間で残ること $this->app の内部状態(singleton インスタンスのプロパティ)がリクエスト間で残ること RequestReceived リスナーが一部の状態を実際にリセットすること 加えて、これらの検証が成立する前提として「同 Worker 内ではリクエストが順次処理される」ことを最初に確認します。 検証環境 検証は macOS 上のローカル Docker コンテナで、以下のバージョン構成で行います。 ソフトウェア バージョン Laravel 12.59.0 Octane 2.17.3 Swoole 6.2.1 Octane は次のコマンドで起動します。 php artisan octane:start --server=swoole --workers=10 --max-requests=500 このとき内部で適用される主な Swoole オプションは次の通りです。 設定 値 由来 enable_coroutine false Octane デフォルト(Laravel 本体がコルーチンセーフでないため意図的に無効化) worker_num 10 --workers=10 で指定 max_request 500 --max-requests=500 で指定(メモリリーク対策) 特別な設定はしておらず、Octane / Swoole のデフォルトのままです。 検証用ルートは routes/web.php に用意し、curl で叩いて結果を観察します。 前提の確認: 同 Worker 内でリクエストが順次処理されること まず大前提として、1 Worker 内では複数リクエストが並行処理されず、1 つずつ順番に処理されることを確認します。これ以降の検証はすべて「同じ Worker に来た複数リクエストが順番に処理される」という前提で議論を組み立てるため、確認します。 確認用コード リクエストを受け取ったら 2 秒スリープして PID とコルーチン ID を返すだけの単純なルートを用意します。 <?php Route :: get ( '/test-coroutine' , function () { $ pid = getmypid () ; $ cid = \Swoole\Coroutine :: getCid () ; sleep ( 2 ) ; return [ 'pid' => $ pid , 'cid' => $ cid , 'is_coroutine' => $ cid !== - 1 , ] ; }) ; 実行 Worker 数を 1 に絞った状態( --workers=1 )で、3 リクエストを並列で投げます。もし並行処理されるなら合計 2 秒前後で完了するはずです。 start= $( date +%s ) curl -s http://localhost:8001/test-coroutine > /tmp/r1 & curl -s http://localhost:8001/test-coroutine > /tmp/r2 & curl -s http://localhost:8001/test-coroutine > /tmp/r3 & wait echo " Total: $(( $ ( date +%s ) - start )) s " 結果 { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } Total : 6s 3 リクエストの PID が一致(=14)し、 cid = -1 でコルーチン外、合計時間が 2 秒×3 = 6 秒となっていることから、1 Worker 内ではリクエストが並行ではなく順次処理されることが確認できました。 検証 1: static 変数 / グローバル変数がリクエスト間で残ること 仕組みの整理で「static 変数は同 Worker 内では持続する」と書きました。これを実際に確かめます。 確認用コード <?php Route :: get ( '/test-static' , function () { static $ counter = 0 ; $ counter ++ ; $ GLOBALS [ 'global_counter' ] = ( $ GLOBALS [ 'global_counter' ] ?? 0 ) + 1 ; return [ 'pid' => getmypid () , 'static_counter' => $ counter , 'global_counter' => $ GLOBALS [ 'global_counter' ] , ] ; }) ; static 変数とグローバル変数の両方をインクリメントするシンプルなコードです。 実行 まず、同 Worker 内挙動を見るため Worker 数 1( --workers=1 )で順次 10 リクエストを投げます。 for i in { 1 .. 10 } ; do curl -s http://localhost:8001/test-static echo done 次に、Worker 数 10( --workers=10 )で並列 20 リクエストを投げ、Worker 間の独立性を確認します。 for i in { 1 .. 20 } ; do curl -s http://localhost:8001/test-static > /tmp/s_ $i & done wait for i in { 1 .. 20 } ; do cat /tmp/s_ $i ; echo; done | sort 結果 順次 10 リクエスト( --workers=1 ): { " pid ": 14 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 14 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 14 ," static_counter ": 3 ," global_counter ": 3 } ... { " pid ": 14 ," static_counter ": 10 ," global_counter ": 10 } 並列 20 リクエスト( --workers=10 ): { " pid ": 22 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 22 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 23 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 23 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 24 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 24 ," static_counter ": 2 ," global_counter ": 2 } ... { " pid ": 31 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 31 ," static_counter ": 2 ," global_counter ": 2 } 順次 10 リクエストでは全て同じ PID(=14)で、 static_counter と global_counter が 1 から 10 まで連続して増加しています。同 Worker 内では static / グローバル変数が持続していることが確認できます 並列 20 リクエストでは 10 個の異なる PID(22 〜 31)にリクエストが分散し、それぞれの Worker でカウンタが独立に 1 から始まっています。Worker 間でメモリが分離されていることが分かります 以上から、 static 変数とグローバル変数は同 Worker 内のリクエスト間で持続し、Worker 間では分離されることが確認できました。 これは、1 リクエスト目の値が 2 リクエスト目に意図せず見えてしまう可能性を意味します。前のリクエストの情報が次のリクエストに残るパターンと考えられるため、利用には注意が必要そうです。 検証 2: $this->app の内部状態がリクエスト間で残ること singleton の中にリクエスト固有のデータを保持して、リクエスト間で値が残ることを確認します。 確認用コード <?php // app/Services/UserContextSingletonService.php class UserContextSingletonService { private ? string $ currentUserName = null ; public function setCurrentUser ( string $ name ) : void { $ this -> currentUserName = $ name ; } public function getCurrentUser () : ? string { return $ this -> currentUserName; } } <?php // app/Providers/AppServiceProvider.php public function register () : void { $ this -> app -> singleton ( UserContextSingletonService :: class ) ; } public function boot () : void { // ベースの $this->app->instances にインスタンスを格納するため、boot 時点で resolve する $ this -> app -> make ( UserContextSingletonService :: class ) ; } 通常の singleton() だけでは、初回 app(...) 解決時にインスタンスが sandbox 側に入り、リクエスト終了時の $sandbox->flush() で破棄されます。リクエスト間で持続する状態を再現するため、サンプルでは boot() で make() を呼んでベースの $this->app->instances にインスタンスを積んでいます。 <?php use App\Services\UserContextSingletonService; Route :: get ( '/test-singleton-set/{name}' , function ( string $ name ) { app ( UserContextSingletonService :: class ) -> setCurrentUser ( $ name ) ; return [ 'pid' => getmypid () , 'action' => 'SET' , 'value' => app ( UserContextSingletonService :: class ) -> getCurrentUser () , ] ; }) ; Route :: get ( '/test-singleton-get' , function () { return [ 'pid' => getmypid () , 'action' => 'GET' , 'value' => app ( UserContextSingletonService :: class ) -> getCurrentUser () , ] ; }) ; 実行 curl http://localhost:8001/test-singleton-set/Alice curl http://localhost:8001/test-singleton-get curl http://localhost:8001/test-singleton-get 結果 { " pid ": 14 ," action ":" SET "," value ":" Alice " } { " pid ": 14 ," action ":" GET "," value ":" Alice " } ← 別リクエストなのに残っている { " pid ": 14 ," action ":" GET "," value ":" Alice " } ← まだ残っている 別のリクエストにもかかわらず、Alice という値がそのまま見えています。 singleton バインディングのインスタンスプロパティに格納した値が残ることが確認できました。 検証 3: RequestReceived リスナーが一部の状態をリセットすること FlushAuthenticationState リスナーが Guard キャッシュを破棄していることを確かめます。 確認用コード <?php use App\Models\User; use Illuminate\Support\Facades\Auth; Route :: get ( '/test-auth-set/{name}' , function ( string $ name ) { $ user = new User ([ 'name' => $ name ]) ; Auth :: setUser ( $ user ) ; return [ 'pid' => getmypid () , 'action' => 'SET' , 'user_name' => Auth :: user () ?-> name , ] ; }) ; Route :: get ( '/test-auth-get' , function () { return [ 'pid' => getmypid () , 'action' => 'GET' , 'user_name' => Auth :: user () ?-> name , ] ; }) ; Auth::setUser() を利用してデフォルトの Guardの $user プロパティに直接 User インスタンスを入れ、Guard キャッシュの状態を作って検証します。 実行 (1) デフォルト構成( FlushAuthenticationState 有効) curl http://localhost:8001/test-auth-set/Alice curl http://localhost:8001/test-auth-get 結果は以下の通りです。 { " pid ": 14 ," action ":" SET "," user_name ":" Alice " } { " pid ": 14 ," action ":" GET "," user_name ": null } ← flush で消えている 実行 (2) FlushAuthenticationState を外した場合 検証のため、 RequestReceived リスナーから FlushAuthenticationState を除外します。 <?php // config/octane.php (listeners 部分のみ抜粋) use Laravel\Octane\Events\RequestReceived; use Laravel\Octane\Listeners\FlushAuthenticationState; use Laravel\Octane\Octane; return [ // ... 'listeners' => [ // ... RequestReceived :: class => array_values ( array_filter ( Octane :: prepareApplicationForNextRequest () , fn ( $ listener ) => $ listener !== FlushAuthenticationState :: class , )) , // ... ] , ] ; 同じ curl を実行します。 { " pid ": 15 ," action ":" SET "," user_name ":" Alice " } { " pid ": 15 ," action ":" GET "," user_name ":" Alice " } ← 前のリクエストの情報が残っている リクエストをまたいで Alice という値が残っています。 検証結果から、 FlushAuthenticationState を外すと認証状態がリクエスト間で残り、有効な場合は Guard キャッシュが毎リクエスト破棄されることが確認できました。フレームワーク標準の Auth が Octane でも安全に使えるのは、このリスナーが裏で動いているからこそだと言えます。 おわりに 本記事では、Octane + Swoole で前のリクエストの情報が次のリクエストに残る仕組みを整理し、サンプルコードで動作を検証しました。その結果、 clone とリスナーによって Auth などのフレームワーク側の状態はリクエストごとにクリアされる一方、 static 変数 / グローバル変数や、Worker boot 時に解決済みの singleton インスタンスのプロパティは自動ではクリアされないことが確認できました。 持続させてはいけない場所に状態を置かないこと、また、もし独自のグローバル状態を持たせる場合には RequestReceived などのイベントに自前のリスナーを追加してクリアすることを意識できればと思います。 最後まで読んでいただきありがとうございました。 参考 Deep Dive into Laravel Octane(Albert Chen)
はじめに こんにちは。開発本部 開発1部 デリッシュリサーチチームの江﨑です。 デリッシュリサーチは、デリッシュキッチンに蓄積された検索ログやレシピへの反応をもとに食トレンドを分析できるサービスです。 本記事では、社内用にデリッシュリサーチのデータを Claude から自然言語で問い合わせられるようにする MCP サーバーを自作した話を紹介します。FastMCP と Databricks Apps で実装した構成、運用上のノウハウ、そしてリリース後に社内で広がった活用事例をまとめます。 はじめに 背景:なぜ自前の MCP サーバーを作ったか システム全体像 使用技術 MCP サーバーの実装 ツール一覧 ツールの基本パターン シノニムの代表語正規化 Databricks Apps での運用ノウハウ app.yaml で起動コマンドを定義 Resource 宣言の上限 20 個 ツール呼び出し履歴を Unity Catalog に蓄積 実際の呼び出しの流れ 試してみた事例 ダッシュボードでは出せない切り口に対応 MCP × Web 検索を組み合わせたトレンド分析 商材タイアップ提案の素材集め まとめ 参考リンク 背景:なぜ自前の MCP サーバーを作ったか デリッシュリサーチには各種分析機能があり、検索トレンド・レビュー・お気に入りレシピなど、マーケティングに使えるデータが一通り揃っています。ただ、実際に業務で使おうとすると、いくつかの壁がありました。 分析の手間 :デリッシュリサーチを開いて、目当ての機能を探して、フィルタを設定して、結果から示唆を得る、という手順が必要です。気になったことをちょっと確認する用途には、やや手数が多くなります。 分析の仕方に一定のスキルが必要 :どの切り口で見るか、どのフィルタを組むか、数字をどう読み解くかは訓練が要る作業で、誰でもすぐに使いこなせるとはいきません。 画面を横断した分析がしづらい :それぞれの機能はタブごとに分かれており、「検索データ × 気温 × 都道府県」のような掛け合わせの問いには答えづらい構造でした。 実際のデリッシュリサーチの画面は次のようになっています。 デリッシュリサーチの画面 デリッシュリサーチは社外のクライアント企業に提供しているサービスですが、社内のメンバーも利用しており、同じ壁にぶつかっていました。一方で、ちょうど社内には Claude が広く配布され始めており、 「Claude で何ができるか」の具体例を作って社内全体での活用を推進したい という思いもありました。デリッシュリサーチのデータを Claude のチャットから自然言語で聞ける MCP を作れば、上の 3 つの壁を越えつつ、社内向けの Claude 活用事例にもなります。 加えて、Databricks には Managed MCP サーバーという仕組みがあります。これは Unity Catalog の汎用ツール(Vector Search、Genie Space、Databricks SQL、Unity Catalog Functions)を MCP として AI クライアントに提供するものです。ただし、シノニム正規化や複数テーブル結合を伴うドメイン特有の集計を Managed MCP に組み込むのは難しく、求めている精度のデータを提供するには物足りませんでした。 そこで、リサーチデータの集計操作そのものをツールとして公開する 自前の MCP サーバー を作ることにしました。 システム全体像 構成は以下のとおりです。 システム全体像 チームの admin が Custom Connector に MCP サーバーを一度登録すれば、利用者は Claude.ai や Claude Desktop から Databricks の SSO でログインするだけで接続できます。MCP サーバー本体は Databricks Apps 上で稼働しており、Databricks SDK を経由して Unity Catalog のテーブルにアクセスします。 Databricks Apps は、Databricks ワークスペース上で Web アプリケーションをホストできる機能です。Unity Catalog と同じワークスペース上で動かせるため、アプリに自動で割り当てられるサービスプリンシパルからそのまま Unity Catalog のテーブルにアクセスできます。 認証は Databricks ワークスペースの OAuth で行います。利用者が Claude から MCP に接続すると、最初に Databricks のログイン画面に飛ばされてログインします。以降の MCP リクエストには発行されたアクセストークンが付与されます。Databricks Apps の手前にあるリバースプロキシがそれを検証したうえで、認証済みのエンドユーザー情報を x-forwarded-email などのヘッダーに載せてアプリに転送してくれます。MCP サーバーのコード側ではこのヘッダーを読むだけで「誰が呼び出しているか」が分かります。 使用技術 本 MCP サーバーで使用している主な技術は以下のとおりです。 項目 技術 言語 Python 3.11 以上 MCP フレームワーク FastMCP 2.x Web フレームワーク FastAPI + uvicorn ホスティング Databricks Apps データ層 Unity Catalog(Delta テーブル) 観測 OpenTelemetry パッケージ管理 uv FastMCP は Python で MCP サーバーを書くためのフレームワークです。MCP の HTTP ベースの通信方式(Streamable HTTP)に対応しており、Databricks Apps 上で動かせます。 MCP サーバーの実装 ツール一覧 現在、MCP サーバーには次のようなツールが登録されています。 キーワードの検索数を期間・粒度を指定して取得 検索ワードランキング 組み合わせ検索ランキング 主ワードに対する副ワードの傾向分析 都道府県別の検索ワードランキング 都道府県別の組み合わせ検索ランキング お気に入りに追加されたレシピのランキング レシピ単位のレビューと平均評価 気温と検索数の相関 食材の物価データ 食材の物価の前年同月比ランキング ツールの基本パターン FastMCP では、Python の関数に @mcp_server.tool デコレータを付けるだけでツールとして登録できます。本 MCP では、関数の docstring・引数のバリデーション・SQL 実行・整形して返却、という流れを全ツールで揃えています。 def register (mcp_server): @ mcp_server.tool # FastMCP にツールとして登録 def get_search_trends ( search_word: str = "" , start_date: str = "" , end_date: str = "" , granularity: str = "monthly" , ) -> dict : """指定ワードの検索数推移を期間・粒度を指定して取得する。 Args: search_word: 検索ワード(例: "キャベツ")。シノニムは自動で代表語に正規化される。 start_date: 開始日(YYYY-MM-DD、省略時は前年同月の月初) end_date: 終了日(YYYY-MM-DD、省略時は前日) granularity: 粒度(daily / weekly / monthly / quarterly / yearly) """ # 引数のバリデーション search_word = search_word.strip() if not search_word: return { "error" : "search_word is required" } # 表記ゆれを代表語に正規化(後述のシノニム正規化) main_word = resolve_to_main(search_word) # SQL を実行し、結果を整形して返却 try : rows = _fetch_basic_trends(main_word, start_date, end_date, granularity) return { "search_word" : main_word, "data_points" : len (rows), "data" : rows} except Exception : # エラーは Unity Catalog の観測テーブルに記録される logger.exception( "get_search_trends failed" , extra={ "tool" : "get_search_trends" }) return { "error" : "Internal error" } ツールの docstring は AI クライアントがそのままツールの説明として参照します。AI 側がどのツールをどんな引数で呼ぶかは、この docstring の質に強く依存します。 Args: の各引数に「何を渡してよいか」「省略時の挙動」を必ず明記する運用にしています。 シノニムの代表語正規化 集計テーブルの search_word は、ETL の段階で表記ゆれや言い換えが代表語に寄せられた状態で保存されています。たとえば「キャベツ」「きゃべつ」のような表記の違いは代表語「キャベツ」に集約されています。MCP のツール側でも、入力されたワードを resolve_to_main() で代表語に変換してから集計テーブルを参照するようにしました。これにより、ユーザーがどの表記で問い合わせても同じ結果を返せます。 Databricks Apps での運用ノウハウ app.yaml で起動コマンドを定義 Databricks Apps では、リポジトリのルートに app.yaml を置いたうえで、Workspace 上の管理画面から GitHub リポジトリを連携してデプロイしています。ソースを更新した後は Workspace 側で再デプロイを実行するだけで反映されます。 command : [ "uv" , "run" , "opentelemetry-instrument" , "custom-mcp-server" ] env : - name : WAREHOUSE_ID value : "<warehouse-id>" opentelemetry-instrument を経由して起動することで、アプリのログ・トレース・メトリクスが自動で Unity Catalog 上の観測テーブルに流れます。 Resource 宣言の上限 20 個 Databricks Apps では、参照したいテーブルや SQL Warehouse を UI上で宣言できます。宣言したリソースはデプロイ時にアプリのサービスプリンシパルへ自動で GRANT され、削除時には自動で REVOKE されます。 運用していて詰まったのが、 1 アプリあたりに宣言できる resource の数は 20 個まで という制約です。これは 2026 年 5 月時点で公式ドキュメントには明記されておらず、実際に 21 個目を追加しようとしてエラーで気付きました。今回の MCP では参照するテーブルが 20 個を超えており、すぐに上限に到達してしまいました。 そのため、UIからではなく Unity Catalog 側で 手動 GRANT を運用する方式 に切り替えました。 GRANT USE CATALOG ON CATALOG external_marketing_research TO `<sp-uuid>`; GRANT USE SCHEMA ON SCHEMA external_marketing_research.search TO `<sp-uuid>`; GRANT SELECT ON TABLE external_marketing_research.search.search_count TO `<sp-uuid>`; ツール呼び出し履歴を Unity Catalog に蓄積 社内向けに公開している以上、誰がどのツールをどの引数で呼んだかを追えるようにしておきたい要件がありました。 FastMCP には Middleware クラスがあり、ツール呼び出しの前後にフックを差し込めます。これを使い、呼び出し履歴を Unity Catalog のテーブルに INSERT する仕組みを入れました。MCP が参照している他の集計テーブルと同じカタログに置くことで一元化できます。 class HistoryMiddleware (Middleware): async def on_call_tool (self, context, call_next): tool_name = context.message.name user = get_request_user() # x-forwarded-email から取得 start = time.perf_counter() is_error = False try : return await call_next(context) except Exception : is_error = True raise # 例外は握りつぶさず外側に伝播させる finally : # 成功/失敗どちらの場合も履歴を書く duration_ms = (time.perf_counter() - start) * 1000 self._schedule_write( tool_name=tool_name, user_email=user[ "email" ] if user else None , arguments_json=_serialize_arguments(context.message.arguments), duration_ms=duration_ms, is_error=is_error, ) Databricks Apps では、リクエストヘッダーの x-forwarded-email にエンドユーザーのメールアドレスが入ります。これを contextvars で受け取り、INSERT 時に user_email として記録しています。 このテーブルを使うと、誰が何を呼んだかを追える監査記録としてだけでなく、次のような切り口で利用状況を集計できます。 ツール別の呼び出し頻度 :どのツールが実際によく使われているかを把握し、機能改善の優先度を判断する ツール別のエラー率と所要時間 :例外発生率と平均レスポンス時間からツールの健全性を確認する ユーザー別の利用状況 :誰がどれくらい使っているかを見て、活用事例のヒアリング相手を決める 実際の呼び出しの流れ たとえば「リサーチ MCP を使用して、キャベツのトレンドを分析してください」と Claude に投げかけると、必要なツールを Claude が自分で判断して順番に呼び出し、結果を分析・可視化して回答してくれます。 MCPの呼び出し例 この例では、次の流れで動いています。 まず利用可能なツールを把握する 過去 2 年分の検索トレンドと組み合わせワードのツールを呼び出す 続けて物価データのツールも呼び出して相関を確認する 取得したデータをもとに、要約(前年同期比、価格との相関係数、ピーク月など)とグラフを生成する 利用者は自然言語で問いを投げるだけで、その背後で複数のツールが呼ばれ、検索データ・物価データ・組み合わせワードを横断したアウトプットが返ってきます。 試してみた事例 社内リリース後、エンジニアからビジネス職まで、さまざまなメンバーに触ってもらいました。まだ実業務での運用に組み込んでいるわけではなく、各自で試しながら可能性を探っている段階ですが、いくつか興味深い使い方が出てきたので紹介します。 ダッシュボードでは出せない切り口に対応 「平日と比較して休日に検索頻度が増加する料理を教えてください」のように、デリッシュリサーチの管理画面上のプリセットでは答えられない切り口の問いにも対応できます。AI 側で日次データを取得するツールを呼び出し、平日/休日の比率を計算する手順を自分で組み立てて回答します。 MCP × Web 検索を組み合わせたトレンド分析 「リサーチ MCP の検索データ」と「Web 検索」を併用して、特定の食材・調理法のブームを背景情報込みで分析するような使い方も試されています。たとえば、せいろブームの背景に Web 上のどんな話題があり、検索数とどう連動しているかをまとめる、といった分析です。 商材タイアップ提案の素材集め 「ビール商材向けにおつまみレシピのタイアップ提案を考えて」のような依頼でも、MCP からレビュー数の多いレシピや時期別の検索傾向を取得し、提案スライドまで作るところまで行われました。 数値や因果関係の最終確認は必要ですし、プロンプトや精度にもまだ改善の余地があります。とはいえ、提案のドラフトを素早く試作する用途として手応えを感じています。 まとめ 今回は社内向けのデリッシュリサーチ MCP サーバーを自作した話を紹介しました。 リリース後、エンジニアからビジネス職までいろいろなメンバーに触ってもらっています。デリッシュリサーチ単体では難しい横断的な分析や、分析からスライド作成までを一気通貫でやれる点に可能性を感じています。SNS など外部のデータソースも組み合わせて、活用の幅を伸ばしていきたいです。 最後まで読んでいただきありがとうございました! 参考リンク Model Context Protocol FastMCP Databricks Apps Databricks マネージド MCP サーバーを使用する Unity Catalog 権限リファレンス
目次 はじめに イベントの様子 スポンサーブース ブース企画 1. アンケートボード「あなたのAI活用、どこまで来てる?」 2. 食クイズ&くじ引き エントランス企画ゾーン セッション紹介 ビジネスモデルから紐解く、AI+型駆動開発 型の「重心」はビジネスモデルで決まる 型の Origin と AI への渡し方 SaaS 型 — フォーム状態を Discriminated Union で渡す マーケットプレイス型 — 中央スキーマを Origin として渡す 所感 TS 7: How We Got There なぜ Go だったのか Snapshot テストと Differential Fuzzing 所感 制約と時代から読み解く TypeScript コンパイラ設計史 TypeScript Compiler の「独特さ」 設計を決めた時代背景と JS の制約 Go port による改善 所感 いつテストを書くか?―ソフトウェア開発における安心と不安について考える 保守性の本質は「変更容易性」である 変更容易性の 2 層モデルと、開放閉鎖原則の再解釈 テストは「不安」と「構造」にフィードバックを与える いつテストを書くか? 各社スポンサーブース レバレジーズさん プレイドさん ZOZOさん まとめ アフターイベントのご案内 最後に はじめに 2026年5月22日(金)、23日(土)に開催された TSKaigi 2026 に、弊社の開発本部から 6 名のエンジニアが参加してきましたので、イベントの様子や印象に残ったセッションをご紹介します。 各セッションのアーカイブも公開予定とのことですので、ぜひ公式サイト・YouTube チャンネルなどをチェックしてみてください。 2026.tskaigi.org www.youtube.com イベントの様子 スポンサーブース エブリーは今回、ゴールドスポンサーとしてブースを出展させていただきました! 足を運んでいただいた皆様、本当にありがとうございました! ブース企画 1. アンケートボード「あなたのAI活用、どこまで来てる?」 ブースでは、エンジニアの皆さんのAI活用状況を伺うアンケートを実施しました。 回答いただいた多くの皆様、ありがとうございました!最終結果はこちらです……! キャリア0〜5年の若手から15年以上の大ベテランまで、非常に幅広い層のエンジニアの皆様に回答いただきました! 結果としては、「指示は出すが都度レビュー」「並列で動かしている」という回答が多く、「AIに全任せするのはまだ難しい...」「並列で回すとコンテキストスイッチが大変...!」といった声も寄せられ、興味深い結果となりました! 2. 食クイズ&くじ引き さらに、エブリーとしては初の試みとなる「クイズ企画」も行いました! クイズに参加していただくことで、キッチングッズ(まな板、計量スプーン、しゃもじ、お箸)が当たるくじを引けるという形式です。 クイズは大盛況で、「これ見たことある!答えなんだっけ!」「簡単じゃん〜……ってうわ!間違えた!」と、皆さん真剣かつ楽しそうに頭を悩ませてくださる姿が印象的でした。ブース担当メンバーも、皆さんのリアクションを見ながら一緒に盛り上がることができ、企画して本当に良かったと感じています! エントランス企画ゾーン Day1にネイル企画がありプロのネイリストによるネイル体験をしてきました! メニューは「ネイルアート」か「ネイルケア」のいずれか好きな方を選ぶことができました。 綺麗にケアされた自分の手元が視界に入るたびにテンションが上がり、大満足の体験でした!キーボードを叩くモチベーションも爆上がりです!! セッション紹介 ビジネスモデルから紐解く、AI+型駆動開発 2026.tskaigi.org 発表者: omote (株式会社 estie) レポート: 江﨑 estie の omote さんによるセッション「ビジネスモデルから紐解く、AI+型駆動開発」について紹介します。スライドは以下で公開されています。 speakerdeck.com 本セッションは「AI 時代における開発のスタート地点はどこか」という問いから始まり、開発体験やフロントエンドのベストプラクティスではなく ビジネスモデルこそが設計の起点になる という仮説のもと、ビジネスモデル別に「型設計を AI とどう組むか」を整理していく構成でした。 型の「重心」はビジネスモデルで決まる 型はクライアント・API・データのどこにでも書けますが、設計エネルギーを最も注ぐ場所はプロダクトごとに違います。omote さんはその場所を 型の重心 と呼び、技術スタックではなく事業構造で分類すべきだと整理していました。具体的には、プロダクトを次の 4 つに分けています。 分類 価値の源泉 型の重心 型の Origin インタラクション中心 SaaS 型 ユーザー入力・編集体験 UI 状態・フォーム・権限 TypeScript 自身 データ依存型 外部データ・ドメイン知識 API レスポンス・ドメインモデル DB / GraphQL スキーマ API 中心マイクロサービス型 API 契約そのもの コントラクト・スキーマ定義 .proto / OpenAPI マーケットプレイス型 マッチング・取引の成立 取引データのスキーマ 中央ドメインモデル 価値の源泉と重心、Origin がフラットに 4 分類で並ぶことで、自分の関わっているプロダクトをそのまま当てはめながら聞ける構成になっていました。 型の Origin と AI への渡し方 もう一段下の問いとして「型はどこから来るのか(Origin)」も提示されていました。SaaS 型・マーケットプレイス型では人間が Origin を定義する側になり、データ依存型・API 中心型では既存スキーマや契約から型を受け取る側になります。つまり TypeScript の役割はビジネスモデルによって 定義する側 と 受け取る側 の 2 つに分かれる、という整理です。 ここを踏まえると、AI に渡す「文脈の深さ」によって出力の質も変わってきます。 Level 1: 自然言語だけ Level 2: TypeScript の派生型を渡す Level 3: Origin 自体、または Origin に近い型情報を渡す Level 3 まで踏み込むと、データソースの制約や整合性まで型として運ばれるため、Claude の出力が事業文脈と整合した実装になりやすい、という話でした。実例として、SaaS 型とマーケットプレイス型の 2 つが、Claude への入力と出力の対比で示されていました。 SaaS 型 — フォーム状態を Discriminated Union で渡す 人間側では、フォームの状態と送信エラーを Discriminated Union で定義した型を Claude に渡します。 type InviteMemberFormState = | { status: "editing"; email: string; role: Role; errors: ValidationError[] } | { status: "confirming"; email: string; role: Role } | { status: "submitting"; email: string; role: Role } | { status: "succeeded"; invitedEmail: string } | { status: "failed"; email: string; role: Role; error: SubmitError }; type SubmitError = | { code: "already_invited"; existingMemberEmail: string } | { code: "quota_exceeded"; currentCount: number; limit: number } | { code: "network_error" }; type Role = "admin" | "editor" | "viewer"; この型を添えて「この InviteMemberFormState を満たす React コンポーネントを実装してください」と依頼すると、Claude は失敗時の表示を次のような switch で出力します。 function FailedView ( { error , onRetry , onCancel } : FailedViewProps ) { switch (error.code) { case "already_invited" : return < p > { error.existingMemberEmail } はすでにメンバーです </ p > ; case "quota_exceeded" : return ( < p > 招待上限( { error.limit } )に達しています(現在 { error.currentCount }{ " " } 名) </ p > ); case "network_error" : return < p > ネットワークエラーが発生しました。再度お試しください </ p > ; } // ↑ 'code' の網羅性が型レベルで保証される。新エラー追加時もここでコンパイルエラー } 各エラーコード固有のプロパティ( existingMemberEmail ・ limit ・ currentCount )に型安全にアクセスできており、 switch の網羅性チェックが型レベルで効いている点が示されていました。新しいエラーコードを追加した際にも同じ箇所でコンパイルエラーになるため、AI 出力に対するガードレールが型で担保されているのがよく分かります。 マーケットプレイス型 — 中央スキーマを Origin として渡す マーケットプレイス型では、売り手・買い手・運営・取引整合性といったあらゆる方向の関心が、中央スキーマ Transaction に集約されます。これを Origin として Claude に渡す形になります。 // あらゆる方向からの依存が、ここに集約される type Transaction = { id: TransactionId; sellerId: UserId; // 売り手の関心 buyerId: UserId; // 買い手の関心 price: number; platformFee: number; // 売り手・運営の関心 shippingAddress: Address; // 買い手のみ trackingNumber: string | null; status: TransactionStatus; // Discriminated Union(全方向の関心) // ... タイムスタンプ群、双方向の評価 }; 「この中央スキーマから SellerTransactionView と BuyerTransactionView を派生させてください」と依頼すると、Claude は Pick で必要なフィールドだけを切り出し、 netRevenue や totalPaid といった派生プロパティを足した型を出力します。 // 売り手向け type SellerTransactionView = Pick< Transaction, | "id" | "itemId" | "buyerId" | "price" | "platformFee" | "shippingFee" | "trackingNumber" | "status" | "buyerRating" > & { netRevenue: number; }; // 除外: shippingAddress, sellerRating // 買い手向け type BuyerTransactionView = Pick< Transaction, | "id" | "itemId" | "sellerId" | "price" | "shippingFee" | "shippingAddress" | "trackingNumber" | "status" | "sellerRating" > & { totalPaid: number; }; // 除外: platformFee, buyerRating Transaction 自体には手を加えず、 Pick で必要なフィールドだけを切り出して派生型を機械的に生成している点がポイントで、「何を売り手に見せ、何を買い手に見せるか」という事業判断は中央スキーマの段階で人間が決めておかなければならない、という対比になっていました。 所感 プロダクト開発の設計と言われると、つい開発体験やフロントエンドのベストプラクティス側から話が始まりがちです。それに対し、本セッションでは 起点をビジネスモデルに置き、型の重心と Origin で具体まで落とす という流れが一貫していて、ビジネスモデルと型の関係性が非常に丁寧に整理されていたのが印象的でした。 実装が AI に委ねられるからこそ、人間が残すべき仕事は「型をどこに置くか」「Origin をどう定義するか」という意思決定だ、というメッセージも明確で、AI と分担して開発を進める際に人間側に残すべき判断をはっきりさせてくれる内容でした。自分が関わっているプロダクトについても、まずは「型の重心はどこにあるのか」を棚卸しするところから取り入れてみたいと感じました。 TS 7: How We Got There 2026.tskaigi.org 発表者: Jake Bailey (Microsoft) レポート: パンダム/rymiyamoto 私からは Microsoft の Jake Bailey さんによるキーノート「TS 7: How We Got There」について紹介します。スライドは以下で公開されています。 jakebailey.dev 本セッションでは、TypeScript の処理系を JavaScript から Go へ移植する取り組みについて、その動機・ポートを支えた手法・段階的なリリース戦略が紹介されました。 なぜ Go だったのか tsc / tsserver は数千万行規模のコードベースや 2,000 個以上の tsconfig.json を扱うユーザーが現れる中で、JavaScript ランタイムの制約(スレッド間でのオブジェクト共有不可、async/await の関数色分け、4GB のメモリ上限)からパフォーマンス改善が限界に近づいていたとのことです。 「rewrite ではなく port」を前提に Rust / C# / Zig などと比較したうえで Go が選ばれた理由として、以下が挙げられていました。 TypeScript(class より data + 関数 + 構造的 interface 寄り)と構造が近く、コードを 1:1 で移し替えやすい GC があるため AST の循環参照を意識せずに書ける goroutine による並行性を、関数の色分けなしに導入できる 学習コストが低く、チームの 10 名ほどが数日で生産的になれた ライブラリ単体ではなく「チーム全員が短期間で動ける」観点で言語選定している点は印象的でした。 Snapshot テストと Differential Fuzzing ポートを支えた手法として、特に Snapshot(ベースライン)テストと Differential Fuzzing が紹介されていました。 Snapshot テストは、出力をファイルとしてリポジトリにコミットし、PR の diff で挙動変化を確認する手法です。TypeScript では新旧コンパイラの出力差分そのものを baseline として扱い、その差分ファイルが消えること自体をポート完了の指標にしているとのことでした。 Differential Fuzzing は、新旧の実装に同じ入力を fuzzer から流し込み、結果が一致しない入力を自動で発見させる手法です。Go では組み込みの fuzzer で以下のように記述できます。 func FuzzToFileNameLowerCase(f *testing.F) { f.Add( "foo/bar/baz.ts" ) f.Add( "C:/foo/bar/baz.ts" ) f.Fuzz( func (t *testing.T, p string ) { want := oldToFileNameLowerCase(p) got := ToFileNameLowerCase(p) assert.Equal(t, want, got) }) } ユニットテストで取りこぼしがちなエッジケースを fuzzer に探させる発想は、性能改善で挙動を保ったまま実装を差し替える場面で特に有効で、自身の業務にも持ち帰りたい手法でした。 所感 会場のライブデモでは、 tsc が数分単位で進まない遅さを見せたあと、 tsgo に切り替わった瞬間に 10 秒足らずで型チェックが終わり、スライドの「VS Code(約 2.3M LoC)で tsc 比 約 10 倍・クラッシュ率約 1/20」という数字をその場で体感することができました。また発表中には「昨年の TSKaigi 2025 のタイミングでは tsgo のクラッシュが多かった」という話に触れられて会場が湧いていましたが、その状態から 1 年でテレメトリと継続的な改善でクラッシュをほぼ潰し、ここまで仕上げている開発スピードには、純粋に驚かされました! 特に Differential Fuzzing は、自分が普段触っている Go バックエンドで「速度改善のために旧実装と挙動を変えずに置き換えたい」場面と相性が良さそうで、まずは小さな関数で testing.F を使った新旧比較から試したいと考えています。一方で、ベースライン駆動のレビューは出力ファイルを丸ごとコミットする運用になるため、レビュー差分の見やすさと引き換えにリポジトリ容量や CI の負荷が膨らみそうで、テスト数を踏まえてどこから採用するかは見極めが必要そうです。フロントエンドの TypeScript 側でも、 tsgo が 7.0 として安定すれば CI の型チェック時間が大きく改善する余地があるので、ベータの様子は引き続き追っていこうと思います。 制約と時代から読み解く TypeScript コンパイラ設計史 2026.tskaigi.org 発表者: Yoshiaki Togami (株式会社メルペイ) レポート: 庄司 私からは Yoshiaki Togami さんによるセッション「制約と時代から読み解く TypeScript コンパイラ設計史」について紹介します。 スライドは以下で公開されています。 www.docswell.com 本セッションでは、TypeScript Compiler がなぜ「独特な内部設計」を持つに至ったのかを、 当時の時代背景と JavaScript の制約を交えつつ、Go port による改善までを整理する構成となっていました。 TypeScript Compiler の「独特さ」 冒頭で TypeScript Compiler のパイプライン(Scanner → Parser → Binder → Checker)と、 識別子から宣言へたどる Symbol が紹介され、Compiler の構造的な「独特さ」として 2 つの特徴が挙げられました。 Binder が AST に semantic を後付けするため、意味情報が構文木と同居する AST の親子や Symbol 間でアクセスできるようにするために循環参照が発生する C# コンパイラで採用されている Red-Green Tree というデータ構造を採用することで、immutability と semantic へのアクセス可能性を両立できないかというアイディアもありましたが、 当時の JS では Worker API などもなく immutability を活用しきれない点やメモリ消費が大きくなってしまうという点から見送られました。 設計を決めた時代背景と JS の制約 TypeScript の設計が決まった 2010 年前後には以下のような技術的背景がありました。 2004〜2006 年の Ajax 革命(Gmail / Google Maps / Google Docs)で「JS で本格的なアプリ」が現実味を帯びる 2008 年に V8 が公開される。(しかし、ES5 段階では Worker API がなく最適化も未熟) JS ツーリングが貧弱で開発体験が悪い これらを踏まえて、以下のような3つのアプローチが取られました。 JS を遠ざける(Script# 等、別言語で書いて JS にトランスパイルする) JS 自体を置き換える(Dart 等) JS のスーパーセットとして型を追加する(Strada -> TypeScript) これは「ターゲット言語からそこまで離れるくらいなら JS 自体を直すべきでは」という発想から出たアイディア 結果的には、SharedArrayBuffer 等の共有メモリ機構が未整備で、オブジェクトヘッダのメモリオーバーヘッドも大きいという背景から、 immutability やメモリ消費を犠牲にして、現在の「AST が意味情報を背負う・循環参照あり・単一ツリー」という設計になった、という整理でした。 Go port による改善 最後には、ここまで触れてきた制約が Go port でどう解消されるのかが紹介されました。 tsc → tsgo の高速化の背景として、大きく 2 つのポイントが挙げられました。 単一の Go バイナリへのネイティブ化によるウォームアップリードタイムの削減 共有メモリによるキャッシュヒット率改善やマルチスレッド並列化 所感 TypeScript Compiler の内部設計の独特さについて、当時の時代背景込みで聞ける機会は少なく、とても面白かったです。 当時の JS 特有の仕様的制約から現実解を模索していった流れは技術選定全般への向き合い方としても興味深いものでした。 利用している技術がどんな課題を解決するために生まれたソリューションなのかを知ることが、 ツールを使いこなす上でも重要であることを改めて感じることができました。 いつテストを書くか?―ソフトウェア開発における安心と不安について考える 2026.tskaigi.org 発表者: lacolaco (株式会社TwoGate) レポート: 黒髙 私からは TwoGate の lacolaco さんによるセッション「いつテストを書くか?―ソフトウェア開発における安心と不安について考える」について紹介します。スライドは以下で公開されています。 bit.ly 私自身、lacolaco さんがパーソナリティを務めるポッドキャスト「 リファラジ|リファクタリングとともに生きるラジオ 」のリスナーだったこともあり、本セッションは聞く前から楽しみにしていました。 加えて最近は AI にテストを書かせる場面が増えたことで「そもそもテストを書く意義はなんだったか」が曖昧になってきており、自分がどう向き合うべきかを改めてはっきりさせたい、というのが個人的な動機でした。 本セッションは、Agentic Coding が広がる中であらためて「テストを書く意義」を問い直すために、 ソフトウェアの本質的な性質である「変更容易性」 を補助線として、テスト駆動開発と開放閉鎖原則の関係を整理しなおしていく構成でした。 保守性の本質は「変更容易性」である このセッションは、AI によって開発速度が上がったからこそ「保守性の限界」がすぐ目に見える形で訪れるようになっている、という提起から始まります。保守性の本質は 変更容易性 であるという前提のもと、「変更容易性はソフトウェアに内在する性質ではなく、人間とソフトウェアとの関係として現れる」と説明されていました。 変更容易性の 2 層モデルと、開放閉鎖原則の再解釈 変更容易性は次の2つの独自定義で捉え直されていました。 予期的変更容易性 : 変更を行う「前」に開発者が抱く「変更のしやすさ」の感覚。必要な作業量・影響範囲・成功確率・失敗時のリスクへの予期。要するに、 開発者の不安や恐怖の程度 そのものである。 経験的変更容易性 : 変更を行っている「最中」に開発者が経験する「変更のしやすさ」の感覚。実際にかかった労力や、影響した範囲、引き起こした副作用。 ソフトウェア側から返ってくる「変更への抵抗」の程度 である。 そして、ソフトウェアが「ソフト」である状態は この 2 つの変更容易性が両立している状態 として定義しなおされます。「容易そうだと思える」ことで変更の機会そのものが増え、「実際に容易である」ことで変更の合理性が高まる。アーキテクチャはこの両立を実現する構造を目指すものだ、という整理です。 同時に、ご存知の方も多いでしょう、 開放閉鎖原則 (OCP) がアーキテクチャの根本原理として登場します。 修正に対して閉じている : 既存の要求を満たす振る舞いが変わらないこと(=安定性) 拡張に対して開いている : 既存の要求を満たしたまま新しい要求に応えられること(=柔軟性) この 2 つが両立することで、変更への不安は解消し、労力も軽減される。つまり OCP の達成は予期的変更容易性と経験的変更容易性の両方を高めることに直結する、という形で、 OCP を「変更容易性そのもの」と等価に位置づけ直す再解釈 が行われました。 「変更容易性」を変更の 前 と 最中 という時間軸で分けながら、その 2 層の両立を 開放閉鎖原則 という一本軸に落とし込んでいくことで、自分の中にあった「テストを書く理由」の曖昧さがそのまま整理されていくような感覚がありました。 テストは「不安」と「構造」にフィードバックを与える さらに、「変更容易性とはコードベースではなく、それを変更しようとしている人間が抱く感覚に依存する」という視点のもと、テスト駆動開発が変更容易性に対して 2 つの方向からフィードバックを与える と整理されていました。 ひとつ目は 予期的変更容易性へのフィードバック 、つまり「開発者の不安を取り除く」役割です。テストケースは「既存の要求と期待される振る舞いの定義」であり、変更しても既存テストが壊れないなら、その範囲では「閉じている」ことが保証されているということです。 ふたつ目は 経験的変更容易性へのフィードバック 、すなわち「構造上の問題を教える」役割です。機能追加が既存のテストに影響するならそのモジュールは閉じていないし、新しい機能のテストを書くのが大変ならそのモジュールは開いていないということになります。 そして開放閉鎖原則そのものは、設計のループの中で 徐々に 満たされていくものとして描かれます。 ここで特に印象的だったのは、開放閉鎖原則を 「達成すべきゴールではなく、漸近していく理想状態」 として位置づけていた点です。OCP は完全には満たせないし、変更容易性は変更してみないとわからない。だからこそ、テストと TDD は OCP を徐々に満たしていくためのワークフローとして必要になる、という説明の流れにはとても納得感がありました。 いつテストを書くか? ここまで積み上げてきた整理を踏まえて、本セッションのタイトルに立ち戻ります。テストを書くべきタイミングは、 変更容易性のどちらが阻害されているか で見え方が変わる、というのが lacolaco さんの答えでした。 予期的変更容易性が低いとき (=変更するのが怖いとき)は、 不安を取り除くためにテストを書く 。実装を支えるテスト。逆に、不安がないなら書く必要はないし、書いても不安が変わらないなら書く意味もない。 経験的変更容易性が低いとき (=構造に問題を感じているとき)は、 構造を学習するためにテストを書く 。設計(リファクタリング)を支えるテスト。OCP が十分に満たされ構造を熟知しているなら書く必要はない。 結合度の高いテスト(結合テスト)は不安の解消に、結合度の低いテスト(ユニットテスト)は構造のフィードバックに、という区別がテストピラミッドの図解で整理されておりとても分かりやすかったです。 このセッションが個人的にとても良かったのは、「いつテストを書くか?」という極めて実践的な問いに対する答えが、 自分がコードの変更に対してどう向き合っているのか(不安なのか、構造に違和感があるのか)への感度を高めること に着地していた点です。テストを書くべきかどうかを「自分の感覚を起点に考える、という視点は、AI と分担して開発する時代だからこそ、改めて言語化して持っておきたい感覚だと感じました。 総じて、ソフトウェアとは何か、保守性とは何かという基礎的な問いから始め、それを「開放閉鎖原則」という古典的な原理で言語化しなおすことで、「自分がどのタイミングでテストに向き合うか」という実践的なところまでイメージができる、学びの深いセッションでした。保守性の限界がこれだけ早く訪れるようになった今こそ、 『テスト駆動開発』 や 『Clean Architecture』 といった書籍をもう一度しっかり読み直して咀嚼する価値があると感じています。 各社スポンサーブース 他社さんのスポンサーブースにもたくさん訪問させていただきました! 各社趣向を凝らしたブースや様々な企画が展開され、会場全体がとても賑わっていました。 いくつかご紹介させていただきます。 レバレジーズさん レバレジーズさんのブースでは、アジャイル開発の業務効率化支援 SaaS「agile effect」について解説していただきました! タスク管理ツールと連携することで進捗把握や調整がしやすくなり、開発プロセス自体を可視化することで感覚に頼らない改善が進められるとのことでした。AI によって開発効率が上がる中で、ボトルネックがどこにあるかを把握する重要性は増していると感じる場面が多く、興味深いサービスでした! プレイドさん プレイドさんのブースでは、弊社でも利用させていただいている CX プラットフォーム「KARTE」について、実際のユースケースを交えて解説していただきました! 設定方法まで丁寧に教えていただき、とてもありがたかったです。ブース内ではおみくじを引かせていただく企画もあり、結果はハズレでしたが、他のブースにはない体験で印象に残りました! ZOZOさん ZOZOさんのブースでは、テックリードお手製の TypeScript クイズに挑戦しました!全問正解者は数人とのことで、なかなか歯ごたえのある内容でした。 型推論の挙動や演算子の細かな仕様など、自分の理解が曖昧だった部分がクイズを通して炙り出され、TypeScript の言語仕様を学び直すきっかけになりました! まとめ TSKaigi 2026 は、TypeScript の最新動向や活用事例から、AI とどう組み合わせて開発を進めるかという最近のトピックまで幅広く語られ、TypeScript コミュニティの盛り上がりを改めて感じられる、とても素敵なイベントでした。 特に今年は、 tsgo / TypeScript 7 のキーノートや TypeScript Compiler の設計史といった 言語処理系そのものの節目 に関するセッションと、AI 時代の型駆動開発や Agentic Coding 時代におけるテストの意義といった AI との向き合い方 を問うセッションが両軸で並んでおり、TypeScript 自身の進化と AI との関わり方の両方を同時に追えるラインナップでした。ブースで実施したアンケートでも「指示は出すが都度レビュー」「並列で動かしている」といった回答が並んでいたことから、参加者の AI 活用が着実に進んでいることも伺えました。 今後も TypeScript コミュニティ、TSKaigi がより一層発展していくことを期待しています。 今回の参加レポートが、TypeScript を学びたい・活用していきたい方の参考になれば幸いです。 運営の皆さん、カンファレンスを開催していただきありがとうございました!! アフターイベントのご案内 TSKaigi 2026 にスポンサーや登壇者で参加した ウェルスナビ・PeopleX・弁護士ドットコム・スリーシェイク・エブリー の 5 社で、2026年6月12日(金)に TSKaigi 2026 のアフターイベントを開催いたします! セッションや公募LTなどのコンテンツを用意しておりますので、みんなでTypeScriptで盛り上がりましょう! every.connpass.com 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
目次 はじめに TSKaigiとは? イベント当日について アフターイベントのご案内 はじめに この度、株式会社エブリーは、2026年5月22日(金)、23日(土)に開催される「TSKaigi 2026」に、ゴールドスポンサーとして協賛することになりました! TSKaigiとは? 2026.tskaigi.org TSKaigiは「学び、繋がり、"型"を破ろう」をミッションに掲げ、2024年に第1回が開催された、TypeScriptに関するあらゆるテーマを扱う国内最大級の技術カンファレンスです。2026年はベルサール羽田空港に会場を移し、2日間にわたって開催されます。 今年の開催概要は以下のとおりです。 開催日時 Day 1: 2026年5月22日(金) Day 2: 2026年5月23日(土) 開催場所 ベルサール羽田空港(東京都大田区羽田空港2-7-1) 開催形態 ハイブリッド開催(現地・オンライン) コンテンツ ・基調講演 ・公募セッション(30分 / 10分) ・スポンサーセッション ・懇親会 セッションは、TypeScriptの基礎から実務に近い内容まで様々なテーマが見られます。TypeScriptの内部実装がGoに移植されたこともあり、TypeScriptの実装に関するセッションも注目したいところです! イベント当日について エブリーのブースでは、料理に関するクイズや、ノベルティ・キッチングッズなどが当たるくじ引き、アンケートボードを設置します。ぜひお立ち寄りください! TSKaigi 2026 で皆さんと良い学び・思い出を作れることを楽しみにしています! アフターイベントのご案内 ウェルスナビ・PeopleX・弁護士ドットコム・スリーシェイク・エブリーの5社共催によるアフターイベントを開催いたします! 弊社からは、 黒髙 が登壇します!セッションや公募LTなどのコンテンツを用意しておりますので、みんなでTypeScriptで盛り上がりましょう! 開催日時 2026年6月12日(金) 開催場所 東京都港区六本木3-2-1 六本木グランドタワー 38階 株式会社エブリー イベントスペース 開催形態 オフライン コンテンツ ・各社のTypeScriptに関するセッション ・公募LT ・交流会 お申し込みは以下で行っております。みなさんのご参加をお待ちしています! every.connpass.com 最後までお読みいただき、ありがとうございました!
はじめに こんにちは、リテールハブ開発部でバックエンドエンジニアをしているホシと申します。 現在、Laravel などを利用しながら小売アプリ開発に取り組んでいます。 少し前になりますが、先日3月17日にLaravel13がリリースされました。 ( https://laravel.com/docs/13.x/releases ) 昨年にもLaravel12について、バージョンアップ内容などを記事にしたのですが、 今年も新バージョンについてお話しできればと思います。 前回同様にまずはサポート期限の一覧を記載します。 1年サイクルでリリースしているのと、サポート期間はあっという間に過ぎてしまうことがわかります。 毎年のバージョンアップは大変でも2年ごとくらいには実施した方が良さそうです。 Laravel13の機能の紹介やバージョンアップ方法などはすでにたくさんあるとは思いますが、 本記事では、Laraveをバージョンアップする際にどんなことが必要かを調べてみたのと、 今回のLaravel13の大きな特徴として「破壊的変更を抑えつつ、AI時代向けにLaravelを進化させたバージョン」のようなので、 AI関連の気になる機能についても、あまり詳しくない私を含め、なるべくわかるような内容にできればと思います。 Laravel13のバージョンアップよりも、PHPバージョンアップが大変? 今回もLaravelのバージョンアップ自体は破壊的変更も少なく、比較的低コストでできるようなのですが、 今回のバージョンでは、PHPが8.3からになりました。 PHP8.2以下の場合、ここが実は一番手間がかかる対応なのかもしれません。 まずはPHP8.3に変更することが問題なくできるかを調べるのが先になります。 また、今回のタイミングでできれば8.4まで上げることができれば、 今後のバージョンアップもスムーズにしやすくなるので、なるべく最新にしたいところです。 ただ、8.5まで上げてしまうとまだサポートしていないものなどでうまく行かない可能性も高くなるので、 8.4で試してみるのがバランスが良いのかなと思いました。 バージョンアップする際の対応項目 先ほどはPHPを上げる方が大変かもと記載していますが、 laravelの変更点もカバーすることを考えるとどちらもそれなりに対応は必要そうです・・・。 以下に現環境で必要な対応項目を整理してみました。 1. ランタイム / 環境の確認 PHP を最低 8.3(推奨 8.4)に統一 composer.json Dockerfile PHP 拡張・PECL(Swoole / OpenSwoole)が PHP 8.3/8.4 対応バージョンか確認 ECS タスク定義側で参照している base image / runtime も同期 2. 現在使用しているLaravelは全て13に対応しているか laravel/sanctum laravel/octane laravel/tinker laravel/boost laravel/pint など、使用しているもの全てが「13」に対応しているか確認しておく。 (上記は一部最新の情報ではないかもしれませんので参考程度にしてもらえると幸いです。) 3. 周辺ライブラリ(互換確認・場合によりバージョンアップ) sentry/sentry-laravel aws/aws-sdk-php など、周辺ライブラリがまだ未対応などがありそうなので、調べておく必要あり。 4. テスト / 静的解析(バージョンアップ対応) PHPUnit: 12.x(Pest 3 が要求) Pest: 3.0 へ移行 tests/Pest.php の uses() / プラグイン構成見直し Larastan phpstan.neon のルール再調整必須 mockery/mockery fakerphp/faker など、テストに関する変更は調べる限り、いろいろ見直しも必要そうです。 ちょっとここは詳しくないので、実際には試行錯誤しながらやらないと詰まりそうな雰囲気も感じています。 5. コード内の修正・確認が必要そうなところ(一部抜粋) Carbon 3 への移行(Laravel 12 から Carbon3.x を使用する必要あり) → now(), Carbon::parse() を使う全箇所を回帰テスト Eloquent / Query Builder → クエリの修正は不要の見込み Container / Service Provider → bootstrap/providers.php に記載すること推奨されていて、 Validation → カスタムルールがある場合、問題ないか確認など Authentication / Authorization → Hash::make の確認、Sanctum トークン形式の互換性確認など Octane → State leak / メモリリークなどが起きないかなど あまり特殊な処理などはしていないのですが、Carbon系は特にたくさん使っているのでよくみた方が良さそうでした。 テスト実行して問題ないか全体を確認は必須ですが、 基本的には利便性向上、追加機能系なので動かなくなるなどほぼほぼなさそうです。 メモリリークとかそういったものがすぐにはわかりにくいのでここもしっかり確認はした方が安全かなと思います。 6. CI / Docker / デプロイ バージョンを変えるなどは必須だと思いますが、 あとは環境やもともと記載している内容に合わせての変更になると思います。 ここは実際に動かしながら修正・動作確認して間違いの無いようにしたいです。 7. アップデート手順 もし複数バージョンアップの場合は、アップデート手順として推奨されているのは、 Laravelでも段階アップデートが安全なようなので、12 → 13 のように段階アップデートにするか、 一気に上げる方法にするかは変更、確認コストを見て決めていけると良いかなと思います。 Laravel 13の気になる機能:AIサポート ここからは、Laravel 13で一番気になったAI関連機能についてお話しできればと思います。 特に注目したいのは以下です。 Laravel AI SDK Embeddings Vector Search Semantic Search 今までは、様々なライブラリや外部サービスを組み合わせてAI検索などを実現する必要がありましたが、 Laravel13では、Laravel標準に近い形で実装できるようになっています。 ただ、Laravel13自体がAIモデルを持っているわけではなく、実際には外部のAIサービスを利用してEmbedding生成や意味検索を行います。 Laravel13では、それらをLaravelらしいAPIで扱いやすくなった、というイメージです。 また、Laravel側でAIサービスの差異をある程度吸収できるため、将来的に利用するAIモデルやサービスを変更しやすくなる可能性があります。 従来のように各AIサービスごとに個別実装するよりも、Laravelアプリへ組み込みやすくなったのかなと思いました。 Embeddingsとは? そもそも、上記の各AIに関連するワードについてもしっかりと理解できていなかったので調べてみました。 Embeddingsとは、文章をAIが扱いやすい数値データに変換する仕組みです。 例えば、 「胃に優しい料理」 という文章を、AIが意味を比較できるベクトルデータに変換します。 内部的には: [0.183, -0.929, 0.442, ...] みたいな大量の数値になります。 この数値化はすでに大量の文章、単語関係、文脈を学習済みのモデルを利用することで実現できています。 use Illuminate\Support\Str; // 検索したい文章をEmbedding化する $embedding = Str::of('胃に優しい料理')->toEmbeddings(); 以下のwhereVectorSimilarTo() は、内部で検索文をEmbedding化し、保存済みEmbeddingとの意味距離を比較するイメージです。 use Illuminate\Support\Facades\DB; $recipes = DB::table('recipe_embeddings') ->whereVectorSimilarTo('embedding', '胃に優しい料理') ->limit(10) ->get(); このようなEmbedding生成やVector SearchをLaravelらしいAPIで扱いやすくなりました。 また、DB保存はPostgreSQLのpgvectorを利用してデータを保存したりします。 他にもいろいろあるようなのですが、ただMySQLだと、すでにVector Search関連機能が追加され、Embeddingを利用した類似検索自体は可能なのですが、 最適化やパフォーマンス面ではpgvectorなどを利用する方が現状は良いようです。 そのため、もしすでにMySQLを使用している場合は使い分けが一番コスト面、メリットの点で良いのかなと思います。 これにより、単なる文字一致ではなく、 数値の近似値の比較をすることができるようになり、 胃に優しい ↓ 雑炊、豆腐、うどん、茶碗蒸し のように、意味が近いものを検索しやすくなります。 Vector Searchとは? Vector Search(ベクトル検索)は、 ベクトル同士の距離を比較して、近いものを探す技術です。 つまり、 検索文 ↓ Embedding(数値化) ↓ DB内のベクトルと距離比較 ↓ 近いものを取得 という流れになります。 意味が近い文章ほど、数値的にも近い位置に配置されるため、Semantic Search「意味検索」をしているようになります。 LIKE検索と意味検索の違い 従来のLIKE検索は、文字が一致するものを探します。 WHERE name LIKE '%豆腐%' これは商品名やコード検索には強いです。 一方、意味検索は、 疲れている時に食べたい 夜でも重くない 夏バテでも食べやすい のような曖昧な検索に向いています。 これにより、レシピや記事など様々な情報を持っているものから キーワード検索のような文字列一致ではなく、意味検索をできることから キーワード検索では拾えないような曖昧な検索にも対応できるようになります。 ハイブリッドがバランス良い 意味検索は強力な検索ですが、上記の商品名やコード検索には弱く、 どちらにもメリット、デメリットがあるため、 LIKE検索から切り替えるのではなく、組み合わせるのが一番バランスが良いかなと思います。 まずLIKE検索 結果が少なければVector Search 重複を除いて結果を返す 商品名検索ならLIKE検索で十分なことも多いです。 一方、レシピ検索や記事検索のように「意味」や「状況」で探したいものは、Vector Searchと相性が良いです。 上記は例になりますが、ここは工夫の余地の大きいところでもあり、 仕様や検索内容、実施頻度、パフォーマンスなどを考慮して実現できるようにしたいです。 精度を上げるポイント ここは私は勘違いしていましたが、 Vector Searchは、データ件数よりも説明文や属性情報が重要です。 確かにいくら大量にデータがあっても、1つ1つの情報が少ないと意味を持たせられないと思いました。 悪い例: 麻婆豆腐 良い例: 辛味が強く、ご飯が進む中華料理。 豆腐とひき肉を使った定番メニュー。 満足感があり、夕食向き。 このように、AIが理解しやすい説明を持たせることで検索精度が上がります。 精度検証の進め方 ここまででとりあえず試してみたいと思った際は、最初から大規模に導入する必要はありません。 検証の際も大量のデータというよりは、1つ1つのデータ量が揃っているかが重要で、 その上で、まずは小さく試すのが良さそうです。 500〜3000件程度のデータを用意 検索クエリを30〜100個作る LIKE検索とVector Searchを比較する 上位5件に納得できる結果があるか確認する 最初は厳密な評価指標より、人間が見て「使えそうか」を確認するだけでも十分です。 最後に いかがでしたでしょうか。 今回のバージョンアップはAI関連の進化が大きな特徴だったことがよくわかりました。 私でも実際に簡単なAI検索であれば、比較的低コストで実装もできそうなので、導入も気軽にできます。 もちろん精度アップや検証面はどうしても最初は大変かと思いますが、少しずつ試しながらノウハウも蓄積しつつ 少しずつ規模を大きくしつつ、応用していくのが大事かなと思いました。 ただ肝心のバージョンアップはそこまでコード改修は不要なものの、 全体を見るとやることは多いので、しっかり時間を確保して対応が必要かなと思います。 今後のLaravelバージョンアップの際にぜひ少しでも参考にしていただければ幸いです。 最後までお読みいただき、ありがとうございました。
Goのtime.Nowとは? 〜synctestを添えて〜 はじめに エブリーでエンジニアをやっております、 赤川 です。食事管理アプリ ヘルシカ の開発を通じてGoを嗜んでいます。 ダイエット・食事管理・体重管理・カロリー計算 - ヘルシカ every, Inc. ヘルスケア/フィットネス 無料 ふと、以下のコードを見て、「Goにおける現在時刻ってなんなんだ…?」となりました。 now := time.Now() OSから取って来ているのは既知とした上で、Goのコードでそれをどのような形で扱っているのか、synctestの仮想時刻を返す挙動がどのように実装されているのかなど、いろいろ気になったのでコードを追っていこうと思います。 本記事で話すこと Goの時刻の扱い方 Goの現在時刻の取得の実装(ある程度高レイヤーの部分のみ) 本記事で話さないこと 他プログラミング言語の現在時刻の取得方法との違い プラットフォーム別実装など低レイヤーの詳細 time.Now の戻り値の作られ方 ウォールクロックとモノトニッククロック まず、OSが提供する時刻ソースには、大きく2種類あります。 種類 内容 特徴 ウォールクロック 現実で扱われている時刻。OSはUNIXエポック(1970-01-01 UTC)からの経過秒数として返すことが多い NTP補正・サマータイム・手動変更で 過去に巻き戻ることがある モノトニッククロック マシン起動などを起点とした、単調増加するカウンタ 必ず単調増加。日付としての意味は持たない これらの扱いについて、 time パッケージの公式ドキュメント に方針が書かれています。 Operating systems provide both a “wall clock,” which is subject to changes for clock synchronization, and a “monotonic clock,” which is not. The general rule is that the wall clock is for telling time and the monotonic clock is for measuring time. Rather than split the API, in this package the Time returned by time.Now contains both a wall clock reading and a monotonic clock reading; later time-telling operations use the wall clock reading, but later time-measuring operations, specifically comparisons and subtractions, use the monotonic clock reading. OSは「ウォールクロック」と「モノトニッククロック」の2つを提供している。ウォールクロックはクロック同期のために変更されうるが、モノトニッククロックは変更されない。一般的なルールとして、ウォールクロックは時刻を知るため(telling time)に、モノトニッククロックは時間を測るため(measuring time)に使う。本パッケージではAPIを分けるのではなく、 time.Now が返す Time にウォールクロックとモノトニッククロックの両方の読み取り値を含めることにしている。以降の「時刻を知る」操作はウォールクロックの値を、「時間を測る」操作(具体的には比較と差分)はモノトニッククロックの値を使う。 time.Timeの構造 「ウォール/モノトニックの両方を1つの Time で扱う」という方針を踏まえて、 src/time/time.go#L140-L161 で定義されている time.Time の構造を見てみましょう。 type Time struct { // wall and ext encode the wall time seconds, wall time nanoseconds, // and optional monotonic clock reading in nanoseconds. // // From high to low bit position, wall encodes a 1-bit flag (hasMonotonic), // a 33-bit seconds field, and a 30-bit wall time nanoseconds field. // The nanoseconds field is in the range [0, 999999999]. // If the hasMonotonic bit is 0, then the 33-bit field must be zero // and the full signed 64-bit wall seconds since Jan 1 year 1 is stored in ext. // If the hasMonotonic bit is 1, then the 33-bit field holds a 33-bit // unsigned wall seconds since Jan 1 year 1885, and ext holds a // signed 64-bit monotonic clock reading, nanoseconds since process start. wall uint64 ext int64 // loc specifies the Location that should be used to // determine the minute, hour, month, day, and year // that correspond to this Time. // The nil location means UTC. // All UTC times are represented with loc==nil, never loc==&utcLoc. loc *Location } 中身は wall 内に持っている hasMonotonic フラグによって2パターンに切り替わります。 hasMonotonic = 1 (ウォール+モノトニック) hasMonotonic = 0 (ウォールのみ) wall フラグ(1) + ウォール秒(33bit, 1885年起点) + ウォールナノ秒(30bit) フラグ(0) + 33bit秒は未使用(0) + ウォールナノ秒(30bit) ext モノトニッククロック値 (プロセス起動からのナノ秒) ウォール秒 (西暦1年起点の符号付き64bit) loc タイムゾーン タイムゾーン ext が hasMonotonic で意味を切り替えるようになっているのは、 wall の33bit秒(1885年起点)だと 1885〜2157年の約272年分 しか表現できないからです。 time.Date(1500, ...) のような範囲外の時刻を扱う hasMonotonic = 0 のケースでは、ウォール秒を wall の33bitから ext (int64, 西暦1年起点) に移してより広い範囲をカバーします。これは Time のサイズを増やさずに「モノトニック付き」と「広い時刻範囲」を両立させるための工夫です。次節の time.Now 実装の中にも、以下のように33bit上限に言及するコメントが出てきます。 // This will be true after March 16, 2157. time.Now 本体の実装 ここまで把握した上で、 src/time/time.go#L1347-L1361 にある time.Now の実装を見ていきます(Go 1.26.3 現在)。 // Now returns the current local time. func Now() Time { sec, nsec, mono := runtimeNow() if mono == 0 { return Time{ uint64 (nsec), sec + unixToInternal, Local} } mono -= startNano sec += unixToInternal - minWall if uint64 (sec)>> 33 != 0 { // Seconds field overflowed the 33 bits available when // storing a monotonic time. This will be true after // March 16, 2157. return Time{ uint64 (nsec), sec + minWall, Local} } return Time{hasMonotonic | uint64 (sec)<<nsecShift | uint64 (nsec), mono, Local} } コードに出てくる定数は同じく src/time/time.go の L163-L169 ( hasMonotonic など)と L535-L568 ( unixToInternal など)、および L1341 ( startNano )に以下のように定義されています。(簡単のため一部省略して記述しています) const ( secondsPerDay = 24 * 60 * 60 // 西暦1年1月1日 〜 UNIXエポック(1970-01-01) の秒数 unixToInternal int64 = ( 1969 * 365 + 1969 / 4 - 1969 / 100 + 1969 / 400 ) * secondsPerDay // 西暦1年1月1日 〜 1885年1月1日 の秒数 wallToInternal int64 = ( 1884 * 365 + 1884 / 4 - 1884 / 100 + 1884 / 400 ) * secondsPerDay ) const ( hasMonotonic = 1 << 63 // wall の最上位bitに立てるフラグ minWall = wallToInternal // wall の33bit秒の起点(= 1885年) nsecShift = 30 // wall に秒を詰めるときのシフト量 ) // プロセス起動時点のモノトニック値(runtime 初期化時にセットされる) var startNano int64 (1969*365 + 1969/4 - 1969/100 + 1969/400) の式は閏年を考慮してその年までの日数を計算しています。これに secondsPerDay を掛けることで「西暦1年1月1日からその年までの 秒数 」になります。 unixToInternal は1970年、 wallToInternal は1885年までのそれにあたります。 役割を整理すると以下のとおりです。 定数 役割 unixToInternal OSが返すUNIX秒を 「西暦1年起点」 に変換するオフセット minWall (= wallToInternal ) 西暦1年起点を 「1885年起点」 にずらすオフセット( wall の33bit秒の基準合わせ) nsecShift wall に秒を詰めるとき左に30bitシフトしてナノ秒の場所を空ける hasMonotonic モノトニックが入っているかのフラグ( wall の最上位bit) startNano プロセス起動時点のモノトニック値。これを引くことで ext を 「プロセス起動からの経過ns」 に正規化する Local loc に入れるデフォルトのタイムゾーン( *Location ) これらを踏まえてもう一度 time.Now を読み直すと、3パターンで Time を組み立てていることがわかります。 func Now() Time { // OSから現在のウォール秒・ナノ秒・モノトニック値を取得 sec, nsec, mono := runtimeNow() // 【パターン1】モノトニッククロックが取れなかった環境 // → ウォールクロックだけを ext に入れて返す(hasMonotonic = 0 のレイアウト) if mono == 0 { // sec(UNIX秒) + unixToInternal で「西暦1年起点の秒」に変換し、ext に詰める return Time{ uint64 (nsec), sec + unixToInternal, Local} } // 以降は mono あり mono -= startNano // モノトニックを「プロセス起動からの経過ns」に正規化 sec += unixToInternal - minWall // sec を「1885年起点の秒」に変換(33bit領域に詰める準備) // 【パターン2】33bitに収まらない(= 2157年3月16日以降) // → モノトニックを諦めて、ウォール秒は ext の方に置く(hasMonotonic = 0 のレイアウト) if uint64 (sec)>> 33 != 0 { // sec はいま1885年起点。minWall を足し戻して「西暦1年起点」に戻してから ext へ return Time{ uint64 (nsec), sec + minWall, Local} } // 【パターン3】通常パス // → hasMonotonic フラグを立て、ウォール・モノトニック両方を wall / ext に詰める // - sec は1885年起点のまま wall の33bit領域へ(<< nsecShift でナノ秒の場所を空けて | で合成) // - mono は正規化済みの値をそのまま ext へ return Time{hasMonotonic | uint64 (sec)<<nsecShift | uint64 (nsec), mono, Local} } runtimeNow() の中身 runtimeNow() は time パッケージ側ではシグネチャだけ書かれており、実体は src/runtime/time.go#L16-L31 にあります。 //go:linkname time_runtimeNow time.runtimeNow func time_runtimeNow() (sec int64 , nsec int32 , mono int64 ) { if bubble := getg().bubble; bubble != nil { sec = bubble.now / ( 1000 * 1000 * 1000 ) nsec = int32 (bubble.now % ( 1000 * 1000 * 1000 )) // Don't return a monotonic time inside a synctest bubble. // If we return a monotonic time based on the fake clock, // arithmetic on times created inside/outside bubbles is confusing. // If we return a monotonic time based on the real monotonic clock, // arithmetic on times created in the same bubble is confusing. // Simplest is to omit the monotonic time within a bubble. return sec, nsec, 0 } return time_now() } 分岐は2つあります。 synctest bubble の分岐 : 実行中のゴルーチンが synctest のバブル内にいる場合、バブルの仮想時刻 bubble.now を ウォール秒・ナノ秒として返し 、モノトニックは 0 を返します。 通常の分岐 : time_now() を呼び出します。これの実体はプラットフォーム別に実装されており、最終的にはどれもOSが提供する時刻取得APIを叩いています。 time_now() の実装は低レイヤーに近い話になるので今回は触れません。 bubble.now は、 src/runtime/synctest.go#L186-L187 の synctestRun で初期化されます。 const synctestBaseTime = 946684800000000000 // midnight UTC 2000-01-01 bubble.now = synctestBaseTime モノトニックを使わない理由については、コメントに書かれています。以下、和訳です。 synctestバブル内ではモノトニック時刻を返さないようにする。 フェイククロックに基づいたモノトニック時刻を返してしまうと、バブル内で作った時刻とバブル外で作った時刻のあいだでの計算結果が紛らわしくなる。 一方で実モノトニッククロックに基づいたモノトニック時刻を返しても、同じバブル内で作った時刻同士の計算が紛らわしくなる。 もっともシンプルな解は、バブル内ではモノトニック時刻を省略することだ。 モノトニッククロックはマシン起動を起点とするものなので、ウォールクロックだけ仮想時刻を進めても両者の値が食い違ってしまいます。一方で synctestBaseTime を起点にしたモノトニック値を別途用意するという選択肢もありますが、その場合もバブル外で取得した Time との差分計算で実時間とバブル内仮想時間が混在してしまいます。これらを避けるために、バブル内ではウォールクロックのみを扱う実装になっている、ということですね。 まとめ time.Now() の経路は以下のようになっていることがわかりました。 time.Now() │ │ ① (sec, nsec, mono) を取得 └── time.runtimeNow() ── linkname ──→ runtime.time_runtimeNow() │ ├── synctest bubble 内 │ sec = bubble.now / 1e9 (バブル仮想時刻) │ nsec = bubble.now % 1e9 │ mono = 0 (バブル内ではmonoを返さない) │ └── 通常経路 runtime.time_now() (プラットフォーム別実装) └─ OSが提供する時刻取得APIを呼ぶ │ │ ② 受け取った値を Time{wall, ext, loc} に組み立てて返す │ ├── mono == 0 → hasMonotonic=0, ext に「西暦1年起点ウォール秒」 ├── 通常 (33bit以内) → hasMonotonic=1, wall=フラグ|33bit秒(1885年起点)|30bitナノ秒, ext=mono(プロセス起動からのns) └── 33bit溢れ (2157年以降) → hasMonotonic=0, ext に「西暦1年起点ウォール秒」 Goが扱う時刻の構造とその設計理由、synctestの分岐実装、そしてウォールとモノトニックを巧みに組み合わせる工夫を知ることができ、とても満足しています。プラットフォームごとの実装は、まずは自分が使っているarm64から見てみたいと思います。 最後までお読みいただきありがとうございました! 参考 Package time - pkg.go.dev Package testing/synctest - pkg.go.dev Go: src/time/time.go Go: src/runtime/time.go Go: src/runtime/synctest.go Go 1.9 Release Notes - Monotonic Clocks Proposal: Monotonic Elapsed Time Measurements in Go
はじめに こんにちは!デリッシュキッチンで主にバックエンドの開発を担当している秋山です。 私たちのチームでは Gemini API を使った機能を運用しており、利用料金をいかに抑えるかは継続的に向き合うべきテーマになっています。 この記事では、Gemini API のコスト削減の選択肢を一通り整理したうえで、私自身が実際に試した中での学びを共有します。 料金体系 まずはGemini APIの料金に対して説明します。 Gemini API の料金は、ざっくり以下の3要素で決まります。 入力トークン : プロンプトやシステム指示、画像・動画・音声などの入力 出力トークン : モデルが返したテキスト 思考(thinking)トークン : 推論モデルが回答前に内部で考えるトークン。※ 出力料金として課金される また、各トークンの料金は使用するモデルによって大きく異なります。 2026年5月時点でGemini3系の100万トークンあたりの標準価格を比較すると、以下のようになります。 モデル 入力(テキスト) 出力 Gemini 3.1 Flash-Lite $0.25 $1.50 Gemini 3 Flash (Preview) $0.50 $3.00 Gemini 3.1 Pro (Preview) $2.00 $12.00 (※入力データの種別(テキスト/画像/動画/音声)やプロンプト長によって単価が変動するため、本表では「テキスト入力・200Kトークン以下」の条件に揃えて比較しています。) ご覧のとおり、FlashとProで出力料金は4倍、Flash-LiteとProでは8倍の差があります。 定期的な価格変動があるため、各モデルのコストについて詳しくは公式ドキュメントをご覧ください。 ai.google.dev コスト削減の選択肢 主なコスト削減手法として次のようなものがあると考えています。 手法 削減効果 モデルダウングレード 数十%以上 Context Caching 入力トークン最大90% Batch API 50% Flex推論 50% Thinking Budget / Level の縮小 思考トークン分 それぞれ簡単に補足していきます。 モデルダウングレード 最も基本的かつ強力です。 料金体系のところで示したとおりモデルによって大きく料金が異なります。 基本的には最新のモデルや推論に適したモデルの料金が高いです。 そのため、Geminiに任せるタスクにおいて、各モデルで精度を検証し、精度と料金を踏まえて使用するモデルを決定することが重要です。 例えば、最新のモデルの方が推論性能が高いことが多いですが、タスクによっては古いモデルでも十分な精度の回答を得られる場合があるため、どのモデル以上であれば要件を満たすことができるかの検証が必要です。 Context Caching 同じシステム指示やドキュメントを繰り返し使用する場合に効きます。 キャッシュ済みの入力トークン単価が大幅に下がります。 Gemini 2.5 以降のモデルではデフォルトで有効になっていますが、コスト削減を保証したい場合は明示的な設定が必要です。 ai.google.dev Batch API Batch APIは、非同期で大量リクエストを処理する仕組みで、料金が標準の半額になります。 24時間以内の完了が目標値で、即応性が不要なバックグラウンド処理向きです。 リクエストの提出方式は、API呼び出しにそのまま埋め込む インライン方式 と、JSONL形式のファイルをアップロードする 入力ファイル方式 の2種類があります。 ジョブとして実行されるので、結果の取得はポーリング( batches.get() )か、 batch.succeeded / batch.failed イベントのWebhook購読で行います。 ジョブには 48時間の有効期限 があり、その間に処理が完了しない場合は JOB_STATE_EXPIRED 扱いとなって失効します。 Batch APIを使用するメリットとして 標準料金の半額で利用できる 1つのバッチで大量のリクエストを行うことができる という点があります。 一方で、 非同期処理のため即時応答ができない ジョブ管理(投入・状態取得・結果回収)のクライアント実装が必要になる というデメリットがあります。 ai.google.dev Flex推論 Flex推論は、レイテンシや信頼性が変動する代わりに、標準料金の50%でAPIを叩ける仕組みです。 2026年5月現在はプレビュー機能です。 Gemini 2.5 系以降の主要モデル(2.5 Pro / Flash / Flash-Lite、3.1 Pro / Flash-Lite、3 Flash など)に対応しており、リクエスト時に service_tier: "flex" を指定するだけで切り替えられます。 Flex推論を使用するメリットとして 標準料金の半額で利用できる 同期APIとして扱えるため、Batch APIのように非同期化(ジョブ投入 → 結果取得)する手間がかからない 「即時応答までは不要だが、同じセッション内で結果を受け取りたい」というユースケースに合う という点があります。 一方で、 標準APIよりも信頼性が低い レイテンシが標準APIより遅く、公式の目安は 1〜15 分 。 Flex推論が枯渇しても自動で標準APIには昇格しないため、クライアント側で 指数バックオフを含むリトライやフォールバックを自前で実装する必要がある というデメリットがあるため、ユースケースに適している場合のみ使用するのが良さそうです。 ai.google.dev Thinking Budget / Level の縮小 Gemini 2.5 / 3 系の推論モデルでは、思考トークン量をパラメーターで制御できます。 デフォルトのままだと簡単なタスクでもモデルが過剰に考え込んでしまい、出力料金とレイテンシが余計にかかってしまうケースがあります。 Gemini 2.5 系では ThinkingBudget (トークン数の上限を数値指定)、Gemini 3 系では ThinkingLevel ( MINIMAL / LOW / MEDIUM / HIGH の段階指定)でこれを制御できます。 Geminiに渡すタスクごとに、このパラメータを調整することでコストやレイテンシーの最適化を行うことができます。 ai.google.dev 実際にFlex推論を試しました ここからは、上で紹介した手法の中から、私が実際に本番運用で試したFlex推論について共有します。 私たちがGeminiに任せているタスクとして、リアルタイム性は必要ないが数分~数十分以内には完了したいタスクがありました。 料金を下げるためにBatch APIも考えましたが、Batch APIだとレイテンシに最大24時間かかるのが懸念でした。 一方Flex推論は目標レイテンシーが1分~15分のため、このタスクには最適だと判断しました。 Batch APIと違いジョブ管理の考慮が不要で、Flex推論のパラメータを指定してリクエストするだけなので、気軽に試すことができて開発体験的にも良かったです。 ただし、Flex推論の欠点として信頼性の低下があるため、私の場合は、 指数バックオフでリトライする処理 リトライに失敗する場合標準APIにフォールバックする処理 を入れました。 リトライ処理については公式でも推奨されています。 フォールバックを入れておくと、平常時はFlex推論の割引を享受しつつ、Flex推論が使えない時も影響を出さずに処理を続けられます。フォールバック時のコストは標準料金に戻りますが、「常に標準APIで叩く」ケースと比べれば全体としては安く済みます。 使ってみての感想ですが、Flex推論は実際の本番運用以外にも、信頼性が求められない日々のモデル検証で使用できそうだと思いました。 まとめ Gemini API のコスト削減には複数の選択肢があります。 そのため、タスクの性質と運用要件を踏まえ、複数の手法を組み合わせて使うことになります。 まだ自分で触れていない Context Caching と Batch API も、ユースケース次第で大きな効果が見込める手段なので、引き続き検証していきたいと考えています。 同じように Gemini API のコストを気にし始めた方にとって、選択肢を整理するきっかけになれば嬉しいです。
エブリー開発本部の塚田です。 バックエンドやデータ基盤をメインに担当しています。 2026年4月に Amazon S3 の新機能として Amazon S3 Files が GA となり、続けて4月後半には Lambda からの利用にも対応 しました。 データエンジニア視点で見ると、「Lambda で並列データ処理を書くときに毎回悩んでいた、状態の持ち回り」がやりやすくなるんじゃないかと感じました。 本記事では、Lambda 上のデータ前処理パイプラインを S3 Files で組み直すとどう変わるか、を検討しました。 なぜ Lambda 前処理で「状態」が悩みの種だったのか Lambda はスケールが効き、起動コストも安いので、データ前処理を分散させる用途には便利です。一方で、Lambda が「ステートレスな関数」であるという前提と、データ処理に必要な「ある程度大きな共有データ・中間成果物のやり取り」が噛み合わないことが多く、設計時に悩む箇所でもあります。 これまで取りうる選択肢と課題感は以下があると思います。 各 Lambda が S3 から個別に GET/PUT : 起動ごとに DL コストがかかり、並列度を上げてもスループットが頭打ちになり、リクエスト課金も比例して膨らむ DynamoDB / ElastiCache を共有ストアにする : 大きめのオブジェクトには不向き、別サービスの運用も乗ってくる Lambda + EFS マウント : ファイル共有はできるが、S3 と二重管理になり、外部から覗きにくい EFS をマウントすれば「並列 Lambda が共有のファイルシステムを持つ」構成自体は以前から作れました。ただし EFS と S3 はそれぞれ別ストレージなので、「分析やバックアップは S3 側のオブジェクトでやるが、パイプラインの共有だけ EFS」というように二重管理になりがちで、ファイルとオブジェクトの間を行き来する同期スクリプトが必ずどこかで生えていました。 S3 Files が変えるもの S3 Files は内部的には Amazon EFS をベースにした NFS v4.2 のファイルシステム で、EC2 / Lambda / ECS / EKS から mount でき、書いたデータはマウント側からは即時に見え、S3 バケット側にも非同期で反映されます。 S3 Files は Mountpoint for Amazon S3 のような機能とは別物です。Mountpoint は S3 の API の上にファイルシステムの振る舞いを「見せる」アプローチなので、たとえばファイルの一部を上書きする操作が原理的にサポートされません。一方 S3 Files はファイルシステム側から見えるのは本物の NFS セマンティクスで、S3 側から見えるのは本物の S3 オブジェクトです。「両者は別物だが、その間の同期レイヤーを AWS が引き受けてくれている」という設計になっています。 ここまでが前提です。データ基盤側にとってこの仕様が嬉しいのは、次の2点あると考えています。 複数 Lambda が同時マウントできる :並列ジョブ間の共有として使える 同じデータをファイル経由でも S3 API 経由でも読める :パイプラインの内部処理はファイル世界で書いて、運用や監査は S3 世界で済ませられる 検証: 並列特徴量生成パイプラインを組んでみる 具体的なシナリオで Before / After を比較します。 シナリオ 入力: 新規レシピ 1000 件(メタデータと本文) 処理: 10 並列の Lambda が分担して特徴量を抽出する 共有して使うマスターデータ: 食材辞書 JSON(数十〜数百 MB) 既存レシピの埋め込みベクトル(数百 MB〜1 GB) 出力: 各 Lambda が抽出した特徴量を後段ジョブが集約 Before: 各 Lambda が S3 から個別 DL する構成 import boto3, json import numpy as np s3 = boto3.client( "s3" ) def handler (event, context): # コールドスタートのたびに、数百MB級のマスターを /tmp に取得 s3.download_file( "recipes-bucket" , "master/ingredients.json" , "/tmp/ingredients.json" ) s3.download_file( "recipes-bucket" , "master/embeddings.bin" , "/tmp/embeddings.bin" ) ingredients = json.load( open ( "/tmp/ingredients.json" )) embeddings = np.fromfile( "/tmp/embeddings.bin" , dtype=np.float32) features = build_features(event[ "recipes" ], ingredients, embeddings) # 結果は S3 に書き戻す body = serialize(features) s3.put_object( Bucket= "recipes-bucket" , Key=f "features/{event['job_id']}/{event['shard']}.parquet" , Body=body, ) この場合、以下のような問題点が考えられます Lambda コールドスタートのたびに数百 MB の DL が走る /tmp の 10 GB 制限に当たりかけることがあり、マスターを増やす際の考慮事項が発生 並列度を上げると、結局 S3 → Lambda の転送スループットがボトルネックになる マスターを更新したときに「全 Lambda がそれを見ている」状態を担保しづらい After: S3 Files をマウントして共有領域として使う /mnt/s3files に recipes-bucket をマウント済みとします。 import json, os import numpy as np # モジュールトップで一度だけ読み、コンテナ再利用時はそのまま使う INGREDIENTS = json.load( open ( "/mnt/s3files/master/ingredients.json" )) EMBEDDINGS = np.fromfile( "/mnt/s3files/master/embeddings.bin" , dtype=np.float32) def handler (event, context): out_dir = f "/mnt/s3files/features/{event['job_id']}" os.makedirs(out_dir, exist_ok= True ) out_path = f "{out_dir}/{event['shard']}.parquet" features = build_features(event[ "recipes" ], INGREDIENTS, EMBEDDINGS) write_parquet(out_path, features) boto3 の呼び出しが消え、純粋に「ファイルを読み、ファイルを書く」コードになっています。Lambda 関数内で s3.put_object を発行する必要がないので、リトライやマルチパートアップロードの考慮もパイプライン側のコードからは消えます。 書き出した特徴量は、後段の集約ジョブからは S3 API で s3://recipes-bucket/features/{job_id}/ を aws s3 ls するだけで一覧でき、運用者から見えるバケットの世界も自然なままです。 Before / After で何が変わるか 指標 Before(個別 DL) After(S3 Files) コールドスタート時の初期化 約 1 GB の DL が発生する マウント越しの mmap / page cache に乗る 並列実行時のスループット S3 → Lambda の DL 帯域がボトルネックになりやすい 各 Lambda が同じファイルをキャッシュ越しに参照 S3 GET リクエスト数 コールドスタート × オブジェクト数 ぶん発生 マスターは初回のみ、出力 PUT は不要 /tmp 使用量 DL したマスターぶん消費 マウント領域は /tmp を消費しない 特に効きそうなのは コールドスタート時の DL コスト と S3 リクエスト数 です。前者はマスターサイズに比例し、後者はそのまま月次コストに跳ねるため、並列度が高いシナリオほど差が広がります。 チェックポイント付きジョブとしての応用 並列処理の共有だけでなく、もう一つ価値が出やすいユースケースが 長時間ジョブのチェックポイント だと考えています。 Lambda は最大実行時間の制限がありますが、それを超える処理は分割して Step Functions で繋ぐ、というのがよくある構成かと思います。ステップ間で「どこまで処理が進んでいるか」を引き渡すのに様々な方法で考慮を入れることが発生します。S3 Files を使うと、これがそのままファイルとして書け、しかも S3 API から覗ける という性質を活かせます。 import json, os WORKSPACE = "/mnt/s3files/agents/recipe-tagger" def handler (event, context): job_id = event[ "job_id" ] state_dir = f "{WORKSPACE}/{job_id}" state_path = f "{state_dir}/state.json" os.makedirs(state_dir, exist_ok= True ) state = json.load( open (state_path)) if os.path.exists(state_path) else { "processed" : 0 , "results" : [], } # 中断された場合は、processed の続きから再開 for item in event[ "items" ][state[ "processed" ]:]: state[ "results" ].append(process(item)) state[ "processed" ] += 1 # 100件ごとにチェックポイントを永続化 if state[ "processed" ] % 100 == 0 : tmp_path = f "{state_path}.tmp" with open (tmp_path, "w" ) as f: json.dump(state, f) os.replace(tmp_path, state_path) # アトミックに置き換える with open (state_path, "w" ) as f: json.dump(state, f) return { "job_id" : job_id, "processed" : state[ "processed" ]} ポイントは、 チェックポイントが S3 API 側からも見える という点です。運用中に「いまどのジョブがどこまで進んでいるか」を aws s3 cp s3://recipes-bucket/agents/recipe-tagger/{job_id}/state.json で確認できます。 向く用途・向かない用途 検証を通じて見えた、自分なりの線引きです。 S3 Files が向く用途 並列ジョブが共通参照する大きめのマスターデータの配置先 並列ジョブの中間成果物を集約する 長時間ジョブのチェックポイント 既存のファイル前提コードを最小改修で S3 へ移すリフトアンドシフト 向かない用途 強整合性が必要なメタデータ管理 大量の小ファイル rename を伴うワークフロー ミリ秒レイテンシが要求されるクリティカルパス ファイル経由と S3 API 経由の両方から同じファイルに書き込む運用 おわりに すべての処理を S3 Files 経由にする必要はなく、並列ジョブの共有、チェックポイント、共通マスターの配布など、「これまでファイルシステムが欲しかったがゆえに EFS を別建てしていた / 自前で同期していた」箇所だけをピンポイントで置き換えるのが、効きの良い使いどころだと現状では考えています。
はじめに こんにちは。リテールハブ開発部の清水です。 私たちのチームでは、外部システムと深夜帯にCSVをやり取りするバッチシステムを開発・運用しています。 これらのバッチ群は適切な順番で適切な設定で実行することが求められるのですが、 新メンバーがジョインしたとき、これをローカル環境で実際に動かして確かめるのはハードルが高いと感じていました。 本記事ではこのようなバッチシステムを動作確認しやすくするために考えた点をご紹介します。 対象のバッチシステム 本番のインフラ構成イメージ ローカル開発環境 Docker Composeで FTP / S3互換ストレージ / MySQL を立てて、Goバッチがそれらに対して動作する形です。 動作確認が大変な理由 外部システム連携であること 外部システム側のフォーマット仕様書は手元にあるのですが、仕様書を読むだけだとピンとこない箇所がそれなりにあります。 さらに本物のCSVは非常にカラム数が多い上、センシティブな情報も含まれるので、軽い気持ちで実物を見るのはためらわれるものです。 こういった状況から気軽にローカルで試すためのテストデータを作成することのハードルがかなり高いです。 バッチ処理を複数に分けていること リトライしやすさを優先して、CSV取得・ETL変換・計算処理・CSVエクスポート・FTP送信・ファイル送信履歴更新とバッチを6つに分けています。 途中まで処理したファイルは都度S3に設置する形を取っています。 初見だと「どの順番でどの環境変数で動かせばいいんだっけ?」と混乱しやすいです。 一度ローカルで一通り実行してみるのがいちばん早いのですが、その「一度通す」までのお膳立てが意外に重い、というのが課題でした。 工夫1: テストデータ作成用コマンドを実装 JSONからテストデータを生成するmakeコマンドを用意しました。 make gen-testdata CONFIG=test_20260507.json { " a ": " 2026-05-07 ", " b ": [ { " c ": " TEST_001 ", " d ": [ { " e ": 1 , " f ": " 10:30:00 ", " g ": [ { " h ": 1 , " i ": 2 , " j ": 500 } ] } ] } ] } JSONを差し替えるだけでさまざまなテストケースを切り替えられます。 本物のCSVに触らずにテストできるようになり、テストデータ作成のハードルがだいぶ下がりました。 実際に使用するときは人間がJSONを用意するのではなく、Claude Codeに「こういうテストケースのデータを作って」と指示を出すとこちらのコマンドが使われる形になります。 工夫2: Claude Codeでオンボーディングスキルを作成 スキルのディレクトリ .claude/skills/onboarding/ ├── SKILL.md ├── references/ │ ├── architecture.md │ └── batch-pipeline.md └── scripts/ ├── check_env.sh └── check_step.sh SKILL.md がスキル本体で、 references/ 以下にアーキテクチャ説明やパイプラインの全体像を、 scripts/ 以下に通過判定用のスクリプトを置いています。 大まかな内容 Phase 1: 環境チェック Docker / docker compose / make / コンテナの起動・healthy 状態をスクリプトで判定する Phase 2: アーキテクチャ説明 + 動作確認 references/ 以下のドキュメントで全体像を伝えてから、6本のバッチを1本ずつ手で動かしてもらう こだわったポイント 実際に手で動かせるようにする 私はどうしてもコードを読むだけでは理解できないと感じることが多いので、ローカル環境で立ち上げて一通り動作させることにこだわりました 表示されたコマンドをコピーして、別タブのターミナルで実行して進める形にしました 通過判定をスクリプトで行う 最初は「ステップごとに Claude Code がユーザーに確認して、その回答を信じて進める」くらいの素朴な作りで考えていました 実際にはまだ前のステップが完了していない (例: DB初期化を忘れている) のに気づかず進んでしまい、後段でエラーになってからようやく手戻りが発生する、ということが起こりました おわりに 新メンバーが触れたときに迷子になりがちな部分を、テストデータ作成コマンドとオンボーディングスキルでだいぶ楽にできた手応えがあります。 特にスキル側では、ステップごとの通過判定をスクリプトに寄せたことで、Claude Codeが「分かったつもり」で先に進んでしまう問題を防げました。 同じように複雑なシステムの動作確認に悩まれている方の参考になれば嬉しいです。
開発2部の内原です。 シェルで >file 、 2>&1 のような記号を使ってリダイレクト処理を行うことは多いかと思いますが、なぜこのような書き方をするのか、それが実際にカーネルやプロセスのレベルで何をやっているのか、は意外と説明しづらい、というかなんとなくふわっとした理解のままでいました。 そこでこの記事ではファイルディスクリプタとUnixシステムコールの観点から、これらの記号の意味を考えてみます。 ファイルディスクリプタ(fd)とは ファイルディスクリプタとは、プロセスごとにカーネルが管理しているファイルテーブルへのインデックス(整数)のことです。プロセスがファイルやソケットを開くと、カーネル側でテーブルにエントリが作られ、その識別子となる整数(0, 1, 2, 3, ...)がプロセスに返されます。 プロセスは以降、この整数を read(2) / write(2) などのシステムコールに渡してファイル操作を行います。 0, 1, 2 という慣習 POSIXにおいてプロセス起動時点で、以下の3つのfdが予め確保されています。 fd 名前 用途 0 stdin 標準入力 1 stdout 標準出力 2 stderr 標準エラー出力 これらのファイルディスクリプタは通常、ターミナルのデバイスファイル( /dev/ttys00N など)にOSによって関連付けられています。 $ lsof -p $$ -a -d 0-2 # 今実行しているシェル自身が開いている標準入出力(fd 0〜2)を表示 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME zsh 49106 uchihara 0u CHR 16,6 0t10 1167 /dev/ttys006 zsh 49106 uchihara 1u CHR 16,6 0t10 1167 /dev/ttys006 zsh 49106 uchihara 2u CHR 16,6 0t10 1167 /dev/ttys006 3つともターミナル端末( /dev/ttys006 )を指していることがわかります。 open(2) で fd を取得する 新しくファイルを開くと、未使用の最小番号の fd が返却されます。起動時点で 0, 1, 2 が使われているので通常は 3 になります。 #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { int fd = open ( "/tmp/hello.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); printf ( "fd = %d\n " , fd); write (fd, "hello \n " , 6 ); close (fd); return 0 ; } $ cc fd_open.c -o fd_open && ./fd_open fd = 3 $ cat /tmp/hello.txt hello 確かに fd = 3 が返ってきています。 dup(2) で fd を複製する dup(2) はオープン済みの fd を複製し、未使用の最小番号で新しい fd を返します。複製された2つの fd は同じファイルテーブルエントリを指すため、ファイル位置(オフセット)も共有されます。 #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { int fd1 = open ( "/tmp/dup.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); int fd2 = dup (fd1); printf ( "fd1= %d , fd2= %d\n " , fd1, fd2); write (fd1, "via fd1 \n " , 8 ); write (fd2, "via fd2 \n " , 8 ); close (fd1); close (fd2); return 0 ; } $ cc fd_dup.c -o fd_dup && ./fd_dup fd1=3, fd2=4 $ cat /tmp/dup.txt via fd1 via fd2 オフセットが共有されているため、 fd1 で書き込んだ続きから fd2 の書き込みが進んでいることがわかります。 dup2(2) で fd 番号を複製する dup2(oldfd, newfd) は newfd 番がすでに使われていれば一旦close してから、 oldfd の複製を newfd 番に作るシステムコールです。これがリダイレクトの実態となります。 #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { printf ( "before redirect \n " ); fflush ( stdout ); int fd = open ( "/tmp/dup2.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); dup2 (fd, 1 ); // fd 1 (stdout) を fd の指す先に差し替える close (fd); // 元の fd はもう不要なので閉じる printf ( "after redirect \n " ); return 0 ; } $ cc fd_dup2.c -o fd_dup2 && ./fd_dup2 before redirect $ cat /tmp/dup2.txt after redirect dup2(fd, 1) を境にして、 printf の出力先が端末からファイルに切り替わっていることがわかります。 なお fflush(stdout) を挟んでいるのはstdioのバッファリングを考慮するためです。バッファに残ったまま fd 1 を差し替えると、後でフラッシュされたタイミングで意図しないファイルに書き込まれてしまいます。 >file の正体 シェルがリダイレクトを行うときの手順は以下の通りです。 (新たに子プロセスでコマンドを実行する場合) fork(2) で子プロセスを生成 子プロセスで出力先ファイルを open(2) その fd を dup2(2) で 1 番(stdout)に複製 元の fd は close(2) で閉じる execve(2) で実コマンドに置き換える 自前でリダイレクトを再現してみる ./mini_redirect <出力ファイル> <コマンド> [引数...] のように実行すると、指定したコマンドの標準出力をファイルへリダイレクトします。 #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> int main ( int argc, char *argv[]) { if (argc < 3 ) { fprintf ( stderr , "usage: %s <output_file> <cmd> [args...] \n " , argv[ 0 ]); return 1 ; } const char *outfile = argv[ 1 ]; pid_t pid = fork (); if (pid < 0 ) { perror ( "fork" ); return 1 ; } if (pid == 0 ) { // 子プロセス: 出力先を open し、stdout (fd 1) に dup2 してから exec int fd = open (outfile, O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); if (fd < 0 ) { perror ( "open" ); _exit ( 1 ); } if ( dup2 (fd, 1 ) < 0 ) { perror ( "dup2" ); _exit ( 1 ); } close (fd); execvp (argv[ 2 ], &argv[ 2 ]); perror ( "execvp" ); _exit ( 1 ); } // 親プロセス: 子の終了を待つ int status; waitpid (pid, &status, 0 ); return WIFEXITED (status) ? WEXITSTATUS (status) : 1 ; } 実行してみます。 $ cc mini_redirect.c -o mini_redirect $ ./mini_redirect /tmp/mini_out.txt echo hello world from mini_redirect $ cat /tmp/mini_out.txt hello world from mini_redirect これは以下と同じ動作です。 $ echo hello world from mini_redirect >/tmp/mini_out.txt 2>&1 とはなにか 2>&1 という書き方忘れたりしません?(自分はたまに忘れます)これは実際には dup2(1, 2) の意味で、シェルの記述とシステムコールの引数が逆になっていて混乱しがちではあります。 あと、標準出力と標準エラー出力をまとめてファイルに書き出したい時に、 >file 2>&1 と 2>&1 >file どっちだっけ?みたいになることもあります。 結論 書き方 stdout の行き先 stderr の行き先 cmd >file 2>&1 file file cmd 2>&1 >file file 変化なし(通常はターミナル) 2>&1 は「fd 2 を fd 1 と同じにする」と説明されますが、これは正確には「 2>&1 を実行した時点の fd 1 の指し先を fd 2 にコピーする」という操作で、以後 fd 1 が別の場所に切り替わっても fd 2 は連動しません。(リンクしているわけではないという意味) シェルでの挙動 $ bash -c 'echo stdout-msg; echo stderr-msg >&2' >/tmp/sh_a.txt 2>&1 $ cat /tmp/sh_a.txt stdout-msg stderr-msg $ bash -c 'echo stdout-msg; echo stderr-msg >&2' 2>&1 >/tmp/sh_b.txt stderr-msg $ cat /tmp/sh_b.txt stdout-msg パターンBではなぜ stderr が端末に残ってしまうのか、再現してみます。 C で再現してみる シェルは > や 2>&1 といったリダイレクト指示を左から右に評価する仕様なので、順序が重要になります。 パターン A: >file 2>&1 #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { int fd = open ( "/tmp/order_a.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); dup2 (fd, 1 ); // ① fd 1 をファイルに差し替え close (fd); dup2 ( 1 , 2 ); // ② fd 2 を「現時点の fd 1」(=ファイル) に差し替え fprintf ( stdout , "stdout: hello \n " ); fflush ( stdout ); fprintf ( stderr , "stderr: world \n " ); fflush ( stderr ); return 0 ; } $ cc order_a.c -o order_a && ./order_a $ cat /tmp/order_a.txt stdout: hello stderr: world ①の時点で fd 1(標準出力)はファイルを指します。 ②でその「ファイルを指している fd 1」をコピーして fd 2(標準エラー出力) にセットしているので、fd 2 もファイルを指すことになります。 結果として両方ファイルに書き込まれます。 パターン B: 2>&1 >file #include <fcntl.h> #include <unistd.h> #include <stdio.h> int main ( void ) { dup2 ( 1 , 2 ); // ① fd 2 を「現時点の fd 1」(=ターミナル) に差し替え int fd = open ( "/tmp/order_b.txt" , O_WRONLY | O_CREAT | O_TRUNC, 0 644 ); dup2 (fd, 1 ); // ② fd 1 をファイルに差し替え(fd 2 は連動しない) close (fd); fprintf ( stdout , "stdout: hello \n " ); fflush ( stdout ); fprintf ( stderr , "stderr: world \n " ); fflush ( stderr ); return 0 ; } $ cc order_b.c -o order_b && ./order_b stderr: world $ cat /tmp/order_b.txt stdout: hello ①の時点では fd 1(標準出力)はまだターミナルを指しており、fd 2(標準エラー出力)が同じところを指します。 ②で fd 1 はファイルに切り替わりますが、fd 2 はすでに「①でコピーされた時点のターミナル」を保持し続けています。 これはシェルの挙動と一致しています。 なぜそうなるのか カーネル内部の構造で見るとわかりやすいです。 各 fd はプロセスごとの fd テーブルから「open file table」のエントリを指している dup2(oldfd, newfd) は「 newfd の指し先を oldfd の指し先と同じにする」というポインタ複製操作 後から oldfd の指し先を変更しても、 newfd は元の指し先を指したまま つまり 2>&1 は「fd 2 と fd 1 を以後リンクする」のではなく、「実行時点の fd 1 の指し先を fd 2 にコピーする」ということになります。 覚え方 ログを全部ファイルに落としたいときは、まずファイルリダイレクト >file を先に書いて、その後 2>&1 でまとめる、と考えるのがよさそうです。 $ cmd >file 2>&1 # 両方 file $ cmd 2>&1 >file # stdout だけ file、stderr は端末 まとめ シェルでよく使う >file や 2>&1 といった記号は、 open dup2 close という Unix システムコールの組み合わせとしてみると、その挙動がそのままシステムコールによる実装になっていることが確認できました。 まあとは言え覚えづらいですよね・・・結局のところ慣れでしかないかもしれません。 あと、パイプやヒアドキュメントについてもまたいつか調べてみたいです。 fd は open 時に未使用の最小番号が返却される整数 >file は open → dup2(fd, 1) → close(fd) 2>&1 は実行時点の fd 1 の指し先を fd 2 にコピーするだけで、以後 fd 1 が変わっても fd 2 は連動しない 評価順は左から右なので、 >file 2>&1 と 2>&1 >file は別物になる
Claude Code を快適に使うための macOS デスクトップ通知セットアップ 背景 なぜ alerter を採用したのか 1. alerter のインストール 2. 通知スクリプトの作成 2-1. notify_alerter.sh(Stop / Notification hook 用) 2-2. notify_pretool.sh(PreToolUse hook 用) 3. Claude Code の hooks 設定 各 Hook の役割 4. VSCode 拡張での Notification hook の扱い 5. macOS のセキュリティ許可 6. 動作確認 通知テスト 確認項目 デバッグログ 7. alerter のプロセス管理で学んだこと 問題: プロセスのゾンビ化 対策1: --group(プロセス蓄積の防止) 対策2: --timeout(最終的なプロセス回収) 溜まったプロセスの手動クリーンアップ 8. なぜ nohup + disown が必要だったか 9. 通知のカスタマイズ 特定ツールの通知をスキップする サウンド --sender(通知アイコン) まとめ 最後に  こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️です。 背景  弊社ではClaude を非エンジニアも含めた全社に展開しており、業務のあらゆる場面で生成AI の活用を推進しています。  そんな中、我々のチーム内でも今年3月から本格的にCursor から移行してClaude Code (VSCode 拡張機能)を日常的に使うようになってから、両者の明らかな違いを実感することになりました。  それは、Cursor が標準搭載しているmacOS デスクトップ通知機能でした。Claude Code にはその機能がないためAgent にプロンプトを投げた後、私自身が他の作業を並行しているとClaude Code 側が permission_prompt のWait でタスクが一向に完了できない状態やタスク完了状態に気付くのが随分遅れてしまうということがしばしばありました(業務効率化のためのAgent ツールなのに、、)。  Claude Code には Hooks という仕組みが用意されています。これは Stop(応答終了)や Notification(許可待ち等)、PreToolUse(ツール実行直前)といったライフサイクルイベントに対して任意のシェルコマンドを実行できる公式機能で、JSON がイベント情報として標準入力から渡ってきます。  本記事ではこの Hooks と alerter というコマンドラインツールを組み合わせて、 タスク完了・許可待ち・入力待ちの デスクトップ通知を出す 通知を クリックすると、対象プロジェクトの VSCode ウィンドウが自動でアクティブになる (全画面の別アプリ上からでも切り替わる) VSCode 拡張版 でも許可待ち通知を取りこぼさない という環境を構築した内容をまとめます。macOS 26 系(Tahoe)環境で動作確認しています。 なぜ alerter を採用したのか  macOS から通知を出すだけなら選択肢は複数あります。今回の要件「通知をクリックしたら VSCode がアクティブになる」を満たせるものを比較した結果を表にまとめます。 ツール 通知表示 クリックイベントの取得 備考 terminal-notifier 環境依存 可能(旧来の定番) 公式リポジトリ の最新リリースは 2017 年 11 月(v2.0.0)で、近年の macOS での動作不具合 Issue( #307 、 #312 、 #319 ほか)が未解決のままです。私の環境(macOS 26 系)では通知が出ませんでした。 osascript ( display notification ) 動作する 不可 AppleScript 公式ドキュメント( Standard Additions: display notification )に「戻り値なし」と明記されており、クリック結果を取得する手段がありません。 alerter 動作する 可能 公式リポジトリ によれば、 terminal-notifier を Swift で書き直した後継で、macOS 13.0 以降対応。クリック時に @CONTENTCLICKED / @ACTIONCLICKED を stdout に出力するため、外部プロセスでの後処理が可能です。   alerter がクリック結果を stdout に返してくれるおかげで、「クリック → open -a "Visual Studio Code" で対象プロジェクトを開く」という連携を、標準ツールの組み合わせだけで実現できました。 1. alerter のインストール  Homebrew で導入します( 公式の導入手順 に準拠)。 brew install vjeantet/tap/alerter  インストール確認: which alerter # /opt/homebrew/bin/alerter alerter --version 2. 通知スクリプトの作成  2 つのスクリプトを ~/.claude/ に配置し、実行権限を付与します。前者は Stop / Notification hook 用、後者は VSCode 拡張向けの PreToolUse hook 用です。 chmod +x ~/.claude/notify_alerter.sh chmod +x ~/.claude/notify_pretool.sh 2-1. notify_alerter.sh (Stop / Notification hook 用)  タスク完了通知および、CLI 版 Claude Code での許可待ち通知を処理します。Hook に渡ってくる JSON の仕様は 公式リファレンスの Stop / Notification セクション に従っています。 notification_type として permission_prompt / idle_prompt が返ってくるため、これで分岐しています。 #!/bin/bash input = $( cat ) echo " $( date ' +%H:%M:%S ' ) $input " >> /tmp/claude_notify_debug.log cwd = $( echo " $input " | jq -r ' .cwd ' ) project = $( basename " $cwd " ) notification_type = $( echo " $input " | jq -r ' .notification_type ' ) # ターミナルアプリの Bundle ID を自動検出 get_terminal_bundle_id() { if [[ -n " ${__CFBundleIdentifier} " ]] ; then echo " ${__CFBundleIdentifier} " return fi case " ${TERM_PROGRAM} " in " Apple_Terminal ") echo " com.apple.Terminal " ;; " iTerm.app ") echo " com.googlecode.iterm2 " ;; " ghostty ") echo " com.mitchellh.ghostty " ;; " WarpTerminal ") echo " dev.warp.Warp-Stable " ;; * ) local pid parent comm pid = $$ while [[ " ${pid} " -ne 1 ]] 2 >/dev/null; do parent = $( ps -p " ${pid} " -o ppid = 2 > /dev/null | tr -d ' ' ) || break [[ -z " ${parent} " ]] && break comm = $( ps -p " ${parent} " -o comm = 2 > /dev/null ) case " ${comm} " in *Terminal* ) echo " com.apple.Terminal "; return ;; *iTerm* ) echo " com.googlecode.iterm2 "; return ;; *Cursor* ) echo " com.todesktop.230313mzl4w4u92 "; return ;; *Code* ) echo " com.microsoft.VSCode "; return ;; *ghostty* ) echo " com.mitchellh.ghostty "; return ;; *warp* ) echo " dev.warp.Warp-Stable "; return ;; * ) ;; esac pid = " ${parent} " done echo "" ;; esac } BUNDLE_ID = $( get_terminal_bundle_id ) send_notification() { local message =" $1 " local sound =" $2 " local group =" $3 " local args = (--title " Claude Code " --subtitle " ${project} " --message " ${message} " ) if [[ -n " ${sound} " ]] ; then args += ( --sound " ${sound} " ) fi args += ( --sender " com.microsoft.VSCode " ) # --group: 同じグループの通知は前のプロセスを自動終了して置き換える args += ( --group " ${group :- claude-default } " ) # --timeout: プロセスのゾンビ化防止(秒)。通知自体は macOS 通知センターに残る local timeout = 86400 local timeout_file =" $HOME /.claude/notify_timeout.conf " if [[ -f " ${timeout_file} " ]] ; then timeout = $( cat " ${timeout_file} " | tr -d ' [:space:] ' ) fi args += ( --timeout " ${timeout} " ) # alerter はクリック待ちでブロックするため、nohup + disown で完全にデタッチ nohup bash -c " result= \$ (alerter $( printf ' %q ' " ${args[ @ ]} " ) 2>/dev/null) if [[ \"\$ {result} \" == \" @CONTENTCLICKED \" || \"\$ {result} \" == \" @ACTIONCLICKED \" ]] && [[ -n \" ${cwd} \" ]]; then open -a \" Visual Studio Code \" \" ${cwd} \" fi " &> /dev/null & disown } case " ${notification_type} " in " permission_prompt ") send_notification " 許可待ち " " Ping " " claude-permission " ;; " idle_prompt ") send_notification " 入力待ち " " Purr " " claude-idle " ;; " stop ") send_notification " タスク完了 " " Glass " " claude-stop " ;; * ) send_notification " 通知 " " default " " claude-other " ;; esac 2-2. notify_pretool.sh (PreToolUse hook 用)  こちらは VSCode 拡張環境向けの「許可待ち通知」の代替実装です。詳細は「4. VSCode 拡張での Notification hook の扱い」で後述します。  ざっくり説明すると、次の 4 つの設定ファイルの permissions.allow リストと照合し、 自動許可されないツールの実行前にのみ 通知を送るというロジックです。 ~/.claude/settings.json (グローバル) ~/.claude/settings.local.json (グローバルローカル) $cwd/.claude/settings.json (プロジェクト) $cwd/.claude/settings.local.json (プロジェクトローカル) #!/bin/bash # PreToolUse hook: 許可が必要なツール実行前に通知を送る # settings.json の allow リストにマッチするツールはスキップする input = $( cat ) tool_name = $( echo " $input " | jq -r ' .tool_name ' ) cwd = $( echo " $input " | jq -r ' .cwd ' ) project = $( basename " $cwd " ) # 常に自動許可されるツール(通知不要) case " ${tool_name} " in Glob|Grep|TodoWrite|Agent|Skill|ToolSearch|SendMessage ) exit 0 ;; esac # ユーザー個別のスキップリスト(~/.claude/notify_skip_tools.txt) SKIP_FILE = " $HOME /.claude/notify_skip_tools.txt " if [[ -f " ${SKIP_FILE} " ]] ; then while IFS = read -r skip_tool; do [[ -z " ${skip_tool} " || " ${skip_tool} " == \# * ]] && continue if [[ " ${tool_name} " == " ${skip_tool} " ]] ; then exit 0 fi done < " ${SKIP_FILE} " fi # allow リストと照合する関数 check_allow_list() { local settings_file =" $1 " [[ -f " ${settings_file} " ]] || return # Bash ツール: コマンドプレフィックスで照合 if [[ " ${tool_name} " == " Bash " ]] ; then local command command= $( echo " $input " | jq -r ' .tool_input.command ' ) while IFS = read -r pattern; do if [[ " ${pattern} " =~ ^Bash\((.+)(:\*|\*)?\)$ ]] ; then local prefix =" ${BASH_REMATCH[ 1 ]} " prefix = " ${prefix % :* } " if [[ " ${command} " == " ${prefix} " * ]] ; then exit 0 fi fi done < < ( jq -r ' .permissions.allow[] ' " ${settings_file} " 2 > /dev/null ) fi # Read ツール: パスパターンで照合 if [[ " ${tool_name} " == " Read " ]] ; then local file_path file_path = $( echo " $input " | jq -r ' .tool_input.file_path ' ) while IFS = read -r pattern; do if [[ " ${pattern} " =~ ^Read\(//(.+)\)$ ]] ; then local path_pattern =" ${BASH_REMATCH[ 1 ]} " local path_prefix =" ${path_pattern %% /** } " if [[ " ${file_path} " == " ${path_prefix} " * ]] ; then exit 0 fi fi done < < ( jq -r ' .permissions.allow[] ' " ${settings_file} " 2 > /dev/null ) fi # MCP ツール・WebSearch 等: 完全一致で照合 while IFS = read -r pattern; do if [[ " ${pattern} " == " ${tool_name} " ]] ; then exit 0 fi done < < ( jq -r ' .permissions.allow[] ' " ${settings_file} " 2 > /dev/null ) } # グローバル設定 check_allow_list " $HOME /.claude/settings.json " check_allow_list " $HOME /.claude/settings.local.json " # プロジェクト設定 check_allow_list " $cwd /.claude/settings.json " check_allow_list " $cwd /.claude/settings.local.json " # 許可リストにマッチしない → 通知を送る echo " $( date ' +%H:%M:%S ' ) PRETOOL_NOTIFY: ${tool_name} " >> /tmp/claude_notify_debug.log nohup bash -c " timeout=86400 timeout_file= \"\$ HOME/.claude/notify_timeout.conf \" if [[ -f \"\$ {timeout_file} \" ]]; then timeout= \$ (cat \"\$ {timeout_file} \" | tr -d '[:space:]') fi result= \$ (alerter --title 'Claude Code' --subtitle ' ${project} ' --message '許可待ち: ${tool_name} ' --sound Ping --sender com.microsoft.VSCode --group claude-pretool --timeout \"\$ {timeout} \" 2>/dev/null) if [[ \"\$ {result} \" == '@CONTENTCLICKED' || \"\$ {result} \" == '@ACTIONCLICKED' ]] && [[ -n ' ${cwd} ' ]]; then open -a 'Visual Studio Code' ' ${cwd} ' fi " & > /dev/null & disown exit 0 3. Claude Code の hooks 設定   ~/.claude/settings.json の hooks セクションに以下を追加します( 公式リファレンス の書式に準拠)。 { " hooks ": { " Stop ": [ { " matcher ": "", " hooks ": [ { " type ": " command ", " command ": " echo '{ \" cwd \" : \" ' \" $(pwd) \" ' \" , \" notification_type \" : \" stop \" }' | ~/.claude/notify_alerter.sh " } ] } ] , " Notification ": [ { " matcher ": "", " hooks ": [ { " type ": " command ", " command ": " ~/.claude/notify_alerter.sh " } ] } ] , " PreToolUse ": [ { " matcher ": "", " hooks ": [ { " type ": " command ", " command ": " ~/.claude/notify_pretool.sh " } ] } ] } } 各 Hook の役割 Hook 発火タイミング 用途 VSCode 拡張 CLI Stop Claude が応答を終えて停止したタイミング 「タスク完了」通知 動作する 動作する Notification 許可待ち・入力待ちなどの通知イベント 「許可待ち」「入力待ち」通知 permission_prompt が発火しないケースあり 動作する PreToolUse ツール実行の直前 VSCode での「許可待ち」通知の代替 動作する 動作する 4. VSCode 拡張での Notification hook の扱い   公式リファレンス では、 Notification hook の notification_type として permission_prompt / idle_prompt / auth_success / elicitation_dialog の 4 種が定義されています。しかし、私の環境で動作確認したところ、 VSCode 拡張版では許可ダイアログが出ても Notification hook( permission_prompt )が発火しないケース があり、「許可待ちなのに通知が来ない」という状態になっていました。CLI 版では同じ設定で期待どおり発火しています。  そのため、VSCode 拡張で使う場合は PreToolUse hook(必ず発火する)でツール実行直前に自前で判定する という回避策を取っています。流れは以下です。 PreToolUse hook がツール実行直前に発火する notify_pretool.sh がツール名(と Bash の場合はコマンド、Read の場合はファイルパス)を受け取り、4 つの設定ファイルの permissions.allow と照合する allow リストに マッチしなかったとき だけ通知を送る(=「このあと許可ダイアログが出るはず」というタイミング)  この方式であれば、 Notification hook の発火有無にかかわらず、VSCode でも CLI でも漏れなく許可待ち通知を届けられます。CLI 版では Notification hook が正常動作するため、重複しないよう --group を claude-permission と claude-pretool で分けています(後述)。 5. macOS のセキュリティ許可   alerter + open -a の組み合わせは、macOS のアクセシビリティ・オートメーション等の追加許可なしで動作しました。初回のみ通知センター側で通知の表示許可を求められる程度で、特別な設定は不要です。 6. 動作確認 通知テスト # タスク完了通知 echo ' {"cwd": " ' $( pwd ) ' ", "notification_type": "stop"} ' | ~/.claude/notify_alerter.sh # 許可待ち通知(CLI の Notification hook 用) echo ' {"cwd": " ' $( pwd ) ' ", "notification_type": "permission_prompt"} ' | ~/.claude/notify_alerter.sh 確認項目 タスク完了通知がデスクトップに表示される 許可待ち通知が表示される(VSCode: PreToolUse / CLI: Notification) VSCode アイコンが通知に表示される( --sender com.microsoft.VSCode ) 通知をクリックすると対象プロジェクトの VSCode ウィンドウがアクティブになる 全画面の別アプリ(Chrome 等)から通知をクリックしても正しいウィンドウに切り替わる 通知後に Claude が WAIT 状態にならず即座に続行する デバッグログ  通知が来ないときはデバッグログを確認します: tail -f /tmp/claude_notify_debug.log 7. alerter のプロセス管理で学んだこと  運用してみて一番ハマったのがプロセス管理です。 問題: プロセスのゾンビ化   alerter は クリックされるまで stdout をブロックし続ける 仕様です( 公式リポジトリ の README にある @CONTENTCLICKED / @ACTIONCLICKED / @TIMEOUT / @CLOSED のいずれかが出力されるまでプロセスが生きる)。通知バッジを macOS 通知センターから消去しても alerter プロセスは終了しません。放置すると各プロセスがメモリを消費し、長時間の利用で数 GB に達するケースがありました。 対策1: --group (プロセス蓄積の防止)  同じ --group の通知が新たに発行されると、前のプロセスが自動で kill されます。グループは用途別に分けており、同時に存在するプロセスは最大 4 つになる設計です: グループ 用途 claude-stop タスク完了 claude-permission 許可待ち(CLI Notification hook) claude-pretool 許可待ち(VSCode PreToolUse hook) claude-idle 入力待ち 対策2: --timeout (最終的なプロセス回収)   --group だけでは最後の 4 プロセスが残り続けるため、 --timeout でプロセスの最大生存時間を設定して確実に回収します。 デフォルト: 86400 秒(1 日) カスタム: ~/.claude/notify_timeout.conf に秒数を書く # 例: 2 時間に変更 echo 7200 > ~/.claude/notify_timeout.conf  なお、timeout が切れてもプロセスが終了するだけで、macOS 通知センターの通知バッジは残ります。 溜まったプロセスの手動クリーンアップ # alerter プロセス数を確認 ps aux | grep alerter | grep -v grep | wc -l # 全 alerter プロセスを終了 pkill -f alerter 8. なぜ nohup + disown が必要だったか  前述のとおり alerter はクリック待ちでブロックします。単純に (...) & でバックグラウンド実行しても、 Claude Code の hook ランナーが子プロセスの終了を待ってしまい、Claude 本体が WAIT 状態のまま止まる (トークンも消費し続けてしまう)という問題がありました。   nohup ... & で SIGHUP を無視させ、さらに disown でジョブテーブルから外すことで、hook プロセスから完全に切り離せます。これにより、通知の表示・クリック待ちとは独立して Claude が動作を継続できるようになりました。 9. 通知のカスタマイズ 特定ツールの通知をスキップする  VSCode の「Edit Automatically」などセッションレベルで自動許可しているツールは settings.json に記録されないため、 ~/.claude/notify_skip_tools.txt に 1 行 1 ツール名で記載する仕組みを入れてあります: # セッションレベルで自動許可しているツール名を 1 行 1 つで記載 Edit  もしくは notify_pretool.sh の先頭付近にあるスキップリスト( Glob|Grep|TodoWrite|... )に追記する方法でも同等です。 サウンド  macOS 標準のサウンド名を指定できます: Ping , Purr , Glass , default , Basso , Blow , Bottle , Frog , Funk , Hero , Morse , Pop , Sosumi , Submarine , Tink 。 --sender (通知アイコン)   --sender に Bundle ID を指定すると通知アイコンが変わります。現在は com.microsoft.VSCode を指定して VSCode アイコンを表示しています。 アプリ Bundle ID VSCode com.microsoft.VSCode Cursor com.todesktop.230313mzl4w4u92 Terminal com.apple.Terminal iTerm2 com.googlecode.iterm2 Ghostty com.mitchellh.ghostty  ただし --sender を指定すると、そのアプリの macOS 通知設定に依存することになります。対象アプリの通知を OFF にしていると通知が表示されなくなるため注意が必要です。 まとめ  本記事では、Claude Code の Hooks 機能と alerter を組み合わせて、 タスク完了・許可待ち・入力待ちのデスクトップ通知を出す 通知クリックでプロジェクトの VSCode ウィンドウを自動でアクティブにする VSCode 拡張でも PreToolUse hook で許可待ち通知を取りこぼさない というセットアップ方法と、その過程で踏んだプロセス管理の落とし穴(ゾンビ化 → --group / --timeout / nohup + disown での回収)をご紹介しました。  Claude Code をバックグラウンドで走らせつつ他の作業を並行して進めるスタイルにおいては、「気づかずに長時間止まっていた」という時間を減らすだけで、体感の生産性が目に見えて向上します。CLI と VSCode 拡張で挙動が異なる部分は PreToolUse hook で吸収できるので、Hooks の仕様を把握したうえで自分の開発スタイルに合わせてカスタマイズしてみてください。 通知例 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
Next.js 16 のキャッシュとどう付き合うか ― 実装と運用のあいだで考えたこと 目次 Next.js 16 のキャッシュとどう付き合うか ― 実装と運用のあいだで考えたこと はじめに Next.js のキャッシュを整理する ブラウザ(Router Cache) CDN・Edge(HTTP Cache) サーバー(Data Cache = use cache) キャッシュに関する思想と変更の歴史 1. App Router 初期 — 暗黙的なキャッシュ 2. Next.js 15 — uncached by default への揺り戻し 3. Next.js 16 — Cache Components による explicit / composable 化 歴史に対してどう立ち向かうか 実装中に気づいた挙動と対策 気づき①: dynamic 判定で意図せず private / no-store が付与される 遭遇したきっかけ 検証(3つのページの比較) 気づき②: layout.tsx が TTL を持つと子ページにも伝搬する 実ビルド出力 実測で対策する A. next build のログを読む B. HTTP ヘッダを直接見る C. テストで Cache-Control を監視する D. 補足: 内部 Data Cache の hit/miss チーム開発を見据えたキャッシュ運用ルール 書き方を縛る TTLプロファイルを活用し、選択肢を増やしすぎない TTL 設定か invalidation か、どちらか統一する 書き方と場所を統一する 機械的に検知する ルールを明文化する 豊富な機能より保守性 おわりに 参考文献 はじめに こんにちは、開発本部の 黒髙 です。普段は デリッシュキッチン の開発に携わっています。 現在、運用中のWebアプリケーションをNext.jsに移行する検討を進めており、その過程で避けて通れないテーマのひとつがキャッシュでした。Next.jsの機能を調べてみると思ったよりも複雑で、理解が難しいと感じました。しかし、アプリの要件上、サーバーリソースの負荷を抑える観点ではある程度キャッシュを考慮すべきであり、完全に無視して運用することは現実的ではありません。 キャッシュの事故でよく耳にするのが、更新したはずのデータが古いままユーザーに届き続ける「stale」と呼ばれる状態です。本記事では細かいパフォーマンス調整よりも、「予期せぬ stale による事故のリスク/その原因となる実装ミスをどう減らすか」という観点を中心に考えます。 まず現状のキャッシュ機構を3層で整理したうえで、方針転換を繰り返してきた歴史と、実装時の注意点を検証も含めて述べていきます。最後に、これらを踏まえてチーム開発でどう運用するかを、コーディングエージェント(AI)との共存も含めて考察します。 Next.js のキャッシュを整理する Next.js のキャッシュは、Router Cache / Full Route Cache / Data Cache といった似た響きの用語と、use cache / cacheLife / revalidateTag など複数のAPIが絡み合っており、公式ドキュメントでも全体を掴むのは難しいと感じました。私は、キャッシュの動作場所に注目して、以下の3層で整理するのがわかりやすいのではないかと考えました。 ブラウザ(Router Cache) CDN・Edge(HTTP Cache) サーバー(Data Cache = use cache ) Webアプリのライフサイクル全体で言えば、バックエンドサーバー自身のキャッシュなども存在しますが、本記事では扱いません。とはいえ、Next.jsを取り巻くキャッシュだけでもWebアプリのライフサイクルの多くをカバーしていることがわかります。 ブラウザ(Router Cache) クライアントのブラウザ上で動作するキャッシュで、 <Link> などによるページ遷移をスムーズに見せるために内部的に保持されるものです。 staleTimes で挙動を調整できますが、基本的には値を細かく設定する層ではない印象です。 <Link> 経由で遷移する先は、ビューポート付近に入ったタイミングで裏側で prefetch され、Reactサーバコンポーネントのpayloadがブラウザ内に保持されます。 import Link from 'next/link' ; // 自動 prefetch(デフォルト) < Link href = "/recipes/123" > 生姜焼きのレシピ </ Link > // prefetch を止めたい場合 < Link href = "/recipes/123" prefetch = { false } > 生姜焼きのレシピ </ Link > 明示的に破棄したい場面では、クライアント側で router.refresh() を呼びます。 Router Cacheの詳細は、 Prefetching を参照してください。 CDN・Edge(HTTP Cache) CDN と Web アプリサーバーの間で働く、HTTPリクエストベースのキャッシュです。前提として、後述のサーバーキャッシュ( use cache , cacheLife )とは別物であり、それらが自動で同期されないことに注意が必要です。 Next.js はルートの分類( ○ Static / ◐ PPR / ƒ Dynamic )に応じて Cache-Control を自動で書き分けます。アプリ側から直接ヘッダを書くことはなく、ビルド時に上書きされます。 ○ Static → s-maxage=<revalidate>, stale-while-revalidate=<expire - revalidate> ◐ PPR → private, no-cache, no-store, max-age=0, must-revalidate ƒ Dynamic → 同上 補足: static / dynamic / PPR Next.js はルートを、ビルド時に確定できる static 、リクエストごとに描画する dynamic 、静的な shell に動的部分を後追いで差し込む PPR (Partial Prerendering) の3種類に分類します。Cache Components を有効にした Next.js 16 の主要機能です。 また、Next.js 独自のヘッダ( x-nextjs-cache , x-nextjs-prerender , x-nextjs-postponed , x-nextjs-stale-time など)も配信されますが、セルフホスティングですべてを扱おうとすると複雑性が増すため、あまり現実的ではありません。 サーバー(Data Cache = use cache ) ユーザーが意図的に管理する、サーバー内でのキャッシュです。 use cache で宣言します。Cache Components という概念自体は Next.js 16 から導入されたもので、寿命(TTL)は cacheLife 、タグによる明示 invalidation は cacheTag + revalidateTag という2系統のコントロール手段が用意されています。 関数・コンポーネント単位で 'use cache' を付けてキャッシュし、寿命は cacheLife で宣言します。 // 関数単位 import { cacheLife } from "next/cache" ; export async function fetchRecipe ( id : string ) { "use cache" ; cacheLife( "hours" ); // 組み込みプリセット: 1時間ごとに再検証 const { data } = await apiClient( `/recipes/ ${ id } ` ); return data; } // コンポーネント単位 async function RecipeList () { "use cache" ; cacheLife( "hours" ); // 1時間ごとに再検証 const recipes = await getRecipes(); return ( < ul > { recipes. map (( r ) => ( < li key = { r. id } > { r. name } </ li > )) } </ ul > ); } 時間ではなく明示的な契機で更新したい場合は、 cacheTag + revalidateTag を組み合わせます。 // 書く側: タグを打つ import { cacheTag } from "next/cache" ; export async function fetchRecipe ( id : string ) { "use cache" ; cacheLife( "days" ); // 1日ごとに再検証 cacheTag( `recipe- ${ id } ` ); const { data } = await apiClient( `/recipes/ ${ id } ` ); return data; } // 更新契機側: 無効化する(Next.js 16 は2引数必須) import { revalidateTag } from "next/cache" ; export async function POST () { revalidateTag( "recipe-1" , "days" ); return Response .json( { ok : true } ); } ただし revalidateTag が効くのはサーバ層の Data Cache のみで、CDN が前段にあれば別途キャッシュを削除する必要があります。3層のキャッシュはそれぞれ独立した寿命と無効化手段を持つため、層をまたいだ無効化には個別の対応が要ります。 なお 'use cache' には、スコープ違いの 'use cache: private' / 'use cache: remote' もあります(詳細は 公式ドキュメント: use cache を参照)。 キャッシュに関する思想と変更の歴史 Next.js のキャッシュの理解が難しいとされるもう一つの要因として、破壊的ともいえる仕様変更・方針転換がこれまで何度か行われてきた歴史も関係しています。 同じ1行の fetch が各バージョンでどう振る舞うかを整理すると、次のようになります。 バージョン const res = await fetch('/api') の挙動 明示するなら Next.js 13(初期) 暗黙にキャッシュされる (デフォルト無期限) { cache: 'no-store' } で opt-out Next.js 14 同上(+ Full Route Cache / Data Cache の概念整理) 同上 Next.js 15 毎回リクエスト(uncached) に反転 { next: { revalidate: N } } で opt-in Next.js 16 同上。ただし 'use cache' で明示宣言した関数のみキャッシュされる 関数に 'use cache' + cacheLife(...) 同じ1行が時期によって「無期限キャッシュ」「毎回リクエスト」「そもそもキャッシュされない」と意味を変えてきています。この履歴を知らずに古いサンプルコードをコピーすると、そのまま事故につながる危うさがあります。 1. App Router 初期 — 暗黙的なキャッシュ App Router 初期は、 fetch がデフォルトで暗黙にキャッシュされる挙動でした。しかもデフォルトでは TTL が設定されず、再検証を明示しない限りキャッシュされたまま残り続けるという仕様になります。 2. Next.js 15 — uncached by default への揺り戻し Next.js 15 では、デフォルトが「キャッシュ」から「uncached」へ真逆に転換されました( 公式ブログ: Next.js 15 RC )。同じ1行の fetch の意味が v14 → v15 で正反対になるため、既存コードの挙動が意図せず変わる可能性があり、移行には慎重な確認が必要だったと思われます。 3. Next.js 16 — Cache Components による explicit / composable 化 現在の中心思想であり、 'use cache' を opt-in 寄りにして明示させる方針です( 公式ブログ: Next.js 16 )。v14 の「暗黙」、v15 の「uncached デフォルト」に対して、v16 は 「 'use cache' と書いた関数だけが、cacheLife で寿命を明示したうえでキャッシュされる」という、キャッシュの有無と寿命をすべてコード上で宣言するモデルです。 歴史に対してどう立ち向かうか 単に使うだけでなく思想や背景まで知ると、キャッシュとPPR方針の関連のような縦の流れが見えて、仕様理解が深まります。とはいえ、Next.js 側が今後どういう振る舞いをしてくるかを予測するのは難しいのも事実です。 そこで、「キャッシュは明示的に書く」「デフォルト挙動に頼らない」の2点を基本にします。暗黙的なコードは移行時に予期せぬ事故を起こす可能性が高く、次に仕様が変わったときに真っ先に壊れるのも「デフォルト挙動に依存したコード」であり、そのリスクはできるだけ回避しておきたいです。 実装中に気づいた挙動と対策 本章で扱う気づきは次の2つです。 気づき① : dynamic 判定で意図せず private / no-store が付与される 気づき② : layout.tsx が cacheLife を持つと子ページにも伝搬する それぞれの遭遇経緯と検証結果を示したうえで、最後に 実践するための型(build ログ / HTTP ヘッダ / 自動テスト) を独立セクションにまとめます。 気づき①: dynamic 判定で意図せず private / no-store が付与される 遭遇したきっかけ Next.js 16 への移行を検討する中で、PPR の挙動を試していたときのことです。「TOPページの大半を 'use cache' で静的に保ちつつ、 <FavoriteInfo /> (cookie からお気に入りIDを読む小さなコンポーネント)だけ <Suspense> で分離する」という構成で実ヘッダを確認したら、 Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate が返ってきて想定外でした。このまま本番に出すと CDN のヒット率が期待どおり出ず、オリジン負荷が上がる可能性があります。 公式ドキュメント( CDN Caching )には static / dynamic それぞれの挙動は書かれているものの、 両者が混ざったルートで HTTP レイヤに返る Cache-Control は明示されていません 。移行判断の材料として、最小構成で挙動を切り分けました。 検証(3つのページの比較) 以下の3ルートをローカルの Next.js 16(Cache Components 有効)で用意して比較しました。 // /case-a : 完全 static(ビルド時に結果が決まる。動的要素なし) async function getStaticPayload () { "use cache" ; cacheLife( "hours" ); return { /* ... */ } ; } export default async function StaticOnlyPage () { const data = await getStaticPayload(); return < main > { /* ... */ } </ main > ; } // /case-b : mixed PPR(static な RecipeList と、リクエストごとに変わる FavoriteInfo が同居) export default function Page () { return ( < main > < RecipeList /> { /* 'use cache' 付き = static 扱い */ } < Suspense fallback = { < div > loading favorite list... </ div > } > < FavoriteInfo /> { " " } { /* cookies() を読む = リクエストごとに変わる動的部分 */ } </ Suspense > </ main > ); } // /case-c : ページ全体が dynamic(動的なcookies() 読み取りだけ) async function DynamicBody () { const favoriteId = ( await cookies()).get( "favoriteId" )?.value ?? "empty" ; return < p > { favoriteId } </ p > ; } export default function DynamicOnlyPage () { return ( < main > < Suspense fallback = { < div > loading... </ div > } > < DynamicBody /> </ Suspense > </ main > ); } pnpm build を実行すると、3ルートの分類は以下のようになります。 Route (app) Revalidate Expire ┌ ◐ /case-b 1h 1d ├ ◐ /case-c └ ○ /case-a 1h 1d next start を起動して実際に返る Cache-Control を確認すると次の通りです。 ルート構成 分類 Cache-Control /case-a (use cache のみ) ○ Static s-maxage=3600, stale-while-revalidate=82800 /case-b (use cache + Suspense 内 cookies) ◐ PPR private, no-cache, no-store, max-age=0, must-revalidate /case-c (shell + Suspense 内 cookies) ◐ PPR private, no-cache, no-store, max-age=0, must-revalidate ルート内に dynamicな要素(cookies / headers / connection)が1か所でも混ざると、HTTP レイヤは一律 no-store になり、 cacheLife で設定した値は効きません。 気づき②: layout.tsx が TTL を持つと子ページにも伝搬する 検証の中で、コンテンツがほぼ空のページの Cache-Control を確かめたところ、「空ページなので CDN に永続(= s-maxage=31536000 )でキャッシュできるはず」という予測が外れ、 s-maxage=3600 が返ってきました。原因は (main)/layout.tsx が cacheLife('hours') (1時間)を持つ関数を内部で呼んでいたことでした。これでは、静的に返したいページが1時間ごとに再検証される構成になってしまいます。 // app/(main)/layout.tsx import { fetchRecipes } from "@/lib/api/recipes" ; // 内部で 'use cache' + cacheLife('hours') export default async function MainLayout ( { children } ) { const recipes = await fetchRecipes(); return ( <> { /* sidebar など */ } { children } </> ); } 実ビルド出力 Route (app) Revalidate Expire ┌ ○ /recipes 1h 1d ← (main) 配下 └ ○ /about ← (static) 配下、永続 実際に返るヘッダを確認すると次の通りです。 /recipes : Cache-Control: s-maxage=3600, stale-while-revalidate=82800 ← (main) 配下 /about : Cache-Control: s-maxage=31536000 ← (static) 配下、永続 ルートの最終 Cache-Control は page + layout + 配下で呼ばれる use cache 関数の最短 cacheLife で決まるため、layout 側に短い TTL があるとそちらが優先されます。 この挙動を意識しながら、layout ごとにキャッシュ寿命が自然に分かれるように設計することと、 next build の出力で全ルートの Revalidate 列を確認する習慣を付けることが、手堅い備えになると感じました。 実測で対策する 上に挙げた気づきはいずれも実測で検知できる種類のものです。ここでは、自分が取り入れている4つの型をまとめます。 A. next build のログを読む next build の最終出力にルート一覧が出ます。これが一次資料です。 Route (app) Revalidate Expire ┌ ○ / 10m 1y ├ ○ /about ├ ○ /categories 10m 1y ├ ◐ /categories/[id] ├ ◐ /recipes/[id] └ ○ /terms ○ (Static) prerendered as static content ◐ (Partial Prerender) prerendered as static HTML + dynamic streaming ƒ (Dynamic) server-rendered on demand 読み方の要点は次の通りです。 ◐ が付いたルートは HTTP 層では必ず no-store になります。 cacheLife は内部にしか効きません。 Revalidate 列は そのルート全体で呼ばれる cacheLife のうち最も短い値 を示すので、想定より短ければ layout のキャッシュ関数が原因になっていることが多いです( 気づき② )。 B. HTTP ヘッダを直接見る Cache-Control や Next.js 独自のヘッダは、ブラウザの DevTools(Network タブ)や curl / httpie など、どの HTTP クライアントでも確認できます。 見るべきヘッダの組み合わせは例えば以下の通りです。 見えたヘッダ 結論 s-maxage=... が含まれる 完全 static、CDN で効く private, no-store が含まれる PPR か dynamic、CDN 効かない C. テストで Cache-Control を監視する Cache-Control の分類を自動テストにしておけば、意図せず分類が変わった瞬間に気付くことができます。Playwright で書くなら、例えば以下の通りです。 test ( "main routes return expected Cache-Control" , async ( { request } ) => { const table = [ { path : "/case-a" , match : /s-maxage=\d+/ } , { path : "/case-b" , match : /no-store/ } , { path : "/case-c" , match : /no-store/ } , ] ; for ( const { path , match } of table) { const res = await request. get (path); expect (res. headers () [ "cache-control" ] ).toMatch(match); } } ); D. 補足: 内部 Data Cache の hit/miss NEXT_PRIVATE_DEBUG_CACHE=1 を付けて起動すると、サーバー側のキャッシュ挙動をサーバーログから見ることもできます。 $ NEXT_PRIVATE_DEBUG_CACHE=1 pnpm start ... FileSystemCache: get /index APP_PAGE false ← 初回 miss use-cache: Resume Data Cache entry found [...] FileSystemCache: get /index APP_PAGE true ← 以降 hit チーム開発を見据えたキャッシュ運用ルール ここまでで、Next.js のキャッシュ構造と歴史、実装で出会った気づきを整理してきました。歴史からは「明示的に書く」「デフォルト挙動に頼らない」、気づきからは「局所視点では誤りやすい」という性質を引き出しました。ここからは、それらを踏まえてチーム開発で運用していくためにはどういう方針を取るべきかを考えます。 人間同士のチーム開発でも、コーディングエージェント(AI)に書かせる場合でも、同じ理由でミスをしてしまうことがあります。実際、 気づき② のケースを AI にコードから予測させてみたところ、人間と同じように外していました。キャッシュ層はファイルをまたいで合成されるため、局所視点では必然的に誤る性質を持っていると推測されます。 そのため、チーム開発でも AI が関わる場合でも、次の4点に注意して開発していきたいと考えています。 書き方を縛る: どこに何を書くかを固定し、選択肢を減らす 機械的に検知する: ESLint / build ログ / 自動テストで違反を落とす ルールを明文化する: AGENTS.md / CLAUDE.md に方針を残す 豊富な機能より保守性: 意図せぬ変更を引き起こさない選択を優先する 以下、この4つの柱を Next.js のキャッシュ運用に当てはめた具体例を示します。 書き方を縛る 選択肢を狭めることは、複雑さを避けて実装者の迷いを減らしたり、予期せぬ変更を防ぐといった保守運用面でのメリットがあります。一方で細かい制御や最適化の機会を失ってしまうため、トレードオフを要件によって見極める必要があります。 TTLプロファイルを活用し、選択肢を増やしすぎない Next.js 組み込みのプリセット( hours , days など)に加えて、 next.config.ts で自前でプロファイル定義することもできます。 これまでの本文では組み込みのプリセットを使ってきましたが、チームで運用する場合は自前のプロファイルを少数だけ許容する方針が良さそうです。 cacheLife: { 'api-default' : { revalidate: 600 } , // 10 分 'api-long' : { revalidate: 10800 } , // 3 時間 } プロファイルを絞り込むと、「期待値はこの範囲で回る」というメンタルモデルがチーム内で共有されます。選択肢を狭めることで細かい制御の機会は失いますが、複雑さを避ける観点も必要です。 TTL 設定か invalidation か、どちらか統一する 前半で触れた通り、キャッシュ更新の方針には大きく2種類あります。 TTL 型 : 全 fetch に cacheLife を付けて時間で更新する Invalidation 型 : cacheTag + revalidateTag で、CMS の webhook などの明示的な契機に合わせて無効化する こちらも同様に、両方を組み合わせてより最適化させる実装を取ることも可能です。しかし、どちらで更新されるかがコードを読むだけでは分からなくなり、判断が難しい領域が増えるといったデメリットも存在します。そのため、今回はTTL型だけに統一する方針をとっています。 // lib/api/recipes.ts export async function fetchRecipe ( id : string ) { "use cache" ; cacheLife( "api-default" ); // 10 分で background revalidate const { data } = await apiClient( `/recipes/ ${ id } ` ); return data; } 書き方と場所を統一する データ取得は lib/api/<domain>.ts に集約し、page では呼ぶだけにします。page / layout / route で 'use cache' を直接書かないようにします。 lib/api/ client.ts ← fetch 共通層 (timeout / retry / log) recipes.ts ← 全関数に 'use cache' + cacheLife categories.ts ← 同上 curations.ts ← 同上 // app/(main)/page.tsx import { fetchRecipes } from "@/lib/api/recipes" ; export default async function HomePage () { const recipes = await fetchRecipes(); // cache はデータ層が知っている return < HomeView recipes = { recipes } /> ; } page 側がキャッシュの寿命を意識しない、 lib/api だけ読めば寿命が分かる、という切り分けにします。キャッシュ関連の変更をするときも、 lib/api/ 配下だけを読めば判断できる状態にしておくのが狙いです。 機械的に検知する より厳密にしたい場合、ESLint の no-restricted-syntax で機械的に縛ることもできます。以下はキャッシュのプロファイル名を制限するコード例です。 // eslint.config.mjs の抜粋イメージ const ALLOWED = [ 'api-default' , 'api-long' ] ; // cacheLife はホワイトリスト外のプロファイル名 / custom options を禁止 { selector: `CallExpression[callee.name='cacheLife'] > Literal[value!=/^( ${ ALLOWED. join ( '|' ) } )$/]` , message: `cacheLife は ${ ALLOWED. join ( ' / ' ) } のみ使用可` , } , { selector: "CallExpression[callee.name='cacheLife'] > ObjectExpression" , message: 'cacheLife に custom options を直書きしない' , } , 機械的な制約として、前章で紹介した「確認の型」( next build のログ、 Cache-Control ヘッダ、自動テスト)をCIに組み込んで検知する仕組みを作る、という選択肢も挙げれられます。 ルールを明文化する Next.js のキャッシュ仕様は版ごとに大きく変わってきたので、AI エージェントは古いバージョンの書き方をしたり、逆に便利そうな新機能を差し込む可能性があります。そもそもそれらを提案させないために、ドキュメントで方針を明示しておくことは、基本的ではありますが重要です。 プロジェクトの CLAUDE.md では、例えば以下のように記述しています。 ### データ取得 (エージェント向け) - データ取得ロジックは ` lib/api/ ` に集約(Single Source of Truth) - SC → lib/api/ の関数を ` use cache ` 付きで直接呼び出す - ISR は使わない。キャッシュは ` use cache ` で TTL 管理 (デフォルト ` api-default ` = 10 分、一部 ` api-long ` = 3 時間) - cacheLife はプロジェクト定義のプロファイルのみ使用。preset ('hours', 'days' 等) や custom options は使わない ローカルではなくプロジェクトでファイル管理することで、これらのルールをそのままチームの共通認識として採用することが可能です。 豊富な機能より保守性 Next.js には便利な機能が豊富に用意されていますが、使うほど仕様変更の影響範囲が広がり、コードを読む際の迷いも増えます。自分自身が多くの機能を使いこなして最適化を頑張ることは魅力的に見えますが、「使わずに済むなら使わない」という決断も必要です。豊富さより簡潔さに倒すほうが、長期的には事故を減らすと感じています。 おわりに 今回の整理を振り返ると、Next.js のキャッシュと付き合う上で重要だと感じた点が自然と見えてきました。まずはブラウザ・CDN・サーバーの3層で構造を捉えること。仕様変更の振れ幅が大きい領域なので、デフォルト挙動に依存しすぎないこと。そして保守性や移植性を優先した簡潔なコードを、チームのルールとして縛ること。このあたりが、今回の整理で見えてきたことです。 振り返ると、実装時の気づきとチーム運用への落とし込みのあいだを行き来しながら、Next.js のキャッシュとどう付き合うかを考える機会になりました。層を意識して明示的に書き、ルールで縛るという地味な積み重ねが結局一番効くのだと感じています。 参考文献 Caching in Next.js | Next.js Prefetching | Next.js CDN Caching | Next.js PPR Platform Guide | Next.js Directives: use cache | Next.js Next.js 15 RC Next.js 16