前回は、当社の社内基盤の今の構成図を1枚にまとめ、変えても崩さなかった6つの考え方を整理しました。

今回は、その中でいちばん大きな変更だった「API を ECS Fargate から Lambda に移す」話です。当社は2026年11月4日に、確認用と本番の両方で、API を ALB+ECS Fargate から API Gateway+Lambda に移しました。この回では、連載のサンプルにも同じ変更を入れ、compute という設定1つで ECS・Lambda・両方を選べるようにします。
「動いているシステムの土台を変えるのは怖い。止めずに移せるの?」
結論から言うと、コンテナはそのままに Lambda 用の小さな部品を1つ足し、移す間だけ ECS と Lambda の両方を動かして、DNS を切り替えて確かめてから古い方を消します。API のプログラム(app/)は1行も変えません。あわせて、当社が実際につまずいた3つの点(Lambda が読めないコンテナの形、記録の置き場の名前の重なり、Lambda の上限)を紹介します。
なぜ Lambda に移したのか
ECS Fargate と ALB の構成(第4回)は、常に動いている分、使わない時間にも費用がかかります。第4回の目安では、ALB が月約18ドル、パブリック IPv4 アドレスが1つあたり約3.7ドル、Fargate が約11ドルでした。確認用の環境は、ふだん止めておく仕組み(第8回)で抑えていましたが、使うたびに動かす手間がありました。
社内基盤の API は、Claude Code のセッションが作業するときにだけ呼ばれます。一日中呼ばれ続けるわけではないので、呼ばれたときだけ動き、呼ばれない時間は費用がかからない Lambda の方が向いています。当社は ECS で一通り試して仕組みが固まったところで、移すことにしました。
| ALB+ECS Fargate | API Gateway+Lambda | |
|---|---|---|
| 動き方 | 常に動いている | 呼ばれたときだけ動く |
| 固定の費用 | ALB・IPv4 アドレス・Fargate の時間料金 | なし(呼び出しの数と動いた時間で決まる。Lambda には毎月の無料枠がある) |
| ネットワーク | VPC・サブネット・セキュリティグループが要る | VPC の外に置ける(作らない) |
| 確認用 | ふだん止める仕組みが要った | 止めなくても、呼ばなければ費用はほぼかからない |
| 上限 | 長い処理・大きなデータも扱える | 1回29秒まで(API Gateway)、1回 6MB まで(Lambda) |
| WAF | ALB に付ける | API Gateway の REST API に付ける |
📰 出典:AWS Lambda の料金
WAF の月額(第8回)は置いている時間で日割りされるので、本番に置く限り、どちらでもかかります。
仕組み:同じコンテナを Lambda で動かす
Lambda は本来、決まった形の「関数」を呼ぶサービスです。uvicorn のような Web サーバーで動く API を、そのままでは受け付けません。そこで、AWS が公開している Lambda Web Adapter という部品をコンテナに入れます。
- Lambda の中では、Lambda Web Adapter が呼び出しを受け取り、コンテナの中の API(uvicorn)に普通の HTTP の呼び出しとして渡します
- Lambda Web Adapter は、Lambda の「拡張機能」(
/opt/extensions/に置いた部品)として動きます。ECS や手元で動かしたときは何もしないので、1つのコンテナを ECS と Lambda の両方で使えます - 入口の API Gateway は、WAF を付けられる REST API(リージョン)を使います。WAF を付けられるのは REST API で、HTTP API には付けられません
📰 出典:AWS Lambda Web Adapter(GitHub)
📰 出典:AWS WAF が守れるもの(AWS WAF Developer Guide)
移す前: DNS → ALB → ECS Fargate(API のコンテナ)
移す間: DNS ─┬→ ALB → ECS Fargate(API のコンテナ) ← DNS が向いている方が使われる
└→ API Gateway → Lambda(同じコンテナ)
移した後:DNS → API Gateway → Lambda(同じコンテナ)
コンテナに Lambda Web Adapter を入れる
Dockerfile に次の変更を加えます(+ が追加)。
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,9 @@
FROM python:3.13-slim
+# Lambda はコンテナのファイルを書き換えられない(読み取り専用)。.pyc を書きに行かない
+ENV PYTHONDONTWRITEBYTECODE=1
WORKDIR /srv
+# Lambda で動かすための部品(AWS の Lambda Web Adapter)。Lambda の中では、受けた呼び出しを uvicorn に渡す。ECS では動かない
+COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter
+ENV AWS_LWA_PORT=8080 AWS_LWA_READINESS_CHECK_PATH=/healthz
COPY pyproject.toml ./
COPY app ./app
COPY --from=...:公開されている Lambda Web Adapter のイメージから、部品のファイルだけを/opt/extensions/に写しますAWS_LWA_PORT:API が待ち受けるポートです。AWS_LWA_READINESS_CHECK_PATH:起動したかを確かめる場所です。既定は/なので、第4回で作った/healthzを指定しますPYTHONDONTWRITEBYTECODE=1:Lambda の中ではコンテナのファイルが読み取り専用になります(書き込めるのは/tmpだけ)。Python が実行のたびに.pyc(読み込みを速くするためのファイル)を書きに行かないようにします
.dockerignore にも、手元の仮想環境などを足しておきます。CDK はコンテナを作るときにプロジェクトのフォルダを写すので、.venv が入っていると、写す量が数百MB に増えます(当社の手元の確認では、写した中身が約634MB になりました)。
--- a/.dockerignore
+++ b/.dockerignore
@@ -6,2 +6,7 @@ infra/
tests/
__pycache__/
+.git/
+.venv/
+*.egg-info/
+.pytest_cache/
+.ruff_cache/
CDK:compute で ECS・Lambda・両方を選ぶ
infra/stacks/api_stack.py を、compute の値で作るものを選ぶ形に書き直します。長いので折りたたんでいます。
infra/stacks/api_stack.py の全文を開く(304行)
# infra/stacks/api_stack.py
"""API を動かす部分(小さく始める構成)。
compute で、API を何で動かすかを選ぶ(同じコンテナを使う)
- ecs:ALB+ECS Fargate(第4回)。常に動く。確認用は running=false で止める(第8回)
- lambda:API Gateway+Lambda。呼ばれたときだけ動く。VPC・ALB・NAT は作らない
- both:移す間だけ両方を作る。DNS が向いている方が使われる
共通
- トークンの表(DynamoDB):ハッシュだけを置く。どちらからも同じ表を読む
- WordPress のパスワード:Secrets Manager の <prefix>/wp/<サイト> を API だけが読む(CDK では作らない)
- WAF(設定で):トークンなしの呼び出しは 5分で100回、全体は 5分で3,000回まで。ALB にも API Gateway にも付ける
- 監視:トークン違い(401)が 5分で20回を超えたら知らせる(ECS・Lambda の記録を合わせて数える)
ECS 側の部品は、名前(ID)を第8回から変えていない。変えると作り直しになり、ALB の DNS 名が変わるため。
"""
from pathlib import Path
import aws_cdk as cdk
from aws_cdk import aws_apigateway as apigw
from aws_cdk import aws_certificatemanager as acm
from aws_cdk import aws_cloudwatch as cw
from aws_cdk import aws_cloudwatch_actions as cw_actions
from aws_cdk import aws_dynamodb as ddb
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_ecr_assets as ecr_assets
from aws_cdk import aws_ecs as ecs
from aws_cdk import aws_elasticloadbalancingv2 as elbv2
from aws_cdk import aws_iam as iam
from aws_cdk import aws_lambda as lambda_
from aws_cdk import aws_logs as logs
from aws_cdk import aws_sns as sns
from aws_cdk import aws_sns_subscriptions as subs
from aws_cdk import aws_wafv2 as wafv2
from constructs import Construct
CODE_DIR = Path(__file__).resolve().parents[2] # Dockerfile のある場所
PORT = 8080
COMPUTE = ("ecs", "lambda", "both")
class ApiStack(cdk.Stack):
def __init__(
self,
scope: Construct,
cid: str,
*,
stage: str,
domain_name: str,
certificate_arn: str,
waf_enabled: bool,
running: bool,
alarm_email: str = "",
compute: str = "ecs",
**kwargs,
):
super().__init__(scope, cid, **kwargs)
if not certificate_arn:
raise ValueError('HTTPS の証明書の ARN を -c certificateArn=... で渡してください(ACM で先に作っておく)')
if compute not in COMPUTE:
raise ValueError(f"compute は {' / '.join(COMPUTE)} のどれかにしてください(今は {compute})")
prefix = f"ai-agent-kiban/{stage}"
cert = acm.Certificate.from_certificate_arn(self, "Cert", certificate_arn)
# --- トークンの表 ---
tokens = ddb.TableV2(
self,
"Tokens",
partition_key=ddb.Attribute(name="token_hash", type=ddb.AttributeType.STRING),
removal_policy=cdk.RemovalPolicy.RETAIN,
)
env = {"STAGE": stage, "TOKENS_TABLE": tokens.table_name, "SECRET_PREFIX": prefix}
def grant(role: iam.IRole):
"""API の権限:トークンの表を読む・自分の環境のパスワードを読む、だけ(ECS と Lambda で同じ)"""
tokens.grant_read_data(role)
role.add_to_principal_policy(
iam.PolicyStatement(
actions=["secretsmanager:GetSecretValue"],
resources=[f"arn:{self.partition}:secretsmanager:{self.region}:{self.account}:secret:{prefix}/*"],
)
)
acl = self._web_acl(cid) if waf_enabled else None
log_groups = {}
if compute in ("ecs", "both"):
log_groups["AuthFailed"] = self._ecs(domain_name, cert, env, grant, running, acl)
if compute in ("lambda", "both"):
log_groups["AuthFailedLambda"] = self._lambda(domain_name, cert, env, grant, acl)
# --- 監視:トークン違い(401)が続いたら知らせる(ECS・Lambda の記録を同じ数字に足す) ---
for filter_id, log_group in log_groups.items():
logs.MetricFilter(
self,
filter_id,
log_group=log_group,
filter_pattern=logs.FilterPattern.string_value("$.event", "=", "auth_failed"),
metric_namespace="ai-agent-kiban",
metric_name=f"{stage}-auth-failed",
metric_value="1",
)
topic = sns.Topic(self, "Alarms")
if alarm_email:
topic.add_subscription(subs.EmailSubscription(alarm_email))
cw.Alarm(
self,
"AuthFailedAlarm",
alarm_description="トークン違い(401)が続いている",
metric=cw.Metric(
namespace="ai-agent-kiban",
metric_name=f"{stage}-auth-failed",
period=cdk.Duration.minutes(5),
statistic="Sum",
),
threshold=20,
evaluation_periods=1,
comparison_operator=cw.ComparisonOperator.GREATER_THAN_THRESHOLD,
treat_missing_data=cw.TreatMissingData.NOT_BREACHING,
).add_alarm_action(cw_actions.SnsAction(topic))
cdk.CfnOutput(
self, "TokensTable", value=tokens.table_name, description="tools/issue_token.py の --table に渡す"
)
def _ecs(self, domain_name, cert, env, grant, running, acl) -> logs.LogGroup:
"""ALB+ECS Fargate(第4回・第8回と同じ。ID も同じ)"""
# --- ネットワーク(NAT なし) ---
vpc = ec2.Vpc(
self,
"Vpc",
max_azs=2,
nat_gateways=0,
subnet_configuration=[ec2.SubnetConfiguration(name="public", subnet_type=ec2.SubnetType.PUBLIC)],
)
# DynamoDB へはインターネットを通らない無料の出入口(ゲートウェイ型の VPC エンドポイント)で行く
vpc.add_gateway_endpoint("DynamoDb", service=ec2.GatewayVpcEndpointAwsService.DYNAMODB)
# --- API(ECS Fargate) ---
cluster = ecs.Cluster(self, "Cluster", vpc=vpc)
task = ecs.FargateTaskDefinition(self, "Task", cpu=256, memory_limit_mib=512)
logs_group = logs.LogGroup(self, "Logs", retention=logs.RetentionDays.THREE_MONTHS)
task.add_container(
"api",
image=ecs.ContainerImage.from_asset(str(CODE_DIR)),
port_mappings=[ecs.PortMapping(container_port=PORT)],
logging=ecs.LogDrivers.aws_logs(stream_prefix="api", log_group=logs_group),
environment=env,
)
grant(task.task_role)
alb_sg = ec2.SecurityGroup(self, "AlbSg", vpc=vpc, allow_all_outbound=False)
alb_sg.add_ingress_rule(ec2.Peer.any_ipv4(), ec2.Port.tcp(443))
alb_sg.add_ingress_rule(ec2.Peer.any_ipv4(), ec2.Port.tcp(80))
task_sg = ec2.SecurityGroup(self, "TaskSg", vpc=vpc, allow_all_outbound=True)
task_sg.add_ingress_rule(alb_sg, ec2.Port.tcp(PORT), "from ALB only")
alb_sg.add_egress_rule(task_sg, ec2.Port.tcp(PORT))
service = ecs.FargateService(
self,
"Service",
cluster=cluster,
task_definition=task,
desired_count=1 if running else 0,
assign_public_ip=True,
security_groups=[task_sg],
circuit_breaker=ecs.DeploymentCircuitBreaker(enable=True, rollback=True),
)
# --- 入口(ALB) ---
alb = elbv2.ApplicationLoadBalancer(
self, "Alb", vpc=vpc, internet_facing=True, security_group=alb_sg, drop_invalid_header_fields=True
)
listener = alb.add_listener(
"Https", port=443, open=False, ssl_policy=elbv2.SslPolicy.RECOMMENDED_TLS, certificates=[cert]
)
listener.add_targets(
"Api",
port=PORT,
protocol=elbv2.ApplicationProtocol.HTTP,
targets=[service],
health_check=elbv2.HealthCheck(path="/healthz"),
)
alb.add_listener(
"Http",
port=80,
open=False,
default_action=elbv2.ListenerAction.redirect(protocol="HTTPS", port="443", permanent=True),
)
if acl:
wafv2.CfnWebACLAssociation(self, "WafAlb", resource_arn=alb.load_balancer_arn, web_acl_arn=acl.attr_arn)
cdk.CfnOutput(
self,
"AlbDnsName",
value=alb.load_balancer_dns_name,
description=f"DNS で {domain_name} の CNAME の向け先にする(ECS で動かすとき)",
)
return logs_group
def _lambda(self, domain_name, cert, env, grant, acl) -> logs.LogGroup:
"""API Gateway(REST API)+Lambda。ECS と同じコンテナを、Lambda Web Adapter で動かす"""
# 反映に失敗して作り直すとき、残った記録の置き場と重ならないよう、スタックと一緒に消す
logs_group = logs.LogGroup(
self, "FnLogs", retention=logs.RetentionDays.THREE_MONTHS, removal_policy=cdk.RemovalPolicy.DESTROY
)
fn = lambda_.DockerImageFunction(
self,
"Fn",
# Lambda は1つの CPU の種類に向けたイメージしか読めない。x86_64 に固定して作る
code=lambda_.DockerImageCode.from_image_asset(str(CODE_DIR), platform=ecr_assets.Platform.LINUX_AMD64),
architecture=lambda_.Architecture.X86_64,
memory_size=1024, # メモリを増やすと CPU も増え、起動(コールドスタート)が速くなる
timeout=cdk.Duration.seconds(30), # API Gateway の上限(29秒)より少し長く
environment=env, # AWS_REGION は Lambda が自分で入れる
log_group=logs_group,
)
grant(fn.role)
# 入口:WAF を付けられる REST API を使う(HTTP API には付けられない)
api = apigw.LambdaRestApi(
self,
"Gateway",
handler=fn,
proxy=True,
endpoint_types=[apigw.EndpointType.REGIONAL],
binary_media_types=["*/*"], # 画像などもそのまま通す
deploy_options=apigw.StageOptions(
stage_name="api", throttling_rate_limit=20, throttling_burst_limit=40
), # 1秒あたりの上限(超えた分は 429)
cloud_watch_role=False,
)
# 独自ドメイン:ALB と同じ証明書を使う(同じリージョンの ACM の証明書)
domain = apigw.DomainName(
self,
"Domain",
domain_name=domain_name,
certificate=cert,
endpoint_type=apigw.EndpointType.REGIONAL,
security_policy=apigw.SecurityPolicy.TLS_1_2,
)
domain.add_base_path_mapping(api, stage=api.deployment_stage)
if acl:
wafv2.CfnWebACLAssociation(
self, "WafGateway", resource_arn=api.deployment_stage.stage_arn, web_acl_arn=acl.attr_arn
)
cdk.CfnOutput(
self,
"ApiGatewayDomainTarget",
value=domain.domain_name_alias_domain_name,
description=f"DNS で {domain_name} の CNAME の向け先にする(Lambda に切り替えるとき)",
)
cdk.CfnOutput(
self,
"ApiGatewayUrl",
value=api.url,
description="DNS を切り替える前に確かめる用(末尾に healthz を付ける)",
)
return logs_group
def _web_acl(self, cid) -> wafv2.CfnWebACL:
"""WAF(第8回と同じ決まり)。同じ1つを ALB にも API Gateway にも付けられる"""
def visibility(name):
return wafv2.CfnWebACL.VisibilityConfigProperty(
cloud_watch_metrics_enabled=True, metric_name=f"{cid}-{name}", sampled_requests_enabled=True
)
def rate(name, priority, limit, scope_down=None):
return wafv2.CfnWebACL.RuleProperty(
name=name,
priority=priority,
action=wafv2.CfnWebACL.RuleActionProperty(block={}),
statement=wafv2.CfnWebACL.StatementProperty(
rate_based_statement=wafv2.CfnWebACL.RateBasedStatementProperty(
limit=limit,
aggregate_key_type="IP",
evaluation_window_sec=300,
scope_down_statement=scope_down,
)
),
visibility_config=visibility(name),
)
without_token = wafv2.CfnWebACL.StatementProperty(
not_statement=wafv2.CfnWebACL.NotStatementProperty(
statement=wafv2.CfnWebACL.StatementProperty(
byte_match_statement=wafv2.CfnWebACL.ByteMatchStatementProperty(
field_to_match=wafv2.CfnWebACL.FieldToMatchProperty(single_header={"Name": "authorization"}),
positional_constraint="STARTS_WITH",
search_string="Bearer th_",
text_transformations=[wafv2.CfnWebACL.TextTransformationProperty(priority=0, type="NONE")],
)
)
)
)
return wafv2.CfnWebACL(
self,
"Waf",
scope="REGIONAL",
default_action=wafv2.CfnWebACL.DefaultActionProperty(allow={}),
visibility_config=visibility("all"),
rules=[rate("rate-without-token", 0, 100, without_token), rate("rate-all", 1, 3000)],
)
要点は次のとおりです。
- ECS 側の部品は、ID を第8回から変えていません。CDK は ID から AWS の中の名前(論理 ID)を決めます。ID が変わると「別のもの」とみなされて作り直しになり、ALB の DNS 名も変わってしまいます。
_ecsという関数に分けましたが、selfの下に同じ ID で作っているので、名前は変わりません - トークンの表と権限は共通です。
grantという関数で、ECS のロールにも Lambda のロールにも同じ権限(トークンの表を読む・自分の環境のパスワードを読む)だけを付けます - Lambda:
DockerImageFunctionで、ECS と同じフォルダのコンテナを使います。platform=LINUX_AMD64で、CPU の種類を1つに固定して作ります(理由は落とし穴の1つ目で説明します)。メモリは 1GB にしました。Lambda はメモリを増やすと CPU も増えるので、呼ばれてから動き出すまで(コールドスタート)が速くなります - API Gateway:
LambdaRestApiで、すべての呼び出しを Lambda に渡します。binary_media_types=["*/*"]で、画像などの中身も変えずに通します。1秒あたりの呼び出しの上限も決めておきます(サンプルの値です) - 独自ドメイン:ALB と同じ ACM の証明書を使います。出力の
ApiGatewayDomainTargetが、DNS の CNAME の向け先になります - WAF と 401 の監視:WAF は1つを作り、ALB と API Gateway の両方に付けます。401 の記録は、ECS と Lambda のどちらの記録からも同じ数字に足すので、移す間も監視が途切れません
infra/app.py で compute を読みます。
--- a/infra/app.py
+++ b/infra/app.py
@@ -6,5 +6,6 @@
- certificateArn:HTTPS の証明書(ACM で、DNS の確認を済ませておく。CDK では作らない)
- wafEnabled:WAF(呼び出し回数の制限)を置くか。本番は true
-- running:API を動かすか(確認用はふだん false にして費用を抑える)
+- running:API を動かすか(確認用はふだん false にして費用を抑える。ECS のときだけ)
+- compute:API を何で動かすか。ecs(ALB+ECS)・lambda(API Gateway+Lambda)・both(移す間だけ両方)
"""
@@ -31,4 +32,5 @@ ApiStack(
running=flag("running", True),
alarm_email=ctx("alarmEmail") or "",
+ compute=ctx("compute") or "ecs",
)
app.synth()
infra/cdk.json では、移し終えた状態(lambda)を既定にしました。app.py は、compute の指定が無いときは ecs として扱うので、第9回までの設定のまま反映しても、ECS から変わりません。
--- a/infra/cdk.json
+++ b/infra/cdk.json
@@ -7,5 +7,6 @@
"wafEnabled": false,
"running": true,
- "alarmEmail": ""
+ "alarmEmail": "",
+ "compute": "lambda"
}
}
テストで確かめる
「Lambda だけのときは VPC・ALB・ECS を作らない」「両方のときは WAF を両方に付け、401 を両方の記録で数える」「Lambda も自分の環境のパスワードしか読めない」を確かめます。
--- a/infra/tests/test_api_stack.py
+++ b/infra/tests/test_api_stack.py
@@ -72,2 +72,46 @@ def test_staging_without_waf_and_stopped():
t.resource_count_is("AWS::WAFv2::WebACL", 0)
t.has_resource_properties("AWS::ECS::Service", {"DesiredCount": 0})
+
+
+def test_lambda_only_has_no_network_or_alb():
+ t = build(compute="lambda")
+ t.resource_count_is("AWS::EC2::VPC", 0)
+ t.resource_count_is("AWS::ElasticLoadBalancingV2::LoadBalancer", 0)
+ t.resource_count_is("AWS::ECS::Service", 0)
+ t.has_resource_properties("AWS::Lambda::Function", {"PackageType": "Image", "Architectures": ["x86_64"]})
+ t.has_resource_properties(
+ "AWS::ApiGateway::DomainName",
+ {"EndpointConfiguration": {"Types": ["REGIONAL"]}, "SecurityPolicy": "TLS_1_2"},
+ )
+ t.resource_count_is("AWS::WAFv2::WebACLAssociation", 1) # API Gateway に付ける
+ t.resource_count_is("AWS::Logs::MetricFilter", 1) # Lambda の記録で 401 を数える
+
+
+def test_both_keeps_ecs_and_adds_lambda():
+ t = build(compute="both")
+ t.resource_count_is("AWS::ECS::Service", 1)
+ t.resource_count_is("AWS::Lambda::Function", 1)
+ t.resource_count_is("AWS::WAFv2::WebACL", 1) # WAF は1つで、ALB と API Gateway の両方に付ける
+ t.resource_count_is("AWS::WAFv2::WebACLAssociation", 2)
+ t.resource_count_is("AWS::Logs::MetricFilter", 2) # 401 は両方の記録を同じ数字に足す
+
+
+def test_lambda_reads_only_its_own_secrets():
+ import json
+
+ t = build(compute="lambda")
+ statements = [
+ s
+ for r in t.find_resources("AWS::IAM::Policy").values()
+ for s in r["Properties"]["PolicyDocument"]["Statement"]
+ if "secretsmanager" in json.dumps(s["Action"])
+ ]
+ assert len(statements) == 1
+ assert json.dumps(statements[0]["Resource"]).endswith('secret:ai-agent-kiban/prod/*"]]}')
+
+
+def test_unknown_compute_is_rejected():
+ import pytest
+
+ with pytest.raises(ValueError):
+ build(compute="ec2")
もう1つ、テストとは別に、compute=ecs のときに作られる設定(CloudFormation のテンプレート)が、変える前と同じこと を手元で確かめました。違ったのは出力の説明文の1か所だけでした。これで、ECS で動いている環境に反映しても、ECS 側が作り直されないと分かります。
落とし穴1:Lambda が読めない形でコンテナが置かれた
当社の社内基盤では、最初に確認用へ反映したとき、Lambda を作るところで次のエラーになって止まりました。
image manifest ... media type is not supported
原因は、コンテナの「形」です。
- Lambda が読めるのは、1つの CPU の種類に向けた1つのイメージだけです。複数の中身をまとめた形(イメージインデックス=目次のようなもの)は読めません
- 新しい docker は、イメージを作るときに既定で「作り方の記録」(provenance attestation)を付けます。記録を付けると、置かれる形がイメージインデックスになります
📰 出典:Create a Lambda function using a container image(AWS Lambda Developer Guide)
執筆環境の docker で、同じ Dockerfile から2回作って比べました。
| 作り方 | できた形 |
|---|---|
そのまま docker build | application/vnd.oci.image.index.v1+json(イメージインデックス) |
BUILDX_NO_DEFAULT_ATTESTATIONS=1 を付けて docker build | application/vnd.oci.image.manifest.v1+json(1つのイメージ) |
BUILDX_NO_DEFAULT_ATTESTATIONS=1 は、docker の公式の説明にある「既定の作り方の記録を付けない」設定です。サンプルでは、GitHub Actions の反映のジョブにこの設定を入れます。
📰 出典:Build variables(Docker Docs)
--- a/deploy.example.yml
+++ b/deploy.example.yml
@@ -27,4 +27,7 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ startsWith(github.ref, 'refs/tags/v') && 'production' || 'staging' }}
+ env:
+ # docker が既定で付ける「作り方の記録」(provenance)を付けない。付けると Lambda が読めない形で置かれる(第10回)
+ BUILDX_NO_DEFAULT_ATTESTATIONS: "1"
steps:
- uses: actions/checkout@v7
当社の社内基盤では、コンテナを作って置く手順(共通の反映の手順)を、Lambda が読める形で置くように直しました。手元の docker や、使っている CI(自動でテストや反映を行う仕組み)の種類によって既定の動きが違うので、ECS では問題なく動いていたコンテナでも、Lambda では止まることがある と覚えておくとよいです。
落とし穴2:失敗したあと、記録の置き場の名前が重なった
1つ目の失敗のあとを確かめると、Lambda の記録の置き場(CloudWatch Logs のロググループ)だけが残っていました。このまま直して反映し直すと、「同じ名前の置き場がすでにある」と止まる状態です。
- CloudFormation は、反映に失敗すると作りかけのものを消して元に戻します。ただし、ロググループは既定では「消さずに残す」設定でした
- 当社は名前を決めてロググループを作っていたので、残った置き場と名前が重なりました
当社は名前を変え、ロググループを「スタックと一緒に消す」設定にしました。サンプルでも removal_policy=cdk.RemovalPolicy.DESTROY にしています(記録は3か月で消える設定なので、消えて困るものではありません)。
落とし穴3:Lambda の上限に当たった
Lambda と API Gateway には、ECS より小さい上限があります。
- 1回の呼び出しは29秒まで(API Gateway)
- 送る・受け取る中身は1回 6MB まで(Lambda)。画像などは中で文字に直して運ぶので、実際は 4MB ほどまで
当社では、移した翌日に、営業の資料(パワーポイント)を Google Drive に送る中継が、大きなファイルで失敗しました。1回でまとめて送っていたためです。Drive の「再開可能なアップロード」(大きなファイルを分けて送り、途中で失敗しても続きから送れる仕組み)に切り替え、2MB ずつ分けて送るようにして直しました。移す前に、いちばん大きなデータといちばん長い処理を洗い出しておく と、このつまずきは防げます。
移す手順
infra/cdk.jsonのcomputeをbothにして、確認用に反映する。ECS はそのまま動き続け、API Gateway と Lambda が足される- 反映の出力の
ApiGatewayUrlの末尾にhealthzを付けて開き、{"ok":true,...}が返ることを確かめる(この URL は、末尾が/api/になっています) - DNS で、
api-stg.example.comの CNAME の向け先を、AlbDnsNameからApiGatewayDomainTargetに変える - Claude Code の確認用のセッションで、
python3 tools/agent.py meなどが動くことを確かめる - 本番も、人がタグを作って反映し、2〜4 を行う
- 両方で問題がなければ、
computeをlambdaにして反映する。ECS・ALB・VPC が消える
DNS は、切り替えてもしばらく古い答えが残ります(第4回)。数分〜1時間ほどかかります。古い答えが残っている間も、ALB の側が動いているので止まりません。これが、移す間だけ両方を動かす理由です。
サンプルは1つのスタックなので、手順6で ECS 側は CDK が消します。当社の社内基盤は ECS と Lambda を別のスタックに分けていたため、ECS のスタックは CloudFormation の画面で人が消しました。トークンの表は消さない設定(第4回)なので、どちらでも残ります。
動作確認
手元で、検査とテストを確かめます。
pip install -e ".[dev]"
ruff check .
pytest -q
cd infra && pip install -r requirements.txt && pytest -q tests && cd ..
プロジェクトの直下で14件、infra で8件が通れば完成です。
コンテナが作れて、Lambda と同じ「読み取り専用」でも動くことは、docker で確かめられます。
docker build -t ai-agent-kiban .
docker run --rm --read-only --tmpfs /tmp -p 8080:8080 -e STAGE=staging ai-agent-kiban
curl localhost:8080/healthz
{"ok":true,"stage":"staging"}
執筆環境では、ここまでと、コンテナの形の違い(落とし穴1の表)を確かめました。AWS への反映と、Lambda の中で動くところは確かめていません(当社の社内基盤は同じ仕組みで動いています)。
つまずきやすい点・セキュリティ上の注意
- ECS 側の ID を変えない:書き直すときに ID を変えると、ALB が作り直されて DNS の向け先が変わり、API が止まります。変える前後でテンプレートを比べると確実です
- 権限は ECS と同じにそろえる:Lambda には CDK が自動でロールを作ります。トークンの表とパスワードを読む権限は、ECS と同じ範囲(自分の環境だけ)にします
- WAF を付け直す:入口が変わると、WAF も新しい入口に付け直す必要があります。付け忘れると、回数の制限が効かない入口ができます
- 大きなデータ・長い処理を先に洗い出す:上限を超えるものは、分けて送るか、別の仕組み(キューで後から処理するなど)にします
発注者向けメモ
ECS(常に動くコンテナ)と Lambda(呼ばれたときだけ動く)のどちらにするかは、費用と使い方で決まります。開発会社には、次を確認しましょう。
- 「この API は、一日のうちどれくらい呼ばれますか。呼ばれない時間の費用はいくらですか」
- 「いちばん大きなデータと、いちばん長い処理は何ですか。Lambda の上限(29秒・6MB)に収まりますか」
- 「動かし方を変えるとき、止めずに切り替えられますか。戻すときはどうしますか」
- 「WAF や監視は、入口が変わっても続きますか」
呼び出しが一日中続く、大きなファイルを扱う、処理に時間がかかる、といった条件があると、Lambda の方が費用が高くなったり、作りを変える工数が増えたりします。どちらが安いかは使い方次第なので、「今の呼び出しの数」を測ってから決めるのが確実です。
まとめと次回予告
- Lambda Web Adapter を入れると、ECS と同じコンテナを Lambda で動かせる。API のプログラムは変えなくてよい
- 移す間だけ両方を動かし、DNS を切り替えて確かめてから古い方を消すと、止めずに移せる。ECS 側の ID は変えない
- Lambda が読めるコンテナの形・失敗後の記録の置き場・29秒と 6MB の上限の3つは、先に手を打っておく
この連載は、今回でいったん区切りとします。当社の社内基盤で新しく分かったことがあれば、また続きを書きます。
この連載の記事一覧
この記事は連載「Claude CodeとAWSで作るAIエージェントの土台」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Claude CodeとAWSで作るAIエージェントの土台 第1回】なぜリポジトリをやめて、役割ごとの API にしたのか
- 【Claude CodeとAWSで作るAIエージェントの土台 第2回】AWS を4つのアカウントに分け、Google Workspace でログインする
- 【Claude CodeとAWSで作るAIエージェントの土台 第3回】GitHub Actions から AWS へ入る(OIDC)と、sub が番号付きになる落とし穴
- 【Claude CodeとAWSで作るAIエージェントの土台 第4回】ECS Fargate と ALB を NAT なしで小さく始める:証明書・DNS の落とし穴
- 【Claude CodeとAWSで作るAIエージェントの土台 第5回】トークンで役割を判定し、Claude Code の環境に持たせる(Network secrets)
- 【Claude CodeとAWSで作るAIエージェントの土台 第6回】CLAUDE.md は入口だけにする:役割ごとの指示書を API で配る
- 【Claude CodeとAWSで作るAIエージェントの土台 第7回】既存のリポジトリから移す:秘密の値の検査と、WordPress への投稿の中継
- 【Claude CodeとAWSで作るAIエージェントの土台 第8回】守りと費用:401 の監視、WAF の回数制限、確認用はふだん止める
- 【Claude CodeとAWSで作るAIエージェントの土台 第9回】構成図で振り返る:AI に鍵を渡さない土台の全体像と6つの考え方
- 【Claude CodeとAWSで作るAIエージェントの土台 第10回】ECS から Lambda へ、同じコンテナのまま移す:切り替えの手順と3つの落とし穴(この記事)








