亚马逊AWS官方博客

FinOps 时代的高可用经济实惠 NAT 网关替代方案

1. 背景

用户在上云初期享受到了云所带来方便的同时,也增加了一些对于成本控制的困惑。对于一些没有必要花费的,还是能省则省。但是很多云原生的托管服务,用户是没有办法进行客户化配置的。例如 NAT 网关,你没有办法因为自己需要的网络流量不大,就选择小一点的配置。或者是由于网络流量大,而不得不支付额外的 NAT 网关流量费用。NAT 网关的收费分两部分:每个小时的使用费用, 每 GB 的数据处理费用。

以北京区域为例,NAT 网关的价格如下:

每 NAT 网关价格(¥/小时):¥ 0.427
处理每 GB 数据的价格(¥):¥ 0.427

如果按照每个月(30天)500GB 的数据计算,配置一个典型的 Amazon VPC(2 个可用区,每个可用区一个 NAT 网关)的费用是 307.44*2+213.5=828.38 元。

除了费用以外,NAT 网关并不具备跨可用区的容错功能,即 1 个可用区的网关失效后,需要人工修改路由表才能使用另一个可用区的网关。

 2. 已有方案

在 NAT 网关推出之前,用户都是选择使用自己搭建 NAT 实例的方法,同时也有一些关于如何搭建高可用的 NAT 实例的文章,但是都有一些局限性。例如:
1)High Availability for Amazon VPC NAT Instances: An Example
这篇文章介绍了利用检查心跳(heat beat)的方式实现高可用,缺点是:

  • 由于心跳的方式是采用运行 ping 命令到对方的主机查看是否有响应,所以只能确保 NAT 实例的内网端口没有问题,无法保证网络包转发功能是否正常。
  • 采用 2 台独立的 Amazon EC2 实例充当 NAT 实例,无法享受 Spot 实例带来的价格实惠。因为 Spot 实例会被随时终止,而如果一台 EC2 被关机,则该方案就无法继续工作了。
  • 路由表信息写在监控脚本里面(sh),如果加入一个新的网段(不使用 Main 路由表)使用 NAT 实例,要修改监控脚本。
  • 用户如果有网段要使用 NAT 实例, 则要手工配置路由表。

2)High Availability NAT with SNS and Lambda
这篇文章介绍了使用 Amazon EC2 Auto Scaling 实现高可用的方法,缺点是:

  •  只能监测 NAT 实例的终止信息(Termination event),不能保证 NAT 实例功能的正常(网络包转发功能)。
  •  每个可用区只支持一个 NAT 实例。
  •  用户如果有网段要使用 NAT 实例, 则要手工配置路由表。

因为 NAT 网关收取数据处理费用,使用 NAT 网关的费用集中在数据处理部分的公司也趋向选择 NAT 实例来降低费用,网上也有类似的方案,例如 alterNAT,这个方案的问题是:如果 NAT 实例或网关的时长费用更主要,则 alterNAT 方案效果不明显。alterNAT 方案可以在大于 10TB 每月流量时节省开支。它使用了自动扩缩容的 NAT 实例、备用 NAT 网关、自动故障转移等机制来减少成本。

综上所述,一个理想的方案应该满足如下条件:

  1. 高可用,采用贴近实际情况的健康检查,不仅检查 Amazon EC2 的硬件故障,也要检测出网络故障或者操作系统故障。
  2. 方便运维,不需要手工配置子网路由,不需要考虑子网路由是否被错误的配置成跨网段访问 NAT 实例,从而带来额外的网络费用。
  3. 支持动态扩展,如果流量增加或者 NAT 实例的性能不够,可以支持在一个 Amazon VPC 可用区内部署多台 NAT 实例,并自动配置到需要使用的子网路由表。
  4. 如果 1 个 Amazon VPC 可用区内使用多个 NAT 实例,要尽量做到不同的子网使用不同的 NAT 实例,以充分发挥 NAT 实例的性能。
  5. 尽量降低成本。

