AWS CloudFormationのLambda-Backed Custom Resourcesを使って最新のAMIを取得する

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

こんにちは、せーのです。今日はLambda-Backed Custom Resourcesを使ってもう一つ、楽をしてみたいと思います。

Lambda-Backed Custom Resourcesとは

Lambda-Backed Custom ResourcesとはCloudFormationに先日加わった新機能です。こちらを使うことでCloudFormationのStackを作成、修正、削除する際にLambdaを発火させることができ、更にその戻り値をCloudFormationのリソースとして使うことができます。
Stackの作成に別StackのID値や既存のリソースの操作を組み込んだりと、CloudFormationを使った構築の幅がグッと広がります。詳しくは昨日の記事を御覧ください。

AMIのメンテナンスは意外と大変

CloudFormationを使ってEC2を構築する際にはどのAMIを使うのか指定する必要があります。リージョンごと、OSごと、また使用するアーキテクチャごとにそれぞれAMIが用意されているので、最初にそちらをある程度リスト化して用意しておく必要があります。大抵はこんな感じにMappingを作っておきます。

 "Mappings": {
    "AWSAmazonLinuxAMI": {
      "us-east-1":      { "name":"Virginia",   "201303": "ami-3275ee5b", "201309": "ami-35792c5c" },
      "us-west-2":      { "name":"Oregon",     "201303": "ami-ecbe2adc", "201309": "ami-d03ea1e0" },
      "us-west-1":      { "name":"California", "201303": "ami-66d1fc23", "201309": "ami-687b4f2d" },
      "eu-west-1":      { "name":"Ireland",    "201303": "ami-44939930", "201309": "ami-149f7863" },
      "ap-southeast-1": { "name":"Singapole",  "201303": "ami-aa9ed2f8", "201309": "ami-14f2b946" },
      "ap-southeast-2": { "name":"Sydney",     "201303": "ami-363eaf0c", "201309": "ami-a148d59b" },
      "ap-northeast-1": { "name":"Tokyo",      "201303": "ami-173fbf16", "201309": "ami-3561fe34", "201403" : "ami-a1bec3a0","hvm" : "ami-18869819" },
      "sa-east-1":      { "name":"SaoPaulo",   "201303": "ami-dd6bb0c0", "201309": "ami-9f6ec982" }
    }
  }

ところがこのMappingをご覧になってもわかるようにAmazon Linuxは現在約6ヶ月ごとのスパンでバージョンが上がっていきます。CloudFormationのテンプレートを使用して最新のAMIを使用するにはUserDataにてyum updateをかけるか、半年ごとに新しいAMIを調べて書いていかなくてはいけません。
「テンプレート」と呼ばれるものはなるべく汎用的に、触りたくないのが人情というもの。なんとかならないものでしょうか。

Lambdaを使って取得する

ここでLambdaを使ってみましょう。Lambda Functionの中身はAWS Javascript SDKですのでEC2のAMIはEC2.describeImages() を使えば取ってこれるはずです。公式AMIの名前は[amzn-ami-hvm-2014.09.0.x86_64-ebs]のように日付が入った形でフォーマットが決まっているため、名前でソートすれば最新のAMIが取得できますね。ではやってみましょう。

やってみる

昨日の記事で使用したテンプレートを使用します。新しいLambda Functionを組みます。

console.log('Loading event');

