AWS Lambda-backed カスタムリソース - AMIを作成する

Lambdaを使うのもカスタムリソースを作成するのもはじめてなので、練習も含めてAMIを作成するカスタムリソースを作成してみた。

# つくるもの

- InstanceIDをパラメータとして、AMIを作成してAMI IDをリターンするカスタムリソース
- CloudFormationのRequestTypeに応じて、挙動を変える
  - Create: AMIの作成
  - Update: 以前のAMIを削除して、新しく作成
  - Delete: 以前のAMIを削除
- Optionで削除をしなくできるようにする

## 使い方イメージ

以下のようなリソースをCloudFormationに入れる感じにしたい。

``` json
"InstanceImage" : {
  "Type" : "Custom::CreateImage",
  "Properties" : {
    "ServiceToken"   : { "Fn::GetAtt" : [ "CreateImageLambdaFunction", "Arn" ] },
    "InstanceId"     : { "Ref" : "Instance" },　<-- InstanceIDをパラメータに
    "DeletionPolicy" : "Retain" <-- Optionで削除しないように
  }
}
```

# 完成コード

node.jsで開発。CloudFormationテンプレートの中で埋め込みしたかったので、外部ライブラリは使わなかった。

```node.js
var AWS      = require('aws-sdk'),
    response = require('cfn-response'),
    domain   = require('domain');
    
exports.handler = function(event, context) {
  var requestType    = event.RequestType,
      resourceId     = event.PhysicalResourceId,
      instanceId     = event.ResourceProperties.InstanceId,
      deletionPolicy = event.ResourceProperties.DeletionPolicy,
      ec2            = new AWS.EC2(),
      d              = domain.create();

  d.on('error', function (err) {
    console.error(err, err.stack);
    return response.send(event, context, response.FAILED);
  });

  var succeed = function (imageId) {
    response.send(event, context, response.SUCCESS, { ImageId: imageId }, imageId);
  };

  var createImage = function (instanceId, callback) {
    ec2.describeInstances({
      InstanceIds: [ instanceId ]
    }, d.intercept(function (data) {
      var instance = data.Reservations[0].Instances[0];
      var instanceName = instance.Tags.filter(function (tag) {
        return tag.Key == 'Name'
      })[0].Value;

      ec2.createImage({
        InstanceId: instanceId,
        Name: instanceName + '-' + Date.now(),
        NoReboot: true
      }, d.intercept(function (data) {
        callback(data.ImageId);
      }));
    }));
  };

  var deleteSnapshots = function (snapshots, callback) {
    if (snapshots.length === 0) {
      return callback();
    }
    var snapshotId = snapshots.shift();
    ec2.deleteSnapshot({
      SnapshotId: snapshotId
    }, d.intercept(function (data) {
      deleteSnapshots(snapshots, callback);
    }));
  };

  var deleteImage = function (imageId, callback) {
    ec2.describeImages({
      ImageIds: [ imageId ]
    }, function (err, data) {
      if (err) {
        return callback();
      }
      var snapshots = data
      .Images[0].BlockDeviceMappings
      .map(function (blockDevice) { return blockDevice.Ebs })
      .map(function (ebs) { return ebs.SnapshotId });
      ec2.deregisterImage({
        ImageId: imageId
      }, d.intercept(function (data) {
        deleteSnapshots(snapshots, callback);
      }));
    });
  };

  if (resourceId && deletionPolicy !== 'Retain') {
    deleteImage(resourceId, function () {
      if (requestType === 'Delete') {
        return succeed(resourceId);
      }
      createImage(instanceId, succeed);
    });
  } else {
    createImage(instanceId, succeed);
  }
};
```

# サンプル

インスタンス作ってついでにAMIも作成するCloudFormation。

