作为 CloudFormation 栈的一部分创建 AMI 镜像。

37
我希望创建一个EC2 CloudFormation堆栈,可以通过以下步骤进行描述:
1.启动实例
2.对实例进行配置
3.停止实例并创建AMI映像
4.使用创建的AMI映像作为源来启动新实例的自动缩放组。
可以在一个CloudFormation模板中执行步骤1和2,在第二个模板中执行步骤4。但是,在CloudFormation模板内部创建AMI映像似乎无法实现,这导致必须手动删除AMI,如果要删除堆栈,则需要删除它。
我的问题是:
1.是否有一种方法可以在CloudFormation模板内部创建AMI映像?
2.如果答案是否定的,则是否有一种方法将AMI映像(或其他资源)添加到已完成的堆栈中?
4个回答

41

可以通过实现一个自定义资源在CloudFormation模板中调用CreateImage API(并在删除时调用DeregisterImageDeleteSnapshotAPI)来从EC2实例创建AMI。

由于AMI有时需要很长时间才能创建完成,因此Lambda支持的自定义资源将需要在Lambda函数超时之前重新调用自身,以等待完成。

这里是一个完整的示例:

Launch Stack

Description: Create an AMI from an EC2 instance.
Parameters:
  ImageId:
    Description: Image ID for base EC2 instance.
    Type: AWS::EC2::Image::Id
    # amzn-ami-hvm-2016.09.1.20161221-x86_64-gp2
    Default: ami-9be6f38c
  InstanceType:
    Description: Instance type to launch EC2 instances.
    Type: String
    Default: m3.medium
    AllowedValues: [ m3.medium, m3.large, m3.xlarge, m3.2xlarge ]
Resources:
  # Completes when the instance is fully provisioned and ready for AMI creation.
  AMICreate:
    Type: AWS::CloudFormation::WaitCondition
    CreationPolicy:
      ResourceSignal:
        Timeout: PT10M
  Instance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ImageId
      InstanceType: !Ref InstanceType
      UserData:
        "Fn::Base64": !Sub |
          #!/bin/bash -x
          yum -y install mysql # provisioning example
          /opt/aws/bin/cfn-signal \
            -e $? \
            --stack ${AWS::StackName} \
            --region ${AWS::Region} \
            --resource AMICreate
          shutdown -h now
  AMI:
    Type: Custom::AMI
    DependsOn: AMICreate
    Properties:
      ServiceToken: !GetAtt AMIFunction.Arn
      InstanceId: !Ref Instance
  AMIFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Code:
        ZipFile: !Sub |
          var response = require('cfn-response');
          var AWS = require('aws-sdk');
          exports.handler = function(event, context) {
            console.log("Request received:\n", JSON.stringify(event));
            var physicalId = event.PhysicalResourceId;
            function success(data) {
              return response.send(event, context, response.SUCCESS, data, physicalId);
            }
            function failed(e) {
              return response.send(event, context, response.FAILED, e, physicalId);
            }
            // Call ec2.waitFor, continuing if not finished before Lambda function timeout.
            function wait(waiter) {
              console.log("Waiting: ", JSON.stringify(waiter));
              event.waiter = waiter;
              event.PhysicalResourceId = physicalId;
              var request = ec2.waitFor(waiter.state, waiter.params);
              setTimeout(()=>{
                request.abort();
                console.log("Timeout reached, continuing function. Params:\n", JSON.stringify(event));
                var lambda = new AWS.Lambda();
                lambda.invoke({
                  FunctionName: context.invokedFunctionArn,
                  InvocationType: 'Event',
                  Payload: JSON.stringify(event)
                }).promise().then((data)=>context.done()).catch((err)=>context.fail(err));
              }, context.getRemainingTimeInMillis() - 5000);
              return request.promise().catch((err)=>
                (err.code == 'RequestAbortedError') ?
                  new Promise(()=>context.done()) :
                  Promise.reject(err)
              );
            }
            var ec2 = new AWS.EC2(),
                instanceId = event.ResourceProperties.InstanceId;
            if (event.waiter) {
              wait(event.waiter).then((data)=>success({})).catch((err)=>failed(err));
            } else if (event.RequestType == 'Create' || event.RequestType == 'Update') {
              if (!instanceId) { failed('InstanceID required'); }
              ec2.waitFor('instanceStopped', {InstanceIds: [instanceId]}).promise()
              .then((data)=>
                ec2.createImage({
                  InstanceId: instanceId,
                  Name: event.RequestId
                }).promise()
              ).then((data)=>
                wait({
                  state: 'imageAvailable',
                  params: {ImageIds: [physicalId = data.ImageId]}
                })
              ).then((data)=>success({})).catch((err)=>failed(err));
            } else if (event.RequestType == 'Delete') {
              if (physicalId.indexOf('ami-') !== 0) { return success({});}
              ec2.describeImages({ImageIds: [physicalId]}).promise()
              .then((data)=>
                (data.Images.length == 0) ? success({}) :
                ec2.deregisterImage({ImageId: physicalId}).promise()
              ).then((data)=>
                ec2.describeSnapshots({Filters: [{
                  Name: 'description',
                  Values: ["*" + physicalId + "*"]
                }]}).promise()
              ).then((data)=>
                (data.Snapshots.length === 0) ? success({}) :
                ec2.deleteSnapshot({SnapshotId: data.Snapshots[0].SnapshotId}).promise()
              ).then((data)=>success({})).catch((err)=>failed(err));
            }
          };
      Runtime: nodejs4.3
      Timeout: 300
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal: {Service: [lambda.amazonaws.com]}
          Action: ['sts:AssumeRole']
      Path: /
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      - arn:aws:iam::aws:policy/service-role/AWSLambdaRole
      Policies:
      - PolicyName: EC2Policy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
              - 'ec2:DescribeInstances'
              - 'ec2:DescribeImages'
              - 'ec2:CreateImage'
              - 'ec2:DeregisterImage'
              - 'ec2:DescribeSnapshots'
              - 'ec2:DeleteSnapshot'
              Resource: ['*']
