为 Auto Scaling 组分配可用的弹性 IP 地址以实现一致的 IP 地址

5 分钟阅读
内容级别:高级
2

本文将指导您如何为 AWS Auto Scaling 组(ASG)中的 EC2 实例自动分配弹性 IP 地址(EIP)。通过利用 AWS 服务如 Lambda、SNS 和生命周期挂钩,该解决方案确保实例使用一致的、加入允许列表的 IP 地址,从而满足严格的安全要求。

简介

许多组织都有严格的安全要求,例如在其本地防火墙或其他资源上仅允许加入允许列表的特定 IP 地址或弹性 IP 地址(Elastic IP,简称 EIP)。然而,当使用 AWS Auto Scaling 组来自动管理 EC2 实例的伸缩时,确保每个实例都使用加入允许列表的 IP 地址可能会面临挑战。

本文提供了一种解决方案,可以自动为 Auto Scaling 组启动的新实例分配可用的 EIP,确保所有实例都使用已知的、加入允许列表的 IP 地址,即使 Auto Scaling 组进行缩容或扩容时也是如此。

问题

Auto Scaling 组会根据需求动态调整 EC2 实例的数量,以确保您的应用程序能够处理不同级别的流量。默认情况下,每个由 Auto Scaling 组启动的新实例都会分配一个动态的公网 IP 地址,如果您的安全策略要求使用特定的、已加入允许列表的 IP 地址,这可能会带来问题。

为了解决这个问题,我们可以使用弹性 IP 地址(EIP),这些是静态的公网 IP 地址,您可以在实例之间重新分配。通过将 EIP 分配给 Auto Scaling 组启动的实例,您可以确保每个实例都使用加入允许列表的 IP 地址,从而满足安全要求,同时保持 Auto Scaling 的灵活性。

解决方案

为了实现这一目标,我们利用了以下 AWS 服务的组合:

  • Auto Scaling 组: 根据需求自动管理您的 EC2 实例数量。
  • 弹性 IP 地址(EIP): 可重新分配到您的 AWS 账户中任何实例的静态 IP 地址池。
  • AWS Lambda: 一种无服务器计算服务,无需管理服务器即可运行代码,用于自动为新启动的实例分配 EIP。
  • Amazon SNS(简单通知服务): 一种消息服务,在新实例启动时触发 Lambda 函数。
  • 生命周期挂钩(Lifecycle Hooks): Auto Scaling 的一个功能,允许在实例启动或终止过程中暂停并执行自定义操作。

步骤流程

1. 分配 IAM 角色

在设置其他组件之前,确保必要的权限已经到位是非常重要的。这一步包括创建 IAM 角色:

  • SNS 发布者角色: 该角色允许 Auto Scaling 组向 SNS 主题发送通知。
  • Lambda 执行角色: 该角色授予 Lambda 函数与 EC2 和 Auto Scaling 服务交互的权限。

2. 创建 SNS 主题

SNS 主题作为通信中心,接收来自生命周期挂钩的通知并触发 Lambda 函数。这样可以将生命周期事件与 Lambda 函数解耦,允许创建一个干净且易于管理的架构。

3. 配置带有生命周期挂钩的 Auto Scaling 组

接下来,配置 Auto Scaling 组并添加生命周期挂钩。生命周期挂钩会暂停实例的启动过程,允许您在实例投入使用之前执行自定义操作,例如分配 EIP。当实例启动时,生命周期挂钩会向 SNS 主题发送通知。

4. 配置 Lambda 函数

Lambda 函数是此解决方案的核心组件。它监听来自 SNS 主题的通知,并在触发时执行以下步骤:

  1. 检查现有 EIP: Lambda 函数首先检查实例是否已经分配了 EIP。
  2. 分配可用 EIP: 如果实例尚未分配 EIP,函数会在您的 EIP 池中搜索可用地址并将其分配给实例。
  3. 验证分配: 在短暂延迟后,函数会验证 EIP 是否已正确关联到实例。
  4. 重试逻辑: 如果 EIP 分配失败或验证未成功,Lambda 函数会多次重试此过程。
  5. 完成: 如果函数成功分配并验证了 EIP,它会通知生命周期挂钩继续并完成实例的启动。如果所有重试都失败,函数会通知生命周期挂钩放弃实例启动。

5. 部署 CloudFormation 模板

