【AWS】NAT Gatewayのスケジュール管理方法【Lambda+EventBridge】

NAT Gatewayスケジュール管理方法のアイキャッチ画像 AWS技術
記事内に広告が含まれています。

AWSのコスト、気になりますよね?
AWSのコスト管理においてなにかと金食い虫なのがNAT Gatewayです。本記事では、NAT Gatewayをコストを削減する方法を紹介します。

【結論】

NAT Gatewayを利用する時間帯のみ、LambdaとEventBridgeを使用して、NAT Gatewayの起動と停止(正確には作成と削除)をスケジュール管理します。

目的

NAT Gatewayのコストを削減するために行います。

NAT Gatewayは利用(経由してインターネットに通信)していなくても、稼働しているだけで料金がかかります。それもけっこう。

NAT ゲートウェイの料金
NAT ゲートウェイをプロビジョンすると、NAT ゲートウェイが使用可能な時間 1 時間ごと、およびゲートウェイが処理するデータ 1 GB ごとに課金されます。
https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/nat-gateway-pricing.html

NAT ゲートウェイあたりの料金 (USD/時)
USD 0.062
https://aws.amazon.com/jp/vpc/pricing

つまり全く使わず放置していても、1ヶ月で、
0.062(USD/時)×720(時/月)=45.26USD!…1$147.63円換算で6,682円!(2025年8月6日時点)

常時インターネットに接続している必要があれば話は別ですが、

  • 検証用に作成したので使いたい時だけ稼働させたい
  • 夜間は止まっていても問題ない

といった場合は、費用削減のためにNAT Gatewayを削除したほうがいいんですね。

ですがいちいち削除して使いたいときに作成するのは手間ですしマネコンでやるのはミスのもとです。

そのため自動化をしたいと思ったのが作成の経緯になります。

前提

個人で検証用に作成したので、下記のような前提のものと作成しました。

  • NAT Gatewayは単一AZにのみ配置します(費用削減)
  • EventBridgeをトリガーとして平日の日中帯(月〜金、9:00〜18:00)だけ稼働させます
  • AWS CLIを用いて手動実行も可能です
  • VPC、プライベートサブネット、プライベートサブネット用ルートテーブルは作成済みの想定です
  • プライベートサブネットのルートテーブルで0.0.0.0/0になるのがNAT Gatewayの想定です(下記に後述するように動的に作成、削除します)
  • NAT Gatewayに付随するリソースとして下記も制御しています。
    • EIP: NAT Gatewayの作成、削除に連動して作成、削除します(費用削減)
    • ルートテーブル: 0.0.0.0/0宛のNAT Gatewayへのルーティングも連動して作成、削除します
  • 個人開発で作成したものです。ご自由に利用可能ですがAWSのリソースを作成、削除するものですので注意して利用ください。なお利用に伴う一切の責任は負いません。

Lambdaの作成

Lambdaソースを以下に示します。

index.js
// Lambda実行関数 NatGatewayを削除するNode.js
const AWS = require('aws-sdk'); //CommonJS(require)なのでESMインポートに移行必要
const { // utils.jsから関数を取得
  waitForNatGatewayDeletion,
  waitForNatGatewayAvailable,
  getNatGatewayIdByTag,
  getEipAllocationIdByTag
} = require('./utils');
const ec2 = new AWS.EC2();