Outputs:
  AMI:
    Value: !Ref AMI

很棒的模板!感谢分享。 - nanestev
从两年前开始,但我不明白lambda何时被执行? - Kaymaz
1
这个 "physicalId.indexOf('ami-')..." 是关于什么的?能详细说明一下吗? - mibollma
1
资源的 PhysicalResourceIdec2.createImage 操作完成后设置为返回的 ImageId,所有有效的镜像 ID 都以 ami- 前缀开头。当物理 ID 设置为有效的 Image ID 时,physicalId.indexOf('ami-') 将等于 0,这意味着在删除资源时需要删除该镜像(通过 ec2.deregisterImage)。如果在删除资源之前从未将物理资源设置为有效的 Image ID,则可以跳过 deregisterImage 操作。 - wjordan
注意:不再支持使用nodejs4.3的运行时参数来创建或更新AWS Lambda函数。建议使用nodejs18.x - undefined

10

值得一提的是,这是 wjordan 的 AMIFunction 定义的 Python 变体,在原回答中。原始 yaml 中的所有其他资源保持不变:

AMIFunction:
  Type: AWS::Lambda::Function
  Properties:
    Handler: index.handler
    Role: !GetAtt LambdaExecutionRole.Arn
    Code:
      ZipFile: !Sub |
        import logging
        import cfnresponse
        import json
        import boto3
        from threading import Timer
        from botocore.exceptions import WaiterError

        logger = logging.getLogger()
        logger.setLevel(logging.INFO)

        def handler(event, context):

          ec2 = boto3.resource('ec2')
          physicalId = event['PhysicalResourceId'] if 'PhysicalResourceId' in event else None

          def success(data={}):
            cfnresponse.send(event, context, cfnresponse.SUCCESS, data, physicalId)

          def failed(e):
            cfnresponse.send(event, context, cfnresponse.FAILED, str(e), physicalId)

          logger.info('Request received: %s\n' % json.dumps(event))

          try:
            instanceId = event['ResourceProperties']['InstanceId']
            if (not instanceId):
              raise 'InstanceID required'

            if not 'RequestType' in event:
              success({'Data': 'Unhandled request type'})
              return

            if event['RequestType'] == 'Delete':
              if (not physicalId.startswith('ami-')):
                raise 'Unknown PhysicalId: %s' % physicalId

              ec2client = boto3.client('ec2')
              images = ec2client.describe_images(ImageIds=[physicalId])
              for image in images['Images']:
                ec2.Image(image['ImageId']).deregister()
                snapshots = ([bdm['Ebs']['SnapshotId'] 
                              for bdm in image['BlockDeviceMappings'] 
                              if 'Ebs' in bdm and 'SnapshotId' in bdm['Ebs']])
                for snapshot in snapshots:
                  ec2.Snapshot(snapshot).delete()

              success({'Data': 'OK'})
            elif event['RequestType'] in set(['Create', 'Update']):
              if not physicalId:  # AMI creation has not been requested yet
                instance = ec2.Instance(instanceId)
                instance.wait_until_stopped()

                image = instance.create_image(Name="Automatic from CloudFormation stack ${AWS::StackName}")

                physicalId = image.image_id
              else:
                logger.info('Continuing in awaiting image available: %s\n' % physicalId)

              ec2client = boto3.client('ec2')
              waiter = ec2client.get_waiter('image_available')

              try:
                waiter.wait(ImageIds=[physicalId], WaiterConfig={'Delay': 30, 'MaxAttempts': 6})
              except WaiterError as e:
                # Request the same event but set PhysicalResourceId so that the AMI is not created again
                event['PhysicalResourceId'] = physicalId
                logger.info('Timeout reached, continuing function: %s\n' % json.dumps(event))
                lambda_client = boto3.client('lambda')
                lambda_client.invoke(FunctionName=context.invoked_function_arn, 
                                      InvocationType='Event',
                                      Payload=json.dumps(event))
                return

              success({'Data': 'OK'})
            else:
              success({'Data': 'OK'})
          except Exception as e:
            failed(e)
    Runtime: python2.7
    Timeout: 300

