MCPサーバーKubernetes本番デプロイ — 52%が死ぬ現実を生き延びる方法

MCPサーバーKubernetes本番デプロイ — 52%が死ぬ現実を生き延びる方法

2026年4月時点でプロダクションMCPエンドポイントの52%が異常状態です。Kubernetesリソース設定からStreamable HTTP移行、 ヘルスチェック自動化、OAuth 2.1認証まで—MCP本番環境で生き残るための実践的デプロイチェックリストを解説します。

先月、外部MCPサーバーをいくつか接続しようとして奇妙なことに気づいた。READMEには「stable」タグがついていて、GitHubスターも数百あるのに、/mcpエンドポイントを叩くと接続がぶつ切りになる。最初は自分の設定の問題だと思ったが、別のサーバーも、さらに別のサーバーも同じだった。

調べてみると、これは自分だけが遭遇している問題ではなかった。2026年4月に実施されたスキャンで2,181件のリモートMCPエンドポイントを調査したところ、52%が完全に死んでいて、「完全に正常」と言えるのはたった9%だった。残りは応答はするが実際に使える状態ではなかった。

MCPサーバーがなぜこれほど多く死ぬのかを把握し、自分のサーバーを生かしておく方法を整理した。

なぜ半数が死ぬのか

死んだエンドポイントの主な原因は3つあった。

放置されたサーバー — 誰かがトイプロジェクトとして作ってデプロイし、忘れてしまう。APIキーが期限切れになったり、依存している外部サービスが変わっても誰も気づかない。サーバープロセスは生きているが実際のレスポンスはエラーだ。

認証情報の問題 — Astrixが5,200件以上のMCPサーバーを分析したところ、88%が認証情報を必要とするが、そのうち53%は長期有効なAPIキーやPAT(Personal Access Token)に依存していた。キーが交換されるとサーバーは静かに壊れる。OAuthを使っているのは8.5%だけだった。

サーバーレスのコールドスタート — AWS LambdaやGoogle Cloud Functionsに乗せたMCPサーバーは、トラフィックがなければインスタンスが落ちる。デフォルトのタイムアウト(30秒)以内に最初のレスポンスを返せなければ、クライアントの視点では死んでいるも同然だ。

自分で運営しているMCPサーバーがあれば、すでにこの3つのうちどれかに晒されている可能性が高い。

準備するもの

このガイドはFastMCP(Python)で書いたMCPサーバーを基準にする。MCPサーバーの最初の作り方は以前のポストで扱ったので、ここではデプロイ部分だけを扱う。

必要なもの:

  • Kubernetesクラスター(1.28以上推奨)
  • kubectl + helmのインストール
  • コンテナレジストリ(Docker Hub、ECR、GCRなど)
  • MCPサーバーのソースコード(FastMCP基準、uvicornでサービング可能であること)

Kubernetesクラスターがなければ、ローカルではkindまたはminikubeでテストできる。

Step 1: Dockerfileから正しく

デプロイ前にコンテナイメージを整理する必要がある。よく見落とされることがある。

FROM python:3.12-slim

# セキュリティ:rootで実行しない
RUN groupadd -r mcpuser && useradd -r -g mcpuser mcpuser

WORKDIR /app

# 依存関係のピン留め — これが一番重要
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# 所有権の移譲
RUN chown -R mcpuser:mcpuser /app

USER mcpuser

EXPOSE 8080

# ENTRYPOINTにexec形式を使用(シグナル処理が正常動作)
ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

requirements.txtで依存関係のピン留めを必ずする。fastmcp>=0.1.0のように緩く書くと、ある日新しいバージョンがリリースされて動作が静かに変わることがある。

# requirements.txt
fastmcp==0.9.2
uvicorn==0.34.0
httpx==0.28.1

Step 2: Kubernetes Deploymentの設定

リソース制限なしでデプロイするのが最もよくある失敗だ。ノードのメモリを全部食い尽くしてOOMKilledされると、原因を見つけるのが難しい。

# mcp-server-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-mcp-server
  labels:
    app: mcp-server
spec:
  replicas: 2
  selector:
    matchLabels:
      app: mcp-server
  template:
    metadata:
      labels:
        app: mcp-server
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        fsGroup: 1000
      containers:
      - name: mcp-server
        image: your-registry/mcp-server:v1.2.3  # 常にタグを固定
        ports:
        - containerPort: 8080
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 30
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 3
        env:
        - name: MCP_API_KEY
          valueFrom:
            secretKeyRef:
              name: mcp-secrets
              key: api-key