exports.handler = async (event) => {
  const action = event.action;  // "start" or "stop" ←Lambda関数のinputで指定する

  try {
    if (action === "stop") {
      // NAT GatewayのIDを取得
      const natGatewayId = await getNatGatewayIdByTag("lambda-nat-gateway");
      if (!natGatewayId) {
        throw new Error("NAT Gateway not found");
      }

      // ルートテーブルからNAT Gatewayへのルートを削除
      try {
        await ec2.deleteRoute({
          RouteTableId: [TODO_ルートテーブルのIDを入力],
          DestinationCidrBlock: "0.0.0.0/0"
        }).promise();
        console.log("Route to NAT Gateway deleted");
      } catch (e) {
        console.warn("Route deletion failed (possibly already deleted:)");
      }

      // NAT Gateway削除
      await ec2.deleteNatGateway({ NatGatewayId: natGatewayId }).promise();

      // NAT Gatewayが削除完了するまで待つ
      await waitForNatGatewayDeletion(natGatewayId);
      console.log(`Nat Gateway ${natGatewayId} stopped`);

      // EIPの割当ID取得と解放
      const allocationId = await getEipAllocationIdByTag("lambda-natgateway-eip");
      if (allocationId) {
        await ec2.releaseAddress({AllocationId: allocationId }).promise();
        console.log(`Elastic IP ${allocationId} released`);
      } else {
        console.warn("ALLOCATION_ID is not set, or EIP not released");
      }
    } else if (action === "start") {
      // Elastic IPを新規割当
      const eipResult = await ec2.allocateAddress({
        Domain: "vpc",
        TagSpecifications: [
          {
            ResourceType: "elastic-ip",
            Tags: [
              { Key: "Name", Value: "lambda-natgateway-eip"},
              { Key: "Description", Value: "Lambdaで自動生成 NAT Gateway用のElastic IP(固定グローバルIP)"}
            ]
          }
        ]
      }).promise();
      console.log(`Elastic IP allocated: ${eipResult.AllocationId}`);

      // NAT Gatewayの作成
      const result = await ec2.createNatGateway({
        AllocationId: eipResult.AllocationId,
        SubnetId:     [TODO_サブネットのIDを入力],
        TagSpecifications: [
          {
            ResourceType: "natgateway",
            Tags: [
              { Key: "Name", Value: "lambda-nat-gateway"},
              { Key: "Description", Value: "Lambdaで自動生成 パブリックサブネット(AZ:1a)に配置されたNAT Gateway"}
            ]
          }
        ]
      }).promise();
      console.log(`NAT Gateway started: ${JSON.stringify(result)}`);

      // NAT Gatewayが利用可能になるまで待つ
      await waitForNatGatewayAvailable(result.NatGateway.NatGatewayId);

      // ルートテーブルにNAT Gatewayへのルートを追加
      await ec2.createRoute({
        RouteTableId: [TODO_ルートテーブルのIDを入力],
        DestinationCidrBlock: "0.0.0.0/0",
        NatGatewayId: result.NatGateway.NatGatewayId
      }).promise();
      console.log(`Route added: 0.0.0.0/0 -> NAT Gateway ${result.NatGateway.NatGatewayId}`);

    } else {
      throw new Error(`Unknown action: ${action}`);
    }
  } catch (err) {
    console.error(err);
    throw err;
  }
};
utils.js

const AWS = require('aws-sdk');
const ec2 = new AWS.EC2();

/**
 * NAT Gatewayが削除完了するまで最大3分間待機する関数
 * @param {string} natGatewayId - NAT GatewayのID
 * @returns {Promise<void>} 削除完了時は何も返さず、タイムアウト時は例外を投げる
 */
async function waitForNatGatewayDeletion(natGatewayId) {
  const maxRetries = 18;    // 3分
  for (let i = 0; i < maxRetries; i++) {
    const describe = await ec2.describeNatGateways({
      NatGatewayIds: [natGatewayId]
    }).promise();

    const state = describe.NatGateways[0]?.State;
    if (state === "deleted" || state === "failed" || !state) {
      return;
    }
    console.log(`Waiting for NAT Gateway deletion... (state: ${state})`);
    await new Promise(resolve => setTimeout(resolve, 10000));
  }
  throw new Error("Timeout: NAT Gateway was not deleted");
}

/**
 * NAT Gatewayが利用可能になるまで最大3分間待機する関数
 * @param {string} natGatewayId - NAT GatewayのID
 * @returns {Promise<void>} 利用可能時は何も返さず、タイムアウト時は例外を投げる
 */
async function waitForNatGatewayAvailable(natGatewayId) {
  const maxRetries = 30;    // 30回 * 10秒 = 5分 
  for (let i = 0; i < maxRetries; i++) {
    const describe = await ec2.describeNatGateways({
      NatGatewayIds: [natGatewayId]
    }).promise();

    const state = describe.NatGateways[0]?.State;
    if (state === "available") {
      console.log(`Nat Gateway is available: ${natGatewayId}`);
      return;
    } else if (state === "failed") {
      throw new Error(`NAT Gateway failed to become available: ${natGatewayId}`);
    } else if (!state) {
      throw new Error(`NAT Gateway state is undefined (ID: ${natGatewayId})`);
    } else {
      console.log(`Waiting for NAT Gateway to become available... (state: ${state})`);
    }
    await new Promise(resolve => setTimeout(resolve, 10000)); // 10秒待機
  }
  throw new Error("Timeout: NAT Gateway did not become available in time");
}

/**
 * 指定したタグ値でNAT GatewayのIDを取得する関数
 * @param {string} tagValue - NAT GatewayのNameタグ値
 * @returns {Promise<string|undefined>} NAT GatewayのID(見つからなければundefined)
 */
async function getNatGatewayIdByTag(tagValue) {
  console.log(`Searching for NAT Gateway with tag: ${tagValue}`);
  const response = await ec2.describeNatGateways({
    Filter: [
      { Name: "tag:Name", Values: [tagValue] },
      { Name: "state", Values: ["available", "pending"] }
    ]
  }).promise();
  const nat = response.NatGateways[0];
  console.log(`Found NAT Gateway: ${nat?.NatGatewayId}`);
  return nat?.NatGatewayId;
}

