Techfirm Cloud Architect Blog

テックファーム株式会社クラウドインフラグループのブログ

Auto Scaling GroupのライフサイクルフックでEIP割り当て

EC2 Auto ScalingのAuto Scaling Groupから起動したEC2サーバにEIPを割り当てる方法を紹介します。
サーバの起動時に割り当てるサーバで動くスクリプトの情報はよく出てきますが、今回はライフサイクルフックを利用しています。

はじめに

小さいシステムでコストを削減していくと、最後はNAT Gatewayの費用すら気になってきます。
そこで、EC2サーバでプロキシサーバを立てた方がいいのではないかと思いたちました。
それを実現するための1歩として、以下の条件でAuto Scaling Groupで起動したEC2サーバにEIPを割り当てます。

  • サブネットでパブリックIPv4アドレスの自動割り当てはしたくない
  • あらかじめ取得しておいたEIPを割り当てたい(接続先での接続元制限を考慮)

調べると、EC2サーバにスクリプトを配置しておいたりユーザデータを使ってEIPを割り当てる方法はすぐに出てきます。
しかし、そのためにはEC2サーバにec2:AssociateAddress権限をつける必要があります。

今回は、EC2サーバの権限を最小限にするためにライフサイクルフックを利用してLambda関数で外から設定するようにしてみました。

ライフサイクルフックとは

今回は、Auto Scaling Groupのライフサイクルフックを利用します。 ライフサイクルフックを利用することにより、EC2サーバが起動するときと停止するときに任意のアクション(Lambda関数等)を実行できます。

ライフサイクルの動作については以下のドキュメントに詳しくあります。

今回は起動時のライフサイクルフック(EC2_INSTANCE_LAUNCHING)を利用します。

起動時、Auto Scaling GroupでEC2インスタンスは以下のような状態遷移をとります。

  1. Pending: インスタンスの起動を開始
  2. Pending:Wait: ライフサイクルアクションを実行を通知して待機
  3. Pending:Proceed: ライフサイクルアクションが完了し起動処理を続行
  4. InService: 起動処理完了

ここで注意が必要なのは、EC2インスタンスの状態ではなくてAuto Scaling Group上での状態であるということです。
Pending:Waitの時点で、ELBには登録されていませんがEC2インスタンスは作成されrunningになっています。
また、Auto Scaling GroupのInServiceはELBでのInServiceというわけでないので、実際にサービスへ組み込まれているとは限りません。 (EC2サーバが起動していればヘルスチェックの猶予期間中でもAuto Scaling GroupではInServiceです)

ライフサイクルフックの話に戻ります。
Pending:Wait状態になるとライフサイクルアクションの通知がされ、カスタムアクションが開始されます。(何も実行せずに一定時間待機するだけということも可能です)
すべてのライフサイクルアクションが完了するかタイムアウトするまでインスタンスの起動処理は待機します。
(実行されたカスタムアクションから定期的にハートビートを送ることで、タイムアウトしないようにすることも可能です)

ライフサイクルアクション開始時の通知方法は以下の通り、EventBridge、SNS、SQSの3種類があります。
これらの通知をトリガーにして自分の実行したい処理を起動します。

現在のベストプラクティスは、EventBridgeでライフサイクルイベントに対するルールを作成することです。

構成

構成図

EC2インスタンスが終了すればEIPは解放されるため、今回は起動時のEIP割り当てのみ実行します。
Lambda関数からEIPを設定をするための構成は以下の通りです。

構成図

まず、Auto Scaling GroupによりEC2インスタンスが作成され(①)、準備ができるとライフサイクルイベントをEventBridgeへ通知します(②)。
次に、EventBridgeのルールがライフサイクルイベントに反応し、EIPを割り当てるためのLambda関数を起動します(③)。
起動されたLambda関数は空いているEIPを見つけて起動処理中のEC2インスタンスへ割り当て(④-1)、成功したらライフサイクルアクションの完了をAuto Scaling Groupへ通知します(④-2)。
処理のどこかで異常が発生して④-2の完了通知が届かない場合は、ライフサイクルアクションがタイムアウトしてAuto Scaling Groupは当該EC2インスタンスの起動処理を中断して作成したEC2インスタンスも終了します。