livenessreadinessを分けた理由がある。livenessが失敗するとPodを再起動し、readinessが失敗するとトラフィックの受信を止める。MCPサーバーが依存する外部APIが一時的にダウンしたときにPodを再起動しても意味がない。readinessだけ失敗させることで、トラフィックを受けずに復旧を待てる。

Step 3: ヘルスチェックエンドポイントを正しく実装する

「サーバーが生きているか」ではなく「サーバーが実際に使える状態か」を確認する必要がある。プロセスが生きていても外部APIが死んでいればクライアントはエラーを受け取り続ける。

FastMCPでヘルスチェックを追加する方法:

# main.py
from fastmcp import FastMCP
from fastapi import FastAPI
from fastapi.responses import JSONResponse
import httpx
import asyncio

mcp = FastMCP("my-tool-server")
app = FastAPI()

# MCPアプリをFastAPIにマウント
app.mount("/", mcp.sse_app())

@app.get("/health")
async def health():
    """基本のlivenessプローブ — プロセスが生きているかだけ確認"""
    return {"status": "ok"}

@app.get("/health/ready")
async def ready():
    """readinessプローブ — 外部依存関係まで確認"""
    checks = {}
    
    # 例:依存している外部APIを確認
    try:
        async with httpx.AsyncClient(timeout=5.0) as client:
            response = await client.get("https://api.your-service.com/ping")
            checks["upstream_api"] = response.status_code == 200
    except Exception as e:
        checks["upstream_api"] = False
    
    all_healthy = all(checks.values())
    status_code = 200 if all_healthy else 503
    
    return JSONResponse(
        status_code=status_code,
        content={"status": "ready" if all_healthy else "not_ready", "checks": checks}
    )

KubernetesはHTTP 200を成功、それ以外を失敗として扱う。503を返すとreadinessチェックが失敗し、そのPodはロードバランサーから外れる。

実際にこれを導入してみて、外部APIチェックのタイムアウトを長く設定しすぎるとreadinessチェック自体が遅くなり、Kubernetesが早めにPodを落としてしまう問題が発生した。5秒以内に切り上げる必要がある。

Step 4: Streamable HTTPトランスポートの設定

2026年現在、リモートMCPサーバーのデフォルトトランスポートはStreamable HTTPだ。単一の/mcpエンドポイントでリクエストとレスポンスを両方処理し、ストリーミングレスポンスが必要な場合は動的にSSEに切り替える。

FastMCPでの設定は簡単だ:

# main.py(Streamable HTTP設定)
from fastmcp import FastMCP

mcp = FastMCP(
    "my-tool-server",
    stateless_http=True,  # ロードバランサー後ろで使うならTrue
)

# uvicornでサービング
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(mcp.streamable_http_app(), host="0.0.0.0", port=8080)

stateless_http=Trueが重要だ。セッション状態をサーバーメモリに保持すると、replicaが2つある場合にクライアントが2つのPodを交互に叩いてセッションが壊れる。状態を保持する必要があるならRedisのような外部セッションストアが必要だ。

Kubernetes Serviceの設定:

apiVersion: v1
kind: Service
metadata:
  name: mcp-server-svc
spec:
  selector:
    app: mcp-server
  ports:
  - protocol: TCP
    port: 443
    targetPort: 8080
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mcp-server-ingress
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "300"  # SSEストリーミング用
    nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
spec:
  rules:
  - host: mcp.your-domain.com
    http:
      paths:
      - path: /mcp
        pathType: Prefix
        backend:
          service:
            name: mcp-server-svc
            port:
              number: 443

nginxのプロキシタイムアウトを延ばす必要がある。デフォルト値(60秒)のままだと長いレスポンスの受信中に接続が切れる。

Step 5: OAuth 2.1で認証情報を管理する

静的APIキーへの依存が最大の長期リスクだ。キーがGitHubに漏れたり、担当者が変わったり、サービスがキーを交換したりすると静かに壊れる。MCPのセキュリティ関連CVE事例を見ると、認証情報管理の失敗が繰り返しパターンになっている。

