CloudTrail+LambdaでRoute 53のDNSレコード登録を自動化する

AmazonLambda

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんばんは、城内です。
今回は、急にAWS Lambdaを触りたくなったので、勉強がてらEC2向けのRoute 53へのDNSレコード登録を自動化してみようと思います。

とは言え、今回やろうとしている処理は、すでに「cloud-initを使ってRoute53をPrivate Dynamic DNSにする」でcloud-initを使って実現されていますし、Lambdaで言えば、直近の「Amazon ECSのDockerコンテナをLambdaで自前オートスケールする」なんて、最近実装されたばかりのSNSイベントをトリガーに使っていてすごく素敵な感じなわけなので、もうかなりの小ネタですが、Lambdaをお試ししてみたいという方はぜひ参考にしてみてください。

はじめに

今回は、LambdaがまだTokyoリージョンに来ていないためOregonリージョンを使用していますが、一応、CloudTrailのログを格納するS3バケットさえLambdaと同じリージョンにあればよいので、TokyoリージョンのCloudTrailからでも連携が可能なようにしました(無理矢理)。

処理の概要としては、CloudTrailのログがS3に出力されることをトリガーに、Lambdaがそのログの中身を読み込んで、EC2の作成と起動のイベント時には該当のEC2のホスト名(タグに定義)とIPをRoute 53に登録し、停止と削除時にはその登録したレコードを削除します。正直、これで自動化はできましたが、すべてのCloudTrailのログに対してLambdaが動いてしまうので、無駄が多いところが欠点です。

実は、作っている最中にLambdaがアップデートされ、SNSがイベントソースとして追加されたので、CloudTrailのCloudWatch Logsオプションとその先のフィルタ+SNS通知でうまいことできないかとも考えたのですが、ちょっと機能用途が違うため今回の要件では利用できませんでした。

実装

今回使用するそれぞれのサービスの設定方法については、すでにいろいろな記事で紹介されているため、ポイントだけを記載します。どうやって設定するのと思った方は、ぜひ当ブログで検索してみてくださいね(右上のメニューに「検索」があります)。

CloudTrailの有効化

まずは、CloudTrailを有効化してください。設定の仕上がりとしては、以下の感じです。

cloudtrail_01

Lambdaファンクションの作成

今回実行する処理は以下のコードです。一応、「HostName」タグが定義されているEC2インスタンスのみを対象にしています。DNS登録する必要がない場合もあると思うので、その場合は「HostName」タグを定義しなければ自動登録はされません。
また、Tokyoリージョンで使用したい場合は、「TARGET_REGION」変数の値を「ap-northeast-1」にして、TokyoリージョンのCloudTrailの設定で、ログの出力先に指定するS3バケットを、Lambdaファンクションを作成するリージョンと同じリージョンに作成すれば動きます。
(なお、私は普段コードを書かない人、かつ、Node.js初心者なので、素人的なイケてなさはご勘弁ください)

var aws  = require('aws-sdk');
var zlib = require('zlib');
var async = require('async');

var TARGET_REGION = 'us-west-2';
var IP_ASSIGN_EVENT_NAME = 'RunInstances|StartInstances';
var IP_RELEASE_EVENT_NAME = 'StopInstances|TerminateInstances';
var EVENT_NAME_TO_TRACK = new RegExp(IP_ASSIGN_EVENT_NAME + '|' + IP_RELEASE_EVENT_NAME); 
var TAG_NAME_TO_TRACK = /HostName/; 
var ROUTE53_HOSTED_ZONE = '/hostedzone/XXXXXXXXXXXXXXX';
var TTL_SECONDS = 600;
var HOSTNAME_SUFFIX = '.local';

var s3 = new aws.S3();

aws.config.update({region: TARGET_REGION});

var ec2 = new aws.EC2();
var r53 = new aws.Route53();