```json
{
  "AWSTemplateFormatVersion": "2010-09-09",

  "Description" : "Sample Template",

  "Parameters" : {
    "KeyName" : {
      "Description"           : "Name of an existing EC2 KeyPair to enable SSH access to the instances.",
      "Type"                  : "AWS::EC2::KeyPair::KeyName",
      "ConstraintDescription" : "can contain only alphanumeric characters, spaces, dashes and underscores."
    },
    "InstanceName" : {
      "Description"           : "Instance Name for node.",
      "Type"                  : "String",
      "AllowedPattern"        : "[-a-zA-Z0-9]+"
    },
    "InstanceType" : {
      "Description"           : "Instance type for node.",
      "Type"                  : "String",
      "Default"               : "t2.nano",
      "AllowedValues"         : [ "t2.nano", "t2.micro","t2.small","t2.medium","m3.medium","m3.large","m3.xlarge","m3.2xlarge","c3.large","c3.xlarge","c3.2xlarge","c3.4xlarge","c3.8xlarge" ],
      "ConstraintDescription" : "must be a valid T2, M3 or C3 instance type."
    },
    "SecurityGroups" : {
      "Description"           : "SecurityGroups.",
      "Type"                  : "List<AWS::EC2::SecurityGroup::Id>"
    },
    "Subnets": {
      "Description"           : "ID of your existing subnets.",
      "Type"                  : "List<AWS::EC2::Subnet::Id>"
    }
  },
  
  "Mappings": {
    "AmazonAMI" : {
      "ap-northeast-1"        : { "AMI" : "ami-383c1956" }
    }
  },
  
  "Resources" : {

    "LambdaExecutionRole" : {
      "Type" : "AWS::IAM::Role",
      "Properties" : {
        "AssumeRolePolicyDocument": {
          "Version"  : "2012-10-17",
          "Statement": [{
            "Effect"    : "Allow",
            "Principal" : { "Service" : [ "lambda.amazonaws.com" ] },
            "Action"    : [ "sts:AssumeRole" ]
          }]
        },
        "Path" : "/",
        "Policies" : [{
          "PolicyName"     : "root",
          "PolicyDocument" : {
            "Version"   : "2012-10-17",
            "Statement" : [{
              "Effect"   : "Allow",
              "Action"   : [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ],
              "Resource" : "arn:aws:logs:*:*:*"
            },
            {
              "Effect"   : "Allow",
              "Action"   : [ "ec2:*" ],
              "Resource" : "*"
            }]
          }
        }]
      }
    },

    "CreateImageLambdaFunction" : {
      "Type" : "AWS::Lambda::Function",
      "Properties" : {
        "Code" : {
          "ZipFile" :  { "Fn::Join" : [ "\n", [
            "var AWS      = require('aws-sdk'),",
            "    response = require('cfn-response'),",
            "    domain   = require('domain');",
            "exports.handler = function(event, context) {",
            "  var requestType    = event.RequestType,",
            "      resourceId     = event.PhysicalResourceId,",
            "      instanceId     = event.ResourceProperties.InstanceId,",
            "      deletionPolicy = event.ResourceProperties.DeletionPolicy,",
            "      ec2            = new AWS.EC2(),",
            "      d              = domain.create();",
            "  d.on('error', function (err) {",
            "    console.error(err, err.stack);",
            "    return response.send(event, context, response.FAILED);",
            "  });",
            "  var succeed = function (imageId) {",
            "    response.send(event, context, response.SUCCESS, { ImageId: imageId }, imageId);",
            "  };",
            "  var createImage = function (instanceId, callback) {",
            "    ec2.describeInstances({",
            "      InstanceIds: [ instanceId ]",
            "    }, d.intercept(function (data) {",
            "      var instance = data.Reservations[0].Instances[0];",
            "      var instanceName = instance.Tags.filter(function (tag) {",
            "        return tag.Key == 'Name'",
            "      })[0].Value;",
            "      ec2.createImage({",
            "        InstanceId: instanceId,",
            "        Name: instanceName + '-' + Date.now(),",
            "        NoReboot: true",
            "      }, d.intercept(function (data) {",
            "        callback(data.ImageId);",
            "      }));",
            "    }));",
            "  };",
            "  var deleteSnapshots = function (snapshots, callback) {",
            "    if (snapshots.length === 0) {",
            "      return callback();",
            "    }",
            "    var snapshotId = snapshots.shift();",
            "    ec2.deleteSnapshot({",
            "      SnapshotId: snapshotId",
            "    }, d.intercept(function (data) {",
            "      deleteSnapshots(snapshots, callback);",
            "    }));",
            "  };",
            "  var deleteImage = function (imageId, callback) {",
            "    ec2.describeImages({",
            "      ImageIds: [ imageId ]",
            "    }, function (err, data) {",
            "      if (err) {",
            "        return callback();",
            "      }",
            "      var snapshots = data",
            "      .Images[0].BlockDeviceMappings",
            "      .map(function (blockDevice) { return blockDevice.Ebs })",
            "      .map(function (ebs) { return ebs.SnapshotId });",
            "      ec2.deregisterImage({",
            "        ImageId: imageId",
            "      }, d.intercept(function (data) {",
            "        deleteSnapshots(snapshots, callback);",
            "      }));",
            "    });",
            "  };",
            "  if (resourceId && deletionPolicy !== 'Retain') {",
            "    deleteImage(resourceId, function () {",
            "      if (requestType === 'Delete') {",
            "        return succeed(resourceId);",
            "      }",
            "      createImage(instanceId, succeed);",
            "    });",
            "  } else {",
            "    createImage(instanceId, succeed);",
            "  }",
            "};"
          ]]}
        },
        "Handler" : "index.handler",
        "Runtime" : "nodejs",
        "Timeout" : "30",
        "Role"    : { "Fn::GetAtt" : [ "LambdaExecutionRole", "Arn" ] }
      }
    },

    "Instance" : {
      "Type"           : "AWS::EC2::Instance",
      "Properties" : {
        "InstanceType" : { "Ref" : "InstanceType" },
        "KeyName"      : { "Ref" : "KeyName" },
        "ImageId"      : { "Fn::FindInMap" : [ "AmazonAMI", { "Ref" : "AWS::Region" }, "AMI"] },
        "Tags": [
          { "Key" : "Name", "Value" : { "Ref" : "InstanceName" } }
        ],
        "NetworkInterfaces": [ {
          "AssociatePublicIpAddress" : true,
          "DeviceIndex"              : 0,
          "SubnetId"                 : { "Fn::Select" : [ "0", { "Ref" : "Subnets" } ] },
          "GroupSet"                 : { "Ref" : "SecurityGroups" }
        } ],
        "UserData" : { "Fn::Base64" : { "Fn::Join" : [ "", [
          "#!/bin/bash\n",
          "yum update -y\n",
          "yum install -y nginx\n",
          "/etc/init.d/nginx start\n",
          "chkconfig nginx on\n",
          "/opt/aws/bin/cfn-signal -e 0 --stack ", { "Ref": "AWS::StackName" },
          " --resource Instance ",
          " --region ", { "Ref" : "AWS::Region" }, "\n"
        ]]}}
      },
      "CreationPolicy" : {
        "ResourceSignal" : {
          "Count"      : 1,
          "Timeout"    : "PT10M"
        }
      }
    },

    "InstanceImage" : {
      "Type" : "Custom::CreateImage",
      "Properties" : {
        "ServiceToken" : { "Fn::GetAtt" : [ "CreateImageLambdaFunction", "Arn" ] },
        "InstanceId"   : { "Ref" : "Instance" }
      }
    }
  }
}
```