最后,使用 CloudFormation 模板自动化整个设置。以下 CloudFormation 模板定义了所有必要的资源,包括 Auto Scaling 组、生命周期挂钩、SNS 主题、Lambda 函数和 IAM 角色。部署模板后,会设置整个流程,确保由您的 Auto Scaling 组启动的每个实例都会自动接收一个 EIP。

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  AutoScalingGroupName:
    Type: String
    Description: Please enter an existing Auto Scaling Group Name

Resources:
  EIPAssignmentSNSTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: EIPAssignmentTopic

  AutoScalingNotificationRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - autoscaling.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: SNSPublishPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - sns:Publish
                Resource: 
                  Ref: EIPAssignmentSNSTopic

  EIPAssignmentLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: EIPAssignmentLambdaPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:DescribeInstances
                  - ec2:DescribeAddresses
                  - ec2:AssociateAddress
                  - autoscaling:CompleteLifecycleAction
                Resource: "*"
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: "*"

  EIPAssignmentLifecycleHook:
    Type: AWS::AutoScaling::LifecycleHook
    Properties:
      AutoScalingGroupName: 
        Ref: AutoScalingGroupName
      DefaultResult: ABANDON
      HeartbeatTimeout: 180  
      LifecycleHookName: EIPAssignment-Hook
      LifecycleTransition: autoscaling:EC2_INSTANCE_LAUNCHING
      NotificationTargetARN: 
        Ref: EIPAssignmentSNSTopic
      RoleARN: 
        Fn::GetAtt: 
          - AutoScalingNotificationRole
          - Arn

  EIPAssignmentLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: EIPAssignmentFunction
      Code:
        ZipFile: |
          const { EC2Client, DescribeInstancesCommand, DescribeAddressesCommand, AssociateAddressCommand } = require("@aws-sdk/client-ec2");
          const { AutoScalingClient, CompleteLifecycleActionCommand } = require("@aws-sdk/client-auto-scaling");

          async function checkEIP(instanceId) {
              const ec2Client = new EC2Client({});
              const params = { InstanceIds: [instanceId] };
              const describeInstancesResponse = await ec2Client.send(new DescribeInstancesCommand(params));
              const instance = describeInstancesResponse.Reservations[0].Instances[0];

              // Get a list of all EIPs in your account
              const describeAddressesResponse = await ec2Client.send(new DescribeAddressesCommand({ Filters: [{ Name: 'domain', Values: ['vpc'] }] }));
              const eipSet = new Set(describeAddressesResponse.Addresses.map(addr => addr.PublicIp));

              // Check if the instance's public IP is one of your EIPs
              for (const eni of instance.NetworkInterfaces) {
                  if (eni.Association && eipSet.has(eni.Association.PublicIp)) {
                      console.log("Instance already has EIP:", eni.Association.PublicIp);
                      return eni.Association.PublicIp;
                  }
              }

              return null;
          }

          async function associateEIP(instanceId) {
              const ec2Client = new EC2Client({});
              // Find an available EIP
              const describeAddressesResponse = await ec2Client.send(new DescribeAddressesCommand({ Filters: [{ Name: 'domain', Values: ['vpc'] }] }));
              const availableAddress = describeAddressesResponse.Addresses.find(addr => !addr.AssociationId);

              if (!availableAddress) {
                  throw new Error("No available EIP found");
              }

              // Associate the EIP with the instance
              const associateParams = {
                  AllocationId: availableAddress.AllocationId,
                  InstanceId: instanceId
              };
              await ec2Client.send(new AssociateAddressCommand(associateParams));
              console.log("Successfully associated EIP", availableAddress.PublicIp, "with instance", instanceId);
              return availableAddress.PublicIp;
          }

          function randomDelay() {
              const min = 2000;
              const max = 4000;
              const delay = Math.floor(Math.random() * (max - min + 1)) + min;
              return new Promise(resolve => setTimeout(resolve, delay));
          }

          exports.handler = async (event) => {
              const asClient = new AutoScalingClient({});
              const instanceId = JSON.parse(event.Records[0].Sns.Message).EC2InstanceId;
              let success = false;
              let retries = 10;  // Increased retries

              while (!success && retries > 0) {
                  try {
                      retries--;

                      // Step 1: Check if the instance already has an EIP from your pool
                      let assignedEIP = await checkEIP(instanceId);
                      if (!assignedEIP) {
                          // Step 2: Associate an EIP
                          assignedEIP = await associateEIP(instanceId);
                      }

                      // Random delay between 2 and 4 seconds before verification
                      await randomDelay();

                      // Step 3: Verify the association
                      const verifiedEIP = await checkEIP(instanceId);
                      if (verifiedEIP === assignedEIP) {
                          console.log("Successfully verified EIP:", verifiedEIP);
                          success = true;
                      } else {
                          console.error("Verification failed. Retrying...");
                      }
                  } catch (error) {
                      console.error("Error during EIP assignment:", error.message);
                      if (retries === 0) {
                          console.error("Max retries reached. Failing the lifecycle action.");
                          const message = JSON.parse(event.Records[0].Sns.Message);
                          const lifecycleParams = {
                              AutoScalingGroupName: message.AutoScalingGroupName,
                              LifecycleHookName: message.LifecycleHookName,
                              LifecycleActionToken: message.LifecycleActionToken,
                              LifecycleActionResult: "ABANDON"
                          };
                          await asClient.send(new CompleteLifecycleActionCommand(lifecycleParams));
                          return;
                      }
                  }
              }

              if (success) {
                  const message = JSON.parse(event.Records[0].Sns.Message);
                  const lifecycleParams = {
                      AutoScalingGroupName: message.AutoScalingGroupName,
                      LifecycleHookName: message.LifecycleHookName,
                      LifecycleActionToken: message.LifecycleActionToken,
                      LifecycleActionResult: "CONTINUE"
                  };
                  await asClient.send(new CompleteLifecycleActionCommand(lifecycleParams));
              }
          };
      Handler: index.handler
      Runtime: nodejs20.x  
      Role: 
        Fn::GetAtt: 
          - EIPAssignmentLambdaRole
          - Arn
      Timeout: 60

  LambdaSNSSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Endpoint: 
        Fn::GetAtt: 
          - EIPAssignmentLambdaFunction
          - Arn
      Protocol: lambda
      TopicArn: 
        Ref: EIPAssignmentSNSTopic

  LambdaSNSInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: 
        Fn::GetAtt: 
          - EIPAssignmentLambdaFunction
          - Arn
      Action: lambda:InvokeFunction
      Principal: sns.amazonaws.com
      SourceArn: 
        Ref: EIPAssignmentSNSTopic