Kubernetes Secretから環境変数として注入する基本的な方法:

# secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: mcp-secrets
type: Opaque
stringData:
  api-key: "your-api-key-here"
  oauth-client-secret: "your-oauth-secret"

OAuth 2.1を使うにはMCPサーバーがBearerトークンを検証する必要がある:

from fastapi import HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import httpx

security = HTTPBearer()

async def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)):
    token = credentials.credentials
    
    # トークンのイントロスペクション
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://your-auth-server.com/oauth/introspect",
            data={"token": token},
            auth=("client_id", "client_secret")
        )
    
    if response.status_code != 200 or not response.json().get("active"):
        raise HTTPException(status_code=401, detail="Invalid token")
    
    return response.json()

正直、OAuth設定がAPIキーより複雑なのは事実だ。イントロスペクションサーバーを別途運営する必要があり、トークン更新ロジックもクライアント側に必要だ。しかし長期運用の観点ではこれが正しい。キーの交換がゼロダウンタイムでできるし、誰がどのツールを呼び出したかの追跡もできる。

Step 6: モニタリング — 52%クラブに入らないために

デプロイが終わりではない。静かに死んでいくサーバーを捕まえるには最低でも2つが必要だ。

外部モニタリング:内部ヘルスチェックだけでは不十分だ。クライアントが実際に接続するように、外部から定期的に/mcpエンドポイントを叩く必要がある。UptimeRobot、Better Uptime、または自作のKubernetes CronJobで実現できる。

# monitor-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: mcp-health-monitor
spec:
  schedule: "*/5 * * * *"  # 5分ごと
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: monitor
            image: curlimages/curl:latest
            command:
            - /bin/sh
            - -c
            - |
              curl -f -X POST https://mcp.your-domain.com/mcp \
                -H "Content-Type: application/json" \
                -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
                --max-time 10 || exit 1
          restartPolicy: Never

上流APIの変更検知:依存している外部APIがレスポンススキーマを変えるとMCPサーバーは生きているが엉뚱한値を返す。定期的に実際のツールを呼び出して結果を検証する統合テストが必要だ。

MCP Gatewayを使えば複数のMCPサーバーの前に単一の入り口を置き、一元的にヘルス状態を管理できる。サーバーが複数あるなら検討する価値がある。

実際にデプロイしてみると

上記の設定で自分のMCPサーバーを上げたとき、2つ予想外の問題があった。

最初はreadinessプローブが攻撃的すぎて、Podが起動した直後に失敗判定が出たことだ。サーバーが完全に起動する前にプローブが/health/readyを叩くので503が返り、Kubernetesが再起動ループに入った。initialDelaySecondsを10秒に延ばして解決した。

2つ目はstateless_http=Trueに設定すると長いストリーミングレスポンスが途切れるケースがあった。FastMCP 0.9.xの既知の問題で、セッションをRedisに乗せる方式で回避した。ストリーミングを使わないなら、statelessのままでいい。

自動再デプロイ設定 — イメージタグ戦略

latestタグを使うといつイメージが変わったかわからない。KubernetesはイメージプルポリシーがIfNotPresentの場合、すでにローカルにあれば新しいイメージを取得しない。

# 悪い例
image: my-registry/mcp-server:latest
imagePullPolicy: IfNotPresent

# 良い例 — GitコミットSHAまたはsemverタグを使用
image: my-registry/mcp-server:v1.2.3
imagePullPolicy: IfNotPresent

CI/CDパイプラインでデプロイのたびにタグを変えるよう自動化する:

# GitHub Actionsの例
IMAGE_TAG="v$(date +%Y%m%d)-${GITHUB_SHA::8}"
docker build -t my-registry/mcp-server:${IMAGE_TAG} .
docker push my-registry/mcp-server:${IMAGE_TAG}

# Kubernetes Deploymentを更新
kubectl set image deployment/my-mcp-server \
  mcp-server=my-registry/mcp-server:${IMAGE_TAG}

ローリングアップデート戦略も設定しておけばデプロイ中のダウンタイムがない:

spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # デプロイ中の最大追加Pod数
      maxUnavailable: 0  # デプロイ中の最小稼働Pod保証

maxUnavailable: 0は常に最小レプリカ数以上を維持するという意味だ。新しいPodがreadinessを通過するまで既存のPodを落とさない。

