【Serverless Framework】EC2インスタンス起動時に動的パブリックIPをLambda Rubyでroute53に登録

CloudWatch EventsのEC2インスタンスの起動をトリガーにLambda関数を実行し、起動したEC2インスタンスの動的パブリックIPをroute53のレコードに登録します。また、EC2の停止をトリガーに対象のレコードを削除します。 VPNが接続できない、したくない環境、Elastic IPを節約したいといような個人ユーザにはちょっぴりうれしいかもしれません。興味本位で以下を触りたかったというのが本音で、お題はおまけです。

  • Lambdaでrubyを使ってみたい
  • Serverless Frameworkを使ってみたい

前提条件

前提条件

  • CentOS 7系利用
  • credentialが設定済みであること
  • Serverless Frameworkインストール済み
  • ruby2.5(Lambdaのサポートバージョン)インストール済み

構成図

機能詳細

EC2インスタンスの起動時に以下の2つのタグを利用して、route53のホストゾーンにAレコードを登録します。SetRoute53タグの値がONの時、[Nameタグの値].[ドメイン名]で、動的パブリックIPをroute53のAレコードに登録します。また、EC2インスタンスの停止時に登録されているAレコードの削除を行います。

利用するタグ

  • Nameタグ
  • SetRoute53タグ(値にONを記載)

設定例

項目 設定値
Nameタグ demo-machine
SetRoute53タグ ON ※その他の値やタグ未設定の場合は登録しない
ドメイン tekunote.com
登録されるAレコードのFQDN demo-machine.tekunote.com

やってみよう

Serverless FrameworkでLambda関数の作成とデプロイ

Lambda関数の雛型作成

サービス名、作成先のパス共にdynamic-dnsAWS Lambda のRuby環境の雛型を作成し、対象のパスへ移動します。

$ sls create --template aws-ruby --path dynamic-dns --name dynamic-dns
$ cd dynamic-dns

dynamic-dnsディレクトリ内に作成されたserverless.ymlhandler.rbの2ファイル利用します。

$ ls -la
total 16
drwxrwxr-x.  3 demo-user demo-user   83 May  9 17:04 .
drwx------. 15 demo-user demo-user 4096 May  9 17:04 ..
-rw-r--r--.  1 demo-user demo-user 1124 May  6 23:23 .gitignore
-rw-rw-r--.  1 demo-user demo-user 1552 May  9 09:50 handler.rb
drwxrwxr-x.  2 demo-user demo-user  157 May  9 16:00 .serverless
-rw-r--r--.  1 demo-user demo-user 1011 May  9 15:56 serverless.yml

環境変数の設定

次の手順で、serverless.ymlを設定しますが、以下のパラメータはべた書きせず、環境変数から読み込むようにしていますので設定しておきます。

$ export AWS_ACCOUNT_ID=111122223333 # AWSアカウントID
$ export HOSTED_ZONE_ID=ABCDEFH012345 # 設定対象のホストゾーンID
$ export AWS_REGION=ap-northeast-1 # リージョン
$ export DOMAIN=tekunote.com # Aレコードを設定する対象のドメイン

serverless.yamlの設定

以下の通り設定します。環境変数HOSTED_ZONE_IDDOMAINについてはLambda関数の環境変数に登録されます。serverless.ymlのリファレンスはこちらです。

service: dynamic-dns

provider:
  name: aws
  stage: nonstage
  runtime: ruby2.5
  region: ${env:AWS_REGION}
  memorySize: 256
  timeout: 60
  iamRoleStatements:
    - Effect: Allow
      Action:
        - ec2:DescribeInstances
      Resource: "*"
    - Effect: Allow
      Action:
        - route53:ChangeResourceRecordSets
        - route53:ListResourceRecordSets
      Resource: "arn:aws:route53:::hostedzone/${self:functions.change_record.environment.HOSTED_ZONE_ID}"

functions:
  change_record:
    handler: handler.change_record
    environment:
     HOSTED_ZONE_ID: ${env:HOSTED_ZONE_ID}
     DOMAIN: ${env:DOMAIN}
    events:
      - cloudwatchEvent:
          event:
            source:
              - "aws.ec2"
            detail-type:
              - "EC2 Instance State-change Notification"
            detail:
              state:
                - running
                - stopped

serverless.yamlの設定内容について補足します。 Lambda関数に割り当てるIAMロールの設定はiamRoleStatements:内で設定しています。Lambda関数にec2:DescribeInstancesroute53:ChangeResourceRecordSetsroute53:ListResourceRecordSetsの権限を設定しています。CloudWatch Logsの操作関連の権限も必要ですが、Serverless Frameworkではデフォルトで付与されるためserverless.yaml内に記載する必要はありません。

  iamRoleStatements:
    - Effect: Allow
      Action:
        - ec2:DescribeInstances
      Resource: "*"
    - Effect: Allow
      Action:
        - route53:ChangeResourceRecordSets
        - route53:ListResourceRecordSets
      Resource: "arn:aws:route53:::hostedzone/${self:functions.change_record.environment.HOSTED_ZONE_ID}"

