亚马逊AWS官方博客

Aurora 全球数据库端点管理自动化方案

Aurora 全球数据库切换概述

Amazon Aurora 全球数据库支持跨多个区域部署数据库集群,由一个可写的主区域和最多 5 个只读的辅助区域组成。所有写操作都直接在主区域进行,数据复制到辅助区域通常不到 1 秒的延迟。Aurora 全球数据库支持两种不同的操作来更改主集群的区域:全球数据库切换(switchover)和全球数据库故障转移(failover)。

  • 全球数据库切换:将此方法用于受控场景,例如操作维护和其他计划内操作过程。由于此特征会在进行任何其他更改之前将辅助数据库集群与主数据库集群同步,因此 RPO 为 0(不会造成数据丢失)。
  • 全球数据库故障转移:用于主区域发生中断时的灾难恢复。使用这种方法,您可以跨区域失效转移到 Aurora 全球数据库中的一个辅助数据库集群。这种方法的 RPO 通常是一个以秒为单位的非零值。数据丢失量取决于发生故障时跨区域的 Aurora 全球数据库复制滞后。

在切换或故障转移后,应用程序需要将数据库连接切换到新主区域的写节点终端节点。多区域部署的应用程序要相应地连接到新主区域或本地辅助区域的读取器终端节点。这需要应用程序能够访问多个终端节点,并根据全球数据库的角色变化动态调整连接。本方案将介绍如何结合 Route53 私有托管区与 RDS 事件订阅功能进行自动 DNS 更新,从而避免手动更改应用连接端点带来的延迟。关于如何执行全球数据库故障转移/切换,请参考:Aurora全局数据库故障转移

方案概述

该方案利用 Amazon Route53 私有托管区来实现自动更新主集群和辅助集群的终端节点而无需手动在应用端进行终端节点更新。主要实现原理以及涉及的相关服务如下:

  1. 在 Amazon Route53 私有托管区中创建 CNAME 记录,将私有托管区中的域名解析到对应的 Aurora 主集群段集群终端节点
    • Amazon Route53 私有托管区域包含指定了如何在 Amazon VPC 中路由流量的记录。具体原理请参考:使用私有托管区
  2. 应用连接到 Amazon Route53 私有托管区中的 CNAME 记录,从而连接到 Aurora 全球数据库主集群的集群终端节点
  3. 当 Aurora 全球数据库的主集群进行手动切换/故障转移时,生成对应的事件并通过事件订阅触发 Lambda 完成 Route53 中记录的更新,将切换前的旧辅助集群终端节点更新为 Route53 CNAME 记录中的主集群终端节点
    • 该方案为事件驱动型,基于 RDS 的 Event Subscription 功能订阅对应的数据库事件从而触发相应的 Lambda 实现 DNS 自动更新的逻辑。该方案中订阅的事件类别为:全局故障转移,RDS 事件 ID:RDS-EVENT-0182 。RDS 事件列表请参考:Amazon RDS事件类别和事件消息
    • 完成事件驱动流程请提前创建 Amazon Lambda 和 Amazon SNS 资源,具体实施步骤将在后续的方案部署环节详细介绍

由于该方案为事件触发型,基于业务需求计算事件触发次数,主要计费包含 Amazon Lambda,Amazon SNS 以及 Route53 Hosted Zone 相关费用,数据库切换事件并非频繁发生事件,Amazon Lambda 及 Amazon SNS 费用可以被每月免费使用量覆盖。Amazon Route53 费用基本在每月 $1 以内,具体计费规则参考:Route53 定价。因此该方案总成本几乎可以忽略不计。

服务组件

下面是整个方案中使用到的服务组件介绍:

  • Amazon Route 53:Amazon Route 53 是一种可用性高、可扩展性强的域名系统(DNS)Web 服务。其中托管区域是一个记录容器,记录中包含的信息说明您希望如何路由特定域(如 com)及其子域(acme.example.com、zenith.example.com)的流量。本方案中使用的为 Route53 的私有托管区,可以用于在一个或多个 VPC 中的某个域及其子域的 DNS 查询。
  • Amazon RDS 事件通知:Amazon RDS 使用 Amazon Simple Notification Service(Amazon SNS)在发生 Amazon RDS 事件时提供通知。这些通知可以采用 Amazon SNS 支持的任何通知形式,例如电子邮件、文本消息或对 HTTP 终端节点的调用。可让您更轻松地构建可扩展的事件驱动型应用程序。事件驱动型架构是一种构建松耦合软件系统的风格,这些系统通过发出和响应事件来协同工作。事件驱动型架构可以帮助您提高敏捷性,并构建可靠、可扩展的应用程序。本方案中通过 Amazon Lambda 订阅 SNS 通知来实现 Route53 中的 DNS 记录更新。
  • Amazon Lambda:Lambda 是一种理想的计算服务,在可用性高的计算基础设施上运行您的代码,执行计算资源的所有管理工作,其中包括服务器和操作系统维护、容量预置和弹性伸缩和记录。使用 Lambda,您只需在 Lambda 支持的一种语言运行时系统中提供代码, 在运行代码而无需预置或管理服务器。

