低コストで実現するプライベートサブネット
こんにちは。天久保 Advent Calender 2023 の 8 日目の記事です。
AWS を活用する際には、多くのケースで VPC の導入が必要です。そして、プライベートサブネットという、デフォルト VPC には存在しないある種のサブネットが必要になる場合が往々にしてあります。IPv4 でプライベートサブネットを実用するには NAT のためのコンポーネントが必要ですが、AWS マネージドな NAT ゲートウェイは個人利用にはコストが高すぎるという問題がありました。本記事では、自前で NAT のためのコンポーネントを用意することで低コストでプライベートサブネットを実現する方法について記述します。
パブリックサブネットとプライベートサブネット
そのルーティングテーブルにインターネットゲートウェイへのルートを持っているサブネットをパブリックサブネットと、そうではないものをプライベートサブネットと呼びます。Amazon VPC の利用開始時に自動的に作成されているデフォルト VPC には、パブリックサブネットのみが存在します。
インターネットゲートウェイはパブリック IP アドレスをもったリソースにインターネットアクセスを提供する Amazon VPC のコンポーネントです。無料です。
パブリックサブネットのデフォルトルートにインターネットゲートウェイを設定することで、パブリックサブネット内でパブリック IP を割り当てられたリソースは直ちにインターネットへのアクセスを得ることができます。そのため、パブリックサブネットでは基本的に自動的にパブリック IP アドレスを割り当てる設定を有効にします。
パブリックサブネットのみの場合。矢印はデフォルトルート
さて、先述の通り、プライベートサブネットのルーティングテーブルにはインターネットゲートウェイが存在しません。VPC 内での通信はもちろんプライベートサブネットからも可能ですが、一方で実用上プライベートサブネットからインターネットへのアクセス[1]も行いたい場合がほとんどでしょう。プライベートサブネットのリソースはプライベート IP アドレスしか持っていないため、インターネットに出るためには NAT を行う必要があります。
そこで、プライベートサブネットからインターネットに接続するために使用できる、NAT ゲートウェイという Amazon VPC のコンポーネントが用意されています。NAT ゲートウェイをパブリックサブネットに配置し、これをプライベートサブネットのデフォルトルートとすることで、プライベートサブネットからインターネットに接続できるようになります。
プライベートサブネットがある場合。プライベートサブネットのデフォルトルートが NAT ゲートウェイに向いている
NAT ゲートウェイは個人利用には高額
さて、NAT ゲートウェイは利用可能な(リソースが存在する)時間とデータ処理量に対して課金されます。
データ処理量は置いておいても、利用時間に対する課金が ap-northeast-1 では $0.062/h ということで、1 ヶ月 NAT ゲートウェイを作成したままにすると、データ処理量に関わらず $0.062×24×30 = $44.64/mo のコストがかかることになります。これは、個人で小規模に AWS を利用している場合では受け入れ難いコストです。
プライベートサブネットのユースケース
基本的にパブリックサブネットを利用すればプライベートサブネットは必要がないと思われるかもしれません。しかし、プライベートサブネット、もといプライベート IP のみを持つリソースのインターネット接続が欲しい理由がいくつか存在します。
- セキュリティ/コンプライアンス上の理由。セキュリティグループやファイアウォールによる保護とあわせて、インターネットからの予期しないアクセスがないことを確実にしたい。
- AWS Lambda 関数を VPC 内に起動する場合。パブリックサブネット内に起動してもパブリック IP が ENI に自動付与されません。
- 2024/2 から、自動付与される IPv4 アドレスに一定のコストがかかるようになるため、コスト観点でパブリック IP を自動付与しないようにするモチベーションがあります。
https://aws.amazon.com/jp/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/
IPv6 について
IPv6 CIDR range を割り振っているサブネットで IPv6 を割り当てる場合、基本的にそれはパブリック IP アドレスとして扱うことができるため、デフォルトルートをインターネットゲートウェイに向けてやることですぐにインターネット接続を得ることができます。逆にプライベートサブネットにおける IPv4 のような、外部から接続できず内側からインターネットにのみ出ることができる挙動を実現するために、Egress-Only インターネットゲートウェイというコンポーネントが用意されています。Egress-Only インターネットゲートウェイは無料です。
すべてのワークロードで IPv6 のみを使い、IPv4 での通信をする必要がなければ、NAT ゲートウェイは必要ありません。しかし、実際のところ外部との通信に IPv4 が必要になることは往々にしてあります。IPv6 で通信ができる AWS サービスも限られており、IPv6 only なサブネットのみでやっていくのはまだ難しい印象です。
NAT インスタンスを NAT ゲートウェイの代わりに使う
NAT ゲートウェイを使わずとも NAT ができれば良く、実際に EC2 インスタンスを利用して NAT ゲートウェイと同様の機能を達成できます。パブリックサブネット内に起動しパブリック IP アドレスを割り当て、入ってきたパケットを NAPT して転送する設定を iptables で書くとそのようなインスタンスを作ることができます。このような NAT インスタンスをプライベートサブネットのデフォルトルートに設定してあげることで、プライベートサブネットからインターネットへの通信が NAPT され、インターネットと通信できるようになります。なお、NAT インスタンスは送信元/送信先がそのインスタンスではないトラフィックを扱うので、EC2 の送信元/送信先チェックを無効にする必要があります。
NAT インスタンスの機能を持つ AWS 公式の NAT AMI はサポートが終了してしまっているのですが、上のようにして自分で AMI をつくってもいいですし、またサードパーティのパブリック AMI が存在しているのでそれを使うこともできます。
このような NAT インスタンスを安価なインスタンスタイプで用意することで、安価にプライベートサブネットが実現できます。当然 NAT ゲートウェイと比べて可用性や帯域幅が劣りますが、個人の小規模な利用ならこれで十分です。
たとえば t4g.nano のオンデマンドインスタンスで起動した場合、コンピューティングコストは $0.0054×24×30 = $3.888/mo で済みます。
ASG 化してさらに省コスト化
スポットインスタンスを利用できれば、さらなる省コスト化が見込めます。しかし、スポットインスタンスとしてインスタンスを起動すると、キャパシティ不足でインスタンスが突然停止/終了させられてしまう場合があります。そういった度に手動でインスタンスを起動し直してルーティングテーブルなど設定しなおすのは非常にめんどうです。
また、一日中 NAT インスタンスを起動しておく必要がない場合もあるでしょう。日中のみ起動していれば十分ということもあると思います。自動でインスタンスの起動/終了が制御できると良いです。
そこで、NAT インスタンスを Auto Scaling グループで制御することで、スポットインスタンスの利用や自動起動/終了を実現できます。Auto Scaling グループを使うことで、インスタンスを一定の数に自動的に保つことができ、今回でいえばスポットインスタンスが中断された際に自動的に新しいインスタンスを起動できます。必要なインスタンス数は自動で制御でき、たとえば日中のみインスタンスを起動しておくといったこともできます。
次のようにして、NAT インスタンスを起動するための Auto Scaling グループを作成できます。スポット価格が安い t4g.micro のスポットインスタンスを使うようにしています。
$ SECURITY_GROUP_ID="" # security group that accepts all packet from private subnet
$ PUBLIC_SUBNET_IDS="" # comma–separated list of public subnet IDs
$ KEY_NAME=""
$ cat <<EOF >lt-nat.json # uses fck-nat-amzn2-hvm-1.2.1-20230209-arm64-ebs for example
{
"ImageId": "ami-02b8e2c2067fd714e",
"InstanceType": "t4g.micro",
"KeyName": "$KEY_NAME",
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": true,
"DeviceIndex": 0,
"Groups": [ "$SECURITY_GROUP_ID" ],
"NetworkCardIndex": 0
}
]
}
EOF
$ aws ec2 create-launch-template \
--launch-template-name nat \
--launch-template-data file://lt-nat.json
$ cat <<EOF >asg-policy-nat.json
{
"InstancesDistribution": {
"OnDemandPercentageAboveBaseCapacity": 0
},
"LaunchTemplate": {
"LaunchTemplateSpecification": {
"LaunchTemplateName": "nat",
"Version": "\$Latest"
}
}
}
EOF
$ aws autoscaling create-auto-scaling-group \
--auto-scaling-group-name nat \
--min-size 0 \
--max-size 1 \
--vpc-zone-identifier "$PUBLIC_SUBNET_IDS" \
--mixed-instances-policy file://asg-policy-nat.json
次のようにしてスケジュールされたアクションを作成し、日中のみ起動するようにできます。
$ aws autoscaling put-scheduled-update-group-action --scheduled-action-name sunrise \
--auto-scaling-group-name nat --recurrence "0 8 * * *" --time-zone 'Asia/Tokyo' --desired-capacity 1
$ aws autoscaling put-scheduled-update-group-action --scheduled-action-name sunset \
--auto-scaling-group-name nat --recurrence "0 20 * * *" --time-zone 'Asia/Tokyo' --desired-capacity 0
起動/終了処理の自動化
さて、NAT インスタンスはその起動時にルーティングテーブルへの登録と送信元/送信先チェックの無効化[2]を行う必要があります。これはインスタンス内から行ってもいいのですが、監視がしにくいのと、単にそのような AMI を作るのが面倒です。そこで、Amazon EC2 Auto Scaling の機能であるライフサイクルフックを用いてこれを実現できます。
EventBridge でライフサイクルフックのイベントを受け取り、起動/終了に対応した処理を行う Lambda 関数を実行します。Lambda 関数はインスタンスの起動時にはルーティングテーブルへの登録と送信元/送信先チェックの無効化を行い、ライフサイクルアクションを完了します。インスタンスの終了時にはルーティングテーブルからインスタンスを削除してライフサイクルアクションを完了します。なお、インスタンスの入れ替え時には一時的にルーティングテーブルからデフォルトルートがなくなる期間があり、その間はプライベートサブネットでインターネットとの通信ができなくなります。
インスタンスの起動/終了処理を行う Lambda 関数のコード例を次に示します。AWS の API を呼び出すだけの単純なものです。Lambda 関数のロールには ec2:CreateRoute
, ec2:DeleteRoute
, ec2:ModifyInstanceAttribute
, autoscaling:CompleteLifecycleAction
の許可ポリシーが必要です。
次のようにしてインスタンスの起動と終了に対応するライフサイクルフックを作成できます。
$ aws autoscaling put-lifecycle-hook --lifecycle-hook-name launching \
--auto-scaling-group-name nat \
--heartbeat-timeout 3600 \
--lifecycle-transition "autoscaling:EC2_INSTANCE_LAUNCHING"
$ aws autoscaling put-lifecycle-hook --lifecycle-hook-name terminating \
--auto-scaling-group-name nat \
--heartbeat-timeout 3600 \
--lifecycle-transition "autoscaling:EC2_INSTANCE_TERMINATING"
そして、次のようにしてライフサイクルフックによって起動する Lambda 関数を設定できます。EventBridge のルールとターゲットに加えて、Lambda 側で EventBridge に対する呼び出し許可を与える必要があります。
$ ASG_ARN="" # ARN of ASG you previously created
$ FUNCTION_ARN="" # ARN of your handler function
$ cat <<EOF >event-pattern.json
{
"source": [
"aws.autoscaling"
],
"detail-type": [
"EC2 Instance-launch Lifecycle Action",
"EC2 Instance-terminate Lifecycle Action"
],
"resources": [
"$ASG_ARN"
]
}
EOF
$ rule_arn=$(
aws events put-rule --name nat-lifecycle-hook-handler \
--event-pattern file://event-pattern.json \
--query RuleArn --output text
)
$ aws events put-targets --rule nat-lifecycle-hook-handler \
--targets "Id"="1","Arn"="$FUNCTION_ARN"
$ aws lambda add-permission --function-name "$FUNCTION_ARN" \
--action lambda:InvokeFunction \
--statement-id AllowExecutionFromEventBridge \
--principal events.amazonaws.com \
--source-arn "$rule_arn"
こうして、NAT インスタンスをさらに低コストでデプロイできました。たとえば t4g.micro のスポット価格が 2023-12-19 時点で $0.0016/h だったので、半日だけ起動することにすればコンピューティングコストは $0.0016×24×30 / 2 = $0.576/mo 程度に抑えられるはずです。
まとめ
NAT インスタンスを自前で用意し、ほぼ無視できるような低コストでプライベートサブネットを実現できました。
Discussion