MENU

問い合わせ


    【Claude CodeとAWSで作るAIエージェントの土台 第4回】ECS Fargate と ALB を NAT なしで小さく始める:証明書・DNS の落とし穴

    前回は、GitHub Actions からアクセスキーを置かずに AWS へ入る仕組み(OIDC)を作りました。

    今回は、API を AWS で動かす部分です。API の骨組み(起動確認だけを返す最小の形)とコンテナの設定を書き、AWS CDK(=AWS の構成を Python などのコードで書き、テストで確かめられる道具)で、ネットワーク・ALB・ECS Fargate を作ります。

    「AWS でサーバーを動かすと、毎月いくらかかるのか見当がつかない…」

    結論から言うと、NAT ゲートウェイを置かず、API をパブリックサブネットに置いて ALB からだけ受け付ける形にすると、小さな API なら月の固定費を大きく抑えられます。あわせて、HTTPS の証明書と、Secrets Manager の値の置き場を「CDK で作らない」理由と、DNS で実際に2時間つまずいた話を紹介します。

    目次

    この回で作る構成

    部品中身
    ネットワーク(VPC)アベイラビリティーゾーン(AZ)2つ、パブリックサブネットだけ、NAT ゲートウェイなし
    ALBHTTPS だけを受ける。HTTP は HTTPS へ転送する。不正なヘッダーを落とす
    ECS FargateAPI のコンテナを 0.25 vCPU・0.5 GB で1つ動かす。ALB からだけ受け付ける
    DynamoDBトークンの表(第5回で使う)。インターネットを通らない無料の出入口で行く
    Secrets ManagerWordPress のパスワードの置き場(第7回で使う)。API が読む権限だけを付ける

    ECS Fargate は、サーバーの管理なしにコンテナを動かせるサービスです。アベイラビリティーゾーンは、同じリージョンの中で独立した設備のまとまりで、2つに分けておくと片方の障害に耐えられます。

    NAT を置かない理由

    一般的な構成では、API をプライベートサブネット(=インターネットから直接届かない場所)に置き、外へ出るときは NAT ゲートウェイを通します。ただ、NAT ゲートウェイは1つあたり時間料金がかかり、東京リージョンでは1時間 0.062 ドル、月にすると約45ドルです(2026年10月時点の目安。AZ ごとに置くとその倍)。

    📰 出典:Amazon VPC の料金

    この連載では、API のコンテナをパブリックサブネットに置いて外(WordPress など)へ直接出し、受け付けはセキュリティグループ(=通してよい相手を決める設定)で ALB からだけ に絞りました。インターネットから API のコンテナに直接つなごうとしても届きません。DynamoDB へは、ゲートウェイ型の VPC エンドポイント(無料の専用の出入口)を通します。

    API の骨組みを書く

    まず、API の最小の形を作ります。この回は起動確認(/healthz)だけです。ALB のヘルスチェック(=定期的に呼んで、動いているかを確かめる仕組み)がここを呼びます。

    # app/main.py
    """渡された文章を WordPress に投稿するだけの中継 API(連載のサンプル)。
    
      GET  /healthz                      起動確認(トークン不要)
    
    起動:uvicorn --factory app.main:create_app
    """
    
    import json
    from pathlib import Path
    
    from fastapi import FastAPI
    
    from .settings import Settings
    
    
    def create_app(settings: Settings | None = None) -> FastAPI:
        settings = settings or Settings()
        app = FastAPI(title='ai-agent-kiban')
        app.state.settings = settings
        app.state.sites = {k: v for k, v in json.loads(Path(settings.sites_file).read_text(encoding='utf-8')).items()
                           if not k.startswith('_')}
    
        @app.get('/healthz')
        def healthz():
            return {'ok': True, 'stage': settings.stage}
    
        return app
    • create_app は API を組み立てる関数です。テストでは設定を差し替えて呼びます
    • 起動は uvicorn --factory app.main:create_app です(--factory は「関数を呼んで API を作る」指定)

    テストを2つのファイルで用意します。conftest.py は、テストで使う部品(ここでは本番の設定で作った API)を用意するファイルです。

    # tests/conftest.py
    import pytest
    from fastapi.testclient import TestClient
    
    from app.main import create_app
    from app.settings import Settings
    
    
    def make(stage):
        settings = Settings(stage=stage, sites_file='config/sites.json')
        return TestClient(create_app(settings))
    
    
    @pytest.fixture
    def prod():
        return make('prod')
    # tests/test_api.py
    def test_healthz_needs_no_token(prod):
        assert prod.get('/healthz').json() == {'ok': True, 'stage': 'prod'}

    コンテナの設定

    API をコンテナにする設定です。

    # Dockerfile
    FROM python:3.13-slim
    WORKDIR /srv
    COPY pyproject.toml ./
    COPY app ./app
    COPY config ./config
    RUN pip install --no-cache-dir . && useradd -r -u 10001 api
    USER api
    EXPOSE 8080
    CMD ["uvicorn", "--factory", "app.main:create_app", "--host", "0.0.0.0", "--port", "8080"]
    # .dockerignore
    .env
    .env.*
    !.env.example
    .tokens.local.json
    infra/
    tests/
    __pycache__/
    • useradd -r -u 10001 api と USER api:コンテナの中で管理者(root)として動かさないための設定です
    • .dockerignore:.env やトークンのファイル、テストと infra をイメージに入れないようにします

    CDK で AWS の構成を書く

    CDK のコードは infra フォルダに置きます。CDK の Python 版は内部で Node.js を使うので、Node.js(執筆時点の LTS)も入れておきます。

    mkdir -p infra/stacks infra/tests
    touch infra/stacks/__init__.py
    # infra/requirements.txt
    aws-cdk-lib>=2.275
    constructs>=10
    pytest>=9

    infra/cdk.json

    {
      "app": "python3 app.py",
      "context": {
        "stage": "staging",
        "domainName": "api-stg.example.com",
        "certificateArn": ""
      }
    }

    cdk.json の context は、反映するときに -c 名前=値 で上書きできる設定です。

    infra/app.py(1行目は実行用の指定なので、パスは書いていません)

    #!/usr/bin/env python3
    """AWS の設定(CDK)。1つのアカウントに、確認用か本番の API を1つ作る。
    
      cdk deploy -c stage=prod -c domainName=api.example.com -c certificateArn=arn:aws:acm:...
    
    - certificateArn:HTTPS の証明書(ACM で、DNS の確認を済ませておく。CDK では作らない)
    """
    
    import aws_cdk as cdk
    from stacks.api_stack import ApiStack
    
    app = cdk.App()
    ctx = app.node.try_get_context
    
    
    stage = ctx("stage") or "staging"
    ApiStack(
        app,
        f"ai-agent-kiban-{stage}",
        stage=stage,
        domain_name=ctx("domainName") or "api.example.com",
        certificate_arn=ctx("certificateArn") or "",
    )
    app.synth()

    本体のスタック(=CDK でまとめて作る AWS のもののひとまとまり)です。長いので折りたたんでいます。

    infra/stacks/api_stack.py の全文を開く(134行)
    # infra/stacks/api_stack.py
    """API を動かす部分(小さく始める構成)。
    
    - ネットワーク:AZ 2つ・パブリックサブネットだけ・NAT なし(NAT は月約45ドルかかるため)
      DynamoDB へはゲートウェイ型の VPC エンドポイント(無料)で行く
    - ALB:HTTPS だけ(HTTP は HTTPS へ転送)。証明書は人が ACM で作り、ARN を渡す
    - ECS Fargate:ALB からだけ受け付ける。失敗したら一つ前の版に自動で戻す
    - トークンの表(DynamoDB):ハッシュだけを置く
    - WordPress のパスワード:Secrets Manager の <prefix>/wp/<サイト> を API だけが読む(CDK では作らない)
    """
    
    from pathlib import Path
    
    import aws_cdk as cdk
    from aws_cdk import aws_certificatemanager as acm
    from aws_cdk import aws_dynamodb as ddb
    from aws_cdk import aws_ec2 as ec2
    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_logs as logs
    from constructs import Construct
    
    CODE_DIR = Path(__file__).resolve().parents[2]  # Dockerfile のある場所
    PORT = 8080
    
    
    class ApiStack(cdk.Stack):
        def __init__(
            self,
            scope: Construct,
            cid: str,
            *,
            stage: str,
            domain_name: str,
            certificate_arn: str,
            **kwargs,
        ):
            super().__init__(scope, cid, **kwargs)
            if not certificate_arn:
                raise ValueError('HTTPS の証明書の ARN を -c certificateArn=... で渡してください(ACM で先に作っておく)')
            prefix = f"ai-agent-kiban/{stage}"
    
            # --- ネットワーク(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)
    
            # --- トークンの表 ---
            tokens = ddb.TableV2(
                self,
                "Tokens",
                partition_key=ddb.Attribute(name="token_hash", type=ddb.AttributeType.STRING),
                removal_policy=cdk.RemovalPolicy.RETAIN,
            )
    
            # --- 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={"STAGE": stage, "TOKENS_TABLE": tokens.table_name, "SECRET_PREFIX": prefix},
            )
            tokens.grant_read_data(task.task_role)
            task.task_role.add_to_policy(
                iam.PolicyStatement(
                    actions=["secretsmanager:GetSecretValue"],
                    resources=[f"arn:{self.partition}:secretsmanager:{self.region}:{self.account}:secret:{prefix}/*"],
                )
            )
    
            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,
                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=[acm.Certificate.from_certificate_arn(self, "Cert", certificate_arn)]
                if certificate_arn
                else [],
            )
            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),
            )
    
            cdk.CfnOutput(
                self,
                "AlbDnsName",
                value=alb.load_balancer_dns_name,
                description=f"DNS で {domain_name} の CNAME の向け先にする",
            )
            cdk.CfnOutput(
                self, "TokensTable", value=tokens.table_name, description="tools/issue_token.py の --table に渡す"
            )

    要点は次のとおりです。

    • ネットワーク:nat_gateways=0 とパブリックサブネットだけの設定で、NAT を作りません
    • トークンの表:removal_policy=cdk.RemovalPolicy.RETAIN で、スタックを消しても表は残します(トークンを作り直す手間を防ぐため)
    • API の権限:トークンの表を読む権限と、Secrets Manager の ai-agent-kiban/<stage>/ で始まる値を読む権限だけを付けます。ほかの値は読めません
    • セキュリティグループ:ALB は 443 と 80 を受け、API のコンテナは ALB からの 8080 だけを受けます
    • サーキットブレーカー:circuit_breaker=...rollback=True で、新しい版がうまく起動しなかったとき、自動で一つ前の版に戻します
    • ALB:443(HTTPS)で受けて API に渡し、80(HTTP)は HTTPS へ転送します。drop_invalid_header_fields=True で、形の正しくないヘッダーを落とします

    📰 出典:Amazon ECS deployment circuit breaker(AWS Documentation)

    CDK の構成をテストで確かめる

    CDK のテストでは、AWS に反映せずに、作られる設定(CloudFormation のテンプレート)を調べられます。「NAT がない」「HTTPS だけ」「API は自分の環境の値しか読めない」を確かめます。

    # infra/tests/test_api_stack.py
    import sys
    from pathlib import Path
    
    import aws_cdk as cdk
    from aws_cdk.assertions import Match, Template
    
    sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
    from stacks.api_stack import ApiStack  # noqa: E402
    
    CERT = "arn:aws:acm:ap-northeast-1:111111111111:certificate/00000000-0000-0000-0000-000000000000"
    
    
    def build(**kw):
        args = dict(stage="prod", domain_name="api.example.com", certificate_arn=CERT)
        args.update(kw)
        app = cdk.App()
        return Template.from_stack(
            ApiStack(app, "test", env=cdk.Environment(account="111111111111", region="ap-northeast-1"), **args)
        )
    
    
    def test_no_nat_and_https_only():
        t = build()
        t.resource_count_is("AWS::EC2::NatGateway", 0)
        t.resource_count_is("AWS::EC2::VPCEndpoint", 1)  # DynamoDB 用(無料のゲートウェイ型)
        t.has_resource_properties("AWS::ElasticLoadBalancingV2::Listener", {"Port": 443, "Protocol": "HTTPS"})
        t.has_resource_properties(
            "AWS::ElasticLoadBalancingV2::Listener",
            {"Port": 80, "DefaultActions": [Match.object_like({"Type": "redirect"})]},
        )
    
    
    def test_api_reads_only_its_own_secrets_and_tokens():
        import json
    
        t = build()
        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/*"]]}')

    CDK で作らないもの:証明書とパスワードの置き場

    CDK で何でも作りたくなりますが、次の2つはあえて人が作りました。

    作らないもの理由どうしたか
    HTTPS の証明書(ACM)CDK で作ると、DNS での確認が終わるまで反映が止まる人が ACM で申請し、DNS の確認を済ませてから、ARN を CDK に渡す
    パスワードの置き場(Secrets Manager)CDK で作ると、設定を変えたときに、人が入れた値が見本の値に戻る恐れがある人が「無ければ空の形で作る、あれば触らない」スクリプトで作る

    ACM(AWS Certificate Manager)は、HTTPS の証明書を無料で発行するサービスです。発行には「そのドメインの持ち主であること」の確認が要り、DNS に確認用の CNAME レコードを足すと確認が進みます。

    📰 出典:DNS validation(AWS Certificate Manager User Guide)

    落とし穴:DNS の設定場所を間違えて、2時間進まなかった

    当社の社内基盤では、証明書の確認が2時間たっても終わりませんでした。

    • 当社の DNS は、レンタルサーバー(Xserver)で管理しています。確認用の CNAME レコードを、ドメインの管理画面の DNS 設定に入れていました
    • ところが、ネームサーバー(=そのドメインの DNS の答えを返すサーバー)がレンタルサーバー側のときは、サーバーの管理画面の DNS 設定に入れないと公開されません。入れ直したら数分で公開され、確認も終わりました
    • しかも、ワイルドカード(*)の A レコードがあったため、レコードが無い名前にも答えが返ってきて、入れ忘れに気付きにくい状態でした

    確認用の CNAME が公開されているかは、ネームサーバーに直接聞くと確実です。

    dig <確認用の名前> CNAME @<ネームサーバー>
    dig <確認用の名前> CNAME @8.8.8.8

    ALB を向けた直後の「証明書が合わない」

    ALB を指す CNAME レコードを足した直後は、古い答え(ワイルドカードの A レコード)が DNS のキャッシュに最大1時間残りました。その間はレンタルサーバー側の証明書が返ってきて、「証明書が合わない」というエラーになります。複数の DNS(Google・Cloudflare など)に聞いて答えを比べ、設定の誤りではなく古い答えが残っているだけだと見分けました。

    組み立てで AWS への問い合わせが起きた

    もう1つ、当社の構成では、CDK が AZ の一覧を引くために、組み立て(synth)の途中で AWS に問い合わせ、キーのない自動の検査で失敗しました。アカウントとリージョンを指定したスタックで起きます。AZ を cdk.context.json に書いておけば、問い合わせなしで組み立てられます。テストの組み立てでは問い合わせが省かれるため、テストだけでは気付けませんでした。連載のサンプルはアカウントを指定しない形にしているので、この問題は起きません。

    反映の順番

    1. ACM で証明書を申請し、DNS に確認用の CNAME を入れる。「Issued」(発行済み)になるのを待つ
    2. Secrets Manager に WordPress の値の置き場を作る(第7回で詳しく説明します)
    3. CDK で反映する(確認用。約5分)
    4. 出力の AlbDnsName を、DNS で api-stg.example.com の CNAME として足す
    5. https://api-stg.example.com/healthz が返ることを確かめる
    6. 本番は、タグを作って同じことをする

    手で反映する場合のコマンドは次のとおりです(ふだんは前回の GitHub Actions から反映します)。

    cd infra
    npx aws-cdk@2 deploy -c stage=staging -c domainName=api-stg.example.com \
      -c certificateArn=arn:aws:acm:ap-northeast-1:<アカウント番号>:certificate/<ID>

    動作確認

    手元で、検査・テストと、API の起動を確かめます。

    pip install -e ".[dev]"
    ruff check .
    pytest -q
    cd infra && pip install -r requirements.txt && pytest -q tests && cd ..
    STAGE=staging uvicorn --factory app.main:create_app --port 8080

    別のターミナルで /healthz を呼びます。

    curl localhost:8080/healthz
    {"ok":true,"stage":"staging"}

    テストは、プロジェクトの直下で3件、infra で2件が通れば完成です。なお、執筆環境ではコンテナのビルドと AWS への反映までは確かめていません(当社の社内基盤は同じ構成で動いています)。

    費用の目安

    月の目安です(2026年10月時点の東京リージョン。1か月を730時間として計算。データ転送などは含みません)。

    部品目安
    ALB(時間料金)約18ドル(1時間 0.0243 ドル。使った量に応じた料金が別に加わる)
    パブリック IPv4 アドレス1つあたり約3.7ドル(1時間 0.005 ドル)。ALB に2つ、API のコンテナに1つ
    ECS Fargate(0.25 vCPU・0.5 GB を1つ)約11ドル
    NAT ゲートウェイ置かないので0(置くと1つあたり約45ドル)

    📰 出典:Elastic Load Balancing の料金

    📰 出典:AWS Fargate の料金

    確認用はふだん止めて、さらに費用を抑えます(第8回)。

    つまずきやすい点・セキュリティ上の注意

    • API のコンテナのセキュリティグループを広げない:受け付けを ALB のセキュリティグループからだけにしておかないと、パブリックサブネットにある API にインターネットから直接届いてしまいます
    • 証明書の確認が進まないときは、ネームサーバーに直接聞く:ドメインの管理画面とサーバーの管理画面の2か所に DNS の設定があるサービスでは、どちらが公開されているかを確かめます
    • 本番では監視を足す:当社の社内基盤では、API が止まったとき・5xx のエラーが続いたときにメールで知らせています。連載のサンプルでは、トークン違いの監視だけを第8回で足します

    発注者向けメモ

    AWS の構成を開発会社に頼むときは、次を確認すると費用と運用の見通しが立ちます。

    • 「NAT ゲートウェイは何台置きますか。置かない構成にできますか(その場合の安全の守り方は)」
    • 「証明書と DNS の設定は、誰が・どの画面で行いますか。ドメインの管理は自社と開発会社のどちらですか」
    • 「新しい版がうまく動かなかったとき、自動で前の版に戻りますか」
    • 「AWS の構成はコード(CDK や Terraform)で残りますか。テストはありますか」

    DNS を別の会社で管理している、社内のネットワークからだけ使わせたい、止まらないことが強く求められる、といった条件があると、構成と工数が変わりやすくなります。

    まとめと次回予告

    • API はパブリックサブネットに置き、受け付けは ALB からだけにすると、NAT ゲートウェイなしで小さく始められる
    • 証明書とパスワードの置き場は CDK で作らない。DNS での確認待ちで反映が止まったり、人が入れた値を戻したりしないため
    • DNS の確認が進まないときは、ネームサーバーに直接聞いて、どこに設定すべきかを確かめる

    次回は、トークンを発行して API で役割を判定し、Claude Code の環境にトークンを持たせる ところを作ります。

    この連載の記事一覧

    この記事は連載「Claude CodeとAWSで作るAIエージェントの土台」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

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


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

      この記事を書いた人

      コメント

      目次