メインコンテンツへスキップ

Langfuse × GCS プライベートバケットで非公開画像をトレース表示する

·10 分
著者
Tomoyuki Kurosawa
目次

こんにちは。ガオ株式会社の黒澤です。この記事では、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」ボタンが表示される
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
403 エラー:Anonymous caller does not have storage.objects.get access

Google アカウントでログイン済みでも、storage.googleapis.com はブラウザのログインセッションを使いません。実は GCS のプライベートオブジェクトには認証の挙動が異なる 2 つの URL があります。

ブラウザ認証なしブラウザ認証あり
ドメインstorage.googleapis.comstorage.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」クリック後、別タブに画像が表示された
「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 によるインライン表示
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

# 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 バケットの設定

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

方式・構成ごとにメリット・デメリットがあることがわかりました。

ユースケースやコスト、セキュリティ要件を踏まえて、本記事が選定の一助となれば幸いです。

補足:署名付き URL はメディア以外でも使われる 本記事ではメディア(画像)表示を扱いましたが、バッチエクスポート(CSV/JSON ダウンロード)でも同じ署名付き URL が生成されます。エクスポートデータには dataset_items 等の全件が含まれるため、URL 露出のポリシーが厳しい環境ではエクスポート機能についても同様の考慮が必要です。

参考
#