方案部署

当前方案部署背景:Aurora 全球数据库主集群位于 us-east-1 区域,Aurora 全球数据库辅助集群位于 ap-northeast-1 区域,事件订阅基于 ap-northeast 区域 Aurora 辅助集群创建,方案主要基于集群终端节点进行写入测试。连接未启用 SSL。

方案优势:当前方案在几乎零成本的条件下,只需要在一个区域创建事件订阅,即可允许在主集群以及辅助集群之间来回切换时自动更新 DNS 记录,无需手动在应用端或 Route53 中更新 DNS 记录以通过自动化的方式减少切换时的读写中断时间。

先决条件

  1. 确保应用和 Aurora 全球数据库的主集群以及辅助集群之间有 VPC peering 以允许跨区域的 VPC 内资源通过亚马逊全球骨干网进行通信。具体 VPC peering 创建请参考:创建 VPC 对等连接
  2. 创建 Amazon Route53 私有托管区之前请确保在对应的两个区域的 VPC 中以下设置设为 true
    • enableDnsHostnames
    • enableDnsSupport
  3. 确保您已经完成 Aurora 全球数据库的创建,并确保您的应用所在的 EC2 可以正常访问您的主集群的集群终端节点,以及您辅助区域的集群读取器终端节点

创建 Route53 私有托管区

您将创建一个 Route53 的私有托管区并创建对应的 DNS 记录:

  1. 找到 Amazon Route53 服务,选择左侧“托管区域”,点击右上角“创建托管区”,定义一个自己的域名,例如“auroragb-db”,类型选择“私有托管区”,并添加对应的区域和 VPC。
  1. 进入创建好的私有托管区,添加对应的 CNAME 记录,将二级域名指向 Aurora 全球数据库的辅助集群的集群终端节点。您可以根据需要添加读取器终端节点的 CNAME 记录,此处 TTL 设置为 5,以减少 DNS 切换过程中 TTL 过长导致数据库集群终端节点切换延迟。如果您的场景主要服务于切换操作,建议此处 TTL 可以在切换前改为 5s,日常 TTL 时间按需调整为大于 5s 的时间,以减少日常连接时的 DNS query 开销。

创建完成对应的 CNAME 记录后,建议您通过创建的记录名称连接数据库以确保 Route53 记录配置正常并生效。