それぞれの構成の設定ポイントについて説明します。

EIP

EC2サーバに割り当てるEIPはあらかじめ作成しておきます。

作成は以下のページを参考にしてください。

Terraformを使う場合の例は以下の通りです。

resource "aws_eip" "fwproxy_eip_pool" {
  count = var.fwproxy_eip_pool_size
  domain = "vpc"
  tags = {
    Name        = "${var.env}-fwproxy-${count.index+1}"
    Application = "${var.env } ForwardProxy"
  }
}

設定のポイントは、Lambda関数が対象EIPを見つけるためのタグ(この場合はApplicationタグ)を設定することです。
また、以下の点を考慮して多めに用意しておく必要があるかと思います。

  • リリース時に一時的にサーバ台数が増える(リリース方式による)
  • 障害時等にEIP解放前に次のインスタンスが起動される可能性がある
  • 将来的にサーバ台数が増える可能性がある(EIPを後から増やすのが難しい場合)

最後の「EIPを後から増やすのが難しい場合」というのは、接続先のシステムで接続元IPアドレス制限をしている場合等です。 接続先システムでの接続元制限設定変更に時間やコストがかかる場合は注意してください。

ライフサイクルフック

対象のAuto Scaling Groupにスケールアウト用のライフサイクルフックを設定します。
設定方法は以下のページを参考にしてください。

ここで、ライフサイクルフックの設定は以下の通りにします。

  • ライフサイクルフック名はEIP割り当て用の任意の名前を設定
  • ライフサイクルの移行インスタンス起動を設定
  • ハートビートタイムアウトは割り当ての試行時間を設定(12分等)
  • デフォルトの結果ABANDONを設定

デフォルトの結果はハートビートタイムアウトでタイムアウトしたときの動作です。
タイムアウトしたときはEIPの割り当てに失敗しているので起動処理を中断させます。

今回のLambda関数の実装はハートビートを送らないため、ハートビートタイムアウトはライフサイクルアクション開始からのタイムアウト時間になります。
これは、割り当て処理に失敗したときの再試行をどのくらい実施するかにより値を決定してください。(実際の再試行はLambda関数の設定になります)
たとえばLambda関数で10分間ほどの再試行を試みる場合、Lambda関数起動の遅延等も考慮して11分や12分を設定します。

ライフサイクルアクションがタイムアウトして起動処理を中断しても、Auto Scaling Groupは次のサーバを起動します。
そのため、早めにタイムアウトさせて起動失敗させた方が監視や動作の分かりやすさの点から運用しやすいかと思います。

最後に、Terraformでの設定例は以下のようになります。

resource "aws_autoscaling_group" "fwproxy_server" {
  ....<設定省略>....

  initial_lifecycle_hook {
    name                 = "${var.env}-fwproxy-associate-eip"
    lifecycle_transition = "autoscaling:EC2_INSTANCE_LAUNCHING"
    default_result       = "ABANDON"
    heartbeat_timeout    = 12*60
  }
}

ライフサイクルフックが設定されていない状態でインスタンスに起動されたくないので、aws_autoscaling_lifecycle_hookリソースではなくaws_autoscaling_groupリソースのinitial_lifecycle_hookブロックで設定します。
この方法だと、最初の作成時にしか反映されません。設定値の変更がTerraformに検知されないので気を付けてください。

Lambda関数のコード

ライフサイクルアクションで実行されるLambda関数のPython3系での実装例です。
(コメントや一部処理を削除しています)

import boto3
from botocore.config import Config
import json
import os
import time

