Langfuse × GCS プライベートバケットで非公開画像をトレース表示する
- 智之 黒澤
- 9 時間前
- 読了時間: 10分
こんにちは。ガオ株式会社の黒澤です。この記事では、Langfuseでトレースに非公開な画像を表示する場合に、Google Cloud Storage(以下、GCS)を用いた場合のアーキテクチャパターンについて、実装を踏まえてご紹介します。
執筆時点の情報(2026年3月) 本記事は Langfuse v3.157.0 をセルフホストした環境での検証をもとにしています。将来のバージョンでよりシンプルな方法が提供される可能性があります。
想定読者
Langfuse を GCP 上でセルフホストしている
画像を入力とする LLM(Gemini、GPT-4o など)を使っており、入力画像をトレースで確認したい
画像は GCS のプライベートバケットに保存している
Langfuse SaaS 版をお使いの方へ SaaS 版でも LangfuseMedia(Langfuse 管理ストレージ)や External Media URL は利用できます。ただし `LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT` などのサーバー側環境変数を変更できないため、「自前の GCS バケットを LangfuseMedia のアップロード先に指定する」構成はセルフホスティングが前提です。
目的
画像を入力とする LLM を本番で使うとき、入力画像がトレースで確認できるかは LLMOps の基本です。モデルの挙動をデバッグするにも、品質評価をするにも、「そのとき何の画像を渡したか」を確認できることが重要です。
しかし GCS のプライベートバケットに保存した画像を Langfuse で表示しようとすると、いくつかの注意点があります。本記事では、実際の検証で遭遇した注意点と、要件に応じた解決策を整理します。
方式 A:External Media URL(自前でアップロード済みの場合)
アプリ側で GCS に画像をアップロードし、その URL を Langfuse に渡します。
with langfuse.start_as_current_observation(
as_type="generation", name="analyze-image",
input={"messages": [{"role": "user", "content": [
{"type": "text", "text": "この画像を分析してください"},
{"type": "image_url", "image_url": {"url": "https://...画像の URL..."}},
]}]},
model="gemini-2.5-pro",
) as gen:
result = call_vision_model(...)
gen.update(output=result)Langfuse はこの URL をそのままブラウザに渡します。自動インライン表示されず「Load Image」ボタンが表示されます。クリックすると別タブで画像が開きます。
▼Langfuse UI に「Load Image」ボタンが表示される

