サーバーレスで本番ぶっ壊した話|Lambda運用3年で踏んだアンチパターン集
「インフラ管理不要で最高」と思ってたのに、気づいたら同期呼び出し地獄やコールドスタート沼にハマってた経験ありませんか?3年・複数プロダクトの運用で踏んだ設計ミスと対策をまとめました。
サーバーレスアンチパターン集2026|本番で踏んだ地雷と対策まとめ
サーバーレス、始めた当初は「インフラ管理しなくていい、最高じゃん」って思ってたんですよね。でも3年以上、複数のプロダクトでガッツリ運用してみると、独特の落とし穴がたくさんあることに気づいてきた。特に2025〜2026年にかけてチームでLambda+EventBridge+SQS構成のシステムをフルリプレイスする機会があって、その過程で過去の設計ミスが次々と発掘された。
今回はそこで再確認した「やりがちだけどやっちゃいけない設計」を、実際のコードや構成図も交えてまとめておく。サーバーレスのアンチパターンについては色々な記事があるけど、2026年時点のAWSサービスの現状(SnapStart GA・Lambda Managed Streaming対応など)を踏まえた内容にしたかった。
アンチパターン1:「とりあえず同期呼び出し」連鎖地獄
これ、マジで一番多く見る。サービスを分割したはいいけど、Lambda → Lambda → Lambda って同期的に数珠つなぎにしてしまうやつ。
# アンチパターン:同期Lambda呼び出しの連鎖
import boto3
import json
lambda_client = boto3.client('lambda')
def handler(event, context):
# Step1: ユーザー検証
resp1 = lambda_client.invoke(
FunctionName='validate-user',
InvocationType='RequestResponse', # 同期呼び出し
Payload=json.dumps(event)
)
user_data = json.loads(resp1['Payload'].read())
# Step2: 注文処理
resp2 = lambda_client.invoke(
FunctionName='process-order',
InvocationType='RequestResponse', # さらに同期呼び出し
Payload=json.dumps(user_data)
)
order_data = json.loads(resp2['Payload'].read())
# Step3: 通知送信
resp3 = lambda_client.invoke(
FunctionName='send-notification',
InvocationType='RequestResponse', # またまた同期...
Payload=json.dumps(order_data)
)
return json.loads(resp3['Payload'].read())
これの何が問題かというと、レイテンシが単純加算されるだけじゃなく、途中でCold Startが発生した瞬間にユーザーへの応答が数秒単位で遅延する。しかも課金は各Lambda実行時間が全部カウントされる。そして一番やばいのがエラーハンドリングの複雑さで、Step2でエラーが起きたときにStep1の処理をロールバックする仕組みを自前で作らないといけなくなる。地味に見落としがちなんだけど、これが一番ランニングコストを押し上げる要因だったりする。
実際に↓みたいな構成で運用していたシステムがあって、ピーク時にp99レイテンシが8秒近くになっていた。
flowchart LR
API[API Gateway] --> |同期| L1[validate-user\nLambda]
L1 --> |同期| L2[process-order\nLambda]
L2 --> |同期| L3[send-notification\nLambda]
L3 --> |レスポンス| API
style L1 fill:#ff6b6b
style L2 fill:#ff6b6b
style L3 fill:#ff6b6b
対策:Step FunctionsかSQS非同期化
ユーザーへのレスポンスに結果が必要なフローと、バックグラウンドでよいフローを分けることが第一歩。うちのチームでは以下のように整理した。
# 改善後:SQSで非同期化
import boto3
import json
import uuid
import os
sqs_client = boto3.client('sqs')
def handler(event, context):
# ユーザー検証だけ同期で行い、後続処理はSQSへ投げる
user_data = validate_user_inline(event) # ローカルで実行
# 後続処理はSQSに投げて即レスポンス
correlation_id = str(uuid.uuid4())
sqs_client.send_message(
QueueUrl=os.environ['ORDER_QUEUE_URL'],
MessageBody=json.dumps({
'correlation_id': correlation_id,
'user_data': user_data,
'original_event': event
}),
MessageGroupId=user_data['user_id'] # FIFO queue
)
return {
'statusCode': 202,
'body': json.dumps({
'message': 'accepted',
'correlation_id': correlation_id
})
}
LambdaのCold Startそのものへの対策についてはLambda Cold Startで本番が死んだ話と、2026年時点でやっと落ち着いた対策に詳しく書いたので参照してほしい。SnapStartとProvisioned Concurrencyの使い分けも整理してある。
アンチパターン2:モノリシックLambda(1関数に全部詰め込む)
今度は逆のパターン。同期連鎖が怖くて「じゃあ全部1つのLambdaに書いちゃえ」になってしまうやつ。実際に引き継いだコードで見たことがあるんだけど、6000行のLambdaハンドラが存在した。デプロイパッケージが400MB近くあって、Cold Startが5〜6秒かかってた。正直、最初に見たときは目を疑った。
xychart-beta
title "Lambda パッケージサイズとCold Start時間の関係"
x-axis ["10MB", "50MB", "100MB", "200MB", "400MB"]
y-axis "Cold Start時間(秒)" 0 --> 8
bar [0.4, 0.9, 1.8, 3.2, 6.1]
Cold Startだけでも十分つらいんだけど、巨大関数になると他にも問題が連鎖する。テストが書きにくくなる(依存関係が密結合になるので)、スケールの粒度が粗くなる(バッチ処理と低レイテンシAPI処理が同じ関数に混在する)、タイムアウト設定を全処理の最長に合わせないといけない、IAMポリシーが「なんでもできる」関数になる、といった具合に問題が積み重なっていく。
# アンチパターン:なんでもLambda
def handler(event, context):
path = event.get('path', '')
if path == '/users':
# ユーザー操作
return handle_users(event)
elif path == '/orders':
# 注文処理
return handle_orders(event)
elif path == '/reports':
# レポート生成(重い処理)
return generate_report(event) # これが15分かかることも
elif path == '/notifications':
# 通知送信
return send_notifications(event)
# ... 以下、延々と続く
この設計、path-based routingをLambda内部でやっていて、実質APIの全機能が1つに集約されてる。タイムアウトをレポート生成に合わせて15分に設定しているので、簡単なユーザー取得APIでもタイムアウト上限15分という状態になってしまっている。
対策:Single Responsibility Lambdaと適切な境界設計
責務単位で関数を分割して、API GatewayかALBでルーティングする。Lambda Function URLs(2026年時点で安定稼働)を使うと、小さな関数を公開するのが楽になった。分割後の構成を整理するとこんな感じになる。
| 関数 | タイムアウト | メモリ | 役割 |
|---|---|---|---|
| user-api | 10秒 | 256MB | ユーザーCRUD |
| order-api | 30秒 | 512MB | 注文処理 |
| report-generator | 15分 | 2048MB | 重いレポート |
| notification-sender | 5秒 | 128MB | 通知送信 |
関数ごとに適切なリソース設定とIAMポリシーを分けられるので、セキュリティ的にも良くなる。個人的にはタイムアウトとメモリを個別に最適化できるようになっただけで、コストが体感3〜4割削減できた印象がある。
アンチパターン3:DLQを設定しない非同期処理
SQSトリガーのLambdaを作るとき、「とりあえず動けばいい」で実装して、DLQ(Dead Letter Queue)を後回しにしていないだろうか。正直、僕もプロトタイプ段階でよくやってたんだけど、本番に持ち込んでそのままになってしまうのが問題なんですよね。
flowchart TB
subgraph "アンチパターン構成"
SQS1[SQS Queue] --> L1[Lambda]
L1 -->|処理失敗| SQS1
SQS1 -->|最大受信数超過| DEL[メッセージ消滅💀]
end
subgraph "正しい構成"
SQS2[SQS Queue\n最大受信数=3] --> L2[Lambda]
L2 -->|処理失敗| SQS2
SQS2 -->|最大受信数超過| DLQ[DLQ\nDead Letter Queue]
DLQ --> ALARM[CloudWatch Alarm]
ALARM --> SNS[SNS通知]
end
DLQなしで処理失敗が続くと、可視性タイムアウト後にメッセージがキューに戻り、maxReceiveCountを超えた段階でメッセージが消える。注文データや決済イベントが黙って消えるのは最悪のケースだけど、実際にDLQ未設定のシステムで発生した事案を知っている。本番障害として発覚したのが翌朝という、一番悪いパターンだった。
SQSのDLQ設定は本当にシンプルなのに効果が高い。設定を後回しにする理由がないレベルで費用対効果が高いので、まずここから手をつけてほしい。CDKで書くとこんな感じ:
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cw_actions from 'aws-cdk-lib/aws-cloudwatch-actions';
import { Duration } from 'aws-cdk-lib';
// DLQの作成
const dlq = new sqs.Queue(this, 'OrderProcessingDLQ', {
queueName: 'order-processing-dlq',
retentionPeriod: Duration.days(14), // 2週間保持
encryption: sqs.QueueEncryption.KMS_MANAGED,
});
// メインキューにDLQを紐付け
const mainQueue = new sqs.Queue(this, 'OrderProcessingQueue', {
queueName: 'order-processing.fifo',
fifo: true,
contentBasedDeduplication: true,
visibilityTimeout: Duration.seconds(300), // Lambda timeout * 6
deadLetterQueue: {
queue: dlq,
maxReceiveCount: 3, // 3回失敗でDLQ送り
},
});
// DLQにメッセージが溜まったらアラート
const dlqAlarm = new cloudwatch.Alarm(this, 'DLQAlarm', {
metric: dlq.metricApproximateNumberOfMessagesVisible(),
threshold: 1,
evaluationPeriods: 1,
alarmDescription: '注文処理DLQにメッセージが存在します',
});
// SNSトピックへの通知設定は省略
SQSとSNSのパターン設計についてはSQS/SNS連携パターンをチームで整理し直した話|Fan-outからDLQ設計までに詳しくまとめているので、そっちも参考にしてほしい。
アンチパターン4:Lambdaに直接DBコネクションを張る
これも根強いアンチパターン。Lambdaから直接RDS PostgreSQLにコネクションを張るやつ。同時実行数が上がった瞬間に「too many connections」でDB側が死ぬ。
# アンチパターン:毎回コネクション生成
import psycopg2
import os
def handler(event, context):
# ハンドラ内でコネクション生成(毎回インスタンス起動時に実行される場合も)
conn = psycopg2.connect(
host=os.environ['DB_HOST'],
database=os.environ['DB_NAME'],
user=os.environ['DB_USER'],
password=os.environ['DB_PASSWORD']
)
cursor = conn.cursor()
cursor.execute("SELECT * FROM orders WHERE id = %s", (event['order_id'],))
result = cursor.fetchone()
# コネクションを閉じ忘れることも多い...
conn.close()
return result
Lambdaの同時実行数はデフォルトで最大1000なので、スパイクトラフィック時にDBへの接続数が一気に数百になる。RDS(例:db.t3.medium)のmax_connectionsは170程度なので普通に超える。しかも「コネクションを閉じ忘れる」という人的ミスが重なると、もっと早い段階でDB側が詰まる。
2026年時点での正解はRDS Proxy
RDS Proxyはコネクションプーリングをマネージドで提供してくれる。2024年末にProxyのコスト構造が見直されて、小規模構成でも使いやすくなった。
graph TB
subgraph VPC
subgraph "Public Subnet (AZ-a)"
APIGW[API Gateway]
end
subgraph "Private Subnet (AZ-a)"
L1[Lambda\n同時実行 ~200]
end
subgraph "Private Subnet (AZ-b)"
L2[Lambda\n同時実行 ~200]
end
subgraph "Data Subnet (AZ-a)"
PROXY[RDS Proxy\nコネクションプール]
RDS_PRIMARY[Aurora PostgreSQL\nPrimary]
end
subgraph "Data Subnet (AZ-b)"
RDS_REPLICA[Aurora PostgreSQL\nReplica]
end
APIGW --> L1
APIGW --> L2
L1 --> PROXY
L2 --> PROXY
PROXY --> RDS_PRIMARY
PROXY --> RDS_REPLICA
RDS_PRIMARY -.->|レプリケーション| RDS_REPLICA
end
subgraph "AWS Services"
SM[Secrets Manager]
IAM[IAM Auth]
end
PROXY --> SM
L1 --> IAM
L2 --> IAM
# 改善後:RDS Proxy + IAM認証
import boto3
import psycopg2
import os
# Lambdaの初期化フェーズ(コールドスタート時のみ実行)
rds_client = boto3.client('rds')
def get_auth_token():
"""RDS Proxy用IAM認証トークンを取得"""
return rds_client.generate_db_auth_token(
DBHostname=os.environ['RDS_PROXY_ENDPOINT'],
Port=5432,
DBUsername=os.environ['DB_USER'],
Region=os.environ['AWS_REGION']
)
# コネクションはグローバルスコープでキャッシュ(ウォームスタート時に再利用)
_conn = None
def get_connection():
global _conn
try:
if _conn is None or _conn.closed:
token = get_auth_token()
_conn = psycopg2.connect(
host=os.environ['RDS_PROXY_ENDPOINT'],
database=os.environ['DB_NAME'],
user=os.environ['DB_USER'],
password=token,
sslmode='require'
)
return _conn
except Exception as e:
_conn = None
raise e
def handler(event, context):
conn = get_connection()
with conn.cursor() as cursor:
cursor.execute(
"SELECT id, status FROM orders WHERE id = %s",
(event['order_id'],)
)
result = cursor.fetchone()
return {'id': result[0], 'status': result[1]}
RDS Proxyを挟むことでLambdaからの実際のDB接続数を大幅に削減できる。うちのチームの実測値だと、同時実行200のLambdaに対してRDS Proxyが実際に張るDBコネクションは15〜20本程度に収まった。数字で見るとその差は歴然だ。
xychart-beta
title "Lambda同時実行数とDBコネクション数の比較"
x-axis ["同時実行10", "同時実行50", "同時実行100", "同時実行200", "同時実行500"]
y-axis "DBコネクション数" 0 --> 500
bar [10, 50, 100, 200, 500]
line [3, 8, 12, 18, 25]
棒グラフがRDS Proxy未使用(コネクション数=同時実行数)、折れ線グラフがRDS Proxy使用時の実コネクション数。同時実行500でも実コネクションが25本程度に抑えられるのは、導入前には信じられなかった。
アンチパターン5:環境変数への秘密情報直書きとオーバーフェッチ
「ちょっとしたテストだから」とSecrets Managerを使わずに環境変数にDBパスワードやAPIキーを直書きするのはよくある話。でも本番に持ち込まれたまま放置されているケースが散見される。
もう一つ意外と見落とされるのが「Secrets Managerの呼び出しをハンドラ内に書く」こと。これ、リクエストのたびにSecrets Managerへの呼び出しが発生してコストとレイテンシが増える。「セキュアにしようとした結果、パフォーマンスを自分で壊してた」という二重苦になるので注意が必要だ。
# アンチパターン:ハンドラ内でSecretsを毎回取得
def handler(event, context):
# リクエストのたびにAPI呼び出しが発生!
secrets = get_secret('prod/myapp/db-credentials')
conn = create_connection(secrets)
# ...
# 正しいパターン:初期化フェーズでキャッシュ
import boto3
import json
import os
from datetime import datetime, timedelta
_secrets_cache = {}
_cache_expiry = {}
def get_secret_cached(secret_name: str, ttl_minutes: int = 10) -> dict:
"""TTL付きシークレットキャッシュ"""
now = datetime.now()
if secret_name in _secrets_cache:
if now < _cache_expiry.get(secret_name, now):
return _secrets_cache[secret_name]
client = boto3.client('secretsmanager')
response = client.get_secret_value(SecretId=secret_name)
secret = json.loads(response['SecretString'])
_secrets_cache[secret_name] = secret
_cache_expiry[secret_name] = now + timedelta(minutes=ttl_minutes)
return secret
# グローバルスコープで初期化(コールドスタート時のみ実行)
DB_CREDS = get_secret_cached(
os.environ['DB_SECRET_NAME'],
ttl_minutes=10
)
def handler(event, context):
# キャッシュから取得(API呼び出しなし)
conn = create_connection(DB_CREDS)
# ...
Secrets Managerの運用でハマった点についてはAWS KMS・Secrets Manager、1年本番運用して見えたハマりどころと現実的な設計にまとめているので、合わせて読んでほしい。ローテーション設定まわりの注意点も書いてある。
ここまで5つのアンチパターンを見てきたけど、「これ、うちのチームもやってる…」ってなったやつ、いくつありましたか?
実際の改善構成:アンチパターンを全部潰したECシステム
ここで、上記のアンチパターンを全部踏まえた構成図を示しておく。実際にリプレイスしたECバックエンドをベースにしている(一部簡略化)。
graph TB
subgraph Internet
CLIENT[クライアント]
end
subgraph AWS
subgraph "Edge Layer"
CF[CloudFront]
WAF[WAF v2]
end
subgraph VPC
subgraph "Public Subnet"
APIGW[API Gateway v2\nHTTP API]
end
subgraph "Private Subnet AZ-a"
L_USER[user-api Lambda\n256MB / 10s]
L_ORDER[order-api Lambda\n512MB / 30s]
L_CONSUMER[order-consumer Lambda\n512MB / 5min]
end
subgraph "Private Subnet AZ-b"
L_REPORT[report-generator Lambda\n2048MB / 15min]
L_NOTIFY[notification Lambda\n128MB / 5s]
end
subgraph "Messaging Layer"
SQS_ORDER[SQS FIFO\nOrder Queue]
SQS_DLQ[SQS\nDead Letter Queue]
SQS_NOTIFY[SQS\nNotification Queue]
EB[EventBridge\nEvent Bus]
end
subgraph "Data Layer AZ-a"
PROXY[RDS Proxy]
AURORA_P[Aurora PostgreSQL\nPrimary]
ELASTICACHE[ElastiCache Redis\nCluster]
end
subgraph "Data Layer AZ-b"
AURORA_R[Aurora PostgreSQL\nReplica]
end
end
subgraph "Storage & Security"
S3[S3 Bucket]
SM[Secrets Manager]
SSM[Parameter Store]
end
subgraph "Observability"
CW[CloudWatch Logs\n+ Metrics]
XRAY[X-Ray]
end
end
CLIENT --> CF
CF --> WAF
WAF --> APIGW
APIGW --> L_USER
APIGW --> L_ORDER
L_ORDER --> SQS_ORDER
SQS_ORDER --> L_CONSUMER
SQS_ORDER -->|失敗時| SQS_DLQ
L_CONSUMER --> EB
EB --> L_REPORT
EB --> SQS_NOTIFY
SQS_NOTIFY --> L_NOTIFY
L_USER --> PROXY
L_ORDER --> PROXY
L_CONSUMER --> PROXY
PROXY --> AURORA_P
PROXY --> AURORA_R
L_USER --> ELASTICACHE
L_ORDER --> ELASTICACHE
L_USER --> SM
L_ORDER --> SM
L_REPORT --> S3
L_USER --> CW
L_ORDER --> CW
L_CONSUMER --> CW
L_USER --> XRAY
L_ORDER --> XRAY
この構成で意識したポイントは4つある。
- API応答が必要な処理(user-api、order-api)とバックグラウンド処理(order-consumer、report-generator)を完全分離
- 全キューにDLQを設定して、メッセージが黙って消えない構成にする
- RDS ProxyでDB接続数を管理し、スパイクトラフィックに耐えられるようにする
- EventBridgeをハブにして疎結合なイベント駆動を実現する
個人的にはEventBridgeをハブに据えたことが一番大きな変化で、新しいコンシューマーを追加するときに既存のLambdaを一切触らなくて済むようになった。これが地味に便利で、デプロイリスクが激減した。
イベント駆動アーキテクチャの設計パターン全般についてはイベント駆動アーキテクチャ実装ガイド|Kafka・マイクロサービス対応も参考になる。
まとめ
今回取り上げた5つのアンチパターンを整理すると:
| # | アンチパターン | 対策 |
|---|---|---|
| 1 | 同期Lambda呼び出し連鎖 | SQS非同期化 or Step Functionsでオーケストレーション |
| 2 | モノリシックLambda | 責務単位で関数分割、タイムアウト・メモリを個別チューニング |
| 3 | DLQなし非同期処理 | 全キューにDLQを設定、CloudWatchアラームとセット |
| 4 | Lambda→DB直接接続 | RDS Proxyを挟んでコネクション数を制御 |
| 5 | 秘密情報の環境変数直書き&毎回フェッチ | Secrets Manager+グローバルスコープキャッシュ |
正直、どれも「知ってれば当たり前」な話ではある。でも実際のプロダクトコードを見ると、どれか一つは必ず踏んでいることが多い。特にDLQとRDS Proxyはリターンに対してコストが低いので、既存システムの改善から着手するなら真っ先にやってほしい。
次のアクション:
- 既存のLambda関数のSQSトリガーにDLQが設定されているか確認する
- RDS直接接続しているLambdaにRDS Proxyを導入するコストを試算する
- 同期呼び出し連鎖がある場合、どこをStep Functionsで置き換えられるか設計レビューする
まだ書ききれていないアンチパターン(VPCコールドスタート問題、冪等性の欠如、Lambda Layerの肥大化など)は続編で取り上げる予定。