EIP_TAG_NAME = 'Application'
EIP_TAG_VALUE = os.environ['EIP_{}'.format(EIP_TAG_NAME)]

TRY_INTERVAL = 30

boto_config = Config(retries={'mode':'legacy', 'total_max_attempts':6})
ec2 = boto3.client('ec2', config=boto_config)
autoscaling = boto3.client('autoscaling', config=boto_config)

def get_available_eips(target_tags):
    # 対象EIPを検索
    describe_filters = []
    for k in target_tags.keys():
        filterkey = 'tag:{}'.format(k)
        filter = { 'Name': filterkey, 'Values': [target_tags[k]] }
        describe_filters.append(filter)
    describe_result = ec2.describe_addresses(Filters=describe_filters)
    # 未割当のものを抽出
    result = []
    result_addresses = describe_result['Addresses']
    for v in result_addresses:
        if 'AssociationId' in v or 'InstanceId' in v:
            continue
        result.append(v['AllocationId'])
    return result

def associate_address(instance_id):
    # EIPリスト取得
    eip_list = get_available_eips(target_tags={EIP_TAG_NAME: EIP_TAG_VALUE})
    if not eip_list:
        print('Warning: 割り当て可能なEIPが見つかりません。')
        return False
    # EIP割り当てを試行
    for eip in eip_list:
        try:
            associate_response = ec2.associate_address(AllocationId=eip, InstanceId=instance_id, AllowReassociation=False)
            return True
        except Exception as e:
            print('Warning: {eip} - {instance} 割り当て失敗: {message}'.format(eip=eip, instance=instance_id, message=str(e)))
    return False

def handler(event, context):
    detail = event['detail']
    action_token = detail['LifecycleActionToken']
    instance_id = detail['EC2InstanceId']
    lifecycle_hook = detail['LifecycleHookName']
    auto_scaling_group = detail['AutoScalingGroupName']
    # 成功するまで繰り返す
    associate_result = False
    while True:
        try:
            associate_result = associate_address(instance_id)
        except Exception as e:
            print('ERROR: {classname}("{message}")'.format(
                      classname = e.__class__.__name__,
                      message = e
                 ))
        if associate_result:
            # 成功時は通知して終了
            autoscaling.complete_lifecycle_action(LifecycleHookName=lifecycle_hook, 
                                                  AutoScalingGroupName=auto_scaling_group,
                                                  LifecycleActionToken=action_token,
                                                  LifecycleActionResult='CONTINUE')
            break
        # 次のループ前にスリープ
        time.sleep(TRY_INTERVAL)
    return {'Result': 'CONTINUE'}

大まかな処理の流れは以下の通りです。

  1. EIPをタグで検索し、どこにも割り当てられてないEIPの一覧を作成する
  2. 1で取得した空いているEIPをインスタンスへ関連付ける
  3. EIPの関連付けが成功していたらライフサイクルアクションの成功を通知して終了する
  4. 1~2でエラーが出ていたら30秒待機してから1に戻る

1~2はassociate_address関数で実装していて、成功するまでこの処理が無限にループしています。 EIPが空いていない場合や何らかの事情でEC2サーバへの割り当てに失敗する場合、LambdaのタイムアウトによりLambda関数は終了します。
そのため、Auto Scaling Groupへ失敗の通知はしていません。失敗はライフサイクルアクションのタイムアウトによります。

1でEIPを検索するときのタグはApplication固定で、Lambda関数にはEIP_Application環境変数から検索する値を取得します。
これは、EIPに実際に設定したタグ名や値とあわせる必要があります。

Lambda関数の設定

IAMロールを用意し、実際にLambda関数を作成します。
詳しい設定方法は以下を参考にしてください。

Lambda関数の実行ロールには、AWSLambdaBasicExecutionRole等のマネージドポリシーの他に以下の権限が必要です。

