亚马逊AWS官方博客
利用跟踪事件建立资源和托管区域记录之间的联动机制
摘要
对于 AWS 的初学者来说,高效管理 Amazon Route 53 托管区域记录集面临不少挑战。当托管区域含有一条记录后,控制台“导入区域文件”按钮变得不可用,增加了批量导入的难度。此外,记录的关联资源(如实例,负载均衡器等)发生变化时,记录本身并不会联动,导致信息不一致,增加维护难度。本文探讨如何自动化管理及维护托管区域记录集的问题。主要思路是跟踪相关资源的变化事件(创建资源、删除资源、添加标签、删除标签等),自动修改托管区域记录并与资源保持一致,减少运维压力,提高效率与准确性。
目标读者
本文预期读者需要掌握以下技术的基础知识:
- AWS 相关服务,包括 CloudTrail, DynamoDB, EC2, ELB, EventBridge, IAM, Lambda, Route53 等
- AWS 云开发工具包
- Javascript 语言及 AWS 软件开发包第二版
开放源代码
本文所述解决方案源代码开放并置于以下代码库:
问题描述
首先是批量添加记录集的问题。目前,在托管区域添加任意记录后,控制台中的“导入区域文件”按钮会变为不可用。其后只能就单个记录进行添加或者修改,颇为不便。如下图所示:

如果是简单记录值,如实例的内网地址,需要在界面依次输入记录名称、类型、缓存存续时间(TTL)值、记录值以及选择路由策略,共五步。如果是其他 AWS 服务,例如负载均衡器别名记录值,则需要在界面依次输入记录名称、类型、路由策略、评估目标运行状况,然后选择别名目标的对应均衡器,也差不多五步。当记录值数量不多时,在图形化控制台利用手工操作基本可以完成。但是对于一个中小型系统而言,记录值的数量可以轻松达到几十到上百个。此外一一录入内网地址,或者点选均衡器,耗时费力程度不容忽视,且容易出错。
再来说修改的问题。当终止实例,或者删除均衡器后,其对应的记录并不会连带删除,需要手动确认 Route53 和相关资源的对应关系,无形中增加运维负担。从删除资源的关联性可以推导出,当资源新建时,如果有需要,最好也可以自动新增记录,而无需人工干预。所以,本文需要解决的问题可以归纳为,如何建立资源和托管区域记录之间的联动机制。
解决方案
利用 AWS 的服务和无服务器设施,可以解决上述问题。概括来讲,利用 AWS CloudTrail 打开资源应用接口的调用跟踪,利用 Amazon EventBridge 匹配相关资源的相关事件,利用 AWS Lambda 读取资源信息并对 Route 53 托管区域记录集进行修改,同时利用 Amazon DynamoDB 记录必要信息以应对资源和标签删除事件,即可完成任务。
架构图
架构图如下所示。实例和均衡器并不需要在同一虚拟网中。事件的大致触发机制为:当相关资源(实例,负载均衡器)的相关事件触发时,该事件被跟踪并触发 Lambda,从而读取资源信息、记录信息到 DynamoDB 表中并修改 Route 53 到记录,完成操作。此外在 DynamoDB 表中,可以一览资源与记录的关联关系,一目了然。