创建 Amazon RDS 事件订阅及 Amazon Lambda

  1. 在辅助区域中(本方案的 ap-northeast-1 区域)创建一个空的基于 python 3.10 runtime 的 Amazon Lambda 函数,创建完成后点击“配置”→“权限”,打开执行角色跳转到 Amazon IAM 服务,添加以下权限:
    • AmazonRDSReadOnlyAccess 托管策略:允许 Lambda 函数获取本区域的 RDS 资源信息
    • AmazonRoute53FullAccess 托管策略:允许 Lambda 函数获取 Route53 的资源信息并进行修改
    • AmazonSNSReadOnlyAccess 托管策略:允许 Lambda 函数对 SNS 数据进行订阅
    • 在创建 Lambda 时自动创建的 IAM 策略中添加以下权限以允许 Lambda 获取跨区域的 Aurora 集群终端节点信息:
      {
      “Version”: “2012-10-17",
      “Statement”: [
      {
      “Effect”: “Allow”,
      “Action”: [
      “rds:DescribeGlobalClusters”,
      “rds:DescribeDBClusters”
      ],
      “Resource”: “*”
      }
      ]
      }
      
  2. 在 Lambda 函数中添加以下 python 代码:
    import json
    import logging
    import boto3
    
    # Set up logging
    logger = logging.getLogger()
    logger.setLevel(logging.INFO)
    
    # Initialize Route 53 and RDS clients
    route53 = boto3.client('route53')
    rds = boto3.client('rds')
    
    # Set up Route 53 parameters
    hosted_zone_id = 'YOUR_ROUTE53_PRIVATE_HOSTED_ZONE_ID'
    record_name = 'YOUR_PRIMARY_CLUSTER_WRITER_ENDPOINT'
    global_database_identifier = 'YOUR_GLOBAL_DATABASE_IDENTIFIER'
    
    def get_secondary_writer_endpoint(global_cluster_id):
        try:
            response = rds.describe_global_clusters(GlobalClusterIdentifier=global_cluster_id)
            global_clusters = response['GlobalClusters']
            
            if global_clusters:
                global_cluster = global_clusters[0]
                secondary_clusters = [member for member in global_cluster['GlobalClusterMembers'] if not member['IsWriter']]
                
    
                if secondary_clusters:
                    secondary_cluster_arn = secondary_clusters[0]['DBClusterArn']
                    # Extract the region from the secondary cluster ARN
                    secondary_region = secondary_cluster_arn.split(':')[3]
                    
                    # Create a new RDS client for the secondary region
                    secondary_rds = boto3.client('rds', region_name=secondary_region)
                    
                    response = secondary_rds.describe_db_clusters(DBClusterIdentifier=secondary_cluster_arn)
                    secondary_cluster = response['DBClusters'][0]
                    print("secondary_cluster_: ",secondary_cluster)
                    return secondary_cluster['Endpoint']
            
            return None
        except Exception as e:
            logger.error(f"Error retrieving secondary writer endpoint: {str(e)}")
            return None
    
    def update_route53_record(hosted_zone_id, record_name, new_value):
        try:
            response = route53.change_resource_record_sets(
                HostedZoneId=hosted_zone_id,
                ChangeBatch={
                    'Changes': [
                        {
                            'Action': 'UPSERT',
                            'ResourceRecordSet': {
                                'Name': record_name,
                                'Type': 'CNAME',
                                'TTL': 5,
                                'ResourceRecords': [
                                    {
                                        'Value': new_value
                                    },
                                ],
                            }
                        },
                    ]
                }
            )
            logger.info(f"Route 53 record updated successfully: {response}")
        except Exception as e:
            logger.error(f"Error updating Route 53 record: {str(e)}")
    
    def lambda_handler(event, context):
        try:
            # Parse the SNS message
            sns_message = json.loads(event['Records'][0]['Sns']['Message'])
    
            # Extract specific details from the event if needed
            event_time = sns_message.get('Event Time')
            source_id = sns_message.get('Source ID')
            event_id = sns_message.get('Event ID')
            event_message = sns_message.get('Event Message')
            
            if "RDS-EVENT-0182" in event_id:
                # Log extracted details
                logger.info(f"Event Time: {event_time}")
                logger.info(f"Source ID: {source_id}")
                logger.info(f"Event ID: {event_id}")
                logger.info(f"Event Message: {event_message}")
                
                # Get the old secondary cluster's writer endpoint
                secondary_writer_endpoint = get_secondary_writer_endpoint(global_database_identifier)
    
                if secondary_writer_endpoint:
                    # Update the Route 53 record with the new writer endpoint
                    update_route53_record(hosted_zone_id, record_name, secondary_writer_endpoint)
                else:
                    logger.error("Failed to retrieve secondary writer endpoint")
            
            return {
                'statusCode': 200,
                'body': json.dumps('Successfully processed RDS event')
            }
        except Exception as e:
            logger.error(f"Error processing event: {str(e)}")
            return {
                'statusCode': 500,
                'body': json.dumps('Error processing RDS event')
            }
    

请注意更新代码中对应的参数为您账号中的资源信息:

  • hosted_zone_id:您创建的 Amazon Route53 私有托管区中,“托管区详细信息”→ “托管区域 ID”
  • record_name:您的 Aurora 全球数据库中,主集群的集群终端节点。请注意,此处使用的是集群终端节点而非实例终端节点,关于终端节点的选择请参考:Aurora 终端节点的类型
  • global_database_identifier:您创建的 Aurora 全球数据库中“全局数据库标识符”

以上代码主要执行的操作为:

