【実務・中級編】 NACLとセキュリティグループの併用時におけるエフェメラルポートの考慮 – クラウド&コンテナネットワーク実践ガイド

パケットはなぜ沈黙したのか?NACLとセキュリティグループの挟撃でハマる「エフェメラルポート」の罠

こんにちは。クラウドの底なし沼……いや、緻密に設計されたネットワークの深淵を日々さまよっているシニアSREの私です。

Web APIの設計やマイクロサービスの構築において、私たちは日々「セキュリティ」という名の厳格な門番と向き合っています。AWSのVPCやGCPのVPCネットワークにおいて、トラフィックを制御する基本単位といえばセキュリティグループ(SG)とネットワークACL(NACL)です。

「SGがあるんだから、NACLなんてとりあえずデフォルト(全許可)でいいや」

そう思っていませんか?あるいは、セキュリティ要件の監査が厳しくなり、「サブネット境界でも明示的な拒否ルール(NACL)を入れるべきだ!」と意気込んで設定をいじった矢先、外部APIへのリクエストがパタリと途絶え、アプリケーションのログが Connection timed out の嵐と化した……。

今回は、そんな現場のエンジニアたちが一度は踏み抜く、「ステートレスなNACLとエフェメラルポートの落とし穴」について、パケットの旅路を追いながら徹底的に解説していきたいと思います。

—

1. 現場の悲劇:なぜ外部API呼び出しが突然死したのか?

ある日の本番リリース後、バックエンドのECSタスク(またはGKEのPod)から、外部の決済APIへHTTPリクエストを送信する処理だけがタイムアウトする障害が発生しました。

アプリケーションのコードを確認し、ローカル環境から同じエンドポイントを叩いてみると何の問題もなくレスポンスが返ってきます。しかし、VPCのプライベートサブネットに閉じ込められたコンテナから外に出ようとすると、まるでブラックホールに吸い込まれるようにパケットが消えていく。

「セキュリティグループの送信(Outbound)ルールは 0.0.0.0/0 で全許可している。なのに、なぜだ?」

原因は、そのサブネットにアタッチされていたNACL(Network Access Control List)のカスタム設定にありました。コンプライアンス要件を満たすために「インバウンドとアウトバウンドを厳しく絞ろう」とした結果、戻りのトラフィックを完全に遮断していたのです。

このトラブルの核心にあるのは、「セキュリティグループはステートフル(状態を記憶する)だが、NACLはステートレス(状態を持たない)である」という、クラウドネットワークの鉄則です。

—

2. ステートフル vs ステートレス:パケットの記憶喪失

まずは、パケットが通過する2つの門番の挙動の違いを整理しておきましょう。

セキュリティグループ(SG):賢い門番

SGはステートフル(Stateful)です。
あなたが内部から外部のAPIサーバー(例: 192.0.2.1 のポート 443)に向けて SYN パケットを飛ばしたとします。SGは「お、この通信はうちから外へ向けて発火したな」と記憶します。
その後、相手から返ってきた戻りパケット(レスポンス)は、あなたがインバウンド(Inbound)の許可ルールを書いていなくても、自動的にスルーされます。門番はあなたの顔を覚えているのです。

NACL:記憶力ゼロの門番

一方、サブネットの境界に立ちはだかるNACLはステートレス(Stateless)です。
NACLは過去の通信の文脈を一切記憶しません。往路のパケットが通ったからといって、復路のパケットを自動で通すことはありません。
つまり、外から戻ってくるパケットを通すためには、アウトバウンドだけでなく、インバウンド(Inbound)ルールでも明示的に許可してやる必要があるのです。

—

3. 「エフェメラルポート」とは何か?RFCが定める一時ポートの範囲

ここで問題になるのが、クライアント側がアウトバウンド通信を行う際に動的に割り当てられるエフェメラルポート(Ephemeral Port / 一時ポート)です。

アプリケーションから curl や Python の requests、Node.js の fetch を使って外部APIに接続するとき、OSのネットワークスタックは送信元IPアドレスと送信元ポートを決定します。宛先IPと宛先ポート(443 など)は固定ですが、送信元ポートにはOSが空いている一時的なポートをランダムに割り当てます。

標準的なOS(Linux、macOS、Windowsなど。IANAの勧告およびRFC 6335に基づく)では、エフェメラルポートの範囲として以下のレンジが使用されます。

  • IANA推奨範囲: 49152 から 65535
  • Linuxカーネル(主流のRHEL、Ubuntu、Amazon Linux等)のデフォルト範囲: 32768 から 60999(または 1024 から 65535 に広げられているケースも多い)

AWSのドキュメントや一般的なネットワーク設計では、安全のために 1024 から 65535 の全範囲をエフェメラルポートとして考慮するのが定石となっています。

パケットの往来をシーケンスで見る

正常な通信と、NACLでハマったときの通信の動きをシーケンスで確認してみましょう。

