EC2に複数のENIをアタッチしてみたら、昔遭遇した問題が自動で解消されるようになっていました。
ただ、少し込み入ったことをやるとやはり同じ問題が起きるので、そのあたり含めて紹介したいと思います。
要約
- ちょっと前、ENIをEC2に複数アタッチしたらルーティングでハマり、すべてのENIが使えるようになるまでに苦労した
- 今はcloud-initが自動でPolicy Based Routingを設定してくれるので便利になった
- でもDockerコンテナとか使うと同じ問題が起きることがあるので気をつけよう
複数のENIを使う理由
私の場合、複数のIPアドレスを使った試験をしたかったからです。
同一VPC内にある試験対象へ複数のIPアドレスを使ってアクセスする必要がありました。
EC2ではインスタンスタイプによって、インスタンスにアタッチできるENIの数とENI1つに割り当て可能なIPアドレスの数が決まっています。
例えばt2.smallでは最大ENI数が3、ENIあたりのIPアドレス数が4、最大で12個使うことが可能です。
ENIあたり4個なので、5個以上使いたければENIを2つ、9個以上なら3つアタッチする必要があります。
これ以外の理由だと1つでは性能が足りない場合や、今だと複数のVPCにつなぐ場合にも使えます。
ハマった原因
通常、ルーティングでは宛先IPアドレスのみで送り出すインターフェースを決定します。
加えてENIにはデフォルトで送信元IPアドレスをチェックし、それが異なる場合パケットをドロップする機能がONになっています。
試験対象は同一VPC内・別サブネットのマシン1つで、全てのIPアドレスから同一の試験対象に接続する必要があったのですが、eth1側の送信元IPアドレスになっているIPパケットも全てENI0に送られ、ENI0でドロップされていました。
解決方法
この問題の解決方法は2つあります。
- ENIの送信元チェック機能を無効化
この機能をOFFにすることで、送信元IPアドレスが原因でのドロップをしなくなります。
性能が必要ないのであればこの方法で十分ですが、OSの逆経路フィルターでドロップされる場合があるのと、ENI0・ENI1双方のセキュリティグループの影響を受けて上手く通信できないこともあります。
- Policy Based Routingを設定
OSによってはPolicy Based Routing(PBR)が使えます。
これは送信先IPアドレスだけではなく、送信元IPアドレス、入力元のNIC、TCPのポート番号など、様々な条件でルーティング先を決定する機能です。
Linuxの場合は、送信元IPアドレスや入力元のネットワークインターフェース、nftablesでつけたマーク等を条件に、参照するルーティングテーブルを変えることで実現します。
例えば以下のように設定すれば、送信元IPアドレス10.0.3.158のパケットはeth1から送信されます。
# 10.0.3.158が送信元IPアドレスのパケットはtable 100を参照
ip rule add from 10.0.3.158 table 100
# table 100にeth1から送信するルールを設定
ip route add default via 10.0.3.1 dev eth1 table 100
ip route add 10.0.3.0/24 dev eth1 table 100
私はPBRを設定して解決しました。
現在のEC2ではcloud-initが自動で設定してくれる
少なくとも2023年前半、Ubuntu22.04のイメージでENIをアタッチした時は上記の問題が発生していました。
この件で記事を書こうと思って久々に最新のUbuntuイメージ(24.04)で動作を検証したところ、なんとENIをアタッチした瞬間に勝手にPBRが設定されました。
ubuntu@ip-10-0-1-40:~$ ip rule show
0: from all lookup local
32765: from 10.0.3.158 lookup 101 proto static
32766: from all lookup main
32767: from all lookup default
32765のルールで、送信元IPアドレス10.0.3.158で101番のテーブルを参照するように設定されています。
ubuntu@ip-10-0-1-40:~$ ip route show table 101
default via 10.0.3.1 dev enX1 proto static onlink
10.0.3.0/24 dev enX1 proto static scope link
こちらが101番のルーティングテーブルで、enX1経由でデフォルトゲートウェイ10.0.3.1にパケットがルーティングされるようになっています。
自動付与の細かい動きはよくわからないのですが、udevの所にcloud-initへhookする設定があるようで、udevでネットワークインターフェースが追加されたのを検知してcloud-initを呼び出しているようです。
SUBSYSTEM=="net", RUN+="/usr/lib/cloud-init/hook-hotplug"
cloud-initの方ではこのあたり設定でネットワークを再設定しているんじゃないでしょうか。
network:
dhcp_client_priority: [dhcpcd, dhclient, udhcpc]
renderers: ['netplan', 'eni', 'sysconfig']
activators: ['netplan','eni', 'network-manager', 'networkd']
便利になったものです。
SNATを使うとやっぱり起こる
実はまだまだこの問題は起こります。EC2内部で送信元アドレス変換(SNAT)する場合です。
具体的にはDockerコンテナを使って外部と通信することを例に考えてもらうと分かりやすいです。
Dockerコンテナは内部にプライベートIPアドレスを持ち、パケットが外部へ送られるタイミングでホストIPアドレスへSNATします。
また、すごく大雑把に書くと、Linux内部でIPパケットは、ルーティング前処理→ルーティング→ルーティング後処理という順で処理されます。
この時SNATはルーティング後処理で実行されます。PBRを設定している場合も例外ではなく、SNAT前にルーティングテーブルが参照されます。
(詳細知りたい人はこことか参考 https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks)
したがって、ルーティングされる時点ではDockerコンテナのプライベートIPアドレスを元に判断され、ホストIPアドレスで設定したPBRは上手く機能しません。
このルーティングの仕組みとSNATのタイミングを把握しておかないと、pingは両方で通るのにhttpでのアクセスは片方からだけ全く応答なしといった地獄みたいな状態になることもあります。
以下はENIを2つアタッチしたマシンでnginxコンテナを立ち上げ、別のマシンからpingとcurlでアクセスした際の例です。
ubuntu@ip-10-0-2-225:~$ ping 10.0.1.40 # 接続可
PING 10.0.1.40 (10.0.1.40) 56(84) bytes of data.
64 bytes from 10.0.1.40: icmp_seq=1 ttl=64 time=3.90 ms
64 bytes from 10.0.1.40: icmp_seq=2 ttl=64 time=2.94 ms
ubuntu@ip-10-0-2-225:~$ ping 10.0.3.158 # 接続可
PING 10.0.3.158 (10.0.3.158) 56(84) bytes of data.
64 bytes from 10.0.3.158: icmp_seq=1 ttl=64 time=3.35 ms
64 bytes from 10.0.3.158: icmp_seq=2 ttl=64 time=2.41 ms
ubuntu@ip-10-0-2-225:~$ curl http://10.0.1.40 # 接続可
<!DOCTYPE html>
<html>
<head>
(中略)
ubuntu@ip-10-0-2-225:~$ curl http://10.0.3.158 # 接続不可
curl: (28) Failed to connect to 10.0.3.158 port 80 after 132479 ms: Couldn't connect to server
これを解消しようとするとより複雑なnftablesの設定とPBRの設定が必要になります。
(セッション毎にnftablesでマークして、マーク毎にPBRを設定するとかすれば解消できそう)
ENIを複数アタッチする機会はそれほどないかもしれませんが、どこかで遭遇するかもしれないので、こんな問題があることは知っておいても損はないでしょう。