exports.handler = function(event, context) {
  var srcBucket = event.Records[0].s3.bucket.name;
  var srcKey = event.Records[0].s3.object.key;

  async.waterfall([
    function fetchLogFromS3(next){
      console.log('Fetching compressed log from S3...');
      s3.getObject({Bucket: srcBucket, Key: srcKey}, next);
    },
    function uncompressLog(response, next){
      console.log('Uncompressing log...');
      zlib.gunzip(response.Body, next);
    },
    function filteringLog(jsonBuffer, next) {
      console.log('Filtering log...');
      var json = jsonBuffer.toString();

      console.log('CloudTrail JSON from S3:', json);
      var records;
      try {
        records = JSON.parse(json);
      } catch (err) {
        next('Unable to parse CloudTrail JSON: ' + err);
        return;
      }

      var matchingRecords = records.Records.filter(
        function(record) {
          return record.eventName.match(EVENT_NAME_TO_TRACK);
        }
      );
      if (matchingRecords.length) {
        next(null, matchingRecords);
      } else {
        next(true);
      }
    },
    function getR53HostedZone(matchingRecords, next) {
      console.log('Getting Route 53 Hosted Zone...');

      r53.getHostedZone({Id: ROUTE53_HOSTED_ZONE},
        function(err, data) {
          if (err) {
            console.log(err, err.stack);
            next(err);
          } else {
            next(null, matchingRecords, data.HostedZone.Name);
          }
        }
      );
    },
    function updateR53RecordSet(matchingRecords, domain, next) {
      console.log('Updating Record Set for Route 53...');

      async.eachSeries(
        matchingRecords,
        function(record, loop) {
          async.eachSeries(
            record.responseElements.instancesSet.items,
            function(instance, loop1) {
              var instanceid = instance.instanceId;
              async.waterfall([
                function getEC2Info(next1) {
                  ec2.describeInstances({"InstanceIds": [instanceid]},
                    function(err, data) {
                      if (err) {
                        console.log(err, err.stack);
                        next1(err);
                      } else {
                        var matchingTags = data.Reservations[0].Instances[0].Tags.filter(
                          function(tag) {
                            return tag.Key.match(TAG_NAME_TO_TRACK);
                          }
                        );
                        if (matchingTags.length) {
                          var hostname = matchingTags[0].Value;
                          if (hostname) {
                            next1(null, data.Reservations[0].Instances[0].PrivateIpAddress, hostname + HOSTNAME_SUFFIX + '.' + domain);
                          } else {
                            next1(true);
                          }
                        } else {
                          next1(true);
                        }
                      }
                    }
                  )
                },
                function setAction(ip, fqdn ,next1) {
                  var str = new RegExp(IP_RELEASE_EVENT_NAME)
                  if (record.eventName.match(str)) {
                    r53.listResourceRecordSets({"HostedZoneId": ROUTE53_HOSTED_ZONE},
                      function(err, data) {
                        var matchingName = data.ResourceRecordSets.filter(
                          function(record) {
                            return record.Name.match(fqdn);
                          }
                        );
                        if (matchingName.length) {
                          next1(null, ip, fqdn, 'DELETE');
                        } else {
                          next1(true);
                        }
                      }
                    );
                  } else {
                    next1(null, ip, fqdn, 'UPSERT');
                  }
                },
                function updateRecordSet(ip, fqdn, action, next1) {
                  console.log('[' + action + ']Target record set ... Name:' + fqdn + ', Value:' + ip);
                  var params = {
                    "HostedZoneId": ROUTE53_HOSTED_ZONE,
                    "ChangeBatch": {
                      "Changes": [
                        {
                          "Action": action,
                          "ResourceRecordSet": {
                            "Name": fqdn,
                            "Type": "A",
                            "TTL": TTL_SECONDS,
                            "ResourceRecords": [
                              {
                                "Value": ip
                              }
                            ]
                          }
                        }
                      ]
                    }
                  };
                  r53.changeResourceRecordSets(params,
                    function(err, data) {
                      if (err) {
                        console.log(err, err.stack);
                      } else {
                        console.log(data);
                      }
                      next1(err);
                    }
                  );
                }
              ], function (err) {
                loop1(err)
              });
            },
            loop
          );
        },
        next
      );
    }
  ], function (err) {
    if (err == true) {
      context.done(null, 'No matching records found.');
    } else if (err) {
      context.done('Failed to update Record Set for Route 53: ' + err);
    } else {
      context.done(null, 'Successfully updated Record Set for Route 53.');
    }
  });
};

そして、処理の中でasyncを使用しているので、ZIPするときに一緒に入れてあげてください。ちなみに、ZIPするときには、ソースファイルとモジュール格納フォルダを直接ZIPするようにしてください(フォルダに入れてZIPしちゃうとハマります)。

lambda-sample.zip
 ┣━ index.js
 ┗━ node_modules
      ┗━ async

ファンクションの作成画面は以下の感じです。

lambda_01

ファンクション側に設定してあるIAM Roleは以下の通りです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "logs:*",
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::*"
    },
    {
      "Effect": "Allow",
      "Action": "ec2:DescribeInstances",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "route53:GetHostedZone",
        "route53:ListResourceRecordSets",
        "route53:ChangeResourceRecordSets"
      ],
      "Resource": "arn:aws:route53:::*"
    }
  ]
}

次に、イベントソースをS3のPutで作成します。

lambda_02

ちなみに、ちょうど私が今回の作業をしている最中にLambdaのアップデートがあり、突然UIが変わって驚きました。以下のように、イベントソースにSNSが選択できるようになったり、S3でもPut以外のイベントも対応できるようになっていました。

lambda_03

lambda_04

なお、Lambdaが動き出すと、自動的にCloudWatch Logsにログが出力されます。ここにフィルタを設定して、処理が失敗したらSNSで通知するとかをするといいかもしれませんね。

cloudwatch_01

実行

EC2起動

では、実際に動かしてみましょう。EC2を起動させます。

ec2_01

すると、こんな感じにLambdaファンクションが実行されて・・・。

cloudwatch_02

Route 53にレコードが登録されます。

route53_02

EC2停止

で、次はEC2を停止します。

ec2_02

すると、こんな感じにLambdaファンクションが実行されて・・・。

cloudwatch_03

先ほど登録されたレコードが削除されます。

route53_03

さいごに

今回はお試しでこんな感じの処理を実装してみましたが、Lambdaはまだまだポテンシャルがあると思うので、もっと有用な使い道を探っていきたいと思います。あと、早くTokyoリージョンに来て欲しいですね!