EC2ステータス変化を検出して別アカウントのRoute53に自動でレコード設定を行う

lambdaから別アカウントのroute53にアクセス
AWS STSのAssume roleを使用してdev環境からprod環境で作成したroleを借りてリソースにアクセスする。

- 参考記事
https://dev.classmethod.jp/cloud/aws/cross-account-lambda-calling/

# 背景

AWS Organizationsでprod/dev環境を分離した。
もともとEIPのコスト削減のために、EC2インスタンスが作成・削除された際自動でレコード追加・削除が行われる仕組み（上記参考記事）をcloudwatch event + lambdaで実装していたのだが、今回dev環境でもその仕組みを実装したい。

### 要点
- dev/prod環境はorganizationでアカウント単位で分離。
- prod側のRoute53に社内用ドメインをホストしている。
- dev側のEC2の管理はcloudformation
    - インスタンスの作成時に自動でGlobalIPをつけている。
    - 作成時にはdomainというタグをつけ、valueで登録したいFQDNを指定する。
- dev側のcloudwatch eventでインスタンスのステータス変化をチェックし、dev側のlambdaでprod側のroute53にレコード操作を行う。

# Route53（prod環境）

- ゾーンIDの確認
![](https://i.imgur.com/VB4mkI4.png)
今回はすでにホスト済みのドメインで実施する

# IAM（prod環境）

stsのAssume Roleを指定して、devアカウントのroleと紐づける。

- policy: AmazonRoute53FullAccess
作成後、概要->信頼関係タブから信頼関係の編集、以下ポリシーを記載

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::[YourDevAccountId]:role/service-role/[YourDevAccountLambdaRoleName]"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
```

Principalにdevアカウント側で作成するロールのArnを指定する。
※ devアカウントのパスは/service-role/としています。

完了するとコンソールでは信頼されたエンティティにdevアカウント側のロールが表示されます。
![](https://i.imgur.com/kuVngEg.png)

# IAM(dev環境)

lambda->route53（prod）アクセス用roleの作成

#### policyの作成

- StsAccessPolicy

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "sts:AssumeRole",
            "Resource": "arn:aws:iam::[YourProdAccountId]:role/[YourProdRoute53AccessRole]",
            "Effect": "Allow"
        }
    ]
}
```

Resourceに先ほど作成したroleのArnを指定

- LambdaBasicAccessPolicy

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "logs:CreateLogGroup",
            "Resource": "arn:aws:logs:ap-northeast-1:[YourDevAccountID]:*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:[YourDevAccountID]:log-group:/aws/lambda/[YourDevAccountLambdaFunctionName]:*",
            "Effect": "Allow"
        }
    ]
}
```

lambdaからcloudwatch logsにアクセスするためのデフォルトポリシー
2つ目のActionのResourceにはLambdaのArnを指定する

#### roleの作成
 
使用するAWSサービス -> lambda
上記二つのポリシーに加えて、以下のポリシーをアタッチ

- AmazonEC2ReadOnlyAccess
- CloudWatchEventsFullAccess

# lambda(dev環境)

レコード変更用関数の作成

- ソース（node.js）

```js

var AWS = require('aws-sdk');
AWS.config.region = 'ap-northeast-1';

function getGlobalIp(instanceId, cb) {
  var ec2 = new AWS.EC2();
  var params = {
    Filters: [
      {
        Name: 'instance-id',
        Values: [ instanceId ]
      }
    ]
  };
  ec2.describeInstances(params, function(err, data) {
    if(err) {
      console.log(err, err.stack);
    } else {
      console.log(data);
      console.log(data.Reservations[0].Instances[0]);
      var instance = data.Reservations[0].Instances[0];
      cb(instance.PublicIpAddress, get('domain', instance.Tags));
    }
  });
}

function get(key, tags) {
  for(var i=0; i<tags.length; i++) {
    if(tags[i].Key == key) {
      return tags[i].Value;
    }
  }
  return null;
}


function setRoute53(hostedZoneId, domain, ip, session_param, cb) {
  var route53 = new AWS.Route53(session_param);
  var params = {
    ChangeBatch: {
      Changes: [
        {
          Action: 'UPSERT',
          ResourceRecordSet: {
            Name: domain,
            Type: 'A',
            ResourceRecords: [
              {
                Value: ip
              },
            ],
            TTL: 300,
          }
        },
      ],
      Comment: ''
    },
    HostedZoneId: hostedZoneId
  };

  route53.changeResourceRecordSets(params, function(err, data) {
    if (err) console.log(err, err.stack); // an error occurred
    else     cb(data);           // successful response
  });
}

function removeRoute53(hostedZoneId, domain, ip, session_param, cb) {
    var route53 = new AWS.Route53(session_param);
    var params = {
      ChangeBatch: {
        Changes: [
          {
            Action: 'DELETE',
            ResourceRecordSet: {
              Name: domain,
              Type: 'A',
              ResourceRecords: [
                {
                  Value: ip
                },
              ],
              TTL: 300,
            }
          },
        ],
        Comment: ''
      },
      HostedZoneId: hostedZoneId
    };

    route53.changeResourceRecordSets(params, function(err, data) {
      if (err) console.log(err, err.stack); // an error occurred
      else     cb(data);           // successful response
    });
}

