AWSやGCPといったメガクラウドの請求書(Billing)を開いた瞬間、背筋が凍るような思いをした経験はないだろうか。「あれ、トラフィック量は大して変わっていないのに、なんで今月のデータ転送料金(Data Transfer)がこんなにはね上がっているんだ……?」
SREとして現場を渡り歩いていると、この手の「クラウドの隠れたコスト」に関する悲鳴を本当によく耳にする。そして、その原因の多くを辿っていくと、決まって犯人は同じやつに行き当たる。そう、「AZ(アベイラビリティゾーン)をまたぐNATゲートウェイへのトラフィック」だ。
今回は、マルチAZ構成のKubernetesやマイクロサービス群が織りなす複雑なネットワークの裏側で、いかにして無駄なクロスAZ通信コストを削ぎ落とし、セキュアかつ効率的なルーティングアーキテクチャを構築するかについて、現場の泥臭い知見を交えて徹底的に解説しよう。
—
なぜ「NATゲートウェイ」はクラウドの財布を直撃するのか?
まず大前提として、パブリッククラウド(ここではAWSを主軸に話すが、GCPのVPC NetworkやInter-AZ通信でも思想は全く同じだ)のデータ転送料金の基本原則を叩き込んでおこう。
クラウドベンダーにとって、同一AZ内の通信は「庭先での立ち話」のようなものでコストはほぼかからない。しかし、異なるAZをまたぐ通信(クロスAZトラフィック)は、物理的な光ファイバーを何キロも這わせ、ラック間のスイッチを跨ぐため、明確に「有料の高速道路」扱いになる。
ここで、よくある「やってしまいがちな設計アンチパターン」を見てみよう。
1. コスト削減と管理の簡素化を狙い、「NATゲートウェイは1つのAZ(例えば az-a)にだけ1台ドンと置けばいいや」とケチる。
2. 他のAZ(az-b や az-c)で動いているKubernetesのPodやEC2インスタンスから外部APIへリクエストが飛ぶ。
3. az-b のワーカーノードから出たパケットは、わざわざAZの壁を越えて az-a にあるNATゲートウェイまで運ばれる。
4. 外部への往復パケットだけでなく、帰り(レスポンス)のパケットも再びクロスAZの課金対象としてカウントされる。
この状態を放置すると、月末に「NAT Gateway Processing Fee(処理料金)」と「Cross-AZ Data Transfer(AZ間データ転送量)」の二重苦で、インフラ担当者が経営陣から詰められるコンボが完成する。これを防ぐのが、今回テーマにする「AZ局所化(AZ-local)ルーティング戦略」だ。
—
黄金律:AZごとにNATゲートウェイを配置する理由
このコストと可用性のジレンマを解決するためのセオリーは極めてシンプルかつハードボイルドだ。
> 「NATゲートウェイは、原則として稼働するすべてのAZに1台ずつ(1-to-1で)配置せよ」
文字にすると当たり前のように聞こえるが、この設計には可用性とコスト最適化の美しいトレードオフが隠されている。
パケットがたどる理想的なルート(AZ局所化のシーケンス)
各AZにNATゲートウェイを配置し、ルートテーブルを適切に切り分けた場合の通信フローを見てみよう。
1. Podの発火: az-b のプライベートサブネットにいるアプリケーションPodが、外部の決済API等へ向けたリクエストを送信する。
2. ルーティングの判定: az-b のプライベートサブネット用のルートテーブル(Route Table)には、0.0.0.0/0 の宛先として「az-b にあるNATゲートウェイ」が明示的に指定されている。
3. ローカル処理: パケットはAZの壁を越えることなく、直近にある az-b のNATゲートウェイに直行する。
4. SNAT(Source Network Address Translation): NATゲートウェイがプライベートIPを自身のElastic IP(EIP)に変換し、インターネットへ送り出す。
5. リターントラフィック: 外部からの応答も、同じ az-b のNATゲートウェイに戻り、そのまま同AZ内のPodへとルーティングされる。
このフローであれば、クロスAZデータ転送のカウントは完全に「ゼロ」になる。NATゲートウェイ自体の時間単価(固定費)は増えるものの、大規模なトラフィックを扱うシステムであれば、クロスAZ転送量(従量課金)を削る方が圧倒的に安上がりになるという算段だ。
—
実践:Terraformで構築するAZ局所化ルーティング
口で言うのは簡単なので、実際のインフラコード(Terraform)を見ていこう。AWS環境において、マルチAZでプライベートサブネットとNATゲートウェイを美しくプロビジョニングする設定例だ。
# ----------------------------------------------------------------------
# 変数の定義(可用性を考慮して最低2つのAZを想定)
# ----------------------------------------------------------------------
variable "azs" {
type = list(string)
default = ["ap-northeast-1a", "ap-northeast-1c"]
}
# ----------------------------------------------------------------------
# Elastic IP の作成(各AZのNATGW用にそれぞれ確保)
# ----------------------------------------------------------------------
resource "aws_eip" "nat" {
count = length(var.azs)
domain = "vpc"
tags = {
Name = "nat-gw-eip-${var.azs[count.index]}"
}
}
# ----------------------------------------------------------------------
# パブリックサブネットの作成(NATGWをここに配置する)
# ----------------------------------------------------------------------
resource "aws_subnet" "public" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index}.0/24"
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = {
Name = "public-subnet-${var.azs[count.index]}"
}
}
# ----------------------------------------------------------------------
# プライベートサブネットの作成(アプリやK8sワーカーが動く場所)
# ----------------------------------------------------------------------
resource "aws_subnet" "private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${10 + count.index}.0/24"
availability_zone = var.azs[count.index]
tags = {
Name = "private-subnet-${var.azs[count.index]}"
}
}
# ----------------------------------------------------------------------
# NATゲートウェイの作成(各AZに1台ずつ、計2台)
# ----------------------------------------------------------------------
resource "aws_nat_gateway" "main" {
count = length(var.azs)
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = {
Name = "nat-gw-${var.azs[count.index]}"
}
# パブリックサブネットとEIPの依存関係を明示
depends_on = [aws_internet_gateway.gw]
}
# ----------------------------------------------------------------------
# プライベートサブネット用ルートテーブル(ここがAZ局所化の肝!)
# ----------------------------------------------------------------------
resource "aws_route_table" "private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
# 「自分のいるAZのNATゲートウェイ」へパケットを流す
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
tags = {
Name = "private-rt-${var.azs[count.index]}"
}
}
# ルートテーブルとプライベートサブネットの紐付け
resource "aws_route_table_association" "private" {
count = length(var.azs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table_association.private[count.index] # 実際には aws_route_table.private[count.index].id を指定
}
このコードの最大のポイントは、count インデックスを使って private-subnet-ap-northeast-1a のルートテーブルには nat-gw-ap-northeast-1a を、1c の方には 1c のNATGWを綺麗に対置させている点だ。この規約(Convention)を守るだけで、クロスAZトラフィックの大部分を物理的に遮断できる。
—
Kubernetes(EKS)環境での罠と実践的アプローチ
クラウドネイティブな世界に身を置く我々にとって、EC2インスタンス単体のルーティングだけでなく、Kubernetes(EKSやGKE)上のトポロジ認識を避けて通ることはできない。
Kubernetesで外部APIを叩くアプリケーション(例えば、Node.jsの fetch や Pythonの requests を使ったマイクロサービス)を動かしているとき、K8sのPodがどのAZにスケジューリングされるかは、K8sのスケジューラー任せになる。
トポロジ認識ルーティング(Topology-aware Routing)の活用
現代のKubernetes(v1.23以降でGA)には、Topology Aware Hints という機能が備わっている。これを有効にすることで、サービス間通信において「できるだけ同じAZ内のPodへトラフィックをルーティングする」ことが可能になる。
しかし、外部へのアウトバウンド通信(NATGW経由)に関しては、Kubernetesレイヤーではなく、AWSのCNI(Amazon VPC CNI)の挙動が鍵を握る。
Amazon VPC CNIを使用している場合、ポッドにはVPC内のプライベートサブネットのIPアドレスが直接割り当てられる。そのため、Podが属するサブネットがどのAZにあるかに応じて、前述したルートテーブルのルール(自分のAZのNATGWへ行くルール)がそのまま適用される。
アプリケーション側(Python)での接続タイムアウト・リトライ設計の注意点
AZ局所化を行っている場合、万が一特定のAZのNATゲートウェイが死んだとき、そのAZにいるPodからの外部通信がすべてスタックすることになる。そのため、アプリケーション層のHTTPクライアントでは、適切なタイムアウトとサーキットブレーカーを実装しておくのがプロの作法だ。
以下に、Python(requests ライブラリ)を用いた、堅牢なAPIフェッチのコード例を示す。
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def create_robust_session():
"""
AZ障害や一時的なネットワーク分断に備え、
リトライ戦略と適切なタイムアウトを持ったセッションを生成する
"""
session = requests.Session()
# べき等性のあるリクエストに対するリトライ設定
retry_strategy = Retry(
total=3, # 最大リトライ回数
backoff_factor=1, # リトライ間隔の係数 (1秒, 2秒, 4秒...)
status_forcelist=[500, 502, 503, 504], # このステータスコードの時だけリトライ
raise_on_status=False
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def call_external_api(api_url: str, payload: dict):
session = create_robust_session()
try:
# 接続(connect)タイムアウトと読み込み(read)タイムアウトを必ず明示する
# 無限待ち(ハング)を防ぐことがSREの第一歩
response = session.post(
api_url,
json=payload,
timeout=(3.0, 5.0)
)
response.raise_for_status()
return response.json()
except requests.exceptions.Timeout as e:
# タイムアウト時のログ出力とアラート連携
print(f"[ERROR] 外部APIへの接続がタイムアウトしました: {e}")
raise
except requests.exceptions.RequestException as e:
print(f"[ERROR] ネットワーク例外が発生しました: {e}")
raise
コード内で timeout=(3.0, 5.0) と記述している点に注目してほしい。TCPのコネクション確立に3秒、データ受信に5秒以上かかった場合は即座に切り捨てる。クラウドのネットワークの片隅で何が起ころうとも、自社のサービス全体がスレッドプール枯渇で共倒れになるのだけは絶対に防がなければならない。
—
デバッグと検証:本当にクロスAZ通信はゼロになったか?
インフラの変更を加えたら、必ず「意図通りに動いているか」を検証・観測するのがエンジニアの流儀だ。このコスト最適化がうまくいっているかをライブで確認するためのデバッグ手順を紹介しよう。
1. VPC Flow Logs のクエリ(Amazon Athena)
AWS環境であれば、VPC Flow LogsをS3に出力し、Amazon Athenaを使ってクロスAZトラフィックの量を可視化するのが最も確実だ。
以下のSQLクエリを実行し、異なるAZ間のトラフィック(送信元と送信先のAZが異なるログ)が激減していることを確認する。
SELECT
az_id,
SUM(bytes) / 1024 / 1024 AS total_mb
FROM
vpc_flow_logs
WHERE
start >= 1640995200 -- 調査開始タイムスタンプ
AND action = 'ACCEPT'
GROUP BY
az_id
ORDER BY
total_mb DESC;
2. 各AZのNATGWメトリクス(CloudWatch)
AWS CloudWatchの AWS/NATGateway 名前空間にある以下のメトリクスを監視ボードに並べよう。
BytesOutToDestination: 外部へ流れたデータ量BytesOutToSource: 内部へ戻ってきたデータ量ConnectionTimedOut: 接続タイムアウト数
もし特定のAZのNATGWだけにトラフィックが偏っている(あるいは特定のAZだけ数値がゼロ)場合、ルートテーブルの関連付け(aws_route_table_association)が正しく行われていないか、PodのAZ偏りが起きているサインだ。直ちにTerraformの状態やEKSのノードグループの配置を確認しよう。
—
まとめ:見えないコストを制する者がクラウドを制す
パブリッククラウドの請求書は、いわばインフラストラクチャの「通信簿」だ。そこには、設計時の配慮の有無が残酷なまでの数字となって現れる。
クロスAZ通信コストの最適化は、派手な新機能の開発ではない地味な作業に見えるかもしれない。しかし、マルチAZ構成のポテンシャルを殺さず、可用性を担保しながら無駄なドブ金を削るこの「NATゲートウェイの局所化配置」は、プロフェッショナルなSRE・クラウドアーキテクトであれば確実に押さえておかなければならない基本中の基本だ。
今週末のデプロイやアーキテクチャ見直しのタイミングで、あなたのVPCのルートテーブルとNATGWの配置を、もう一度冷徹な目で点検してみてほしい。驚くようなコスト削減のポテンシャルが、まだ眠っているはずだ。
コメント