假定资源和记录有一一对应关系,又假设域名为 example.com。要记录资源对应的二级域名关系,并尽可能减少运维压力,元数据标签是顺理成章的选择。当资源拥有一个特殊键值对时,程序即自动为其维护资源与记录的对应关系。此处标签的值,定义为二级域名。通过二级域名可以算出顶级域名,从而找到其在 Route53 的托管区域,完成后续操作。例如以下标签键值对:
{
    "key": "route53:record",
    "value": "a.example.com"
}以实例和负载均衡器为例,跟踪特定事件。对实例来说,跟踪以下事件:
- 启动实例 RunInstances
- 终止实例 TerminateInstances
- 创建标签 CreateTags
- 删除标签 DeleteTags
对均衡器来说,跟踪以下事件:
- 新建负载均衡器 CreateLoadBalancer
- 删除负载均衡器 DeleteLoadBalancer
- 创建标签 AddTags
- 删除标签 RemoveTags
需要特别注意的是,当删除资源时,资源的相关信息随即失效,不能再读取。例如所有的标签、实例内网地址、均衡器的 DNS 名称等。删除资源时,唯一可用的信息唯有资源编号。所以,为了自动化删除记录,需要事先在其他地方保存资源信息,以留后用。这里选择 DynamoDB 来做持久保存。
利用域名查找托管区域的类。如果域名正确且存在,则会找到唯一的结果。
class HostedZone {
    zone;
    constructor(dnsName) { this.dnsName = dnsName; }
    async load() {
        const data = await route53.listHostedZonesByName({DNSName: this.dnsName}).promise();
        this.zone = data.HostedZones[0];
    }
    get id() { return this.zone.Id; }
}
保存资源相关信息的持久层类。表名是事先指定的。表主键直接使用资源编号,可以确保唯一性。额外记录二级域名和资源信息即可。当资源删除时,可以在表中读取资源删除前的属性。
class RecordTable {
    static TABLE_NAME = "Route53Records";
    async getItem(id) {
        const item = await dynamodb.get({
            TableName: RecordTable.TABLE_NAME,
            Key: {"id": id}
        }).promise();
        return item ? item.Item : null;
    }
    async update(id, alias, object) {
        await dynamodb.put({
            TableName: RecordTable.TABLE_NAME,
            Item: {"id": id, "alias": alias, "object": object}}).promise();
    };
    async remove(id) {
        await dynamodb.delete({
            TableName: RecordTable.TABLE_NAME,
            Key: {"id": id}}).promise();
    }
}
实例管理
限于篇幅,这里假定启动或删除实例时只启动一台实例。启动或删除多台实例的情况可以照例类推处理,在此从略。修改标签时也假定仅对一台实例操作。分析实例跟踪事件可以发现,在删除标签的事件中,标签的值也附在其中。所以创建标签和删除标签事件可以合并处理,仅以事件名称来区分即可。
function getDomain(url) {
    return url.substring(url.indexOf(".") + 1);
}
function findEntry(tags) {
    return tags ? tags.filter(tag => tag.key == TAG_KEY).pop() : null;
}
async function processEc2(detail) {
    const parameters = detail.requestParameters;
    var zone;
    var entry;
    var handler;
    var instanceId;
    switch (detail.eventName) {
        case "RunInstances":
            if (!parameters.tagSpecificationSet) { return; }
            
            entry = findEntry(parameters.tagSpecificationSet.items[0].tags);
            if (!entry) { return; }
            
            instanceId = detail.responseElements.instancesSet.items[0].instanceId;
            handler = new Ec2Handler(instanceId);
            await handler.load();
            zone = new HostedZone(getDomain(entry.value));
            await zone.load();
            await handler.updateRecords(zone.id, entry.value, true);
            break;
        case "TerminateInstances":
            instanceId = parameters.instancesSet.items[0].instanceId;
            const item = await table.getItem(instanceId);
            if (!item) { return; }
            
            zone = new HostedZone(getDomain(item.alias));
            await zone.load();
            handler = new Ec2Handler(item.object.InstanceId);
            handler.instance = item.object;
            await handler.updateRecords(zone.id, item.alias, false);
            break;
        case "CreateTags":
        case "DeleteTags":
            entry = findEntry(parameters.tagSet.items);
            if (!entry) { return; }
            
            instanceId = parameters.resourcesSet.items[0].resourceId;
            handler = new Ec2Handler(instanceId);
            await handler.load();
            zone = new HostedZone(getDomain(entry.value));
            await zone.load();
            await handler.updateRecords(zone.id, entry.value, detail.eventName == "CreateTags");
            break;
    }
}具体到实例记录的修改,单独抽象为一个类。首先通过实例 ID 读取实例信息,然后根据增删改操作,在相应的持久层表中添加或者删除相应记录,并对托管区域记录值进行修改。代码为:
class Ec2Handler {
    instance;
    constructor(instanceId) { this.instanceId = instanceId; }
    async load() {
        const data = await ec2.describeInstances({InstanceIds: [this.instanceId]}).promise();
        this.instance = data.Reservations[0].Instances[0];
    }
    async updateRecords(zoneId, alias, upsert) {
        const ttl = DEFAULT_TTL;
        const changeBatch = [{
            Action: upsert ? "UPSERT" : "DELETE",
            ResourceRecordSet: {
                Type: "A",
                TTL: ttl,
                Name: alias,
                ResourceRecords: [{ Value: this.instance.PrivateIpAddress }]
            }}];
        if (upsert) {
            await table.update(this.instance.InstanceId, alias, this.instance);
        } else {
            await table.remove(this.instance.InstanceId);
        }
        const data = await route53.changeResourceRecordSets({
            HostedZoneId: zoneId,
            ChangeBatch: {Changes: changeBatch}
        }).promise();
        console.log("Change ID: " + data.ChangeInfo.Id);
    }
}负载均衡器管理
和实例不同,负载均衡器的创建与删除不能成批进行,略为简单。但是就标签操作而言,与实例跟踪事件比起来,负载均衡器稍微复杂一些。分析均衡器跟踪事件可以发现,在删除标签的事件中,标签的值没有附在其中。所以创建标签和删除标签事件需分开处理。
function findKey(tags) {
    return tags ? tags.filter(tag => tag == TAG_KEY).pop() : null;
}
async function processElb(detail) {
    const parameters = detail.requestParameters;
    var zone;
    var entry;
    var handler;
    var lbArn;
    var item;
    switch (detail.eventName) {
        case "CreateLoadBalancer":
            entry = findEntry(parameters.tags);
            if (!entry) { return; }
            
            lbArn = detail.responseElements.loadBalancers[0].loadBalancerArn;
            handler = new ElbHandler(lbArn);
            await handler.load();
            zone = new HostedZone(getDomain(entry.value));
            await zone.load();
            await handler.updateRecords(zone.id, entry.value, true);
            break;
        case "DeleteLoadBalancer":
            lbArn = parameters.loadBalancerArn;
            item = await table.getItem(lbArn);
            if (!item) { return; }
            
            zone = new HostedZone(getDomain(item.alias));
            await zone.load();
            handler = new ElbHandler(lbArn);
            handler.lb = item.object;
            await handler.updateRecords(zone.id, item.alias, false);
            break;
        case "AddTags":
            entry = findEntry(parameters.tags);
            if (!entry) { return; }
            
            lbArn = parameters.resourceArns[0];
            handler = new ElbHandler(lbArn);
            await handler.load();
            zone = new HostedZone(getDomain(entry.value));
            await zone.load();
            await handler.updateRecords(zone.id, entry.value, true);
            break;
        case "RemoveTags":
            entry = findKey(parameters.tagKeys);
            if (!entry) { return; }
            
            lbArn = parameters.resourceArns[0];
            item = await table.getItem(lbArn);
            zone = new HostedZone(getDomain(item.alias));
            await zone.load();
            handler = new ElbHandler(lbArn);
            handler.lb = item.object;
            await handler.updateRecords(zone.id, item.alias, false);
            break;
    }
}
具体到均衡器别名记录的修改,单独抽象为一个类。首先通过均衡器 ARN 读取均衡器信息,然后根据增删改操作,在相应的持久层表中添加或者删除相应记录,并对托管区域记录值进行修改。此外如果是非网络均衡器,需要对其 DNS 名称添加前缀。这里也一并处理。代码为:
class ElbHandler {
    lb;
    constructor(lbArn) { this.lbArn = lbArn; }
    async load() {
        const data = await elbv2.describeLoadBalancers({LoadBalancerArns: [this.lbArn]}).promise();
        this.lb = data.LoadBalancers[0];
    }
    async updateRecords(zoneId, alias, upsert) {
        const changeBatch = [{
            Action: upsert ? "UPSERT" : "DELETE",
            ResourceRecordSet: {
                Type: "A",
                Name: alias,
                AliasTarget: {
                    HostedZoneId: this.lb.CanonicalHostedZoneId,
                    DNSName: (this.lb.Type == "application" ? "dualstack." : "") + this.lb.DNSName + ".",
                    EvaluateTargetHealth: true
                }
            }
        }];
        if (upsert) {
            await table.update(this.lb.LoadBalancerArn, alias, this.lb);
        } else {
            await table.remove(this.lb.LoadBalancerArn);
        }
        console.log("Change LB batch: " + JSON.stringify(changeBatch));
        const data = await route53.changeResourceRecordSets({
            HostedZoneId: zoneId,
            ChangeBatch: {Changes: changeBatch}
        }).promise();
        console.log("Change ID: " + data.ChangeInfo.Id);
    }
}
最后,Lambda 函数的处理方式就很直观了,根据跟踪事件导航到不同的函数即可,如下所示。
exports.handler = async function(event) {
    switch (event.source) {
        case "aws.ec2":
            await processEc2(event.detail);
            break;
        case "aws.elasticloadbalancing":
            await processElb(event.detail);
            break;
    }
};
部署资源
资源记录联动机制的程序虽然只有短短数百行,但因为涉及的服务多(还有没有列出来的 IAM 等服务)、相互依赖关系深,故要正确部署各服务、配置访问授权并顺利运行该工具,仍是个不大不小的挑战。利用 AWS CDK,可以一键部署运行该程序的所涉资源。部署完成后,AWS 即开始跟踪相关资源(实例,均衡器)的相关事件(四个),并通过触发 Lambda 按设计预期进行操作,实现资源与记录之间的联动机制,无需额外人工干预。
资源自动化部署方面,选择 AWS CDK 的方式。其特点是代码简洁,易读易用。具体如下:
class R53RecordsStack extends Stack {
    constructor(scope) {
        super(scope, "R53-Records");
        this.bucket = this.bucket();
        this.dynamodb();
        this.cloudtrail();
        this.events(this.lambda());
    }
    bucket() {
        return new Bucket(this, "Bucket", {
           autoDeleteObjects: true,
           removalPolicy: RemovalPolicy.DESTROY
       });
    }
    dynamodb() {
        return new Table(this, "Table", {
            tableName: "Route53Records",
            removalPolicy: RemovalPolicy.DESTROY,
            partitionKey: {
                name: "id",
                type: AttributeType.STRING
            }
        });
    }
    cloudtrail() {
        new Trail(this, "Trail", {
            bucket: this.bucket,
            s3KeyPrefix: "trail",
            isMultiRegionTrail: false,
        });
    }
    events(updateFunction) {
        new Rule(this, "ec2", {
            ruleName: "R53-Records-EC2",
            description: "Register a record to its private IP address",
            eventPattern: {
                source: [ "aws.ec2" ],
                detailType: [ "AWS API Call via CloudTrail" ],
                detail: {
                    "eventSource": [ "ec2.amazonaws.com" ],
                    "eventName": [
                        "RunInstances",
                        "TerminateInstances",
                        "CreateTags",
                        "DeleteTags"
                    ]
                }
            },
            targets: [ new LambdaFunction(updateFunction) ]
        });
        new Rule(this, "elb", {
            ruleName: "R53-Records-ELB",
            description: "Register an alias record to its DNS name",
            eventPattern: {
                source: [ "aws.elasticloadbalancing" ],
                detailType: [ "AWS API Call via CloudTrail" ],
                detail: {
                    "eventSource": [ "elasticloadbalancing.amazonaws.com" ],
                    "eventName": [
                        "CreateLoadBalancer",
                        "DeleteLoadBalancer",
                        "AddTags",
                        "RemoveTags"
                    ]
                }
            },
            targets: [ new LambdaFunction(updateFunction) ]
        });
    }
    lambda() {
        const role = new Role(this, "Role", {
            assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
            managedPolicies: [
                ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
                ManagedPolicy.fromAwsManagedPolicyName("AmazonDynamoDBFullAccess"),
                ManagedPolicy.fromAwsManagedPolicyName("AmazonEc2ReadOnlyAccess"),
                ManagedPolicy.fromAwsManagedPolicyName("AmazonRoute53FullAccess"),
                ManagedPolicy.fromAwsManagedPolicyName("ElasticLoadBalancingReadOnly")
            ]
        });
        return new Function(this, "Function", {
            functionName: "R53-UpdateRecords",
            handler: "update-records.handler",
            role: role,
            runtime: Runtime.NODEJS_12_X,
            timeout: Duration.minutes(5),
            logRetention: RetentionDays.ONE_MONTH,
            description: "Update Route53 records.",
            code: Code.fromAsset("../lambda/r53")
        });
    }
}
扩展工作
可以扩展的工作包括对其他支持的 AWS 服务添加 Route53 记录的联动机制支持。相信即便前述“导入区域文件”按钮恢复可用以后,本文所描述的联动机制工具,凭借其轻量、自动化的特性,仍然有独特的用武之地。