Action Resource 概要
autoscaling:CompleteLifecycleAction AutoScalingGroup ライフサイクルアクションの完了を通知する
ec2:DescribeAddresses * EIP一覧を取得する
ec2:AssociateAddress EIP/EC2インスタンス EIPをEC2サーバへ関連付ける

ec2:AssociateAddressはリソースにEIPとEC2サーバの両方が必要です。
EC2サーバについてはConditionを利用すれば、タグを使って対象サーバを絞り込み可能です。

その他にLambda関数自体の設定は、環境変数、タイムアウト、非同期呼び出しの設定に注意します。
AWSのAPIを叩くだけなのでVPCで動かす必要はありません。

環境変数にはEIP_Applicationを設定してください。値はEIPのApplicationタグに設定したものと同じ値です。

タイムアウトと非同期呼び出しは以下のような設定項目があります。
(下表のメニューは、Lambda関数画面の設定タブを開いたときの左側のメニュー名です)

メニュー 項目 値例
一般設定 タイムアウト 10分
非同期呼び出し 最大有効期間 10分
非同期呼び出し 再試行 2

EIPをEC2サーバへ割り当てできないときは無限ループするスクリプトなので、どのくらい再試行するかはLambda関数のタイムアウトで決まります。
これは、ライフサイクルアクションのタイムアウトと同じか、それより短く設定しておきます。
ライフサイクルアクションがタイムアウトしたあとに成功しても、Lambda関数はエラーになり実行時間がムダになるためです。

Lambda関数はEventBridgeから非同期呼び出しされますが、処理の詳細は以下のドキュメントに詳しくあります。

内部のイベントキューに入れられてLambda関数が実行されます。エラー時の再試行処理もイベントキューを使って行われます。
最大有効期間はキューに存在する最大期間で、デフォルトは6時間です。
再試行はコードがエラーを返したときやタイムアウトのときの再試行回数で、デフォルトは2です。 同時実行数によるスロットリングやシステムエラーのときは再試行回数に関係無く最大有効期間の間だけ再試行されます。

ライフサイクルアクションのタイムアウト後にこの関数を実行してもエラーになるだけなので、最大有効期間は短くしておきましょう。
再試行はデフォルトである2のままでいいと思います。 Lambdaの実行タイムアウト時は最大有効期間を過ぎているため、再試行の設定はそのままでもタイムアウト時の再試行はされません。

Terraformでこれらの設定をする場合、タイムアウトの設定はaws_lambda_functionリソースにあります。
対して、非同期呼び出しの設定はaws_lambda_function_event_invoke_configリソースを利用して行います。
以下が設定の例です。

resource "aws_lambda_function" "fwproxy_asg_associate_eip" {
   ....<設定省略>....
  timeout          = 10*60
  environment {
    variables = {
      EIP_Application = "${var.env } ForwardProxy"
    }
  }
}
resource "aws_lambda_function_event_invoke_config" "fwproxy_asg_associate_eip" {
  function_name                = aws_lambda_function.fwproxy_asg_associate_eip.function_name
  maximum_event_age_in_seconds = 10*60
  maximum_retry_attempts       = 2
}

(何分間か分かりやすいように秒数は分×60秒という書き方をしています)

EventBridge

最後にLambda関数を起動するEventBridgeルールを作成します。
ルールの作成方法は以下のページを参考にしてください。

イベントパターンは以下のようになります。

{
  "detail": {
    "LifecycleHookName": ["<ライフサイクルフック名>"]
  },
  "detail-type": ["EC2 Instance-launch Lifecycle Action"],
  "source": ["aws.autoscaling"]
}

<ライフサイクルフック名>に作成したライフサイクルフックの名前を書きます。
この他に、必要に応じてAutoScalingGroupNameも条件に入れることができます。
(ライフサイクルフック名だけでユニークになるのでAutoScalingGroupNameは省略しています)

ターゲットには作成したLambda関数を指定してください。 このとき、イベントの最大有効期間はLambda関数の最大有効期間と同じように短くしておきます。