然而,我想在创建镜像后立即终止实例。在上述函数中,我在第一个success()调用之前添加了一行代码boto3.resource('ec2').Instance(instanceId).terminate()。但是它会出现错误“无效的响应对象:属性数据的值必须是一个对象”。有什么想法吗? - Jaydeep Ranipa

2
  1. 不。
  2. 我认为可以。一旦您创建了堆栈,就可以使用"更新堆栈"操作。您需要在同一文件中提供初始堆栈的完整JSON模板和更改内容(已更改的AMI)。建议您先在测试环境中运行此操作(而非生产环境),因为我不确定该操作会对现有实例造成何种影响。

为什么不在CloudFormation之外最初创建一个AMI,然后在最终的CloudFormation模板中使用该AMI?

另一种选择是编写一些自动化代码来创建两个CloudFormation堆栈,一旦您创建的AMI完成,您可以删除第一个堆栈。


Rico,如果我没记错的话(而且我现在正在做这件事,所以我认为我没有记错),你可以通过更新来修改创建后的堆栈。目前我处理创建 AMI 的想法是在 cloudformation 外部进行。基本上,我使用一个带有 3 个步骤的 ansible playbook:
  1. 使用 cloudformation 创建实例
  2. 使用 ansible 创建该实例的 AMI
  3. 使用 ansible 创建的 AMI 更新已创建的其余堆栈 我的问题实际上是关于将 AMI 部分作为堆栈或作为 cloudformation 步骤的一部分。我会更新我的问题以澄清。
- user2422451
@dibits 知道了。所以我已经修改了我的第二个答案。现在我记得有一个“更新堆栈”的操作。您需要在同一文件中提供初始堆栈的完整JSON模板和您的更改。 - Rico
如果我理解你的意思,那么我只需要在云形成模板中更新AMI id,但不会将AMI镜像合并到堆栈中。为了进一步澄清,由于我正在使用一个堆栈完成所有操作,我希望能够删除该堆栈,并且AMi能够与堆栈自动注销(就像实例、负载均衡器等一样)。 - user2422451
我相信是这样的。我自己没有尝试过。很想看看结果是什么。 - Rico

1
虽然 @wjdordan 的解决方案对于简单的用例很好,但更新用户数据不会更新 AMI。
(免责声明:我是原作者)cloudformation-ami 的目标是允许您在 CloudFormation 中声明可靠地创建、更新和删除的 AMI。使用 cloudformation-ami,您可以像这样声明自定义 AMI:
MyAMI:
  Type: Custom::AMI
  Properties:
    ServiceToken: !ImportValue AMILambdaFunctionArn
    Image:
      Name: my-image
      Description: some description for the image
    TemplateInstance:
      ImageId: ami-467ca739
      IamInstanceProfile:
        Arn: arn:aws:iam::1234567890:instance-profile/MyProfile-ASDNSDLKJ
      UserData:
        Fn::Base64: !Sub |
          #!/bin/bash -x
          yum -y install mysql # provisioning example
          # Signal that the instance is ready
          INSTANCE_ID=`wget -q -O - http://169.254.169.254/latest/meta-data/instance-id`
          aws ec2 create-tags --resources $INSTANCE_ID --tags Key=UserDataFinished,Value=true --region ${AWS::Region}
      KeyName: my-key
      InstanceType: t2.nano
      SecurityGroupIds:
      - sg-d7bf78b0
      SubnetId: subnet-ba03aa91
      BlockDeviceMappings:
      - DeviceName: "/dev/xvda"
        Ebs:
          VolumeSize: '10'
          VolumeType: gp2

1
说实话,扩展我的答案以在更新UserData后更新AMI相对简单,只需创建新的“WaitCondition”和“Custom :: AMI”资源(或重命名现有定义的逻辑ID,这将创建一个新的并销毁旧的)。 对于我自己的非简单用例,我使用ERB模板包装我的CloudFormation模板,并在逻辑ID中包含提交哈希的一部分,例如,“AMICreate<%= commit%>”和“AMI<%= commit%>”。 - wjordan
是的,那很有道理!谢谢你的提示。 - spg

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接