亚马逊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 记录的联动机制支持。相信即便前述“导入区域文件”按钮恢复可用以后,本文所描述的联动机制工具,凭借其轻量、自动化的特性,仍然有独特的用武之地。

参考资料

本篇作者

袁文俊

AWS 解决方案架构师。曾在亚马逊美国西雅图总部工作多年,就职于 Amazon Relational Database Service (RDS) 核心服务团队,拥有丰富的后端开发、运维经验。现负责业务持续性及可扩展性运行、企业应用及数据库上云迁移、云上灾难恢复管理系统等架构咨询、方案设计及项目实施等工作。他拥有复旦大学理学学士学位。

刘育新

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