Edited at

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

More than 3 years have passed since last update.

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


つくるもの


  • InstanceIDをパラメータとして、AMIを作成してAMI IDをリターンするカスタムリソース

  • CloudFormationのRequestTypeに応じて、挙動を変える


    • Create: AMIの作成

    • Update: 以前のAMIを削除して、新しく作成

    • Delete: 以前のAMIを削除



  • Optionで削除をしなくできるようにする


使い方イメージ

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

"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。

{

"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" }
}
}
}
}