Outputs:
  EIPAssignmentLambdaFunction:
    Value: 
      Ref: EIPAssignmentLambdaFunction

注意:在部署前,请确保您的 AWS 账户在目标区域有足够的空闲EIP,以避免部署失败。

该方法的优势

  • 一致的 IP 允许列表: 通过使用 EIP 池,您的实例始终拥有已知的 IP 地址,简化了防火墙规则和安全配置。
  • 自动化: 该过程通过 AWS Lambda 和 CloudFormation 完全自动化,减少了手动干预的需要并降低了出错风险。
  • 可扩展性: 该解决方案与 Auto Scaling 组无缝配合,确保即使您的基础设施动态扩展,您的 IP 允许列表要求也能得到满足。

结论

为 Auto Scaling 组中的实例分配固定的 EIP,可以在动态云环境中保持 IP 地址的一致性,从而实现 IP 允许列表。通过利用 AWS 服务如 Lambda、SNS 和生命周期挂钩,您可以自动化该过程,确保每个由 Auto Scaling 组启动的新实例都分配了已知且列入允许列表的 IP 地址。

该解决方案提供了一种稳健、可扩展且自动化的方法来满足严格的安全要求,同时享受 Auto Scaling 组的灵活性和强大功能。通过提供的 CloudFormation 模板,您可以在几分钟内将此解决方案部署到您的 AWS 环境中,确保您的 Auto Scaling 实例始终使用所需的 IP 地址。

下一步

  • 在您的 AWS 环境中部署提供的 CloudFormation 模板。
  • 通过在 Auto Scaling 组中启动新实例并验证它们是否接收了正确的 EIP 来测试该解决方案。
  • 根据您的具体用例,调整 Lambda 函数逻辑或 EIP 池。

通过遵循这些步骤,您可以确保 Auto Scaling 组实例始终拥有您安全和操作需求所要求的 IP 地址。

免责声明:

此脚本已在受控实验环境中多次测试,并始终表现良好。然而,该脚本是“按原样”提供的,尽管我们已尽最大努力确保其可靠性,但我们无法保证其在其他环境中的准确性或功能。用户有责任在非生产环境中彻底评估和测试此脚本,然后再将其部署到生产环境中。使用此脚本的风险由用户自行承担,AWS 对因使用该脚本引起的任何损害或问题不承担任何责任。如需帮助或有任何疑问,请联系 AWS 支持团队

profile pictureAWS
支持工程师
Tim
已​发布 1 个月前2784 查看次数