// Acquisition of assumerole using sts.
async function stsAssumeRole() {
  const sts = new AWS.STS();
  const params = {
    RoleArn: "arn:aws:iam::[YourProdAccountId]:role/[YourProdRoute53AccessRole",
    RoleSessionName: 'CrossAccountCredentials',
  };

  const assumeRole = await sts.assumeRole(params).promise();
  console.log('Changed Credentials');

  const accessparams = {
    accessKeyId: assumeRole.Credentials.AccessKeyId,
    secretAccessKey: assumeRole.Credentials.SecretAccessKey,
    sessionToken: assumeRole.Credentials.SessionToken,
  };
  console.log(accessparams);
  return accessparams;
}


exports.handler = (event, context, callback) => {
  console.log(event);
  console.log(context);

  // Promiss return of stsassumerole function by async.
  stsAssumeRole().then(function(param){
    var state = event.detail['state'];
    if(state == 'running') {
      getGlobalIp(event.detail['instance-id'], function(ip, domain) {
        if(!domain) {
          callback(null, 'skip: ' + event.detail['instance-id']);
        } else {
          setRoute53("[YourRoute53HostedZoneId]", domain, ip, param, function(data) {
            callback(null, 'Success: ' + data);
          });
        }
      });
    } else if(state == 'shutting-down') {
      getGlobalIp(event.detail['instance-id'], function(ip, domain) {
        if(!domain) {
          callback(null, 'skip: ' + event.detail['instance-id']);
        } else {
          removeRoute53("[YourRoute53HostedZoneId]", domain, ip, param, function(data) {
            callback(null, 'Success: ' + data);
          });
        }
      });
    } else {
      callback(null, "invalid state: " + state);
    }
  });
};