なぜ自動表示されないのか?
外部の信頼できない URL を自動で読み込むセキュリティリスクを避けるため、意図的にこの挙動になっています(langfuse/langfuse#5030)。自動レンダリングを設定可能にする Feature Request(#5142)はありますが、現時点では未実装です。
注意点:ブラウザ認証なしの URL では 403 になる
storage.googleapis.comを渡すと、「Load Image」をクリックしても 403 になります。
① UI に「Load Image」ボタンが表示される
② クリック → ブラウザが storage.googleapis.com に直接 GET
③ 403 "Anonymous caller does not have storage.objects.get access"▼ 403 エラー:Anonymous caller does not have storage.objects.get access

Google アカウントでログイン済みでも、storage.googleapis.com はブラウザのログインセッションを使いません。実は GCS のプライベートオブジェクトには認証の挙動が異なる 2 つの URL があります。
ブラウザ認証なし | ブラウザ認証あり | |
ドメイン | storage.googleapis.com | storage.cloud.google.com |
認証 | なし(IAM で許可されていないと 403) | ブラウザの Google ログインセッション |
プライベートバケット | ❌ 403 になる | ✅ IAM 権限があれば表示される |
解決策:ブラウザ認証ありの URL を使う
storage.googleapis.com の代わりに storage.cloud.google.com を使います。
# ❌ これは 403 になる
# url = "https://storage.googleapis.com/your-bucket/path/to/image.png"
# ✅ これなら表示される
url = "https://storage.cloud.google.com/your-bucket/path/to/image.png"実装例:
BUCKET_NAME = "your-gcs-bucket"
def gcs_uri_to_authenticated_url(gcs_uri: str) -> str:
"""gs://bucket/key → https://storage.cloud.google.com/bucket/key"""
path = gcs_uri.removeprefix("gs://")
return f"https://storage.cloud.google.com/{path}"
authenticated_url = gcs_uri_to_authenticated_url(f"gs://{BUCKET_NAME}/uploads/image.png")
with langfuse.start_as_current_observation(
as_type="generation", name="analyze-image",
input={"messages": [{"role": "user", "content": [
{"type": "text", "text": "この画像を分析してください"},
{"type": "image_url", "image_url": {"url": authenticated_url}},
]}]},
model="gemini-2.5-pro",
) as gen:
gen.update(output={"result": "..."})「Load Image」をクリックすると、ブラウザが Google アカウントのログインセッションを使って GCS にアクセスし、別タブで画像が表示されます。
▼ 「Load Image」クリック後、別タブに画像が表示された


条件
Langfuse ユーザーが Google アカウントでブラウザにログインしていること
そのアカウントに GCS バケットの `storage.objectViewer`(または同等の権限) が付与されていること
社内チームで Langfuse を使っている場合、チームメンバーは通常 GCP プロジェクトへのアクセス権を持っているため、この条件を満たしているケースが多いです。
制約
「Load Image」ボタンを 1 クリックする必要があります(自動インライン表示ではない)
URL にバケット名やオブジェクトパスが含まれるため、URL 自体を隠したいケースには不向きです
GCS の IAM をユーザーに付与できない場合(外部パートナーなど)は後述のプロキシ構成を検討してください
方式 B:LangfuseMedia(Langfuse にアップロードを任せる場合)
`LangfuseMedia` を使うと、SDK が自動でアップロードし、Langfuse UI では自動インライン表示されます。
from langfuse.media import LangfuseMedia
with open("image.png", "rb") as f:
media = LangfuseMedia(content_bytes=f.read(), content_type="image/png")
with langfuse.start_as_current_observation(
as_type="generation", name="analyze-image",
input={"messages": [{"role": "user", "content": [
{"type": "text", "text": "この画像を分析してください"},
{"type": "image_url", "image_url": {"url": media}},
]}]},
model="gemini-2.5-pro",
) as gen:
result = call_vision_model(...)
gen.update(output=result)▼ LangfuseMedia によるインライン表示

設定
Langfuse サーバー(docker-compose)に GCS の設定を追加します。
# docker-compose.yml
environment:
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: your-gcs-bucket
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: <GCS HMAC Access Key>
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: <GCS HMAC Secret Key>
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: https://storage.googleapis.com # GCS の S3 互換エンドポイント
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
AWS_REQUEST_CHECKSUM_CALCULATION: when_required`LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT` について
AWS SDK はデフォルトで AWS S3 に接続します。GCS を使う場合は `https://storage.googleapis.com` を明示的に指定する必要があります。
`AWS_REQUEST_CHECKSUM_CALCULATION` について
Langfuse の AWS SDK v3 はデフォルトで `x-amz-checksum-sha256` ヘッダーを付与しますが、GCS の S3 互換 API はこれを認識せず 400 エラーになります。`when_required` に設定することで回避できます。注意点:署名付き URL がブラウザに露出する
この構成では、Langfuse サーバーが 署名付き URL(presigned URL) を生成してブラウザに渡します。署名付き URL は有効期限内であれば認証なしでアクセスできます。
署名付き URL で要件を満たせるかどうかは、扱うデータの性質や組織のポリシーといったセキュリティ要件によります。URL 露出が NG な場合は後述のプロキシ構成を検討してください。
注意点:データ量に応じてコストが増加する
LangfuseMedia では SDK が画像を GCS に PUT するため、画像の枚数やサイズに応じて GCS のストレージ費用とオペレーション費用が発生します。方式 A(認証済み URL)は既に GCS にある画像を参照するだけなので、追加のストレージ費用はかかりません(GETオペレーション費用は発生しますが、10,000リクエストあたり数円程度です)。大量の画像を扱う場合はコストを考慮してください。
URL 露出 NG / IAM を渡せない場合:Cloud Run プロキシ構成
以下のいずれかに該当する場合は、Cloud Run プロキシを経由させます。方式 A・B どちらにも対応できます。
有効期限付きであっても URL が外部に漏れてほしくない
Langfuse ユーザーに GCS の IAM 権限を付与できない(外部パートナー、委託先など)
画像へのアクセスを特定の IP アドレスに制限したい
アーキテクチャ
ブラウザ → Cloud LB → Cloud Armor(IP制限) → Cloud Run プロキシ → GCS▼ アーキテクチャ図

ポイント:
GCS の URL はブラウザに一切渡らない
Cloud ArmorでIP制限を行う
補足:IP制限ではなくIAMで制限を行いたい場合は、Cloud RunのIAM制限を行う。この場合は、Cloud LBおよびCloud Armorは不要。また、IAM制限とIP制限との併用も可能(Cloud RunのIAM制限は本記事では解説対象外です)。
プロキシ(Cloud Run)
※ソースコード他は長いため折りたたんでいます
ソースコード
# main.py
import os
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response
from google.cloud import storage
app = FastAPI()
client = storage.Client()
ALLOWED_BUCKETS = set(os.environ.get("ALLOWED_BUCKETS", "").split(","))
ALLOWED_PREFIX = os.environ.get("ALLOWED_PREFIX", "media/")
MAX_UPLOAD_SIZE = int(os.environ.get("MAX_UPLOAD_SIZE", 10 * 1024 * 1024)) # デフォルト 10MB
def _validate(bucket_name: str, object_key: str):
if bucket_name not in ALLOWED_BUCKETS:
raise HTTPException(status_code=403, detail="Bucket not allowed")
if not object_key.startswith(ALLOWED_PREFIX):
raise HTTPException(status_code=403, detail="Path not allowed")
@app.get("/{bucket_name}/{object_key:path}")
def get_object(bucket_name: str, object_key: str):
"""ブラウザからの画像取得リクエストを GCS に転送する"""
_validate(bucket_name, object_key)
bucket = client.bucket(bucket_name)
blob = bucket.blob(object_key)
if not blob.exists():
raise HTTPException(status_code=404, detail="Not found")
content = blob.download_as_bytes()
return Response(content=content, media_type=blob.content_type or "application/octet-stream")
@app.put("/{bucket_name}/{object_key:path}")
async def put_object(bucket_name: str, object_key: str, request: Request):
"""LangfuseMedia からのアップロードリクエストを GCS に転送する"""
_validate(bucket_name, object_key)
content_length = int(request.headers.get("content-length", 0))
if content_length > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=413, detail="File too large")
content = await request.body()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=413, detail="File too large")
content_type = request.headers.get("content-type", "application/octet-stream")
bucket = client.bucket(bucket_name)
blob = bucket.blob(object_key)
blob.upload_from_string(content, content_type=content_type)
return Response(status_code=200)デプロイ用ファイル
# requirements.txt
fastapi
uvicorn
google-cloud-storage
補足:`--source` デプロイでは Buildpacks が `main.py` と `requirements.txt` を自動検出するため、Dockerfile は不要です。デプロイ
```bash
export PROJECT_ID=your-project
export BUCKET_NAME=your-gcs-bucket
# サービスアカウント作成・権限付与
gcloud iam service-accounts create langfuse-media-proxy-sa \
--project="${PROJECT_ID}"
gcloud storage buckets add-iam-policy-binding "gs://${BUCKET_NAME}" \
--member="serviceAccount:langfuse-media-proxy-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
--role="roles/storage.objectAdmin"
# デプロイ(ingress 制限 + デフォルト URL 無効化を同時に設定)
gcloud run deploy langfuse-media-proxy \
--source=. \
--region=asia-northeast1 \
--service-account="langfuse-media-proxy-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
--allow-unauthenticated \
--ingress=internal-and-cloud-load-balancing \
--no-default-url \
--set-env-vars="ALLOWED_BUCKETS=${BUCKET_NAME}" \
--port=8080
```
補足:アクセス制御について
`--allow-unauthenticated` を指定していますが、Cloud Run 自体の認証ではなく Cloud Armor の IP 制限 + ingress 制限でアクセスを制御します。`--ingress=internal-and-cloud-load-balancing` により LB 経由のアクセスのみ許可され、`--no-default-url` で `*.run.app` URL を無効化するため、Cloud Armor をバイパスして直接アクセスすることはできません。Global LB + Cloud Armor
```bash
# Serverless NEG
gcloud compute network-endpoint-groups create langfuse-proxy-neg \
--region=asia-northeast1 \
--network-endpoint-type=serverless \
--cloud-run-service=langfuse-media-proxy \
--project="${PROJECT_ID}"
# バックエンドサービス
gcloud compute backend-services create langfuse-proxy-backend \
--global \
--project="${PROJECT_ID}"
gcloud compute backend-services add-backend langfuse-proxy-backend \
--global \
--network-endpoint-group=langfuse-proxy-neg \
--network-endpoint-group-region=asia-northeast1 \
--project="${PROJECT_ID}"
# URL マップ・HTTPS プロキシ・転送ルール
gcloud compute url-maps create langfuse-proxy-lb \
--default-service=langfuse-proxy-backend \
--project="${PROJECT_ID}"
gcloud compute target-https-proxies create langfuse-proxy-https \
--url-map=langfuse-proxy-lb \
--ssl-certificates=your-ssl-cert \
--project="${PROJECT_ID}"
gcloud compute forwarding-rules create langfuse-proxy-rule \
--target-https-proxy=langfuse-proxy-https \
--ports=443 \
--global \
--project="${PROJECT_ID}"
# Cloud Armor セキュリティポリシー
gcloud compute security-policies create langfuse-media-policy \
--project="${PROJECT_ID}"
gcloud compute security-policies rules update 2147483647 \
--security-policy=langfuse-media-policy \
--action=deny-403 \
--project="${PROJECT_ID}"
gcloud compute security-policies rules create 1000 \
--security-policy=langfuse-media-policy \
--expression="inIpRange(origin.ip, 'YOUR_IP/32')" \
--action=allow \
--project="${PROJECT_ID}"
gcloud compute backend-services update langfuse-proxy-backend \
--security-policy=langfuse-media-policy \
--global \
--project="${PROJECT_ID}"
```GCS バケットの設定
```bash
gcloud storage buckets create "gs://${BUCKET_NAME}" \
--uniform-bucket-level-access \
--pap \
--project="${PROJECT_ID}"
gcloud storage buckets add-iam-policy-binding "gs://${BUCKET_NAME}" \
--member="serviceAccount:langfuse-media-proxy-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
--role="roles/storage.objectAdmin"
```
補足:バケット側の IP フィルタリングは不要です。アクセス制御は Cloud Armor に一元化します。使い方
方式 A の場合: プロキシ URL を Langfuse に渡します。
PROXY_BASE_URL = "https://your-proxy-domain.com"
BUCKET_NAME = "your-gcs-bucket"
def gcs_uri_to_proxy_url(gcs_uri: str) -> str:
path = gcs_uri.removeprefix("gs://")
return f"{PROXY_BASE_URL}/{path}"方式 B の場合: `LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT` にプロキシの URL を指定します。
```yaml
# docker-compose.yml
environment:
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: your-gcs-bucket
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: <GCS HMAC Access Key>
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: <GCS HMAC Secret Key>
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: https://your-proxy-domain.com
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: media/
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
```
補足:CORS について
Langfuse UI とプロキシが異なるドメインの場合、クロスオリジンリクエストになります。Langfuse は現在 `<img>` タグで画像を読み込むため CORS ヘッダは不要ですが、将来 `fetch` ベースに変更された場合はプロキシ側で CORS ヘッダの設定が必要になる可能性があります。懸念点
認証済み URL はセキュリティ的に大丈夫か?
この認証方式は GCS コンソールからオブジェクトをダウンロードする際にも使われている GCP の標準的な仕組みです。
認証済み URL(`storage.cloud.google.com`)は認証なしではアクセスできません。URL を知っていても IAM 権限がなければ 403 になります。
ただし、Langfuse ユーザー全員に Google アカウントと GCS の IAM 権限が必要なため、外部パートナーや Google アカウントを持たないユーザーがいる場合はプロキシ構成を検討してください。
IP制限はGCS の IP フィルタリングで代用できないか?そうすればLB+Cloud Armorは不要では?
検証した限りでは、以下の点で運用が難しいと感じました。
IPv4 だけでなく IPv6 も管理が必要(接続元がIPv6 対応の場合、ブラウザは IPv6 を優先する(Happy EyeballsによりIPv6を先に試す))
まとめ
方式 A:認証済み URL | 方式 B:LangfuseMedia 標準 | プロキシ構成(A・B 共通) | |
画像の表示方法 | 「Load Image」ボタン | 自動インライン | A: Load Image / B: 自動インライン |
プロキシ / LB | 不要 | 不要 | 必要 |
GCS URL のブラウザ露出 | あり(認証付き) | あり(署名付き URL) | なし |
ユーザーに必要な権限 | GCS IAM | なし | なし |
URL 単体でのアクセス | 不可(IAM 認証が必要) | 可(有効期限内) | 不可(プロキシ経由) |
実装コスト | 低 | 低 | 中 |
主なランニングコスト | 最小 | GCS ストレージ・オペレーション(データ量に応じて増加) | LB + Cloud Run + Cloud Armor |
方式・構成ごとにメリット・デメリットがあることがわかりました。
ユースケースやコスト、セキュリティ要件を踏まえて、本記事が選定の一助となれば幸いです。



コメント