CloudFormationでいつでも最新AMIからEC2を起動したい

2016.04.30

西澤です。検証目的の場合はいつも割安な海外Regionを使っているのですが、特にWindows環境の検証をするときに最新の日本語AMIを探してから起動する作業が面倒で仕方がありませんでした。目新しいことは何もないのですが、今回CloudFormationから最新AMIを取得できるテンプレートを作成したのでご紹介してみようと思います。

テンプレートの概要

本記事はほぼせーの | Developers.IOさんの下記記事を丸パクリ大いに参考にさせていただいただけの内容です。詳しくは下記の素晴らしい記事をご覧ください。

この記事を踏まえて、下記の流れで利用できるようにしました。

  1. Baseスタックから取得したい最新AMIのIDを取得(Lambdaファンクションの作成とその実行結果を取得)
  2. メインスタックからEC2を起動

最新AMIのIDを取得するBaseスタック

せーのさんのLambdaファンクションそのままですが、LambdaファンクションをCloudFormationから作成して、AmiNameパラメータから最新AMIのIDを指定できるようにして、outputに返すようにしただけです。一応動作確認をしたWindows2012R2日本語、Windows2008R2SP1日本語、AmazonLinuxだけ指定できるようにしていますが、フィルタ条件は要件に合わせて変更して使ってみていただければと思います。

"Runtime"の指定を、"nodejs"から"nodejs4.3"に修正しました。

