亚马逊AWS官方博客

即快又省的规划 EC2 实例机型升级

摘要

根据亚马逊云科技最佳实践,当推出新的 EC2 实例机型后,建议升级到最新的可用机型。原因有二:其一,新机型计算、存储和网络传输等性能提升;其二,单位时间实例使用成本降低。少量实例机型升级并不难,然而当实例数量较多、实例关系较复杂时,要规划可行、低成本的实例升级,手工操作的耗时和难度会显著增加。本文聚焦如何有效应对大规模实例机型升级。有效机型升级具体体现有三。成本优化:在新机型成本降低的基础上,充分考虑预留实例期限限制,最大化预留实例收益;兼顾实例关系:全面兼顾实例间复杂的关联与依赖关系,如主从数据库对,负载均衡器组等,把实例升级对系统整体运行的影响降至最低,使其平稳过渡;照顾现实约束:实际生产过程中实例有轻重缓急之别,升级过程中有各种限制,例如工作日节假日的安排,日处理或停机实例上限等等。上述要点无形中增加了规划实例机型升级的困难与复杂度,使得手工规划耗时费力。本文利用轻量的类结构化查询语言和亚马逊云科技无服务器解决方案,实现一套崭新和通用的规划实例机型升级架构,以达到两个目的。一是把手工需要数小时的规划操作大幅减少到数秒完成;二是根据预留期限和其他约束条件规划升级以尽量降低成本。

目标读者

本文预期读者需要掌握以下技术的基础知识:

  • 亚马逊云科技相关服务,包括 EC2 实例,S3, Lambda, DynamoDB 等;
  • Javascript 语言及亚马逊云科技软件开发包第二版;
  • 类结构化查询语言 PartiQL。

开放源代码

本文所述解决方案源代码开放并置于以下代码库:

绪论

亚马逊云科技实例机型总是不断更新换代,目前最新一代是第六代。从机型代码可以识别,例如通用类型 m6g.large。相应的前代机型为 m5.large, m4.large, m3.large 等。实例机型升级,通常意味着性价比提升。单一实例机型升级过程并不复杂,在完成相应驱动安装并打开增强网络支持后,借助相关命令可以轻松完成,例如:

aws ec2 modify-instance-attribute --instance-id $INSTANCE_ID --instance-type $NEW_TYPE

当实例数量变庞大时,各种各样的考虑因素接踵而至。例如,各实例轻重缓急不同,复杂的实例间关联关系,预留实例到期期限不一等。综合上述各种因素,如何快速、有效、低成本的规划实例升级顺序是本文着重解决的问题。

问题描述

问题定义为:给定一定数量的实例及其属性,实例间关联关系,以及规划约束条件,规划实例升级顺序。其中实例属性包括实例类别、机型、可用区、预留期限、所属应用等。本文抽象出两种实例间关联关系,主从数据库对关系和负载均衡器组关系。规划约束条件作为输入,包括开始日期,每日处理数量上限,按需实例排序依据,节假日以及处理星期等。

局限性

实例升级规划结果是以日为单位的实例升级顺序表。该表并不是最终结果。相反,用户可以该表为基础,根据实际情况,进一步微调和优化实例升级顺序。本文的工作旨在解决实例升级规划伊始最繁杂的部分。用户通过调整规划约束条件,亦可快速生成其他约束条件下的实例升级规划表,以便参考比较。

总体思想

以日为最小单位规划批次。通常情况下,实例可以大致分为两个类别,即生产实例和非生产实例。生产实例处理起来需更加谨慎,例如放到非工作日处理。从降低成本的角度考虑,部分实例会购买预留实例。预留期限通常是一年。要节约开销,在预留到期日附近升级实例较好。此外从稳定性角度考虑,需要对关联实例做特殊处理。例如从库可以先升级,而负载均衡器不同组的实例要错开等。本文抽象了两种常见的关联关系。

  • 主从数据库对:从库应该提前升级,因为不影响系统运行。平稳运行一段时间之后做一次主从数据库切换,然后对原来的主库升级。这样可以把对系统的影响降至最低。
  • 负载均衡器组:负载均衡器分发实例按所在可用区分组。本文假定负载均衡器只有两个组。不同组实例应置于不同批次内,且前组实例全部升级完成后,再升级后组实例。亦即甲组的升级日期与乙组升级日期不相交,确保系统平稳过渡。具体来说,假设日期 A ⩽ B ⩽ C ⩽ D甲组实例在首轮规划中升级日期在 AC 之间,乙组在 BD 之间,如下图所示。此时需要将乙组规划在 BC 之间的实例推后到 C 之后升级,确保甲乙两组升级日期不重叠。

