要約
- 個人運営の Web サービス (Go + Next.js を ECS Fargate で運用) から NAT Gateway と public IPv4 をほぼ全廃した。VPC は IPv6 デュアルスタック、Web タスクは IPv4 アドレスを持たない IPv6-only サブネットで動き、ALB は
dualstack-without-public-ipv4で public IPv4 を持たない。 - 動機は固定費。NAT Gateway は 1 AZ あたり時間課金 (執筆時点の東京で約 0.062USD/時、月約 45USD) に加えて処理データ量課金があり、小規模サービスでは最大の固定費になりうる。public IPv4 アドレスも 2024 年から時間課金 (約 0.005/USD、こちらはリージョン非依存の一律料金) になった。
- 「AWS の API も外部 API も全部 IPv6 で届く」わけではない。届かないものをどう扱うかの設計が本題で、答えは 3 通り: dualstack エンドポイントの明示、IPv4 が必要な処理だけ public IPv4 を付けて隔離、どうしても IPv4 が要る呼び出しを VPC 外の Lambda に肩代わりさせる。
構成
- VPC:
natGateways: 0。Public / Isolated の 2 種サブネットに Amazon 提供の IPv6 CIDR を追加し、さらに AZ ごとにipv6Native: trueの IPv6-only パブリックサブネットを作る。ルートは::/0→ Internet Gateway。VPC エンドポイントは無料の S3 Gateway Endpoint だけ。 - ALB:
ipAddressType = dualstack-without-public-ipv4、ターゲットグループもipv6。前段に Cloudflare を置き、ALB のセキュリティグループは Cloudflare の IPv6 帯だけを 443/80 で許可する (CDN バイパス防止を兼ねる)。 - ECS Fargate: Web タスク (ARM64 / Graviton、Fargate Spot 優先) は IPv6-only サブネットに
assignPublicIp: falseで配置。コンテナイメージは ECR の dualstack URI (*.dkr-ecr.<region>.on.aws) から pull する。 - Aurora:
networkType: DUAL。
落とし穴
落とし穴 1: AWS サービスの「既定エンドポイント」は IPv4-only のものがある
SDK の既定エンドポイントは多くが IPv4-only で、IPv6-only のタスクからは名前解決はできても接続できない。対処はタスクの環境変数 AWS_USE_DUALSTACK_ENDPOINT=true を全タスクに付けること。これで S3 / SSM / CloudWatch Logs / Secrets Manager / ECR などの dualstack エンドポイントに切り替わる。
ただし例外がある。
- Athena は SDK 側でも明示的に
UseDualStackEndpointを有効にする必要があった。 - 一部の AWS API (Bedrockなど) は執筆時点で dualstack 未対応で、逆に
UseDualStackEndpoint = Disabledを明示して IPv4 経路に固定した (後述の public IPv4 付きバッチで実行する)。 - SES の
SendEmailは dualstack エンドポイントを持つが、既定の IPv4 経路をそのまま使う実装にしたため、メール送信を含むバッチは同じく public IPv4 側に寄せた (同居するバッチとの整合を優先した)。
「対応済み」と思い込まず、サービスごとに dig AAAA と実接続で確認するのが早い。IndexNow の送信先 (api.indexnow.org) のように、確認したら dualstack だったので IPv6-only 側に残せたものもある。
落とし穴 2: 外部 API と CDN の IPv6 対応状況
外部サービスは IPv4-only がまだ多い。このサービスでは次が該当した。
- 提携先の商品 API と画像 CDN (IPv4-only)
- LINE Messaging API (
api.line.me/api-data.line.me、IPv4-only) - 一部の決済系テストネット
対処は用途で分けた。
- バッチ処理: Step Functions から起動する Fargate タスクのうち、IPv4 が必要な実行モード (商品 API 取得、画像取得、外部投稿、メール送信) だけを public IPv4 付きでパブリックサブネットに配置し、それ以外のモードは IPv6-only サブネットで動かす。モードごとの振り分けは CDK の定数リストで管理する。public IPv4 は時間課金なので、短時間で終わるバッチに限定すれば NAT Gateway の常時課金より安い。
- Web からの同期呼び出し: LINE bot の画像取得と返信は Web サーバーから同期的に呼ぶ必要があり、Web タスクに IPv4 を付けたくない。そこで この 2 呼び出しだけを VPC 外の Lambda (AWS マネージドの IPv4 出口を持つ) に肩代わりさせる「egress proxy」を置いた。Lambda は資格情報を持たず、トークンは invoke の payload で渡す。Lambda 同期 invoke の 6 MB 制限に合わせ、画像は 4 MiB 上限にした。署名検証は Go 側で行い、Lambda は純粋な HTTP 転送に徹する。
落とし穴 3: SSR の fetch が「必ず失敗する IPv4」を試しに行く
Next.js の SSR から API を呼ぶ経路で、ある日突然 500 が出た。Sentry には IPv4 の ENETUNREACH 2 件と IPv6 の ETIMEDOUT 2 件が束ねられた AggregateError が残っていた。
原因は Happy Eyeballs。API の FQDN が A と AAAA の両方を返すため、Node の undici が IPv4 も試し、IPv6-only 環境では 構造的に必ず失敗する IPv4 試行が発生していた。普段は IPv6 側が先に成功するので気づかず、IPv6 側が一時的に遅い瞬間だけ全体が落ちる。
対処は undici のグローバル Dispatcher に connect: { family: 6 } を設定し、IPv4 候補の生成自体を止めること。「IPv6-only の環境では、IPv4 を試す余地を残さない」が教訓。
落とし穴 4: SSR の API 呼び出しを CDN 経由にしない
SSR からの API 呼び出しを公開 FQDN (Cloudflare 経由) にすると、同じ VPC 内の ALB に行くのに一度インターネットへ出ることになる。internet-facing ALB の ENI に付く IPv6 アドレスは VPC の IPv6 CIDR 内なので、Cloudflare を通さない DNS-only のホスト名を用意して ALB に直接向ければ、VPC 内のローカルルーティングで届く。ALB のセキュリティグループには VPC の IPv6 CIDR からの 443 だけを追加し、外部からそのホスト名を引いても Cloudflare 帯と VPC CIDR 以外は弾かれるので実質内部専用になる。
落とし穴 5: 既存 ALB から public IPv4 は簡単には消えない
既存 ALB を dualstack-without-public-ipv4 に変更しても public IPv4 が残留することがある。ECS サービスを CodeDeploy (Blue/Green) 制御にしていると、CloudFormation から ALB やターゲットグループを差し替える更新が通らないケースがあり、結局スタックをバージョン付きで並行作成して切り替えた。最初から public IPv4 なしで作る方がはるかに楽。
効果
- NAT Gateway の時間課金と処理量課金がゼロになった。public IPv4 の課金は短時間のバッチ実行分だけ。
- 引き換えに増えたのは「dualstack 対応状況を確認する運用」と、IPv4 が必要な呼び出しを隔離する設計。どちらも一度整えれば増えない。
感想
IPv6もっと普及してくれ。あとAWSはIPv4割り当てに料金とるならばIPv6 onlyでは使えないものを無くしてくれ。
まとめ
- IPv6-only は「対応しているサービスを選ぶ」のではなく「対応していないものをどこに隔離するか」を設計する話だった。
-
AWS_USE_DUALSTACK_ENDPOINT=trueを全タスクに、SDK 側で個別指定が要るサービスはコードで明示、IPv4 が要る処理は public IPv4 付きバッチか VPC 外 Lambda へ。 - Happy Eyeballs の IPv4 試行は明示的に殺す。