VercelからAWS Aurora Postgres에接続する際にmax_connectionsエラーを防ぐ方法
サーバーレスインフラのチュートリアルは、ほとんどが接続ボタンを数回押すプロセスだけを示しています。問題はその先です。コードをデプロイし、トラフィックが少し集中しただけでDBコネクションがパンクしたり、静的なAccess Keyを管理して冷や汗をかいたり、データ移動費用として数百ドルの請求書が届くことになります。
VercelフロントエンドとAWSバックエンドを連携する際に直面する実務上のボトルネックと解決策をまとめました。
1. VercelとAWS Aurora Postgres接続時のコネクションプーリング設定
PostgreSQLはクライアントが接続するたびに新しいプロセスを立ち上げます。プロセス1つにつきメモリを2MBから8MB以上消費します。Vercelのサーバーレス関数はリクエストが来るとステートレスなインスタンスを無制限に増やすため、Aurora Postgresのmax_connectionsの限界値(100〜300個)を数秒で使い果たしてしまいます。
結局、FATAL: sorry, too many clients alreadyエラーが発生し、DBのCPU使用率が100%に達してサービスが停止します。
サーバーレス関数とAuroraの間にPgBouncerを配置し、**トランザクションプーリング(Transaction Pooling)**モードを有効にする必要があります。セッションプーリングはコネクションを1:1で保持するため役に立たず、ステートメントプーリングは複数ステートメントのトランザクションが使えません。トランザクションプーリングは、まさにクエリトランザクションが実行されている間だけDBコネクションを割り当て、COMMITが完了すると即座にプールへ返却します。
pgbouncer.iniファイルの基準設定です。
`ini
[pgbouncer]
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 20
reserve_pool_size = 10
max_db_connections = 80
`
pool_mode = transactionでトランザクション単位の再利用を有効にします。
max_client_connは10000程度に設定し、OSのulimit -nの上限を増やしておきます。
default_pool_sizeはDBユーザーおよびDBのペアごとにAuroraに対して保持するコネクション数です。10〜20の間に設定します。
max_db_connectionsを80にぴったり制限しておくことで、Auroraインスタンスに接続される実際のコネクション上限を強制的にコントロールできます。
Vercel Fluid Compute環境では、複数の実行呼び出しがグローバルスコープを共有します。そのため、DBドライバーのプールをモジュールスコープで宣言する必要があります。@vercel/functionsパッケージのattachDatabasePoolを使用すると、関数インスタンスが終了する前にアイドル状態のコネクションをきれいにクリーンアップします。
`typescript
import { Pool } from 'pg';
import { attachDatabasePool } from '@vercel/functions';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 5000,
});
attachDatabasePool(pool);
export default pool;
`
接続文字列も環境ごとに分ける必要があります。ローカル開発環境(.env.local)ではAuroraのポート(5432)に直接接続するかローカルのPgBouncerを指定し、プロダクション環境(.env.production)ではPgBouncerのポート(6432)とpgbouncer=trueパラメータを付与します。
`bash
.env.local (Direct Connection)
DATABASE_URL="postgresql://dbuser:dbpassword@aurora-cluster.us-east-1.rds.amazonaws.com:5432/app_dev?sslmode=require"
.env.production (PgBouncer)
DATABASE_URL="postgresql://dbuser:dbpassword@pgbouncer-proxy.internal:6432/app_prod?sslmode=require&pgbouncer=true"
`
2. Access Keyの代わりにOIDCでIAM権限を管理する
Vercelの環境変数にAWS_ACCESS_KEY_IDとAWS_SECRET_ACCESS_KEYを直接埋め込むのは危険です。キーが流出するとインフラ全体が不正利用されるリスクがあります。AdministratorAccessのような管理者権限を付与していた場合、状況はさらに深刻です。
OIDC(OpenID Connect)を使用してください。Vercelが発行した署名付きJWTをAWS STSのAssumeRoleWithWebIdentity APIに渡すことで、1時間有効な一時認証情報を取得する方式です。ハードコーディングされたキー自体が存在しなくなります。
AWS IAM Roleの信頼関係ポリシーには必要最小限の権限のみを定義します。
`json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.vercel.com/my-team-slug"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.vercel.com/my-team-slug:aud": "https://vercel.com/my-team-slug",
"oidc.vercel.com/my-team-slug:sub": "owner:my-team-slug:project:my-ai-app:environment:production"
}
}
}
]
}
`
- IAM Identity Providerに
[oidc.vercel.com/my-team-slug](https://oidc.vercel.com/my-team-slug)を登録します。
audクレームをVercelのチームURLに制限し、他の組織からのアクセスを防ぎます。
subクレームの条件としてプロジェクト名(my-ai-app)と環境(production)を正確に指定します。Confused Deputy攻撃を防ぐための核心的な設定です。
権限ポリシーも必要なリソースのみを開放します。特定のS3バケットとLambda関数の呼び出し権限のみを付与する例です。
`json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RestrictedS3BucketAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-production-ai-assets",
"arn:aws:s3:::my-production-ai-assets/*"
]
},
{
"Sid": "RestrictedLambdaInvocation",
"Effect": "Allow",
"Action": [
"lambda:InvokeFunction"
],
"Resource": [
"arn:aws:lambda:us-east-1:123456789012:function:python-ml-inference-service"
]
}
]
}
`
Node.jsコードでは@vercel/oidc-aws-credentials-providerを利用して一時認証情報を取得します。
`typescript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
const credentials = awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN!,
});
const s3Client = new S3Client({ region: 'us-east-1', credentials });
const lambdaClient = new LambdaClient({ region: 'us-east-1', credentials });
export async function POST(req: Request) {
const body = await req.json();
await s3Client.send(new PutObjectCommand({
Bucket: 'my-production-ai-assets',
Key: inputs/${Date.now()}.json,
Body: JSON.stringify(body),
}));
const lambdaRes = await lambdaClient.send(new InvokeCommand({
FunctionName: 'python-ml-inference-service',
Payload: Buffer.from(JSON.stringify(body)),
}));
return Response.json({
status: 'success',
result: JSON.parse(Buffer.from(lambdaRes.Payload!).toString()),
});
}
`
3. リージョン差によるAPIレイテンシの削減
Vercelのフロントエンドが東京PoPで動作し、AWSバックエンドが米国東部(us-east-1)にある場合、1回のリクエストが行き来するたびに150ms〜250msの物理的なレイテンシが加算されます。
頻繁に変更されないAPIレスポンスは、Vercel Edge Middleware層でキャッシュし、バックエンドへの呼び出し自体を行わないようにするのが効果的です。
`typescript
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: ['/api/v1/ml-models/:path*'],
};
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=120'
);
response.headers.set(
'Vercel-CDN-Cache-Control',
's-maxage=300, stale-while-revalidate=600'
);
return response;
}
`
s-maxage=60, stale-while-revalidate=120を設定すると、60秒間はEdgeキャッシュから即座にレスポンスを返し、キャッシュが切れた後の120秒間はひとまず古いレスポンスを返しつつバックグラウンドでキャッシュを更新します。
サービス間のトレーシングを連携するには、OpenTelemetryのW3C TraceContext規格(traceparentヘッダー)を伝播する必要があります。Vercel側の設定です。
`typescript
// instrumentation.ts
import { registerOTel } from '@vercel/otel';
export function register() {
registerOTel({
serviceName: 'vercel-frontend-service',
instrumentationConfig: {
fetch: {
propagateContextUrls: ['api.my-aws-backend.com', '*.amazonaws.com'],
},
},
});
}
`
AWS FastAPI側でも同様にOpenTelemetryのレシーバーを組み込むことで、フロントエンドからバックエンドまで単一のTrace IDで紐付けられます。
`python
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces"))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
app = FastAPI(title="Python ML Service")
FastAPIInstrumentor.instrument_app(app)
@app.get("/api/v1/ml-models/predict")
async def predict():
tracer = trace.get_tracer(name)
with tracer.start_as_current_span("ml_inference_execution"):
return {"status": "completed", "prediction": [0.95, 0.05]}
`
4. データ転送費用と無制限スケーリングによるコストの防止
コスト事故は通常、2つの場所で発生します。VercelのFast Data Transfer超過料金(1TBを超えると1GBあたり0.15)と、AWSのDataTransferOut料金(1GBあたり0.09)です。ここにトラフィックが集中し、サーバーレス関数とLambdaが同時にスケーリングを開始すると、請求額の桁が変わってしまいます。
Vercelの支出上限ウェブフックを受け取り、Slackへ通知を送信するAPI Routeの例です。HMAC SHA1署名検証を行うことで安全性を確保します。
`typescript
import crypto from 'crypto';
export async function POST(req: Request) {
const payload = await req.text();
const signature = req.headers.get('x-vercel-signature');
const expectedSignature = crypto
.createHmac('sha1', process.env.VERCEL_SPEND_WEBHOOK_SECRET!)
.update(payload)
.digest('hex');
if (signature !== expectedSignature) {
return new Response('Invalid Signature', { status: 401 });
}
const event = JSON.parse(payload);
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: Vercel支出警告: 予算上限(${event.payload.spendAmount} USD)に達しました。,
}),
});
return new Response('OK', { status: 200 });
}
`
次の2つのセーフティ装置は必ず有効にしてください。
- Vercelプロジェクトの Settings > Billing で Pause production deployment を有効にします。予算上限に達した際にサービスが停止しても、口座残高を守ることができます。
- AWS Lambdaの Reserved Concurrency(予約済みの同時実行数)を50前後に制限します。インスタンスが無制限に増えてDBを圧迫し、同時に高額な費用が請求される状況を遮断できます。