Terraformでの設定例は以下の通りです。

resource "aws_cloudwatch_event_rule" "fwproxy_asg_associate_eip" {
  name           = "${var.env}-fwproxy-asg-associate-eip"
  event_pattern  = jsonencode({
    source      = ["aws.autoscaling"]
    detail-type = ["EC2 Instance-launch Lifecycle Action"]
    detail      = {
      LifecycleHookName    = ["${var.env}-fwproxy-associate-eip"]
    }
  })
}
resource "aws_cloudwatch_event_target" "fwproxy_asg_associate_eip" {
  rule           = aws_cloudwatch_event_rule.fwproxy_asg_associate_eip.name
  target_id      = "FWProxyAssociateEIP"
  arn            = aws_lambda_function.fwproxy_asg_associate_eip.arn
  retry_policy {
    maximum_event_age_in_seconds = 10*60
    maximum_retry_attempts       = 185
  }
}

EventBridgeルールから呼び出すため、aws_lambda_permissionリソースでLambda関数への実行許可も設定してください。

サーバ側の注意事項

ここまででAWS側の設定は完了しました。

最後に、起動されたEC2サーバ側で注意点があります。
ライフサイクルフックの説明で書いた通り、ライフサイクルアクションを実行しているときはサーバの起動処理がすでに始まっています。
最終的にEIPが設定されるとしても、OSの起動処理中はインターネット接続できない時間があるということです。 (ネットワーク接続が完了してもパブリックIPアドレスがないためにインターネット接続できない状態となります)

起動時からインターネット接続が必要なサービスがある場合は、インターネットへ接続できるようになるまで待機する必要があります。
たとえば、AmazonLinux 2023のようなSystemdを利用するOSなら以下のような方法があります。

  1. インターネットへの疎通を確認できるまでループするスクリプトを作成する
    • curlコマンドやaws sts get-caller-identityコマンド等で疎通を確認
  2. 上記のスクリプトをSystemdのサービスとして登録する
    • Type=oneshotRemainAfterExit=yesを設定
    • TimeoutStartSecにタイムアウト時間を設定(オプション)
    • InstallセクションのRequiredByにインターネット接続が必須のサービスを設定
    • InstallセクションのWantedByにインターネット接続のあった方がいいサービスを設定
    • UnitセクションのBeforeに上記のサービスを設定

作成したサービスがインターネットへの疎通を待つため、後続のサービスはインターネット接続確認後に起動を開始できます。

終わりに

今回は、Auto Scaling Groupからの起動時にLambda関数からEIPを割り当てる方法を紹介しました。
実のところ、Lambda関数で実行していることをサーバ起動時にサーバ側で実行するようにする方が実装は簡単です。
しかし、今回の構成の方がたとえば以下のようなメリットがあります。

  • EC2サーバのOSに依存しない
  • EC2サーバからAPIを叩く必要がない
    • EC2サーバにEIP関連の許可が不要
    • パブリックIPv4アドレスの自動割り当てやVPCエンドポイントが不要
  • 何もしなくてもEIP割り当てに関するログをCloudWatch Logsに残せる
    • EC2サーバのログを残すためにはEIP割り当て処理前にログ転送の仕組みの起動が必要
  • 起動失敗時、起動処理で失敗したのか起動後に何か問題がおきたのか分かりやすい
    • EIP割り当て失敗はAuto Scaling Groupのアクティビティに起動キャンセルとして記録される(ライフサイクルアクションでの問題とすぐにわかる)
    • サーバ側で実施する場合、Auto Scaling Group上は起動成功後の異常検知となるのでEC2サーバのログの解析が必要

EC2サーバへ直接EIPを割り当てたいことは少ないかもしれません。(通常はNAT Gatewayを利用するのがいいと思います)
しかし、EIPを設定する機会があったときにはこの方法も検討して頂ければと思います。