[アプリ / コンテナ]                  [NACL (Outbound)]          [外部APIサーバー]
        |                                  |                           |
        |--- 1. SYN (Port: 54321 -> 443) ->|--- 1. SYN ---------------->| (往路: OK)
        |                                  |                           |
        |    (OSはエフェメラルポート       |                           |
        |     「54321」を割り当て)         |                           |
        |                                  |                           |
        |                                  |<-- 2. SYN-ACK -------------| (復路)
        |                                  |                           |
        |                                  |   ★ここでNACLのInboundで  |
        |                                  |     「Port 1024-65535」が   |
        |                                  |     許可されていないと...   |
        |                                  |                           |
        |<-- 3. (ドロップされる) -----------|X-- 3. (遮断) --------------| (通信断!)

外部APIからの戻りパケット(SYN-ACK や ACK、データパケット)は、宛先ポートとしてクライアント側が使ったエフェメラルポート(例: 54321)を指定して返ってきます。
つまり、サブネットのNACLのインバウンドルールにおいて、宛先ポート 1024-65535(または 32768-65535)からの流入を許可していないと、すべての戻りパケットがNACLでドロップされるのです。

—

4. 実践:NACLの正しい設定とTerraformコード例

それでは、このエフェメラルポート問題をクリアしつつ、最小権限の原則に基づいた堅牢なNACLを設定するための具体的なコードを見てみましょう。

以下は、AWSのTerraform環境におけるセキュアなプライベートサブネット向けNACLの定義例です。

# プライベートサブネット用ネットワークACLの定義
resource "aws_network_acl" "private_subnet_nacl" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "prod-private-subnet-nacl"
  }
}

# ==========================================
# インバウンドルール (Inbound Rules)
# ==========================================

# 1. 内部からの通信に対する「戻りパケット」を受け入れるためのエフェメラルポート許可
resource "aws_network_acl_rule" "inbound_ephemeral" {
  network_acl_id = aws_network_acl.private_subnet_nacl.id
  rule_number    <span class="o">=</span> 100
  egress         <span class="o">=</span> false # インバウンド
  protocol       <span class="o">=</span> "tcp"
  rule_action    <span class="o">=</span> "allow"
  cidr_block     <span class="o">=</span> "0.0.0.0/0"
  from_port      <span class="o">=</span> 1024
  to_port        <span class="o">=</span> 65535
}

# 2. 内部のロードバランサーや踏み台からの特定のインバウンド通信(例: 内部アプリ用ポート 8080)
resource "aws_network_acl_rule" "inbound_app" {
  network_acl_id = aws_network_acl.private_subnet_nacl.id
  rule_number    <span class="o">=</span> 200
  egress         <span class="o">=</span> false
  protocol       <span class="o">=</span> "tcp"
  rule_action    <span class="o">=</span> "allow"
  cidr_block     <span class="o">=</span> "10.0.0.0/16" # 自VPC内からのトラフィックのみ許可
  from_port      <span class="o">=</span> 8080
  to_port        <span class="o">=</span> 8080
}

# ==========================================
# アウトバウンドルール (Outbound Rules)
# ==========================================

# 1. 外部API(HTTPS)へのリクエストアウトバウンド許可
resource "aws_network_acl_rule" "outbound_https" {
  network_acl_id = aws_network_acl.private_subnet_nacl.id
  rule_number    <span class="o">=</span> 100
  egress         <span class="o">=</span> true # アウトバウンド
  protocol       <span class="o">=</span> "tcp"
  rule_action    <span class="o">=</span> "allow"
  cidr_block     <span class="o">=</span> "0.0.0.0/0"
  from_port      <span class="o">=</span> 443
  to_port        <span class="o">=</span> 443
}

# 2. 外部API(HTTP)へのリクエストアウトバウンド許可(必要に応じて)
resource "aws_network_acl_rule" "outbound_http" {
  network_acl_id = aws_network_acl.private_subnet_nacl.id
  rule_number    <span class="o">=</span> 110
  egress         <span class="o">=</span> true
  protocol       <span class="o">=</span> "tcp"
  rule_action    <span class="o">=</span> "allow"
  cidr_block     <span class="o">=</span> "0.0.0.0/0"
  from_port      <span class="o">=</span> 80
  to_port        <span class="o">=</span> 80
}

# 3. DNS問い合わせ(Route 53 Resolver等)のためのUDP/TCP 53許可
resource "aws_network_acl_rule" "outbound_dns_tcp" {
  network_acl_id = aws_network_acl.private_subnet_nacl.id
  rule_number    <span class="o">=</span> 120
  egress         <span class="o">=</span> true
  protocol       <span class="o">=</span> "tcp"
  rule_action    <span class="o">=</span> "allow"
  cidr_block     <span class="o">=</span> "0.0.0.0/0"
  from_port      <span class="o">=</span> 53
  to_port        <span class="o">=</span> 53
}