3. 全新的 NAT 实例集群设计方案

本方案采用 Amazon EC2 Auto Scaling 来管理跨 Amazon VPC 可用区的 NAT 实例,同时采用 Spot 实例进一步降低使用成本。参照上面的例子,如果使用本方案的成本如下:

2 个 c6g.medium spot 实例,每个实例的价格 ¥0.01572/小时;

Lambda:128MB 内存,运行时长单价:¥0.0000000142(每 1 毫秒的价格 (北京))。方案中的 Lambda 每 15 分钟运行一次,每次 15 分钟,总计 25.92 元。每 100 万个请求 ¥1.36,总计 0.0039168 元;

AmazonEC2 终端节点:¥0.1120/小时。处理的每 GB 数据定价(¥)为 0.07。

总费用:22.6368(EC2)+25.92(Lambda 运行)+0.0039168(Lambda 请求)+161.28(终端节点)+0.14(终端节点流量)=209.98,相比原来的费用,节省了 74.6%。

如果你的企业本来就要使用 Amazon EC2 终端节点和 Amazon EC2 Auto Scaling  终端节点,那么采用 NAT 实例的成本每月仅为 ¥48.7元。如果考虑到 2024 年开始收取的公网 IP 地址费用,¥0.033/小时,总费用会再增加 47.52 元。

系统架构

下面是这个解决方案的架构图:

在 Public 子网内部署 NAT 实例

假设已经提前部署好的接口终端节点也在这个子网里面。这部分的 CloudFormation 示例代码如下:

AutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      MinSize: "2"
      MaxSize: "2"
      VPCZoneIdentifier: !Ref SubnetIDs
      MixedInstancesPolicy:
        InstancesDistribution:
          OnDemandBaseCapacity: 0
          OnDemandPercentageAboveBaseCapacity: 0
          SpotAllocationStrategy: "lowest-price"
        LaunchTemplate:
          LaunchTemplateSpecification:
            LaunchTemplateId: !Ref LaunchTemplate
            Version: !GetAtt LaunchTemplate.LatestVersionNumber
  LaunchTemplate:
    Type: "AWS::EC2::LaunchTemplate"
    Properties:
      LaunchTemplateName: "SpotInstanceLaunchTemplate"
      LaunchTemplateData:
        InstanceType: !Ref InstanceType
        ImageId: !Ref LatestAmiId 
        IamInstanceProfile:
          Name: !Ref InstanceProfile
        NetworkInterfaces:
          - DeviceIndex: 0
            AssociatePublicIpAddress: true
            DeleteOnTermination: true
            Groups:
              - !Ref SecurityGroup
        UserData:
          Fn::Base64: |

可以看到,这里我们采用了“lowest-price”的 Spot 实例价格策略。对于一般性的网络需求,可以选择 t4g.micro(5Gbps 带宽)或者 c6g.medium(10Gbps 带宽)两种示例类型。

UserData 部分的内容如下:

#cloud-config
              cloud_final_modules:
              - [scripts-user, always]

              runcmd:
                - |
                  #!/bin/bash

                  # Install awscurl and pip if not already installed

                  if ! command -v pip &> /dev/null; then
                    yum install -y pip
                  fi

                  if ! command -v awscurl &> /dev/null; then
                      pip install awscurl
                  fi

                  #BASH_ACTION="ModifyInstanceAttribute"
                  BASH_SOURCE_DEST_CHECK="false"
                  #VERSION="2016-11-15"
                  # Request a token for IMDSv2
                  tkn=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

                  # Extract AWS temporary credentials from instance metadata using the token
                  crd=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s http://169.254.169.254/latest/meta-data/iam/security-credentials/)
                  ak=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s http://169.254.169.254/latest/meta-data/iam/security-credentials/${crd} | grep AccessKeyId | cut -d'"' -f4)
                  sk=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s http://169.254.169.254/latest/meta-data/iam/security-credentials/${crd} | grep SecretAccessKey | cut -d'"' -f4)
                  ss_tkn=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s http://169.254.169.254/latest/meta-data/iam/security-credentials/${crd} | /bin/sed -n 's/.*"Token" : "\(.*\)",/\1/p')
                  mac_id=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s  http://169.254.169.254/latest/meta-data/network/interfaces/macs)
                  eni_id=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s http://169.254.169.254/latest/meta-data/network/interfaces/macs/{$mac_id}interface-id)

                  #Get az information
                  az=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s http://169.254.169.254/latest/meta-data/placement/availability-zone/)
                  if [ $? -ne 0 ]
                  then
                      exit 255
                  fi

                  # Validate the az
                  if [[ ! "${az}" =~ ^([a-z]+-){2,3}[0-9][a-z]$ ]]
                  then
                      exit 255
                  fi

                  #Extract region from az
                  rgn=$(/bin/echo "${az}" | /bin/sed -n 's/\(\([a-z]\+-\)\+[0-9]\+\).*/\1/p')

                  # Get domain_name for calls
                  domain_name=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s http://169.254.169.254/latest/meta-data/services/domain/)
                  if [ $? -ne 0 ]
                  then
                      exit 255
                  fi

                  endpoint_name="ec2.${rgn}.${domain_name}"
                  ec2_pnt="https://${endpoint_name}"


                  # Get the instance ID and availability az
                  instance_id=$(curl -H "X-aws-ec2-metadata-token: $tkn" -s http://169.254.169.254/latest/meta-data/instance-id)

                  counter=0
                  while [ $counter -lt 10 ]; do
                      output=$(awscurl --service ec2 \
                        --region ${rgn}\
                        --access_key ${ak}\
                        --secret_key ${sk}\
                        --session_token ${ss_tkn}\
                      "${ec2_pnt}/?Action=ModifyInstanceAttribute&InstanceId=${instance_id}&SourceDestCheck.Value=${BASH_SOURCE_DEST_CHECK}&Version=2016-11-15")
                      
                      # 检查输出是否为成功消息
                      if [[ $output == *"<return>true</return>"* ]]; then
                          echo "Success!"
                          break
                      else
                          echo "Attempt $(($counter + 1)) failed. Retrying..."
                          counter=$((counter + 1))
                          sleep 2 # 等待2秒再试
                      fi
                  done

                  counter=0
                  while [ $counter -lt 10 ]; do
                      output=$(awscurl --service ec2 \
                        --region ${rgn}\
                        --access_key ${ak}\
                        --secret_key ${sk}\
                        --session_token ${ss_tkn}\
                        "${ec2_pnt}/?Action=CreateTags&ResourceId.1=${instance_id}&Tag.1.Key=Name&Tag.1.Value=nat(${eni_id: -4:4})-${az}&Version=2016-11-15")

                      # 检查输出是否为成功消息
                      if [[ $output == *"<return>true</return>"* ]]; then
                          echo "Success!"
                          break
                      else
                          echo "Attempt $(($counter + 1)) failed. Retrying..."
                          counter=$((counter + 1))
                          sleep 2 # 等待2秒再试
                      fi
                  done
                  # NAT configuration
                  sudo sysctl -w net.ipv4.ip_forward=1 | sudo tee -a /etc/sysctl.conf
                  sudo yum install -y nftables
                  sudo nft add table nat
                  sudo nft -- add chain nat prerouting { type nat hook prerouting priority -100 \; }
                  sudo nft add chain nat postrouting { type nat hook postrouting priority 100 \; }
                  sudo nft add rule nat postrouting oifname "$(ip -o link show device-number-0 | awk -F': ' '{print $2}')" masquerade
                  # NAT 设定保存
                  sudo nft list table nat | sudo tee /etc/nftables/al2023-nat.nft
                  echo 'include "/etc/nftables/al2023-nat.nft"' | sudo tee -a /etc/sysconfig/nftables.conf
                  # 启动+自动启动设定 
                  sudo systemctl start nftables
                  sudo systemctl enable nftables

由于 NAT 实例采用最小内核的 Amazon Linux 2023 操作系统(配置如下):

Type: 'AWS::SSM::Parameter::Value<String>'
Default: '/aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-6.1-arm64'

操作系统预先不安装 AWS CLI, 因此我们安装了比较小的 awscurl 工具(这样做的目的是加快 NAT 实例的启动), 调用 AWS 命令采用了 RestFul API 的方式来完成如下功能:

  1. 关闭 Source/Destination 检查
  2. 创建 EC2 名称标签

NAT 实例的服务器名称采用如下命名方式:nat(ENI 网卡后 4 位})-“AZ 信息”, 例如 nat(767f)-cn-north-1a。