```

stsAssumeRoleで一時的な認証情報を取得し、prod環境のroute53にアクセスする。
（非同期処理なので実行順序に気をつけるのが厄介でした。）

- role
先ほど作成したroleを指定

- timeout
sts鍵取得に若干時間がかかるため30sに指定

# cloudwatch event（dev環境）

EC2のステータスがrunningまたはshutdownになった場合にlambdaを実行する

#### イベントルールの作成

- イベントソース -> イベントパターン
    - サービス名: EC2
    - イベントタイプ: EC2 instance State-change Notification
        - 特定の状態: running, shuting-down
- ターゲット: 先ほど作成したLambda関数

# テスト

dev環境でEC2を作成して確認。
作成時にdomainというタグをつけ、valueにFQDN(subdomain.yourdomain.xx)を指定し、prod環境のRoute53にレコードが追加されていれば完成。
EC2を削除すれば自動でレコードも削除されます。

# cloudformation化

dev環境側の設定をcloudformation化して運用している。
（スタックを再作成した場合にprod側IAMの信頼関係を再設定する必要がある。）

- template

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Description:
  Create DDNS for Route53 in prod

Resources:
  IamRole:
    Type: 'AWS::IAM::Role'
    Properties:
      RoleName: '[YourDevAccountLambdaRole]'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action: 'sts:AssumeRole'
      Path: '/service-role/'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess'
        - 'arn:aws:iam::aws:policy/CloudWatchEventsFullAccess'
  StsPolicy:
    Type: 'AWS::IAM::Policy'
    Properties:
      PolicyName: 'StsAccessPolicy'
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action: 'sts:AssumeRole'
            Resource: 'arn:aws:iam::[YourProdAccountId]:role/[YourProdAccountRoute53AccessRole]'
      Roles:
        - Ref: 'IamRole'
  LambdaPolicy:
    Type: 'AWS::IAM::Policy'
    Properties:
      PolicyName: 'LambdaBasicAccessPolicy'
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action: 'logs:CreateLogGroup'
            Resource: 'arn:aws:logs:ap-northeast-1:[YourDevAccountId]:*'
          - Effect: Allow
            Action:
              - 'logs:CreateLogStream'
              - 'logs:PutLogEvents'
            Resource: 'arn:aws:logs:ap-northeast-1:[YourDevAccountId]:log-group:/aws/lambda/[YourDevAccountLambdaName]:*'
      Roles:
        - Ref: 'IamRole'
  Lambda:
    Type: 'AWS::Lambda::Function'
    Properties:
      Code:
        ZipFile: |
          var AWS = require('aws-sdk');
          AWS.config.region = 'ap-northeast-1';

          function getGlobalIp(instanceId, cb) {
            var ec2 = new AWS.EC2();
            var params = {
              Filters: [
                {
                  Name: 'instance-id',
                  Values: [ instanceId ]
                }
              ]
            };
            ec2.describeInstances(params, function(err, data) {
              if(err) {
                console.log(err, err.stack);
              } else {
                console.log(data);
                console.log(data.Reservations[0].Instances[0]);
                var instance = data.Reservations[0].Instances[0];
                cb(instance.PublicIpAddress, get('domain', instance.Tags));
              }
            });
          }

          function get(key, tags) {
            for(var i=0; i<tags.length; i++) {
              if(tags[i].Key == key) {
                return tags[i].Value;
              }
            }
            return null;
          }


          function setRoute53(hostedZoneId, domain, ip, session_param, cb) {
            var route53 = new AWS.Route53(session_param);
            var params = {
              ChangeBatch: {
                Changes: [
                  {
                    Action: 'UPSERT',
                    ResourceRecordSet: {
                      Name: domain,
                      Type: 'A',
                      ResourceRecords: [
                        {
                          Value: ip
                        },
                      ],
                      TTL: 300,
                    }
                  },
                ],
                Comment: ''
              },
              HostedZoneId: hostedZoneId
            };

            route53.changeResourceRecordSets(params, function(err, data) {
              if (err) console.log(err, err.stack); // an error occurred
              else     cb(data);           // successful response
            });
          }

          function removeRoute53(hostedZoneId, domain, ip, session_param, cb) {
              var route53 = new AWS.Route53(session_param);
              var params = {
                ChangeBatch: {
                  Changes: [
                    {
                      Action: 'DELETE',
                      ResourceRecordSet: {
                        Name: domain,
                        Type: 'A',
                        ResourceRecords: [
                          {
                            Value: ip
                          },
                        ],
                        TTL: 300,
                      }
                    },
                  ],
                  Comment: ''
                },
                HostedZoneId: hostedZoneId
              };

              route53.changeResourceRecordSets(params, function(err, data) {
                if (err) console.log(err, err.stack); // an error occurred
                else     cb(data);           // successful response
              });
          }

          // Acquisition of assumerole using sts.
          async function stsAssumeRole() {
            const sts = new AWS.STS();
            const params = {
              RoleArn: "arn:aws:iam::[YourProdAccountId]:role/[YourProdAccountRoute53AccessRole]",
              RoleSessionName: 'CrossAccountCredentials',
            };

            const assumeRole = await sts.assumeRole(params).promise();
            console.log('Changed Credentials');

            const accessparams = {
              accessKeyId: assumeRole.Credentials.AccessKeyId,
              secretAccessKey: assumeRole.Credentials.SecretAccessKey,
              sessionToken: assumeRole.Credentials.SessionToken,
            };
            console.log(accessparams);
            return accessparams;
          }


          exports.handler = (event, context, callback) => {
            console.log(event);
            console.log(context);

            // Promiss return of stsassumerole function by async.
            stsAssumeRole().then(function(param){
              var state = event.detail['state'];
              if(state == 'running') {
                getGlobalIp(event.detail['instance-id'], function(ip, domain) {
                  if(!domain) {
                    callback(null, 'skip: ' + event.detail['instance-id']);
                  } else {
                    setRoute53("[YourRoute53HostedId]", domain, ip, param, function(data) {
                      callback(null, 'Success: ' + data);
                    });
                  }
                });
              } else if(state == 'shutting-down') {
                getGlobalIp(event.detail['instance-id'], function(ip, domain) {
                  if(!domain) {
                    callback(null, 'skip: ' + event.detail['instance-id']);
                  } else {
                    removeRoute53("[YourRoute53HostedId]", domain, ip, param, function(data) {
                      callback(null, 'Success: ' + data);
                    });
                  }
                });
              } else {
                callback(null, "invalid state: " + state);
              }
            });
          };
      Description: [YourDevAccountLambdaName]
      FunctionName: [YourDevAccountLambdaName]
      Handler: index.handler
      MemorySize: 128
      Role: !GetAtt IamRole.Arn 
      Runtime: nodejs8.10
      Timeout: 30
  Rule:
    Type: 'AWS::Events::Rule'
    Properties:
      Description: [YourCloudWatchEventName]
      Name: [YourCloudWatchEventName]
      EventPattern:
        source:
          - aws.ec2
        detail-type:
          - EC2 Instance State-change Notification
        detail:
          state:
            - 'running'
            - 'shutting-down'
      State: ENABLED
      Targets:
        - Arn: !GetAtt Lambda.Arn
          Id: lambda
  LambdaEvent:
    Type: 'AWS::Lambda::Permission'
    Properties:
      Action: 'lambda:InvokeFunction'
      FunctionName: !Ref Lambda
      Principal: 'events.amazonaws.com'
      SourceArn: !GetAtt Rule.Arn
```
あとはstackを作成するだけ。今回は省略します。

# 感想
以上で完成。
stsを利用することで、より柔軟に組織的な権限設定が可能になりますね。

はじめは概念を理解するのが大変でしたが一度動かしてみて便利さに気づきました。
他にも色々と流用できそうです。

ちなみに、今回はじめてnodejsを使ってlambda書いてみましたが（もともとがnodejsだったため）、非同期処理を意識するのは大変ですね…。
精進します。