约束条件

下表总结了通常情况下实例升级规划的约束条件。大部分条件都不言自明。其中按需实例规划策略有两种(预留实例基本上以预留期限排序):

  • 按应用:把相同应用的实例排列在一起,减少单次升级涉及应用数量,便于安排和协调停机。
  • 按机型:按实例机型贵贱排序,先升级昂贵机型,后升级便宜机型,可以有效减低成本。
变量 类型 默认值
开发实例可排星期 枚举 工作日,如 1, 2, 3, 4, 5
生产实例可排星期 周日,如 0
开发实例日处理上限 整数 10
生产实例日处理上限 50
开始日期 日期
节假日 日期数组 需要避开的日期组
按需实例排序策略 枚举 按应用/按机型

约束条件也可以表示为:

{
    "startDate": "2021-03-01",
    "sortBy": "app/type",

    "holidays": [
        "2021-04-03",
        "2021-04-04",
        "2021-04-05"],  

     "devAllowedDays": [1, 2, 3, 4, 5],
    "prodAllowedDays": [0],

     "devDailyLimit": 10,
    "prodDailyLimit": 50
}

查看实例机型代码可以发现,从加大型 xlarge 开始,可以用数字前缀比较大小,之前的机型需要重新定义微小中大型的顺序。一个简单的映射关系函数如下:

class Instance {     
    static mapType(type) {
        const subtype = type.substring(3);
        const result  = /([0-9]+)xlarge/.exec(subtype);
        if (result != null) { return parseInt(result[1]) + 10; }

        switch (subtype) {
            case "nano":   return 1;
            case "micro":  return 2;
            case "small":  return 3;
            case "medium": return 4;
            case "large":  return 5;
            case "xlarge": return 6;
            case "metal":  return 100;
        }
    }

    static compareType(a, b) {
        return Instance.mapType(a) - Instance.mapType(b);
    }
}

算法总览

要节约成本,对预留实例来说,在预留到期日当天升级可以把升级成本降至最低。但这是理想情况,实际生产中并不能保证。例如某天到期的实例过多,超过了每日处理实例上限。此时需要将溢出的实例推后到符合条件的日期。又如主从数据库到期日相同,此时需要将其拆分到不同的批次升级,以减少对系统的影响。诸如此类。所以总体思想是先按预留到期日和约束条件规划,然后根据其他关联关系调整。

算法基本上可以分成四大步,即生产和非生产实例的首轮规划,依据实例关联关系二次调整,包括主从数据库对和负载均衡器组关系。两个首轮规划可以并行,如若数据量不大则串行处理亦可。下图是算法总览的工作流示意图。后节中会结合代码实现具体展开。如果有其他关联关系需要在首轮规划的基础上调整,可后置于最后一个调整模块。

系统架构

实例和相关结构化数据,通过文件存入 S3 桶,而后通过 Lambda 导入 DynamoDB 表。规划通过 Lambda 运算,结果可以选择存入 DynamoDB 表,或者以文件存入 S3 桶。如果实例存在且有相应权限,实例相关属性信息可以直接读取。整体系统架构图如下所示:

算法现实

数据导入

DynamoDB 去年底开始支持类结构化查询语言 PartiQL (一种与 SQL 兼容的查询语言)查询、插入、更新和删除表数据,十分便利。例如实例数据的导入,可以通过类 SQL 语言完成:

class InstanceLoader {
    // instances: 通过 S3 的 CSV 文件读入实例数组,在此从略
    async insert(instances) {
        for (const instance of instances) {
            const select = `select * from InstanceTable where id = '${instance.id}'`;
            const items = (await dynamodb.executeStatement({Statement: select}).promise()).Items;
            switch (items.length) {
            case 0:
                const insert = `insert into InstanceTable value {
                    'id': '${instance.id}',
                    'mode': '${instance.mode}',
                    'zone': '${instance.zone}',
                    'type': '${instance.type}',
                    'application': '${instance.application}',
                    'reserveExpiryDate': '${instance.reserveExpiryDate}'
                }`;
                console.log(`Instance ${instance.id} does not exist, insert it.`);
                await dynamodb.executeStatement({Statement: insert}).promise();
                break; 

            case 1:
                const update = `update InstanceTable
                    set mode = '${instance.mode}'
                    set zone = '${instance.zone}'
                    set type = '${instance.type}'
                    set application = '${instance.application}'
                    set reserveExpiryDate = '${instance.reserveExpiryDate}'
                    where id = '${instance.id}'`;
                console.log(`Instance ${instance.id} exists, update it.`);
                await dynamodb.executeStatement({Statement: update}).promise();
                break;
            }
        }
    }
}

