AWS SAMを使って効率的にNAT Gatewayを管理する

はじめに

こんにちは、エンジニアの石橋です。 今回はNAT GatewayをLambda(Python)で任意の時間だけ立ち上げ、管理するお話です。

VPC Lambdaにおいて、何もしなければLambdaにパブリックIPアドレスが付与されていないためインターネットへの接続が失敗します。 そのため、下図の構成でNAT Gatewayを通してインターネットへ接続するようにします。*1

AWSアーキテクチャ

しかしながら、NAT Gatewayは2024年8月時点、アジアパシフィック(東京)において、何もしなくてもおいておくだけで0.062USD/時 掛かります*2。これは約46USD/月=6670円/月になります。(1ドル=145円計算) 1日1回しかLambdaを起動しない、もしくは夜間の業務時間外は確実に動かさない等の状況であれば、NAT Gatewayを使わない間は止めておけば節約できるというのが、今回のモチベーションです。 ただし、節約と言ってもその準備に時間が掛かると元も子もないので、IaCを利用したサーバーレスアプリケーションを構築するフレームワークを利用し、今回はAWS SAM*3で管理できるようにしました。この記事ではVPCを0から作りながら、NATGatewayをLambdaでできるようになるまでを紹介していきます。

ちなみにネットワークインターフェースにElastic IP(EIP)をアタッチする方法もありますが*4、今回はLambdaをプライベートサブネット上で動かす前提なので触れません。

下準備

まずはcloudformationでVPC、SecurityGroup, Elastic IPを用意します。 コードは以下に用意してあり、これを活用していきます。

github.com

後述のrainコマンドやsamコマンドを利用するにあたり、AWSへの認証手続きについては省略するので適宜設定をお願いします。*5

VPC作成

Cloudformationのテンプレートファイル( cloudformation/network.yaml )とrainコマンドを利用してVPCとサブネットを用意します。

$ rain deploy cloudformation/network.yaml dev-network

dev-networkはスタック名なので、適宜自由に変更しても構いません。 環境毎に分けられるようにEnvパラメータを設定していますが、今回はデフォルトのdevのままにしておきます。

rainコマンドの詳細については以下を参考にしてください。

github.com

SecurityGroup作成

VPCと同様にセキュリティグループも設定します。このセキュリティグループはNAT Gatewayが立ち上がった後に正しくインターネットに接続できるかどうかを確認するためにLambdaに設定するもので、便宜上用意しています。見ての通り、特にAWSリソースへのトラフィックを特別に制御しているわけではなく、デフォルトのままとなっています。NAT Gatewayを通してインターネットに接続するLambdaにおいて、AWSリソースへのトラフィックを制御したい場合にここを編集するとよいです。

Resources:
  SecurityGroupLambda:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub "${Env}-sg-lambda"
      GroupDescription: security group for lambda
      VpcId:
        Fn::ImportValue: !Sub "${Env}VPCID"
      Tags:
        - Key: Name
          Value: !Sub "${Env}-sg-lambda"
        - Key: Env
          Value: !Ref Env

cloudformation/sg.yamlを利用して以下のコマンドでセキュリティグループを設定します。

$ rain deploy cloudformation/sg.yaml dev-sg

EnvパラメータはVPCで設定したものと同じものにしておきます。(デフォルトではdev)

Elastic IP作成

Elastic IPはNAT Gateway立ち上げ毎に用意するのではなく、予め用意しておきます。常時パブリックIPv4アドレスを維持するコストとして、約3.7ドル/月(=536円/月) 掛かりますが、毎回NAT Gatewayが作成される毎にLambdaのパブリックIPv4アドレスが変わることはありません。

cloudformation/eip.yamlを利用して以下のコマンドでElastic IPを用意します。

$ rain deploy cloudformation/eip.yaml dev-eip

EnvパラメータはVPCで設定したものと同じものにしておきます。(デフォルトではdev)

NAT Gatewayを管理するLambda

Lambdaのコード

↓Nat Gatewayを作成するLambdaのコード

import os
import boto3

ec2 = boto3.client('ec2')

def lambda_handler(event, context):
    public_subnet_id = os.environ['PUBLIC_SUBNET_ID']
    eip_allocation_id = os.environ['EIP_ALLOCATION_ID']
    rtb_id = os.environ['RTB_ID']

    response = ec2.create_nat_gateway(
        AllocationId=eip_allocation_id,
        SubnetId=public_subnet_id
    )
    nat_id = response['NatGateway']['NatGatewayId']
    ec2.get_waiter('nat_gateway_available').wait(NatGatewayIds=[nat_id])

    response = ec2.create_route(
        DestinationCidrBlock = '0.0.0.0/0',
        NatGatewayId = nat_id,
        RouteTableId = rtb_id
    )

↓Nat Gatewayを削除するLambdaのコード

import os

import boto3

ec2 = boto3.client('ec2')

def lambda_handler(event, context):
    rtb_id = os.environ['RTB_ID']

    response = ec2.delete_route(
        DestinationCidrBlock = '0.0.0.0/0',
        RouteTableId = rtb_id
    )

    filters = [{'Name': 'state', 'Values': ['available']}]
    response = ec2.describe_nat_gateways(Filters=filters)
    for rec in response['NatGateways']:
        natgw = rec['NatGatewayId']
        ec2.delete_nat_gateway(NatGatewayId=natgw)

Lambdaの実行Role

