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