exports.handler = function(event, context) {
    console.log("REQUEST RECEIVED:\n", JSON.stringify(event));
    
    if (event.RequestType == "Delete") {
        sendResponse(event, context, "SUCCESS");
        return;
    }
    
    var Owner = "amazon";
    var ExecutableUsers = "all";
    var architecture = "x86_64";
    var name = "amzn-ami-hvm-*";
    var root_device_type = "ebs";
    var virtualization_type = "hvm";
    var volumetype = "gp2";
    var responseStatus = "FAILED";
    var responseData = {};
  
   
    var aws = require("aws-sdk");
    var ec2 = new aws.EC2();
    
    var params = {
      DryRun: false,
      ExecutableUsers: [
        ExecutableUsers
      ],
      Filters: [
        {
          Name: "architecture",
          Values: [
            architecture
          ]
        },
        {
          Name: "root-device-type",
          Values: [
            root_device_type
          ]
        },
        {
          Name: "name",
          Values: [
            name
          ]
        },
        {
          Name: "virtualization-type",
          Values: [
            virtualization_type
          ]
        },
        {
          Name: "block-device-mapping.volume-type",
          Values: [
            volumetype
          ]
        }
      ],
      Owners: [
        Owner
      ]
    };  
    console.log("describeImage start");
    ec2.describeImages(params, function(err, data) {
      if (err) {
            responseData = {Error: "DescribeAMIs call failed"};
            console.log(responseData.Error + ":\n", err);
        }
        // Populates the return data with the outputs from the specified stack
        else {
            //sort AMIs
            responseStatus = "SUCCESS";
            data.Images.sort(function(a,b){
              if(b.Name.indexOf("rc-") >= 0){
                  return -1;
              }else{
                if(a.Name > b.Name){
                  return -1;
                } else {
                  return 1;
                }
              }
            });
            
            var latestName = data.Images[0].Name;
            var latestAMIId = data.Images[0].ImageId;
            console.log("name : " + latestName + " AMI ID : " + latestAMIId);
            responseData["LatestAmiId"] = latestAMIId;
        }
        sendResponse(event, context, responseStatus, responseData);
    });
};

//Sends response to the pre-signed S3 URL
function sendResponse(event, context, responseStatus, responseData) {
   var responseBody = JSON.stringify({
        Status: responseStatus,
        Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName,
        PhysicalResourceId: context.logStreamName,
        StackId: event.StackId,
        RequestId: event.RequestId,
        LogicalResourceId: event.LogicalResourceId,
        Data: responseData
    });
    
    console.log("RESPONSE BODY:\n", responseBody);

    var https = require("https");
    var url = require("url");

    var parsedUrl = url.parse(event.ResponseURL);
    var options = {
        hostname: parsedUrl.hostname,
        port: 443,
        path: parsedUrl.path,
        method: "PUT",
        headers: {
            "content-type": "",
            "content-length": responseBody.length
        }
    };

    var request = https.request(options, function(response) {
        console.log("STATUS: " + response.statusCode);
        console.log("HEADERS: " + JSON.stringify(response.headers));
        // Tell AWS Lambda that the function execution is done  
        context.done();
    });

    request.on("error", function(error) {
        console.log("sendResponse Error:\n", error);
        // Tell AWS Lambda that the function execution is done  
        context.done();
    });

    // write data to request body
    request.write(responseBody);
    request.end();
}

EC2のAMI一覧をhvm, SSDボリューム, Amazon Linux, という条件で検索し、名前の降順 + RC版を下にするようにソートして一番上を返しています。レスポンスの返し方は昨日の記事を御覧ください。

それでは上のコードで作成したLambda Function[test-get-latest-ami-id]を作成します。一覧を取得する際の時間を考えてタイムアウト値を少し長めの20秒としています。

cfn_latest_ami1

カスタムリソースを作る

上で作ったLambdaをCloudFormationでカスタムリソース化するとこのようになります。

