Route 53 Hosted Zoneに登録されているAuto ScalingするEC2インスタンスのレコードを動的に更新してみた

ユーザーデータを使えば大概のことはできる
2023.10.09

IPアドレスが変わったら動的にレコードを更新させたい

こんにちは、のんピ(@non____97)です。

皆さんはAuto ScalingするEC2インスタンスのRoute 53 Hosted Zoneのレコードを動的に更新したいなと思ったことはありますか? 私はありません。

以下の記事で紹介しているようなAuto ScalingするEC2インスタンスのホスト名を固定にしたい場面に直面したことがないためです。

上述のような対応が必要になった場合、割り当てたホスト名について名前解決できないと困る場面があると考えます。

当然ですがAuto ScalingでEC2インスタンスが増減すると、その度にプライベートIPアドレスは変動します。(頑張れば以下記事で紹介している手法で固定することはできますがオススメしません)

動的に変動するIPアドレスに対応するために、Auto Scalingが動作する度に手動でRoute 53 Hosted Zone上のレコードを変更するのはイケていません。今回はユーザーデータで対応するレコードを更新するように実装します。

いきなりまとめ

  • ユーザーデータを使えばRoute 53 Hosted Zone上のレコードを簡単に更新することができる
  • その場合IAMロールではroute53:ChangeResourceRecordSetsNormalizedRecordNamesなどの条件キーでEC2インスタンスが操作できるレコードを制限しよう。

使用するユーザーデータ

先述の記事で使用したユーザーデータを活用します。ホスト名が連番で設定されるようにします。

OSにホスト名を設定したあと、Route 53 Hosted Zoneにレコードの追加 or 更新を行います。

EC2インスタンスからRoute 53 Hosted Zoneのレコードを操作する場合、操作可能なレコードを絞りたいところです。その様な場合は以下記事で紹介している様にroute53:ChangeResourceRecordSetsNormalizedRecordNamesなどの条件キーを使うと良いでしょう。

使用したユーザーデータは以下のとおりです。__hostname_prefix____hostname_domain__など変数名の前後にアンダースコアを2つ付与している文字列はプレースホルダーです。AWS CDKでsynthする際に置換するための目印として使用しています。

#!/bin/bash

set -ue

# Redirect /var/log/user-data.log and /dev/console
exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1

declare -r max_retry_interval=8                       # リトライ間隔の最大値 ループする度に0から指定した値までランダムな値でsleepする
declare -r max_retries=16                             # リトライ回数の上限
declare -r hostname_prefix=__hostname_prefix__        # ホスト名のプレフィックス
declare -r hostname_domain=__hostname_domain__        # ホスト名のドメイン
declare -r filter_tag_key=__filter_tag_key__          # ホスト名を連番にしたいEC2インスタンスをフィルターする際に使用するタグのキー
declare -r filter_tag_value=__filter_tag_value__      # ホスト名を連番にしたいEC2インスタンスをフィルターする際に使用するタグの値
declare -r hosted_zone_id=__hosted_zone_id__          # Route 53 Hosted ZoneのID

# インスタンスIDとプライベートIPアドレスの取得
token=$(curl \
  -s \
  -X PUT \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" \
  "http://169.254.169.254/latest/api/token"
)
instance_id=$(curl \
  -s \
  -H "X-aws-ec2-metadata-token: $token" \
  "http://169.254.169.254/latest/meta-data/instance-id"
)

ip_address=$(curl \
  -s \
  -H "X-aws-ec2-metadata-token: $token" \
  "http://169.254.169.254/latest/meta-data/local-ipv4"
)