当事件 ID 为“RDS-EVENT-0182”时,执行以下逻辑

  • 获取旧的辅助集群中集群终端节点的信息,创建新的 RDS 客户端以防在跨区域的场景下获取 Aurora 集群终端节点失败
  • 将旧的辅助集群中的集群终端节点更新为 Route53 中的主集群写入终端节点以持续接收读写流量

3. 接下来我们将完成 RDS 事件订阅的创建(辅助区域):

    • 首先需要创建一个 Amazon SNS 标准主题,可选项都保持默认,只输入主题名称即可。进入主题,点击右下角“创建订阅”,协议选择“AWS Lambda”,并选择对应的 Lambda 函数作为终端节点,其他保持默认。该 SNS 主题用于 RDS 事件订阅并将事件发布给刚创建好的 Lambda 函数
    • 最后在 RDS 中创建相应的事件订阅即可。打开 RDS 界面,在左侧选择“事件订阅”→ “创建事件订阅”,“目标”选择“ARN”,下来选择刚创建好的 SNS 主题,“源类型”选择“集群”→ “选择特定的集群”→ 选择 Aurora 全球数据库主集群,即和前面创建的 Amazon SNS,Amazon Lambda 出于同一区域的数据库集群,本示例中为 ap-northeast-1 区域的数据库集群→ “特定事件类别”选择“global failover”。点击右下角完成创建

您现在已经完成了方案的创建部署,可以参考以下方式进行验证。

方案验证

验证方式主要通过 shell 脚本持续写入数据,验证自动切换的流程并观察写入中断的时间。参考脚本如下,请将脚本中的参数替换为您数据库中的参数,为测试辅助区域中自动切换的过程及中断事件,将该脚本部署在辅助区域,即 ap-northeast-1 中进行测试验证。选择对应的 Aurora 全球数据库,并选择“切换”执行手动切换,具体操作过程请参考对 Aurora 全球数据库执行切换

#!/bin/bash
# 每秒连接一次数据库

PASS='YOUR AURORA DATABASE PASSWORD'
HOST='YOUR PRIMARY CONNECTION ENDPOINT DEFINED IN ROUTE53'
DB="YOUR DATABASE"
USER="admin"
SQL="INSERT INTO users (name, email, age) VALUES ('New User', CONCAT('newuser', FLOOR(RAND() * 1000000), '@example.com'), FLOOR(RAND() * 60 + 20));"
LOG_FILE="insert_data.log"

while true
do
    # Connect to the database and execute the SQL query
    RESULT=$(mysql -u "$USER" -p"$PASS" -h "$HOST" "$DB" -e "$SQL" 2>&1)
    EXIT_CODE=$?
    
    LOG_MESSAGE="Connecting to MySQL server: $HOST as user $USER\nExecuting SQL: $SQL\n Query result: $RESULT\n"

    # Check the exit code and print the result
    if [ $EXIT_CODE -eq 0 ]; then
        echo "Data inserted successfully."
    else
        echo "Error: $RESULT"
    fi

    # Write to the log file
    echo "$(date +'%Y-%m-%d %H:%M:%S') - $LOG_MESSAGE" >> "$LOG_FILE"

    # Wait for 1 second before inserting the next row
    sleep 1
done

以上脚本将日志写入“insert_data.log”,在数据库集群切换过程中观察到无法连接 Route53 中的域名记录的日志:“ERROR 2002 (HY000): Can’t connect to MySQL server on ‘primary-writer.auroragb-db’”,写入中断时间大致为 2min30s 左右,将以上脚本中的 sql query 改为读取语句,并将连接节点更新为 Route53 中创建的集群读取器终端节点对读请求的中断时间进行测试,观察到读取中断时间大致为 1min20s 左右。具体中断时间取决于当前负载情况以及 DNS 缓存时间,以上数据仅供参考。

总结

本篇博客介绍了如何在 Aurora 全球数据库进行切换时,结合 Route53 私有托管区以及 RDS 的事件订阅功能,利用事件驱动通过极低的成本自动更新连接数据库终端节点的 DNS 记录,从而避免了手动操作带来的延迟及误差。

本篇作者

曹阳

亚马逊云科技解决方案架构师,负责基于 AWS 云计算方案的架构咨询与设计,同时致力于亚马逊云科技在各行业中的应用与推广,目前侧重于移动应用以及物联网领域的研究。

陈阳

亚马逊云科技数据库专家架构师,十余年数据库行业经验,主要负责基于亚马逊云计算数据库产品的解决方案与架构设计工作。