DynamoDB 和该类结构化语言都支持数组类型。例如负载均衡器不同组内有多个实例,可以按数组同时存储到一个值内:

class LoadBalancing {
    id;
    groupA = [];
    groupB = [];
    toQuotedString(arr) { return "'" + arr.join("', '") + "'"; }
}

class LoadBalancingLoader {
    // lbs: 通过 S3 的 CSV 文件读入负载均衡器数组,在此从略
    async insert(lbs) {
        for (const lb of lbs) {
            const select = `select * from LoadBalancingTable where id = '${lb.id}'`;
            const items = (await dynamodb.executeStatement({Statement: select}).promise()).Items;
            switch (items.length) {
            case 0:
                const insert = `insert into LoadBalancingTable value {
                    'id': '${lb.id}',
                    'groupA': [${lb.toQuotedString(lb.groupA)}],
                    'groupB': [${lb.toQuotedString(lb.groupB)}]
                }`;
                console.log(`Load balancing ${lb.id} does not exist, insert it.`);
                await dynamodb.executeStatement({Statement: insert}).promise();
                break;

            case 1:
                const update = `update LoadBalancingTable
                    set groupA = [${lb.toQuotedString(lb.groupA)}]
                    set groupB = [${lb.toQuotedString(lb.groupB)}]
                    where id = '${lb.id}'`;
                console.log(`Load balancing ${lb.id} exists, update it.`);
                await dynamodb.executeStatement({Statement: update}).promise();
                break;
            }
        }
    }
}

主从数据库对比负载均衡器组略简单,因为是单一值,不是数组值。相关数据处理代码在此从略。

算法核心

规划算法的核心是以日为单位的批次及其管理。一个批次定义为某日处理某组实例。批次管理最重要的是根据欲升级日期和约束条件,新建或者查找符合约束条件的批次。即该日星期数为可排星期且非节假日,该批次实例未达到日处理上限等。如果给定的日期不满足,则往后依次轮询。

class Batch {
    date;
    instances = [];

    get key()  { return this.date.toDateString(); }    
    get size() { return this.instances.length; }    

    addInstance(instance) { this.instances.push(instance); }
}

class BatchManager {
    batchMap = new Map();

    createBatch(date, limit) {
        if (this.batchMap.has(date.toDateString())) {
            const batch = this.batchMap.get(date.toDateString());
            return batch.size < limit ? batch : null;
        }

        const batch = new Batch(new Date(date), this);
        this.batchMap.set(batch.key, batch);
        return batch;
    }

    retrieveBatch(date, limit, allowedDays, holidays) {
        var batch = null;
        do {
            date = date.nextValidDate(allowedDays, holidays);
            batch = this.createBatch(date, limit);
            if (batch == null) { date = date.plusOneDay(); }
        } while (batch == null);
        return batch;
    }
}

规划预留实例,主要是根据其预留到期日排列。利用批次管理器,从实例预留到期日开始,找到符合条件的批次,放置实例。

class Scheduler {
    async scheduleRdInstances(mode, limit, allowedDays, holidays) {
        const instances = await this.instanceManager.selectReservedInstances(mode);

        for (var i = 0; i < instances.length; i++) {
            const item = instances[i];
            const instance = new Instance(item.id.S, item.mode.S, item.zone.S, item.type.S, item.application.S, item.reserveExpiryDate.S);
            const date = new Date(instance.reserveExpiryDate);
            const batch = this.batchManager.retrieveBatch(date, limit, allowedDays, holidays);
            batch.addInstance(instance);
        }
    }
}

规划按需实例,利用批次管理器,从升级首日开始,找到符合条件的批次,放置实例。这里在读取按需实例时,会根据约束条件按应用或者机型排序。

class InstanceManager {
    async selectOdInstances(mode, sortBy) {
        const select = `select * from InstanceTable where reserveExpiryDate = 'OD' and mode = '${mode}'`;
        const items = (await dynamodb.executeStatement({Statement: select}).promise()).Items;

        switch (sortBy) {
            case 'app':
                console.log("Sort by application.")
                items.sort((i, j) => i.application.S.localeCompare(j.application.S));
                break;

            case 'type':
                console.log("Sort by instance type.")
                items.sort((i, j) => Instance.compareType(j.type.S, i.type.S));
                break;
        }
        return items;
    }
}    