for i in $(seq 1 $max_retries); do
  echo "======================================================="

  # ホスト名を連番にしたいEC2インスタンスに設定されているホスト名の配列
  hostname_list=($(aws ec2 describe-instances \
    --filters Name=tag:$filter_tag_key,Values=$filter_tag_value \
      Name=instance-state-code,Values=0,16 \
    --query "Reservations[].Instances[].[Tags[?Key=='HostName'].Value][]" \
    --output text \
    | grep $hostname_prefix \
    | sort -n
  ))

  # 設定するホスト名の連番の候補
  candidate_hostname_number="1"
  
  for hostname in "${hostname_list[@]}"; do
    # ホスト名の末尾の数字を取得
    hostname_number=$(echo $hostname | grep -o -E '[0-9]+$')
    
    echo "--------------------------------------------------"
    echo candidate_hostname_number : $candidate_hostname_number
    echo hostname : $hostname
    echo hostname_number : $hostname_number

    # ホスト名の末尾の数値 と 設定するホスト名の連番の候補 が異なるか
    if [[ $((10#$hostname_number)) -ne $candidate_hostname_number ]]; then
      # 異なる場合は空いているホスト名と判断
      break
    fi
    # 同じである場合は、設定するホスト名の連番の候補を変更する
    candidate_hostname_number=$(($candidate_hostname_number+1))
  done
  
  # 設定するホスト名の候補
  candidate_hostname=$(printf "%s%02d" $hostname_prefix $candidate_hostname_number)
  
  echo "--------------------------------------------------"
  echo candidate_hostname : $candidate_hostname

  # ホスト名の候補をHostNameタグに割り当て
  aws ec2 create-tags \
    --resources $instance_id \
    --tags Key=HostName,Value=$candidate_hostname

  # 同じHostNameタグを割り当てたEC2インスタンスのIDを取得
  instance_ids=($(aws ec2 describe-instances \
    --filters Name=tag:$filter_tag_key,Values=$filter_tag_value \
      Name=tag:HostName,Values=$candidate_hostname \
      Name=instance-state-code,Values=0,16 \
    --query "Reservations[].Instances[].[InstanceId]" \
    --output text
  ))
  
  echo "--------------------------------------------------"

  # 自身が設定したホスト名が一意であるか
  # = 取得したEC2インスタンスIDが自身のEC2インスタンスIDである かつ EC2インスタンスIDの配列の長さが1
  if [[ "${instance_ids[0]}" == "$instance_id" && "${#instance_ids[@]}" == 1 ]]; then
    # OSのホスト名を設定とRoute 53 Private Hosted Zoneにレコードを追加/更新してループを抜ける
    hostname="${candidate_hostname}.${hostname_domain}"

    # OSホスト名の設定
    echo "Set HostName ${hostname}"
    hostnamectl set-hostname ${hostname}
    
    echo "hostnamectl :
      $(hostnamectl)"

    # 既にレコードが登録されているか確認
    rrset_exists=$(dig $hostname +short)

    # レコードが登録されていない場合は追加を
    # 登録されている場合は更新を行う
    rrset_action=""
    if [[ -z $rrset_exists ]]; then
      rrset_action=CREATE
    else
      rrset_action=UPSERT
    fi

    change_resource_record_sets_input=$(cat <<EOF
    {
      "Changes": [
        {
          "Action": "$rrset_action",
          "ResourceRecordSet": {
            "Name": "${hostname}",
            "Type": "A",
            "TTL": 300,
            "ResourceRecords": [
              {
                "Value": "$ip_address"
              }
            ]
          }
        }
      ]
    }
EOF
)

    # レコードの追加 or 更新
    aws route53 change-resource-record-sets \
      --hosted-zone-id "$hosted_zone_id" \
      --change-batch "$change_resource_record_sets_input"
    break
  else
    # ホスト名が重複していると判断
    # HostNameタグを削除
    aws ec2 delete-tags \
      --resources $instance_id \
      --tags Key=HostName

    # リトライ間隔を計算し、計算した秒数sleep
    retry_interval=$(($RANDOM % $max_retry_interval))
    echo "Failed to allocate hostname $candidate_hostname, retrying in $retry_interval seconds..."
    sleep $retry_interval
  fi
done

# 最大リトライ回数に達してもホスト名が決まらない場合
if [[ $i == $max_retries ]]; then
  echo "Failed to allocate a unique hostname after $max_retries retries. Please manually assign a hostname."
fi

動作確認

実際に動作確認をしてみます。

動作確認を行う検証環境は全てAWS CDKでデプロイしました。使用したコードは以下リポジトリに保存しています。

EC2インスタンスに付与するIAMロールにて、Route 53 Hosted Zoneの命名規則に合う特定のレコードタイプしか操作できないようにしています。

./lib/constructs/autoscaling-group.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as fs from "fs";
import * as path from "path";

export interface AutoScalingGroupProps {
  vpc: cdk.aws_ec2.IVpc;
  hostedZone: cdk.aws_route53.HostedZone;
}

export class AutoScalingGroup extends Construct {
  readonly asg: cdk.aws_autoscaling.AutoScalingGroup;

  constructor(scope: Construct, id: string, props: AutoScalingGroupProps) {
    super(scope, id);

    const autoScalingGroupName = "asg";
    const hostname_prefix = "web-";
    const hostname_domain = `asg.${props.hostedZone.zoneName}`;
    const filter_tag_key = "aws:autoscaling:groupName";

    // IAM Role
    const role = new cdk.aws_iam.Role(this, "Role", {
      assumedBy: new cdk.aws_iam.ServicePrincipal("ec2.amazonaws.com"),
      managedPolicies: [
        new cdk.aws_iam.ManagedPolicy(this, "Policy", {
          statements: [
            new cdk.aws_iam.PolicyStatement({
              effect: cdk.aws_iam.Effect.ALLOW,
              resources: ["*"],
              actions: ["ec2:DescribeInstances", "ec2:DescribeTags"],
            }),
            new cdk.aws_iam.PolicyStatement({
              effect: cdk.aws_iam.Effect.ALLOW,
              resources: ["*"],
              actions: ["ec2:CreateTags", "ec2:DeleteTags"],
              conditions: {
                StringEquals: {
                  [`aws:ResourceTag/${filter_tag_key}`]: autoScalingGroupName,
                },
              },
            }),
            new cdk.aws_iam.PolicyStatement({
              effect: cdk.aws_iam.Effect.ALLOW,
              resources: [props.hostedZone.hostedZoneArn],
              actions: ["route53:ChangeResourceRecordSets"],
              conditions: {
                StringLike: {
                  "route53:ChangeResourceRecordSetsNormalizedRecordNames": `${hostname_prefix}*.${hostname_domain}`,
                },
                StringEquals: {
                  "route53:ChangeResourceRecordSetsRecordTypes": ["A"],
                },
              },
            }),
          ],
        }),
      ],
    });
.
.
(以下略)
.
.

ユーザーデータ内のプレースホルダーはreplace()で置換してあげます。

./lib/constructs/autoscaling-group.ts

    // User data
    const userDataScript = fs.readFileSync(
      path.join(__dirname, "../ec2/user-data.sh"),
      "utf8"
    );

    const userData = cdk.aws_ec2.UserData.forLinux();
    userData.addCommands(
      userDataScript
        .replace(/__hostname_prefix__/g, hostname_prefix)
        .replace(/__hostname_domain__/g, hostname_domain)
        .replace(/__filter_tag_key__/g, filter_tag_key)
        .replace(/__filter_tag_value__/g, autoScalingGroupName)
        .replace(/__hosted_zone_id__/g, props.hostedZone.hostedZoneId)
    );

    this.asg = new cdk.aws_autoscaling.AutoScalingGroup(this, "Default", {
      autoScalingGroupName,
      machineImage: cdk.aws_ec2.MachineImage.latestAmazonLinux2023({
        cachedInContext: true,
      }),
      instanceType: new cdk.aws_ec2.InstanceType("t3.nano"),
      vpc: props.vpc,
      vpcSubnets: props.vpc.selectSubnets({
        subnetGroupName: "Public",
      }),
      maxCapacity: 2,
      minCapacity: 2,
      role,
      ssmSessionPermissions: true,
      userData,
      healthCheck: cdk.aws_autoscaling.HealthCheck.elb({
        grace: cdk.Duration.minutes(3),
      }),
    });
    this.asg.scaleOnCpuUtilization("CpuScaling", {
      targetUtilizationPercent: 50,
    });
  }
}

デプロイ後、EC2インスタンスを確認します。

EC2インスタンスが2台作成されていました。

EC2インスタンスが作成されたことを確認

それぞれのEC2インスタンスのユーザーデータのログを確認します。

web-01のユーザーデータのログ

$ cat /var/log/user-data.log
=======================================================
--------------------------------------------------
candidate_hostname : web-01
--------------------------------------------------
Set HostName web-01.asg.corp.non-97.net
hostnamectl :
       Static hostname: web-01.asg.corp.non-97.net
       Icon name: computer-vm
         Chassis: vm 🖴
      Machine ID: ec247bd2759a5e2c81f2a1cb109f26c2
         Boot ID: 52f26d034119453fb4073ddfb5339825
  Virtualization: amazon
Operating System: Amazon Linux 2023
     CPE OS Name: cpe:2.3:o:amazon:amazon_linux:2023
          Kernel: Linux 6.1.55-75.123.amzn2023.x86_64
    Architecture: x86-64
 Hardware Vendor: Amazon EC2
  Hardware Model: t3.nano
Firmware Version: 1.0
{
    "ChangeInfo": {
        "Id": "/change/C09175532WOJA1MJO370H",
        "Status": "PENDING",
        "SubmittedAt": "2023-10-09T06:07:32.310000+00:00"
    }
}

web-02のユーザーデータのログ

$ cat /var/log/user-data.log
=======================================================
--------------------------------------------------
candidate_hostname_number : 1
hostname : web-01
hostname_number : 01
--------------------------------------------------
candidate_hostname : web-02
--------------------------------------------------
Set HostName web-02.asg.corp.non-97.net
hostnamectl :
       Static hostname: web-02.asg.corp.non-97.net
       Icon name: computer-vm
         Chassis: vm 🖴
      Machine ID: ec24c461d70b00f42ce438ddf4f8e422
         Boot ID: 618ddbb14b8e4281864530fe288dc138
  Virtualization: amazon
Operating System: Amazon Linux 2023
     CPE OS Name: cpe:2.3:o:amazon:amazon_linux:2023
          Kernel: Linux 6.1.55-75.123.amzn2023.x86_64
    Architecture: x86-64
 Hardware Vendor: Amazon EC2
  Hardware Model: t3.nano
Firmware Version: 1.0
{
    "ChangeInfo": {
        "Id": "/change/C09234691HR5CKXZNGTZK",
        "Status": "PENDING",
        "SubmittedAt": "2023-10-09T06:07:39.889000+00:00"
    }
}

どちらもエラーなく正常に実行完了しています。

Route 53 Private Hosted Zoneを確認すると、各EC2インスタンスのホスト名に対応したレコードが作成されていました。

Route 53 Private Hosted Zone

実際に名前解決できるか確認してみます。

$ dig web-01.asg.corp.non-97.net +short
10.10.10.27

$ dig web-02.asg.corp.non-97.net +short
10.10.10.53

どちらも名前解決できますね。

続いて、レコードの更新ができるかも確認します。

EC2インスタンスを削除して、Auto Scalingによって再作成されることを確認します。

EC2インスタンスが作り直されたことを確認

その後、Route 53 Private Hosted Zoneを確認します。

レコードが更新されたことを確認

web-01.asg.corp.non-97.net10.10.10.27から10.10.10.8web-02.asg.corp.non-97.net10.10.10.53から10.10.10.40にレコードが更新されていることが分かります。

名前解決できるかも確認します。

$ dig web-01.asg.corp.non-97.net +short
10.10.10.8

$ dig web-02.asg.corp.non-97.net +short
10.10.10.40

名前解決の結果も変わっていることを確認できました。

ユーザーデータを使えば大概のことはできる

Route 53 Hosted Zoneに登録されているAuto ScalingするEC2インスタンスのレコードを動的に更新してみました。

ユーザーデータを使えば大概のことはできますね。ただし、オーバーエンジニアリングにならないように気をつけましょう。

作り込む場合は「要件を満たすために何が必要なのか」、「その要件は本当に必要なのか」、「実装した場合の運用コストや影響範囲」を意識しながら対応しましょう。シンプルに対応できることを無理に複雑化する必要はありません。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!