如果企业对 NAT 的带宽有要求,我们可以通过部署多个 NAT 来解决,例如可以为每个子网都设立一个 NAT。Lambda 监控程序将自动将 NAT 分配给不同的子网。

在第三个 AZ 部署监控 Lambda

Lambda 部署在含有独立路由表的私有子网内,其监控的实现思路是这样的:

  • 通过环境变量设置位于因特网的测试站点,例如 http://z.cn 和 https://console.amazonaws.cn。 通过更改自己所在的子网路由依次使用被监控的 NAT 实例,测试利用这些 NAT 实例访问外部网站的可能性,来判断 NAT 实例是否工作正常。
def check_websites_reachable(urls, nat_instance_ids):
    urls = parse_input(urls)
    nat_instance_ids = parse_input(nat_instance_ids)

    route_tables = get_route_tables_by_tag('monitor')

    for nat_instance_id in nat_instance_ids:
        # Delete the 0.0.0.0/0 route only once at the beginning
        for route_table in route_tables:
            delete_route(route_table['RouteTableId'], "0.0.0.0/0")

        for route_table in route_tables:
            current_nat_id = False
            if not update_route_table(route_table['RouteTableId'], current_nat_id, nat_instance_id, '0.0.0.0/0'):
                logger.warning(
                    f"Route table {route_table['RouteTableId']} not updated correctly for NAT instance {nat_instance_id}, current_nat_id={current_nat_id}, nat_instance_id={nat_instance_id}.")
                return False

        logger.info(f"Using {nat_instance_id} to check")

        if check_websites_reachable_concurrently(urls):
            # Delete the 0.0.0.0/0 route only once after the check
            for route_table in route_tables:
                delete_route(route_table['RouteTableId'], "0.0.0.0/0")
            return True

    return False
  • 如果发现 NAT 实例工作不正常,Lambda 会依照以下原则选择合适的备选 NAT 实例,并自动修改使用 NAT 实例的路由表信息(这些路由表都有一个 tag,key 是 nat-instance):
    1. 得到所有使用这个 NAT 实例的路由表信息,得到其所在的 AZ 信息。查看所有健康的 NAT 实例信息,优先选择没有被使用的且与需要替换路由的路由表在同一个 AZ 的 NAT 实例。
    2. 如果在同一 AZ 的 NAT 实例都被其他路由表使用了,就随机选择一个同 AZ 的 NAT 实例。
    3. 如果不存在相同 AZ 的健康 NAT 实例,就在其他 AZ 优先选择一个没有被使用的健康 NAT 实例。
    4. 如果在其他 AZ 不存在空闲的健康 NAT 实例,就随机选一个健康的 NAT 实例。
    5. 终止所有不健康的 NAT 实例,Amazon EC2 Auto Scaling 会重新生成新的替代实例。是否健康的判断方法:Lambda 通过 socket 检查目标端口是否可以联通,如果在规定的 timeout 时间内无法联通,就会判断为不健康。这可能是因为网络原因引起,也可能是因为 NAT 的性能差而引起的。