CloudWatch Eventのトリガ(EC2起動時に実行)はevents:内で設定しています。

    events:
      - cloudwatchEvent:
          event:
            source:
              - "aws.ec2"
            detail-type:
              - "EC2 Instance State-change Notification"
            detail:
              state:
                - running
                - stopped

handler.rbの作成

今回作成してみたLambda関数のソースは以下です。handler.rbに上書きします。

require 'aws-sdk'

def change_record(event:, context:)
  ttl = '60'
  record_type = 'A'
  filter_tag_key = 'SetRoute53'
  filter_tag_value = 'ON'

  ec2_client = Aws::EC2::Client.new
  route53_client = Aws::Route53::Client.new

  # タグとCloudWatchのイベントから取得したインスタンスIDでフィルタ
  ec2_resp = ec2_client.describe_instances(
    filters: [
      {
        name: "tag:#{filter_tag_key}",
        values: [filter_tag_value.to_s]
      }
    ],
    instance_ids: [(event['detail']['instance-id']).to_s]
  )

  instance = ec2_resp.reservations[0].instances[0]
  instance.tags.each do |tag|
    # レコードの更新にはNameタグの値を利用するので、Nameタグと一致するときのみ処理
    next unless tag[:key] == 'Name'

    route53_resp = route53_client.list_resource_record_sets(
      hosted_zone_id: (ENV['HOSTED_ZONE_ID']).to_s,
      start_record_type: record_type,
      start_record_name: "#{tag[:value]}.#{ENV['DOMAIN']}.",
      max_items: 1
    )

    record_name = route53_resp.resource_record_sets[0].name
    record_value = route53_resp.resource_record_sets[0].resource_records[0].value

    # 起動時は、EC2インスタンスに割り当てられたパブリックIPを利用して更新処理
    if instance.state.name == 'running'
      record_set_action = 'UPSERT'
      ip_address = instance.public_ip_address
    # 停止時は、既にAレコードに登録済みのIPを取得し削除処理
    elsif instance.state.name == 'stopped'
      record_set_action = 'DELETE'
      ip_address = record_name == "#{tag[:value]}.#{ENV['DOMAIN']}." ? record_value : nil
    end

    next if ip_address.nil?

    # Aレコードの更新、削除処理
    route53_client.change_resource_record_sets(
      change_batch: {
        changes: [
          action: record_set_action,
          resource_record_set: {
            name: "#{tag[:value]}.#{ENV['DOMAIN']}.",
            type: record_type,
            ttl: ttl.to_s,
            resource_records: [
              {
                value: ip_address
              }
            ]
          }
        ]
      },
      hosted_zone_id: ENV['HOSTED_ZONE_ID']
    )
    # CloudWatch Logに出力
    puts "#{record_set_action} Record Infomation: type=#{record_type} fqdn=#{tag[:value]}.#{ENV['DOMAIN']}. ip=#{ip_address}"
  end
end

デプロイ

CloudFormationスタックdynamic-dns-nonstageが作成され、CloudFormationから各リソースが作成されます。deploy時のオプションの詳細表示(-v)で、CloudFormationからどのリソースが作成されるか確認できますが、今回は指定せずに伏せています。

[demo-user@demo-machine dynamic-dns]$ sls deploy
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
.....
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service dynamic-dns.zip file to S3 (1.92 KB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
.....................
Serverless: Stack update finished...
Service Information
service: dynamic-dns
stage: nonstage
region: ap-northeast-1
stack: dynamic-dns-nonstage
resources: 7
api keys:
  None
endpoints:
  None
functions:
  change_record: dynamic-dns-nonstage-change_record
layers:
  None

動作確認

EC2インスタンスにタグを設定し起動

以下のようにEC2インスタンスのタグを設定し、起動してみます。

Route53のレコード確認

IPはマスクしていますが、このような形でレコードが登録されます。

CloudWatch Log確認

こちらもIPはマスクしていますが、このような形で表示されます。 EC2を停止した場合は、登録したAレコードが削除されます。

最後に

Serverless Frameworkが、CloudFormationでデプロイされることさえ、使ってみるまで知らなかったです。。。CloudFormationの変更セット適用するとき、差分チェックやら置換の有無やら結構たいへんだったなぁという思い出に浸りました。読んでいただきありがとうございました。