RBAC — 最小権限の原則

MCPサーバーがKubernetesクラスター内部のリソースにアクセスする必要がある場合(例:Pod一覧取得、ConfigMap読み取りなど)、ServiceAccountとRBACを適切に設定する必要がある。

# serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: mcp-server-sa
  namespace: default

---
# role.yaml — 必要な権限のみ最小限に
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: mcp-server-role
  namespace: default
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list"]  # create, deleteは必要な場合のみ
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["get"]

---
# rolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: mcp-server-rolebinding
  namespace: default
subjects:
- kind: ServiceAccount
  name: mcp-server-sa
roleRef:
  kind: Role
  apiGroup: rbac.authorization.k8s.io
  name: mcp-server-role

DeploymentにServiceAccountを指定:

spec:
  template:
    spec:
      serviceAccountName: mcp-server-sa
      automountServiceAccountToken: false  # 直接管理する場合のみfalse

トラブルシューティングFAQ

実際にデプロイして最もよく遭遇した問題だ。

Q: PodがCrashLoopBackOff状態だ

まずログを確認する:

kubectl logs deployment/my-mcp-server --previous

--previousは以前のコンテナインスタンスのログを見る。現在再起動中のコンテナのログは空の場合がある。

主な原因:

  • 環境変数の欠如(Secret参照エラー)
  • 依存関係のバージョン競合
  • ポートバインディングの失敗(既に使用中のポート)

Q: readinessプローブは通るのに実際のMCPリクエストが失敗する

ヘルスチェックと実際のMCPリクエストが異なるコードパスを通るケースだ。/health/readyが外部APIを正しくチェックしているか再確認する。

Q: Streamable HTTP接続が途中で切れる

nginx ingressを使っているならプロキシタイムアウト設定を確認する:

annotations:
  nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
  nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
  nginx.ingress.kubernetes.io/proxy-connect-timeout: "30"

Q: replicaが2つあるがセッションが時々壊れる

FastMCPをstateless_http=False(デフォルト)で運用しながらロードバランサーの後ろに置くケースだ。同じクライアントが別のPodにルーティングされるとセッション情報がなくてエラーになる。

解決策:stateless_http=True、またはnginx ingressのセッションアフィニティ(スティッキーセッション)設定。

HorizontalPodAutoscaler — トラフィックによる自動スケーリング

通常はreplica 2つで十分だが、エージェントトラフィックが集中する時は素早く増えなければならない。HPAを設定しておくとCPUまたはメモリ使用量に応じてPod数が自動的に調整される。

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: mcp-server-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-mcp-server
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30   # 素早く増やす
    scaleDown:
      stabilizationWindowSeconds: 300  # ゆっくり減らす(リクエスト中にPodが落ちないように)

scaleDown.stabilizationWindowSecondsを長くした理由がある。Podが突然落ちると処理中のMCPリクエストが中断される。300秒(5分)の安定化ウィンドウがあれば、ほとんどのリクエストが完了するのに十分な時間だ。

まだ解決できていないこと

KubernetesでMCPサーバーを運用しながら、まだ明確な答えが見つかっていないことがある。

水平スケーリングとセッション状態のトレードオフ。Streamable HTTPの2026ロードマップでは「セッションをステートレスに扱う方向」でトランスポートを改善するとされているが、それまでの間はスケールアウトのたびにセッションスティッキネスを手動で管理しなければならない。

.well-knownメタデータエンドポイントの標準化もまだ議論中だ。レジストリやクローラーがMCPサーバーに実際に接続せずに何ができるかを知れるようにする標準で、これがないと死んでいるサーバーか生きているサーバーかを確認するには必ず接続してみるしかない。

この2つが整理されれば「52%が死んでいる」問題がかなり解決されるだろう。それまでは、ここで扱ったヘルスチェック、依存関係のピン留め、OAuth認証情報管理が最善の対応だ。

他の言語で読む

この記事は役に立ちましたか?

より良いコンテンツを作成するための力になります。コーヒー一杯で応援してください。

著者について

jw

Kim Jangwook

AI/LLM専門フルスタック開発者

10年以上のWeb開発経験を活かし、AIエージェントシステム、LLMアプリケーション、自動化ソリューションを構築しています。Claude Code、MCP、RAGシステムの実践的な知見を共有します。

ブログリストへ