natgw_scheduler/up.pynatgw_scheduler/down.pyを実行する上で必要最低限のものだけを以下のように設定しています。

  FunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${Env}-natgw-scheduler-execution-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: !Sub ${Env}-natgw-scheduler-policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - logs:TagResource
                Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*
              - Effect: Allow
                Action:
                  - ec2:CreateRoute
                  - ec2:DeleteRoute
                  - ec2:CreateNatGateway
                  - ec2:DescribeNatGateways
                  - ec2:DeleteNatGateway
                Resource: "*"

SAMのtemplate.yaml

NAT Gatewayを作成する natgw_scheduler/up.py のSAMの設定は以下のとおりです。

Resources:
  UpNatGWFunction:
    Type: AWS::Serverless::Function
    Properties:
      Timeout: 180
      FunctionName: !Sub "${Env}-nat-up"
      CodeUri: natgw_scheduler/
      Handler: up.lambda_handler
      Role: !GetAtt FunctionRole.Arn
      Events:
        Schedule1:
          Type: ScheduleV2
          Properties:
            Name: !Sub "${Env}-nat-up-schedule"
            ScheduleExpression: "cron(50 6 * * ? *)"
            ScheduleExpressionTimezone: "Asia/Tokyo"

ScheduleExpression "cron(50 6 * * ? *)"ScheduleExpressionTimezone "Asia/Tokyo"の設定により毎日、日本時間の6:50にNAT Gatewayが起動するようにLambdaが実行されます。 natgw_scheduler/up.py のLambdaの処理は約2分程度掛かるので、 Timeout: 180タイムアウトを3分に設定しています。

NAT Gatewayを削除するnatgw_scheduler/down.pyのSAMの設定は以下のとおりです。

Resources:
  # (中略)
  DownNatGWFunction:
    Type: AWS::Serverless::Function
    Properties:
      Timeout: 180
      FunctionName: !Sub "${Env}-nat-down"
      CodeUri: natgw_scheduler/
      Handler: down.lambda_handler
      Role: !GetAtt FunctionRole.Arn
      Events:
        Schedule1:
          Type: ScheduleV2
          Properties:
            Name: !Sub "${Env}-nat-down-schedule"
            ScheduleExpression: "cron(10 23 * * ? *)"
            ScheduleExpressionTimezone: "Asia/Tokyo"

こちらは毎日日本時間の23:10にNAT Gatewayが削除するようにLamdbaが実行されます。

今回は1日1回NAT Gatewayを作成するように設定していますが、1日に何度もNAT Gatewayの作成と削除を繰り返すと以下の費用が発生する可能性があるのでご注意ください。*6

USD 0.10: キャリア IP アドレスのリマップ 1 回あたり – 1 か月間で 100 リマップを超える追加分

環境変数は以下にてdev-networkのスタックの出力よりインポートして設定しています。

Globals:
  Function:
    Environment:
      Variables:
        PUBLIC_SUBNET_ID:
          Fn::ImportValue: !Sub "${Env}PublicSubnet1"
        RTB_ID:
          Fn::ImportValue: !Sub "${Env}PrivateRouteTable"
        EIP_ALLOCATION_ID:
          Fn::ImportValue: !Sub "${Env}ElasticIPAllocationId"

SAMでデプロイ

template.yamlがある場所で以下のコマンドを実行します。これでNAT Gatewayを管理するLambdaのデプロイが完了します。

$ sam build --use-container
$ sam deploy --guided --capabilities CAPABILITY_NAMED_IAM

デプロイ時に実行ロールを設定しているため--capabilities CAPABILITY_NAMED_IAMオプション*7が必要になります。 要求されるパラメータについてはデフォルト値で問題ありません。

テスト

実はtemplate.yamlにテスト用のLambda関数(dev-hello-world)を設定してあるので、インターネットへ接続するdev-hello-worldというLambda関数がすでにデプロイされています。

import json
import requests

def lambda_handler(event, context):
    response = requests.get("https://www.industry-one.com/")
    if response.status_code != 200:
        return {
            "statusCode": 500,
            "body": json.dumps({
                "message": "failed to get industry-one.com",
            }),
        }

    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "hello world",
        }),
    }

Resources:
  # (中略)
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub "${Env}-hello-world"
      CodeUri: hello_world/
      Handler: app.lambda_handler
      VpcConfig:
        SubnetIds:
          - Fn::ImportValue: !Sub "${Env}PrivateSubnet1"
          - Fn::ImportValue: !Sub "${Env}PrivateSubnet2"
        SecurityGroupIds:
          - Fn::ImportValue: !Sub "${Env}SecurityGroupLambda"

上記Lambda関数でNAT Gatewayがないときとあるときの挙動の確認を行います。

NAT Gatewayがない場合

以下のようにdev-hello-worldを実行してもインターネットへの接続ができず、タイムアウトします。

NAT Gatewayがある場合

以下のように成功します。

Cleanup

NAT Gatewayが作成されている場合は適宜削除してください。その後、以下のコマンドで今回利用した環境を削除することができます。

$ sam delete --stack-name "natgw-scheduler"
$ rain rm dev-eip
$ rain rm dev-sg
$ rain rm dev-network

おわりに

以上で、プライベートサブネットで動作するLambda関数のインターネット接続をするためのNAT GatewayをLambdaで管理できました。プロジェクトの性質に依存しますが、時折Lambdaを使って処理をするケースがあるので、必要があるときのみNAT Gatewayを作成することでNAT Gatewayの費用を抑えることができると思います。

インダストリー・ワンでは一緒に働く仲間を募集しています。エンジニアの採用は以下より、

herp.careers

もし、より弊社について知りたい方は、以下よりカジュアル面談のご応募お待ちしております。

herp.careers