{
  "AWSTemplateFormatVersion" : "2010-09-09",
  "Description" : "Get Latest AMI Template",
  "Parameters" : {
    "AmiName" : {
      "Type" : "String",
      "Default" : "Windows_Server-2012-R2_RTM-Japanese-64Bit-Base-*",
      "AllowedValues" : [
        "Windows_Server-2012-R2_RTM-Japanese-64Bit-Base-*",
        "Windows_Server-2008-R2_SP1-Japanese-64Bit-Base-*",
        "amzn-ami-hvm-*"
      ],
      "Description" : "AMI Name."
    }
  },
  "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": "lambda-inline-policy",
          "PolicyDocument": {
            "Version" : "2012-10-17",
            "Statement": [ {
              "Effect": "Allow",
              "Action": [
                "ec2:DescribeImages",
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
              ],
              "Resource": "*"
            } ]
          }
        } ]
      }
    },
    "LambdaFunction" : {
      "Type" : "AWS::Lambda::Function",
      "Properties" : {
        "Code" : {
          "ZipFile" : { "Fn::Join" : ["", [
            "console.log('Loading event');", "\n",
            "exports.handler = function(event, context) {", "\n",
            "  console.log(\"REQUEST RECEIVED:\\n\", JSON.stringify(event));", "\n",
            "  ", "\n",
            "  if (event.RequestType == \"Delete\") {", "\n",
            "    sendResponse(event, context, \"SUCCESS\");", "\n",
            "    return;", "\n",
            "  }", "\n",
            "  ", "\n",
            "  var Owner = \"amazon\";", "\n",
            "  var ExecutableUsers = \"all\";", "\n",
            "  var architecture = \"x86_64\";", "\n",
            "  var name = \"", { "Ref" : "AmiName" }, "\";", "\n",
            "  var root_device_type = \"ebs\";", "\n",
            "  var virtualization_type = \"hvm\";", "\n",
            "  var volumetype = \"gp2\";", "\n",
            "  var responseStatus = \"FAILED\";", "\n",
            "  var responseData = {};", "\n",
            "  ", "\n",
            "  ", "\n",
            "  var aws = require(\"aws-sdk\");", "\n",
            "  var ec2 = new aws.EC2();", "\n",
            "   ", "\n",
            "  var params = {", "\n",
            "    DryRun: false,", "\n",
            "    ExecutableUsers: [", "\n",
            "      ExecutableUsers", "\n",
            "    ],", "\n",
            "    Filters: [", "\n",
            "      {", "\n",
            "        Name: \"architecture\",", "\n",
            "        Values: [", "\n",
            "          architecture", "\n",
            "        ]", "\n",
            "      },", "\n",
            "      {", "\n",
            "        Name: \"root-device-type\",", "\n",
            "        Values: [", "\n",
            "          root_device_type", "\n",
            "        ]", "\n",
            "      },", "\n",
            "      {", "\n",
            "        Name: \"name\",", "\n",
            "        Values: [", "\n",
            "          name", "\n",
            "        ]", "\n",
            "      },", "\n",
            "      {", "\n",
            "        Name: \"virtualization-type\",", "\n",
            "        Values: [", "\n",
            "          virtualization_type", "\n",
            "        ]", "\n",
            "      },", "\n",
            "      {", "\n",
            "        Name: \"block-device-mapping.volume-type\",", "\n",
            "        Values: [", "\n",
            "          volumetype", "\n",
            "        ]", "\n",
            "      }", "\n",
            "    ],", "\n",
            "  };", "\n",
            "  console.log(\"describeImage start\");", "\n",
            "  ec2.describeImages(params, function(err, data) {", "\n",
            "    if (err) {", "\n",
            "      responseData = {Error: \"DescribeAMIs call failed\"};", "\n",
            "      console.log(responseData.Error + \":\\n\", err);", "\n",
            "    }", "\n",
            "    // Populates the return data with the outputs from the specified stack", "\n",
            "    else {", "\n",
            "      //sort AMIs", "\n",
            "      responseStatus = \"SUCCESS\";", "\n",
            "      data.Images.sort(function(a,b){", "\n",
            "        if(b.Name.indexOf(\"rc-\") >= 0){", "\n",
            "          return -1;", "\n",
            "        } else {", "\n",
            "          if(a.Name > b.Name){", "\n",
            "            return -1;", "\n",
            "          } else {", "\n",
            "            return 1;", "\n",
            "          }", "\n",
            "        }", "\n",
            "      });", "\n",
            "      ", "\n",
            "      var latestName = data.Images[0].Name;", "\n",
            "      var latestAMIId = data.Images[0].ImageId;", "\n",
            "      console.log(\"name : \" + latestName + \" AMI ID : \" + latestAMIId);", "\n",
            "      responseData[\"LatestAmiId\"] = latestAMIId;", "\n",
            "    }", "\n",
            "    sendResponse(event, context, responseStatus, responseData);", "\n",
            "  });", "\n",
            "};", "\n",
            "", "\n",
            "//Sends response to the pre-signed S3 URL", "\n",
            "function sendResponse(event, context, responseStatus, responseData) {", "\n",
            "  var responseBody = JSON.stringify({", "\n",
            "    Status: responseStatus,", "\n",
            "    Reason: \"See the details in CloudWatch Log Stream: \" + context.logStreamName,", "\n",
            "    PhysicalResourceId: context.logStreamName,", "\n",
            "    StackId: event.StackId,", "\n",
            "    RequestId: event.RequestId,", "\n",
            "    LogicalResourceId: event.LogicalResourceId,", "\n",
            "    Data: responseData", "\n",
            "  });", "\n",
            "  ", "\n",
            "  console.log(\"RESPONSE BODY:\\n\", responseBody);", "\n",
            " ", "\n",
            "  var https = require(\"https\");", "\n",
            "  var url = require(\"url\");", "\n",
            " ", "\n",
            "  var parsedUrl = url.parse(event.ResponseURL);", "\n",
            "  var options = {", "\n",
            "    hostname: parsedUrl.hostname,", "\n",
            "    port: 443,", "\n",
            "    path: parsedUrl.path,", "\n",
            "    method: \"PUT\",", "\n",
            "    headers: {", "\n",
            "      \"content-type\": \"\",", "\n",
            "      \"content-length\": responseBody.length", "\n",
            "    }", "\n",
            "  };", "\n",
            "  ", "\n",
            "  var request = https.request(options, function(response) {", "\n",
            "    console.log(\"STATUS: \" + response.statusCode);", "\n",
            "    console.log(\"HEADERS: \" + JSON.stringify(response.headers));", "\n",
            "    // Tell AWS Lambda that the function execution is done  ", "\n",
            "    context.done();", "\n",
            "  });", "\n",
            "  ", "\n",
            "  request.on(\"error\", function(error) {", "\n",
            "    console.log(\"sendResponse Error:\\n\", error);", "\n",
            "    // Tell AWS Lambda that the function execution is done  ", "\n",
            "    context.done();", "\n",
            "    });", "\n",
            "  ", "\n",
            "  // write data to request body", "\n",
            "  request.write(responseBody);", "\n",
            "  request.end();", "\n",
            "}", "\n"
          ]]}
        },
        "Handler" : "index.handler",
        "Role": { "Fn::GetAtt" : ["LambdaExecutionRole", "Arn"] },
        "Runtime" : "nodejs4.3",
        "Timeout": "30"
      }
    },
    "NetworkInfoGetAMI": {
      "Type": "Custom::NetworkInfo",
      "Properties": {
        "ServiceToken": { "Fn::Join": [ "", [ "arn:aws:lambda:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":function:", {"Ref" : "LambdaFunction"} ] ] }
      }
    }
  },
  "Outputs" : {
    "LatestAmiId" : {
      "Value" : { "Fn::GetAtt": [ "NetworkInfoGetAMI", "LatestAmiId" ] }
    }
  }
}

テンプレートはメインスタックからから呼び出せるようにS3バケットに配置しました。公開状態で置いていますので、とにかく試したい方はぜひ使ってみてください。

Baseスタックを利用したメインスタック

参考として載せておきますが、この中身は何でも良いです。BaseスタックのOutput({ "Fn::GetAtt" : [ "BaseStack", "Outputs.LatestAmiId" ] })からAMIIDを指定するところがポイントです。

{
  "AWSTemplateFormatVersion" : "2010-09-09",
  "Description" : "Launch from Latest AMI Template",
  "Parameters" : {
    "BaseStackTemplateURL"  : {
      "Type" : "String",
      "Default" : "https://s3-ap-northeast-1.amazonaws.com/cm-nishizawa.tetsunori/public/GetLatestAMI.template"
    },
    "AmiName" : {
      "Type" : "String",
      "Default" : "Windows_Server-2012-R2_RTM-Japanese-64Bit-Base-*",
      "AllowedValues" : [
        "Windows_Server-2012-R2_RTM-Japanese-64Bit-Base-*",
        "Windows_Server-2008-R2_SP1-Japanese-64Bit-Base-*",
        "amzn-ami-hvm-*"
      ],
      "Description" : "AMI Name."
    },

    "VPC" : {
      "Type" : "AWS::EC2::VPC::Id",
      "Description" : "VPC ID"
    },
    "Subnet" : {
      "Type" : "AWS::EC2::Subnet::Id",
      "Description" : "Subnet ID"
    },
    "KeyName": {
      "Description": "Name of an existing EC2 KeyPair to enable SSH access to the instances",
      "Type": "AWS::EC2::KeyPair::KeyName",
      "Default" : "servicekey"
    },
    "SourceIpFrom" : {
      "Description" : "CIDR of access source IP.",
      "Type" : "String",
      "Default" : "0.0.0.0/0"
    },
    "InstanceType" : {
      "Type" : "String",
      "Default" : "t2.medium",
      "Description" : "EC2 Instance Type."
    },
    "EC2Tag" : {
      "Type" : "String",
      "Default" : "ec2test",
      "Description" : "Name Tag for EC2 Instance."
    }

  },
  "Resources" : {
    "BaseStack" : {
      "Type" : "AWS::CloudFormation::Stack",
      "Properties" : {
        "TemplateURL" : { "Ref" : "BaseStackTemplateURL" },
        "Parameters" : {
          "AmiName" : { "Ref" : "AmiName" }
        }
      }
    },

    "EC2IamRole" : {
      "Type" : "AWS::IAM::Role",
      "Properties" : {
        "AssumeRolePolicyDocument" : {
          "Statement" : [ {
            "Effect" : "Allow",
              "Principal" : {
                "Service" : [ "ec2.amazonaws.com" ]
              },
              "Action" : [ "sts:AssumeRole" ]
          } ]
        },
        "ManagedPolicyArns" : [ 
          "arn:aws:iam::aws:policy/ReadOnlyAccess"
        ],
        "Path" : "/"
      }
    },
    "EC2InstanceProfile" : {
      "Type" : "AWS::IAM::InstanceProfile",
      "Properties" : {
        "Path" : "/",
        "Roles" : [ { "Ref" : "EC2IamRole" } ]
      }
    },
    "EC2SecurityGroup" : {
      "Type" : "AWS::EC2::SecurityGroup",
      "Properties" : {
        "VpcId" : { "Ref" : "VPC" },
        "GroupDescription" : "Enable RDP and SSH access",
        "SecurityGroupIngress" : [
          { "IpProtocol" : "tcp", "FromPort" : "22",  "ToPort" : "22",  "CidrIp" : { "Ref" : "SourceIpFrom" }},
          { "IpProtocol" : "tcp", "FromPort" : "3389",  "ToPort" : "3389",  "CidrIp" : { "Ref" : "SourceIpFrom" }}
        ]
      }
    },
    "EC2InstanceEIP": {
      "Type": "AWS::EC2::EIP",
      "Properties": {
        "Domain": "vpc"
      }
    },
    "EC2EipAssociation" : {
      "Type": "AWS::EC2::EIPAssociation",
      "Properties": {
        "InstanceId" : { "Ref" : "EC2Instance" },
        "AllocationId": { "Fn::GetAtt" : [ "EC2InstanceEIP", "AllocationId" ] }
      }
    },
    "EC2Instance": {
      "Type": "AWS::EC2::Instance",
      "Properties": {
        "InstanceType": { "Ref": "InstanceType" },
        "KeyName": { "Ref": "KeyName" },
        "IamInstanceProfile" : { "Ref" : "EC2InstanceProfile" }, 
        "SubnetId" : { "Ref" : "Subnet" },
        "ImageId" : { "Fn::GetAtt" : [ "BaseStack", "Outputs.LatestAmiId" ] },
        "SecurityGroupIds" : [
          { "Ref" : "EC2SecurityGroup" }
        ],
        "Tags": [
          { "Key": "Name", "Value": { "Ref": "EC2Tag" } }
        ]
      }
    }
  },
  "Outputs" : {
    "EC2InstanceEIP" : {
      "Value" : { "Ref" : "EC2InstanceEIP" }
    }
  }
}

最新AMIでEC2起動

それでは早速起動してみましょう。

launchlatest1

BaseスタックでWindows2012R2の最新AMIが取得できています。せーのさんありがとう!

launchlatest2

無事に最新AMIからEC2インスタンスが起動してきました!

launchlatest3

今回は検証目的での構築・削除をイメージしたテンプレートですので、このテンプレートを何度も使うとLambdaファンクションがたくさん作成されてしまうので注意してください。構築後も利用し続けるリソースはCloudFormationのDeletionPolicyを利用する等して、管理できるように考える必要があると思います。

まとめ

構築手順を再現するのにとても便利なCloudFormationですが、AMIのIDを指定するところが毎度面倒でした。これで、最新AMIを使った構築作業も捗りそうです。

個人的には面倒な作業はそれほど苦手では無い方だと思うのですが、同じことを二度三度行うときには、やはり自動化しておきたいところですよね。CloudFormationをBaseスタックでネスト、Lambda-Backed Custom Resourcesの利用、はまともに作ったのが初めてだったのでとても勉強になりました。そして、Lambdaを使った処理は楽しい!コードをかけなくても素晴らしいサンプルを多くの人が紹介してくれていますので、ぜひ活用しましょう。

この内容がどこかの誰かのお役に立てば嬉しいです。