def handle_route_updates(healthy_nat_instances, route_table, subnet_az, assigned_instances):
    rt_instance = None
    need_adjust = True 
    assigned_nat_instance = assigned_instances.get(route_table['RouteTableId'], None)
    nat_instances_in_az = [nat for nat in healthy_nat_instances if nat['AvailabilityZone'] == subnet_az]
    unassigned_in_az = [nat['NatInstanceId']for nat in nat_instances_in_az if nat['NatInstanceId'] not in assigned_instances.values()]
    other_az_instances = [nat for nat in healthy_nat_instances if nat['AvailabilityZone']!= subnet_az and nat['NatInstanceId'] not in assigned_instances.values()]
    
    # If the current instance is located in the same Availability Zone (AZ), there's no need for any modifications.
    for route in route_table['Routes']:
      if route.get('DestinationCidrBlock', False) == '0.0.0.0/0':
        rt_instance = route.get('InstanceId', False)
        if rt_instance and rt_instance in [nat['NatInstanceId'] for nat in healthy_nat_instances] and rt_instance in [nat['NatInstanceId'] for nat in nat_instances_in_az]:
            if rt_instance in unassigned_in_az:
              need_adjust = False
              assigned_instances[route_table['RouteTableId']]=rt_instance
            unassigned_in_az = [nat['NatInstanceId']
                    for nat in nat_instances_in_az if nat['NatInstanceId'] not in assigned_instances.values()]
            logger.info(f"rt_instance: {rt_instance} in route_table_id: {route_table['RouteTableId']} does not need adjustment") 
        
    if need_adjust:
      # Attempt to locate a NAT instance within the same AZ that hasn't been assigned yet.
      if unassigned_in_az:
        new_nat_id = random.choice(unassigned_in_az)
        assigned_instances[route_table['RouteTableId']] = new_nat_id
      else:
        # If there are no available unassigned instances, select a functioning instance from the same AZ.
        if nat_instances_in_az:
          new_nat_id = random.choice(nat_instances_in_az)['NatInstanceId']
          assigned_instances[route_table['RouteTableId']] = new_nat_id
        else:
            other_az_instances = [nat for nat in healthy_nat_instances if nat['AvailabilityZone']!= subnet_az and nat['NatInstanceId'] not in assigned_instances.values()]  
            #  If the current instance is located in a different Availability Zone, there's no need for any modifications.
            if rt_instance and rt_instance in other_az_instances:
                need_adjust = False
                assigned_instances[route_table['RouteTableId']]=rt_instance
            else:
                # If there's no instance available in the same AZ, choose one from a different AZ that hasn't been assigned yet.
                other_az_instances = [nat for nat in healthy_nat_instances if nat['AvailabilityZone']!= subnet_az and nat['NatInstanceId'] not in assigned_instances.values()]
                if other_az_instances:
                  new_nat_id = random.choice(other_az_instances)['NatInstanceId']
                  assigned_instances[route_table['RouteTableId']] = new_nat_id
                else:
                    # If there are no unassigned instances from other AZs, select a functioning instance.
                    if not other_az_instanc and healthy_nat_instances:
                        new_nat_id = random.choice(healthy_nat_instances)['NatInstanceId']
                                         
    if need_adjust and new_nat_id:
        update_route_table(route_table['RouteTableId'], rt_instance, new_nat_id, '0.0.0.0/0')

性能

经过测试,在一台 NAT 实例失效的情况下,系统需要 7 秒(09:44:13-09:44:20)的时间切换到健康的实例。

原因是:我们设立了 2 个 Web 网站的检查点,每个检查点的超期时间(timeout)是 1 秒, 重试次数为 3 次。这样 2 个网站的检测总共需要 6 秒的时间。