"NetworkInfoGetAMI": {
  "Type": "Custom::NetworkInfo",
  "Properties": {
    "ServiceToken": { "Fn::Join": [ "", [ "arn:aws:lambda:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":function:", {"Ref" : "AMIGetLambdaFunctionName"} ] ] }
  }
}

[ServiceToken]にはLambdaのARNをセットします。CloudFormationは独自関数や変数が使えるのでこのように書くと使い回しが効きます。{"Ref" : "AMIGetLambdaFunctionName"}にはfunction名の[test-get-latest-ami-id]をパラメータとしてセットします。

こちらを踏まえて昨日の記事の[app]を書き換えるとこうなります。

{
  "AWSTemplateFormatVersion" : "2010-09-09",
  "Description" : "AWS CloudFormation Sample Template VPC_with_PublicIPs_And_DNS: Sample template that creates a VPC with DNS and public IPs enabled. Note that you are billed for the AWS resources that you use when you create a stack from this template.",
  "Parameters": {
    "Env": {
      "Description": "Choose the environment to create: 'Green', 'Blue' or 'Base'",
      "Type": "String",
      "Default" : "Green", 
      "AllowedValues" : ["Green", "Blue", "Base"]
    },
     "KeyName": {
      "Description": "Name of an existing EC2 KeyPair to enable SSH access to the instances",
      "Type": "String",
      "MinLength": "1",
      "MaxLength": "64",
      "AllowedPattern": "[-_ a-zA-Z0-9]*",
      "ConstraintDescription": "can contain only alphanumeric characters, spaces, dashes and underscores."
    },
    "NetworkStackName": {
      "Description": "Name of an active CloudFormation stack that contains the networking resources, such as a subnet or security group, that will be used in this stack.",
      "Type": "String",
      "MinLength" : 1,
      "MaxLength" : 255,
      "AllowedPattern" : "^[a-zA-Z][-a-zA-Z0-9]*$",
      "Default" : "test-based"
    },
    "LambdaFunctionName": {
      "Description": "Name of the Lambda function.",
      "Type": "String",
      "MinLength" : 1,
      "MaxLength" : 255,
      "AllowedPattern" : "^[a-zA-Z][-a-zA-Z0-9]*$",
      "Default" : "test-cfn-to-lambda"
    },
    "AMIGetLambdaFunctionName": {
      "Description": "Name of the Lambda function.",
      "Type": "String",
      "MinLength" : 1,
      "MaxLength" : 255,
      "AllowedPattern" : "^[a-zA-Z][-a-zA-Z0-9]*$",
      "Default" : "test-get-latest-ami-id"
    }
  },
  "Resources" : {
    "InstanceIAMRole" : {
      "Type" : "AWS::IAM::Role",
      "Properties" : {
        "AssumeRolePolicyDocument" : {
          "Statement" : [ {
            "Effect" : "Allow",
              "Principal" : {
                "Service" : [ "ec2.amazonaws.com" ]
              },
              "Action" : [ "sts:AssumeRole" ]
          } ]
        },
        "Path" : "/"
      }
    },
    "EC2ReadOnlyPolicy" : {
      "Type" : "AWS::IAM::Policy",
      "Properties" : {
        "PolicyName" : "EC2ReadOnly",
        "PolicyDocument" : {
          "Statement" : [
            {
              "Effect": "Allow",
              "Action": "ec2:Describe*",
              "Resource": "*"
            },
            {
              "Effect": "Allow",
              "Action": "elasticloadbalancing:Describe*",
              "Resource": "*"
            },
            {
              "Effect": "Allow",
              "Action": [
                "cloudwatch:ListMetrics",
                "cloudwatch:GetMetricStatistics",
                "cloudwatch:Describe*"
              ],
              "Resource": "*"
            },
            {
              "Effect": "Allow",
              "Action": "autoscaling:Describe*",
              "Resource": "*"
            }
          ]
        },
        "Roles" : [ { "Ref" : "InstanceIAMRole" } ]
      }
    },
    "InstanceIAMProfile" : {
      "Type" : "AWS::IAM::InstanceProfile",
      "Properties" : {
        "Path" : "/",
        "Roles" : [ { "Ref" : "InstanceIAMRole" } ]
      }
    },

    "Web01" : {
      "Type" : "AWS::EC2::Instance",
      "Properties" : {
        "InstanceType" : "t2.micro",
        "BlockDeviceMappings" : [
          {
            "DeviceName" : "/dev/xvda",
            "Ebs" : { "VolumeSize" : "8" }
          }
        ], 
        "KeyName" : { "Ref" : "KeyName" },
        "IamInstanceProfile" : { "Ref" : "InstanceIAMProfile" }, 
        "SubnetId" : { "Fn::GetAtt": [ "NetworkInfo", "PublicSubnetAza" ] },
        "SourceDestCheck" : "true",
        "ImageId" : { "Fn::GetAtt": [ "NetworkInfoGetAMI", "LatestAmiId" ] },
        "SecurityGroupIds" : [
          { "Ref" : "WebSecurityGroup" },
          { "Fn::GetAtt": [ "NetworkInfo", "DefaultSecurityGroup" ] }
        ],
        "UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
          "#cloud-config","\n",
          "resize_rootfs: true","\n",
          "timezone: Asia/Tokyo","\n",
          "locale: ja_JP.UTF-8","\n"
        ] ] } },
        "Tags" : [
          {"Key" : "Name", "Value" : "web01" }
        ]
      }
    },
    "Web02" : {
      "Type" : "AWS::EC2::Instance",
      "Properties" : {
        "InstanceType" : "t2.micro",
        "BlockDeviceMappings" : [
          {
            "DeviceName" : "/dev/xvda",
            "Ebs" : { "VolumeSize" : "8" }
          }
        ], 
        "KeyName" : { "Ref" : "KeyName" },
        "IamInstanceProfile" : { "Ref" : "InstanceIAMProfile" }, 
        "SubnetId" : { "Fn::GetAtt": [ "NetworkInfo", "PublicSubnetAzc" ] },
        "SourceDestCheck" : "true",
        "ImageId" : { "Fn::GetAtt": [ "NetworkInfoGetAMI", "LatestAmiId" ] },
        "SecurityGroupIds" : [
          { "Ref" : "WebSecurityGroup" },
          { "Fn::GetAtt": [ "NetworkInfo", "DefaultSecurityGroup" ] }
        ],
        "UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
          "#cloud-config","\n",
          "resize_rootfs: true","\n",
          "timezone: Asia/Tokyo","\n",
          "locale: ja_JP.UTF-8","\n"
        ] ] } },
        "Tags" : [
          {"Key" : "Name", "Value" : "web01" }
        ]
      }
    },
    
    "WebSecurityGroup" : {
      "Type" : "AWS::EC2::SecurityGroup",
      "Properties" : {
        "VpcId" : { "Fn::GetAtt": [ "NetworkInfo", "VPCId" ] },
        "GroupDescription" : "Allow all communications in VPC",
        "SecurityGroupIngress" : [
          { "IpProtocol" : "-1", "FromPort" : "80", "ToPort" : "80", "CidrIp" : "0.0.0.0/0" }
        ],
        "Tags" : [
          { "Key" : "Name", "Value" : { "Fn::Join" : [ "-", [ "web", { "Ref" : "Env"} ] ] } }
        ]
      }
    },
    "NetworkInfo": {
	  "Type": "Custom::NetworkInfo",
	  "Properties": {
	    "ServiceToken": { "Fn::Join": [ "", [ "arn:aws:lambda:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":function:", {"Ref" : "LambdaFunctionName"} ] ] },
	    "StackName": {
	      "Ref": "NetworkStackName"
	    }
	  }
	},
	"NetworkInfoGetAMI": {
	  "Type": "Custom::NetworkInfo",
	  "Properties": {
	    "ServiceToken": { "Fn::Join": [ "", [ "arn:aws:lambda:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":function:", {"Ref" : "AMIGetLambdaFunctionName"} ] ] }
	  }
	}
  },
  "Outputs" : {
    "Web01id" : {
      "Description" : "Web01 ID",
      "Value" :  { "Ref" : "Web01" }
    },
    "Web02id" : {
      "Description" : "Web02 ID",
      "Value" :  { "Ref" : "Web02" }
    },
    "webSecurityGroup" : {
      "Description" : "WebSecurityGroup ID",
      "Value" :  { "Fn::GetAtt" : ["WebSecurityGroup", "GroupId"] }
    }
  }
}

Mapping要素だったAMIを全て削除することが出来ました。ではこれを流してみましょう。結果はこうなります。

cfn_latest_ami2

無事にインスタンスが出来たようです。EC2を見てみましょう。

cfn_latest_ami3

AMIが最新のAmazon Linuxになっています。成功です!

まとめ

いかがでしょうか。AMIのメンテは思っていたより大変です。また今回はLambda内の変数としてFilter要素をベタ打ちしましたが、CFnからのパラメータによってAmazon LinuxとCent-OSを選べるように組んだりするとよりわかりやすくなりますね。参考になさって下さい。

参考サイト