class Scheduler {
    async scheduleOdInstances(mode, limit, allowedDays, holidays, sortBy, startDate) {
        const instances = await this.instanceManager.selectOdInstances(mode, sortBy);
        const date = new Date(startDate);
        for (var i = 0; i < instances.length; i++) {
            const item = instances[i];
            const instance = new Instance(item.id.S, item.mode.S, item.zone.S, item.type.S, item.application.S, item.reserveExpiryDate.S);
            const batch = this.batchManager.retrieveBatch(date, limit, allowedDays, holidays);
            batch.addInstance(instance);
        }
    }
}

至此,规划算法就比较清楚了,结合算法总览图,大体上是以下几步:① 规划非生产预留实例,② 规划非生产按需实例,③ 规划生产预留实例,④ 规划生产按需实例,⑤ 调整主从数据库实例,⑥ 调整负载均衡器实例。最后两步是根据实例间关联关系调整。调整思路在算法总览一节有描述,在此不赘述。最后把批次按日期排序,把排期日期、实例各属性、各关联关系信息,依次填入汇总表即可完成。

class Scheduler {
    consolidate() {
        const instances = [];
        const batches = Array.from(this.batchManager.batchMap.values());
        batches.sort((i, j) => i.key.localeCompare(j.key));
        for (const batch of batches) {
            for (const i of batch.instances) {
                const db = this.instanceManager.checkDatabaseReplica(i.id);
                const lb = this.instanceManager.checkLoadBalancing(i.id);
                instances.push(new Scheduled(i.id, i.mode, i.zone, i.type, i.application, i.reserveExpiryDate, batch.date, db[0], db[1], lb[0], lb[1]));
            }
        }
    }

    async schedule(event) {
        await this.scheduleRdInstances("dev", event.devDailyLimit, event.devAllowedDays, event.holidays);
        await this.scheduleOdInstances("dev", event.devDailyLimit, event.devAllowedDays, event.holidays, event.sortBy, event.startDate);
        await this.scheduleRdInstances("prod", event.prodDailyLimit, event.prodAllowedDays, event.holidays);
        await this.scheduleOdInstances("prod", event.prodDailyLimit, event.prodAllowedDays, event.holidays, event.sortBy, event.startDate);
        await this.adjustDatabaseReplica(event);
        await this.adjustLoadBalancing(event);
        this.consolidate();
    }
}

辅助函数

为了便于编码,在函数调用入口定义了数个日期类的辅助函数。

exports.handler = async event => {
    Date.prototype.toDateString = function() { return this.toISOString().substring(0, 10);};
    Date.prototype.plusDays = function(days) { const d = new Date(this); d.setDate(d.getDate() + days); return d; };
    Date.prototype.plusOneDay   = function() { return this.plusDays(1); };
    Date.prototype.nextValidDate = function(allowedDays, holidays) {
        var date = this;
        while (!allowedDays.includes(date.getDay()) || holidays.includes(date.toDateString())) {
            date = date.plusOneDay();
        };
        return date;
    }
    await new Scheduler().schedule(event);
};

性能测试

利用一套模拟数据集对本系统进行测试。该数据集包含 362 台实例,其中非生产预留实例 9 台,非生产按需实例 105 台,生产预留实例 167 台,生产按需实例 81 台。预留到期日分布于数个时间节点。另外有 5 对 10 台主从数据库,41 个负载均衡器组共 84 台实例。测试结果显示,针对各项约束条件下的规划耗时均在秒级,通常在 3 秒内完成。

结论

本文以 DynamoDB 存储实例相关信息,通过 PartiQL 类结构化查询语言,搭建了一套无服务器的通用实例升级规划架构。该规划系统考虑了实例升级规划中的普遍性问题,例如将实例区分为生产与非生产,考虑实例预留期限,照顾升级实际需求的约束条件等。用户可以根据不同的实际情况,改变约束条件,快速得到多种条件下的规划情况。选择较优的规划,展开进一步微调和优化,从而提高工作效率。

工作展望

有几个方面可以拓展上述工作。其一可以扩大实例类别。除了生产非生产二元划分,支持多元划分,使得实例升级规划更细腻、更贴近现实。其二是支持超过两个组的负载均衡器,当区域有三个或以上可用区时,就有可能有多个组。其三是借助亚马逊云科技其他服务的支持,例如实例成本与账单信息,对规划结果进行成本预估,对实际使用情况进行费用核算等。

参考资料

本篇作者

袁文俊

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

刘育新

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