def is_port_open(ip, port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(1)
    try:
        s.connect((ip, port))
        s.close()
        return True
    except socket.error:
        return False


def parse_input(input_data):
    return input_data.split(',') if isinstance(input_data, str) else input_data


def check_website_reachable(url, max_retries=3):
    parsed_url = urlparse(url)
    port = parsed_url.port if parsed_url.port else 443 if parsed_url.scheme == 'https' else 80

    retries = 0
    while retries < max_retries:
        if is_port_open(parsed_url.hostname, port):
            logger.info(f"{parsed_url.hostname}:{port} is reachable")
            return True
        retries += 1

    logger.warning(
        f"{parsed_url.hostname}:{port} is not reachable after {max_retries} retries")
    return False

当我们调整检测站点的数量(1 个)和重试次数(2 次)后,恢复速度有所提高,由 7 秒变成 4 秒(14:00:30-14:00:34)。

关于部署

这个解决方案的示例代码分为 2 部分:CloudFormation 模版用于创建 NAT 实例,Python 脚本用于创建 Lambda

  1. 确保部署的 Amazon VPC 内能使用两种 VPC 终端节点:Amazon EC2 接口终端节点和 Amazon EC2 Auto Scaling 接口终端节点。
  2. 下载 CloudFormation 模版,在 AWS 控制台上运行创建 NAT 实例以及 Amazon EC2 Auto Scaling,模版目前支持 2 种类型的 Amazon EC2 实例:micro 和 c6g.medium,缺省创建 2 个 NAT 实例,也可以修改模版创建 4 个或更多。
  3. Lambda 的环境参数包含在第一步创建的 Amazon EC2 Auto Scaling,检查站点 URL 以及是否需要固定外网 IP(NAT 实例的 IP 地址不会改变),示例如下:
  1. Lambda 的运行时间设置为 14 分钟 10 秒。
  2. 创建 EventBridge 规则,每隔 14 分调用一次 Lambda 函数。这里有 10 秒的时间重合,是因为 Lambda 的初始化和首次检查联通性也要耗费一些时间。这样做主要是保证监控的连续性。
  3. 为每个使用 NAT 实例的子网路由表添加 tag,只添加 key(nat-instance)即可。
  4. 为 Lambda 所在的子网路由表添加 tag,只添加 key(monitor)即可。

4. 多账户共享的架构

如果想进一步节省费用,可以在一个账户内建立共享的 Amazon VPC,我们这里暂且称为 Egress VPC,利用 Amazon 中转网关(Transit Gateway,以下简称为 TGW)连接多个 Amazon VPC(这些 VPC 可以在不同的 AWS 账户内),这样就可以实现多个 Amazon VPC 共享一套 NAT 实例集群了。示意架构图如下,配置可以参考 AWS 官方博客的内容。配置共享架构时,您只需参考博客内关于 TGW 的配置内容,Egress VPC 私有子网的路由配置已经由上面所说的 Lambda 脚本自动设置完成。

5.  如何确定替换 NAT 网关会不会影响网络性能?

NAT 网关可以提供极高的网络传输性能(100Gbps),但是你是否知道你当前到底用了多少呢?如果发现 NAT 网关的性能没有被完全利用, 你可以做个简单的计算来评估一些替换方案是否会节省成本。这个经过修改的 CloudFormation 模版提供了一个 Amazon CloudWatch 仪表盘(原版来自 https://github.com/marbot-io/monitoring-jump-start/blob/master/marbot-nat-gateway.yml ),你可以清楚的了解 NAT 网关的资源使用情况,同时,如果要采用 NAT 实例集群替换 NAT 网关,也可以根据这个仪表盘的数据估算一下使用什么类型的 Amazon EC2 实例。

6. 结论

本文中所讲的 NAT 实例集群方案是一种适合大多数场景的低成本网络解决方案,除非一些极端的情况,例如需要单个 NAT 的网络吞吐能力达到 100 Gbps, 因为中国区域目前没有 c5n 类型的 EC2 实例,NAT 实例集群暂时无法满足此类需求。或者您对网络的稳定性要求极高,任何时候使用 NAT 传输都不能有中断,且应用无重试功能,这种情况下,NAT 网关会是一个正确的选择。

本篇作者

刘育新

AWS ProServe 团队高级顾问,长期从事企业客户入云解决方案的制定和项目的实施工作。