resource "aws_network_acl_rule" "outbound_dns_udp" {
  network_acl_id = aws_network_acl.private_subnet_nacl.id
  rule_number    <span class="o">=</span> 130
  egress         <span class="o">=</span> true
  protocol       <span class="o">=</span> "protocol" # UDPを示す場合はプロトコル番号「17」を指定
  protocol       <span class="o">=</span> "17"
  rule_action    <span class="o">=</span> "allow"
  cidr_block     <span class="o">=</span> "0.0.0.0/0"
  from_port      <span class="o">=</span> 53
  to_port        <span class="o">=</span> 53
}

この設定のポイントは、インバウンド側のルール番号 100 にて、プロトコル tcp で from_port = 1024, to_port = 65535 を許可している点です。これが抜けると、外部への 443 通信の往路は通っても、戻りパケットがすべてNACLの網にかかって落とされてしまいます。

—

5. アプリケーションコード側での挙動とデバッグTips

インフラ側でNACLの設定ミスやポートの抜けがあるとき、アプリケーション側ではどのようなエラーとして観測されるでしょうか。

ここでは、よく使われる言語やツールでの挙動と、現場で使えるデバッグ手順を共有します。

1. curl による疎通確認

コンテナや踏み台サーバーにログインできる状態であれば、まずは curl に -v(詳細出力)オプションを付けて実行します。

curl -v https://api.example.com/v1/health

NACLやSGの戻りパケットブロックでハマっている場合、以下のように Trying <IP_address>... で止まったまま数秒〜数十秒経過し、最終的にタイムアウトします。

*   Trying 203.0.113.50:443...
* Connected to api.example.com (203.0.113.50) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
*(ここで数秒〜数十秒フリーズし、タイムアウトする)*
* curl: (28) Connection timed out

2. Python (requests) での実装例とタイムアウト例外

バックエンドのPythonアプリから外部APIを叩く際、接続タイムアウトのハンドリングを行わないと、コネクションプールの枯渇につながる危険があります。

import requests
from requests.exceptions import Timeout, RequestException

def call_external_api():
    url = "https://api.example.com/v1/data"
    try:
        # 接続(connect)および読み込み(read)にタイムアウトを設定する
        # NACLミスによるタイムアウトの場合は connect 側で引っかかる
        response = requests.get(url, timeout=(3.1, 5.0))
        
        response.raise_for_status()
        return response.json()

    except Timeout as e:
        print(f"ネットワークタイムアウトが発生しました(NACLやSGのブロック、ルーティングミスの可能性): {e}")
    except RequestException as e:
        print(f"APIリクエストに失敗しました: {e}")

if __name__ == "__main__":
    call_external_api()

3. トラブルシューティングの決定版:VPC Flow Logs の活用

「設定を見直したはずなのに、まだ繋がらない……」という泥沼にハマったとき、推測で設定をいじるのはエンジニアの恥です。客観的な事実(パケットの生死)を VPC Flow Logs で確認しましょう。

CloudWatch Logs Insights などを使って、該当するENI(Elastic Network Interface)のログをクエリします。

fields @timestamp, srcAddr, dstAddr, srcPort, dstPort, protocol, packets, bytes, action, logStatus
| filter dstAddr like '203.0.113.50'
| sort @timestamp desc
| limit 20

ここで、action カラムに注目してください。

  • ACCEPT: セキュリティグループもNACLも通過しています。
  • REJECT: どこかでブロックされています。もし dstPort が 443 で action が REJECT ならセキュリティグループかNACLのアウトバウンド、あるいは相手サーバー側の問題です。逆に、外部からの戻りパケットの srcPort(こちらのエフェメラルポート)に対するインバウンドが REJECT になっている場合は、まさに今回解説したNACLのエフェメラルポート抜けが原因です。

—

6. まとめ:セキュアかつ堅牢なネットワーク設計のために

今回は、NACLとセキュリティグループの併用時に見落としがちな「エフェメラルポート」の仕様とトラブルシューティングについて解説しました。

今回の重要な学びを振り返ってみましょう。

1. NACLはステートレス:往路が通っても復路のパケットは自動で通らない。戻りパケットを受け入れるためのインバウンドルールが必ず必要。
2. エフェメラルポートの範囲:クライアント側の一時ポート(1024-65535)をNACLのインバウンドで許可し忘れると、すべての外部通信がタイムアウトする。
3. 迷ったらVPC Flow Logsを見る:推測するな、計測せよ。REJECT ログのポート番号を見れば、どの方向の通信がどこで阻まれているかが一目瞭然。

クラウドインフラのセキュリティを厳格にすることは素晴らしい取り組みですが、レイヤーごとの挙動(ステートフル vs ステートレス)を理解せずに闇雲にルールを締め上げると、自分たちのアプリケーションの首を絞める結果になります。

この記事が、あなたの現場のネットワークトラブルを華麗に解決し、より堅牢なクラウドアーキテクトとしての歩みを支える一助となれば幸いです。

それでは、良きSREライフを!

コメント

タイトルとURLをコピーしました