MENU

問い合わせ


    【Claude CodeとAWSで作るAIエージェントの土台 第10回】ECS から Lambda へ、同じコンテナのまま移す:切り替えの手順と3つの落とし穴

    前回は、当社の社内基盤の今の構成図を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 FargateAPI Gateway+Lambda
    動き方常に動いている呼ばれたときだけ動く
    固定の費用ALB・IPv4 アドレス・Fargate の時間料金なし(呼び出しの数と動いた時間で決まる。Lambda には毎月の無料枠がある)
    ネットワークVPC・サブネット・セキュリティグループが要るVPC の外に置ける(作らない)
    確認用ふだん止める仕組みが要った止めなくても、呼ばなければ費用はほぼかからない
    上限長い処理・大きなデータも扱える1回29秒まで(API Gateway)、1回 6MB まで(Lambda)
    WAFALB に付けるAPI Gateway の REST API に付ける

    📰 出典:AWS Lambda の料金

    📰 出典:Amazon API Gateway の料金

    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 buildapplication/vnd.oci.image.index.v1+json(イメージインデックス)
    BUILDX_NO_DEFAULT_ATTESTATIONS=1 を付けて docker buildapplication/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 ずつ分けて送るようにして直しました。移す前に、いちばん大きなデータといちばん長い処理を洗い出しておく と、このつまずきは防げます。

    移す手順

    1. infra/cdk.json の compute を both にして、確認用に反映する。ECS はそのまま動き続け、API Gateway と Lambda が足される
    2. 反映の出力の ApiGatewayUrl の末尾に healthz を付けて開き、{"ok":true,...} が返ることを確かめる(この URL は、末尾が /api/ になっています)
    3. DNS で、api-stg.example.com の CNAME の向け先を、AlbDnsName から ApiGatewayDomainTarget に変える
    4. Claude Code の確認用のセッションで、python3 tools/agent.py me などが動くことを確かめる
    5. 本番も、人がタグを作って反映し、2〜4 を行う
    6. 両方で問題がなければ、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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      目次