前回は、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 ゲートウェイなし |
| ALB | HTTPS だけを受ける。HTTP は HTTPS へ転送する。不正なヘッダーを落とす |
| ECS Fargate | API のコンテナを 0.25 vCPU・0.5 GB で1つ動かす。ALB からだけ受け付ける |
| DynamoDB | トークンの表(第5回で使う)。インターネットを通らない無料の出入口で行く |
| Secrets Manager | WordPress のパスワードの置き場(第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 に書いておけば、問い合わせなしで組み立てられます。テストの組み立てでは問い合わせが省かれるため、テストだけでは気付けませんでした。連載のサンプルはアカウントを指定しない形にしているので、この問題は起きません。
反映の順番
- ACM で証明書を申請し、DNS に確認用の CNAME を入れる。「Issued」(発行済み)になるのを待つ
- Secrets Manager に WordPress の値の置き場を作る(第7回で詳しく説明します)
- CDK で反映する(確認用。約5分)
- 出力の
AlbDnsNameを、DNS でapi-stg.example.comの CNAME として足す https://api-stg.example.com/healthzが返ることを確かめる- 本番は、タグを作って同じことをする
手で反映する場合のコマンドは次のとおりです(ふだんは前回の 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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【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 の回数制限、確認用はふだん止める










コメント
コメント一覧 (1件)
[…] 【Claude CodeとAWSで作るAIエージェントの土台 第4回】ECS Fargate と ALB を NAT な… […]