/**
 * 指定したタグ値でElastic IPのAllocationIdを取得する
 * @param {string} tagValue - Elastic IPのNameタグ値
 * @returns {Promise<string|undefined>} AllocationId(見つからなければundefined)
 */
async function getEipAllocationIdByTag(tagValue) {
  console.log(`Searching for Elastic IP with tag: ${tagValue}`);
  const response = await ec2.describeAddresses({
    Filters: [
      { Name: "tag:Name", Values: [tagValue] }
    ]
  }).promise();
  const eip = response.Addresses[0];
  console.log(`Found Elastic IP: ${eip?.AllocationId}`);
  return eip?.AllocationId;
}

module.exports = {
  waitForNatGatewayDeletion,
  waitForNatGatewayAvailable,
  getNatGatewayIdByTag,
  getEipAllocationIdByTag
};

ソースの置き換え箇所

実行環境に合わせてソースの下記部分を修正する必要があります。
(ソース内に[TODO: ]と記入しています。)

  • RouteTableId: [TODO_ルートテーブルのIDを入力]
    →NAT Gatewayへのルーティングが書かれているルートテーブルのIDで置き換えます
  • SubnetId: [TODO_サブネットのIDを入力]
    →NAT Gatewayが作られるサブネットのIDで置き換えます

Lambda関数の作成

Lambda関数は下記条件で作成します。

  • 名前: nat-gateway-scheduler
  • ランタイム: Node.js 20.x
  • アーキテクチャ: x86_64
  • アクセス権限: 下記カスタムIAMロールを作成し使用します
IAMロール
{
    "Statement": [
        {
            "Action": [
                "ec2:DescribeNatGateways",
                "ec2:DescribeAddresses",
                "ec2:CreateNatGateway",
                "ec2:DeleteNatGateway",
                "ec2:DescribeSubnets",
                "ec2:AllocateAddress",
                "ec2:ReleaseAddress",
                "ec2:CreateTags",
                "ec2:CreateRoute",
                "ec2:DeleteRoute"
            ],
            "Effect": "Allow",
            "Resource": "*"
        },
        {
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents",
                "sns:Publish"
            ],
            "Effect": "Allow",
            "Resource": "*"
        }
    ],
    "Version": "2012-10-17"
}
  • Lambdaソースをzipファイルにまとめて(後述)Lambdaにアップロードします。

注意事項等

  • Node.js 18xから、Lambda関数へAWS SDKを同梱する必要があります。
    下記コマンドを実行し「node_modules」を取得します。
    取得した「node_modules」は、次項のlambda.zipへ含めましょう。
npm install aws-sdk

lambda.zipの作り方

  • Lambda関数、「node_modules」、package.jsonを「lambda.zip」へまとめます。
  • 上記が同一ディレクトリにある場合は下記コマンドで作成可能です。
zip -r lambda.zip index.js utils.js node_modules/ package.json

参考:手動実行方法

AWS CLIから下記コマンドを実行することで手動実行(テスト)可能です。
ポイントは後述するEventBridgeでも設定していますが、
タイムアウトを300秒と長めに設定することです。
(デフォルトの180秒=3分だとNAT Gatewayの作成、削除が終わらず、再試行ポリシーにより3回繰り返し(=NAT Gatewayが3個作成される)になります)

# 作成
aws lambda invoke --function-name nat-gateway-scheduler --payload '{"action":"start"}' --cli-binary-format raw-in-base64-out --cli-read-timeout 300 result.json

# 削除
aws lambda invoke --function-name nat-gateway-scheduler --payload '{"action":"stop"}' --cli-binary-format raw-in-base64-out --cli-read-timeout 300 result.json

EventBridgeルールの作成

EventBridgeルールは下記条件で作成します。

起動用ルール

  • 名前: natgateway-start-schedule
  • ルールタイプ: スケジュール
  • cron式: 0 0 ? * MON-FRI *
  • ターゲット: 作成したLambdaを指定します
  • ターゲット入力: JSON(定数)で下記を指定
{
  "action": "start"
}
  • イベントの有効期間: 300秒
  • 再試行回数: 0回

停止用ルール

  • 名前: natgateway-stop-schedule
  • ルールタイプ: スケジュール
  • cron式: 0 9 ? * MON-FRI *
  • ターゲット: 作成したLambdaを指定します
  • ターゲット入力: JSON(定数)で下記を指定
{
  "action": "stop"
}
  • イベントの有効期間: 300秒
  • 再試行回数: 0回

まとめ

Lambda関数とEventBridgeで金食い虫のNAT Gatewayと上手に付き合いましょう!

コメント

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