亚马逊AWS官方博客

使用Amazon CloudFront和Amazon Lambda完成对象存储源站平滑迁移至Amazon S3

如上图所示的源站迁移场景,旧的源站和亚马逊云之间没有专线连接,而需要迁移的数据又是PB级别,迁移耗时可能需要1个月之久。在这1个月期间,可能又有新的文件被上传到旧源站。怎样保证完整迁移所有数据?怎样保证源站迁移不影响最终用户体验?

Day0(或者说Month0)的解决方案,是利用原生的Amazon DataSync服务,或者亚马逊云科技解决方案团队制作的 Data Transfer Hub批量迁移数据。原则是,集中资源,以特定的成本,在有限的时间内,尽可能多地迁移数据。在这个阶段,CloudFront的源站仍然是旧的第三方源站。

持续迁移2-4周,在大部分文件都已经被迁移到S3的前提下,将CloudFront的源站切到S3。同时,将迁移方案换成Day1(Month1)方案。也就是本文接下来要介绍的,利用Amazon CloudFront,Amazon Lambda@Edge,Amazon Lambda,Amazon SQS,Amazon DynamoDB,Amazon EventBridge和Amazon Secrets Manager等服务,帮助客户将对象存储源站平滑迁移至Amazon S3。

解决方案整体架构

整体解决方案架构如上图所示。方案设计遵循以下原则:

  • 尽量减少或者不增加回源请求的延时
  • 尽可能地降低迁移成本
  • 避免源站迁移影响最终用户的体验

工作流程

为了避免跨Region回源和迁移数据,降低回源延迟,提高CDN缓存命中率,本方案推荐开启源盾(Origin Shield)。通过开启源盾还可以显著减少Lambda@Edge与SQS之间的网络延迟,进而减少Lambda@Edge的运行时间,降低迁移的成本。

启用Origin Group,将S3设置为Primary Origin,将旧源站设置为Secondary Origin。如果用户请求的文件在S3存储桶里不存在,则S3会返回HTTP status 403。如果文件在S3存储桶里被删除,则S3会返回HTTP status 404。CloudFront如果收到Primary Origin返回的403或404,则立即向Secondary Origin发起新的请求。这样,可以尽可能少地引入额外的回源延迟,保障最终用户的体验。

Lambda@Edge的触发条件为源响应(Origin Response)。与源请求(Origin Request)相比,使用Origin Response作为Lambda@Edge的触发条件有以下好处:

  • Origin Request Lambda@Edge需要发起Request再跟踪Response status code,而Origin Response Lambda@Edge直接被Response触发。所以后者的运行时间比前者至少少1个RTT,成本更低
  • Origin Response Lambda@Edge可以直接从HTTP响应中获取Content-Length和Content-Type,它们是后台数据同步工作流所必需的2个参数。因此,Lambda@Edge或者Lambda无需向旧源站发起一次额外的HTTP HEAD请求,也无需构建复杂的代码逻辑。
    • 如果旧源站返回HTTP status 200,直接携带Content-Type和完整文件的Content-Length标头
    • 如果用户发起的是一个RangeGET请求,则旧源站返回HTTP status 206,响应中会携带Content-Type和Content-Range(Value格式为Range: Bytes=<start_byte>-<end_byte>/<total_bytes>)标头
    • 如果CloudFront之前从旧源站缓存了该文件,但是过期了,那么,无论用户发起的请求是不是RangeGET,旧源站都会返回HTTP status 304。在304的响应中不包含Content-Type或Content-Length或Content-Range,所以我们将Content-Length先设置为0,将Content-Type先设置为“none”。后台数据同步工作流看到这2个Value之后,会先向旧源站发起HTTP HEAD(HTTP方式)或者Get Metadata(API方式),获取到正确的Value,再进行数据同步
    • 如果旧源站返回了其他HTTP status code,则直接将HTTP响应返回给CloudFront
    • 这部分处理逻辑的Lambda@Edge代码如下(编程语言为js):
let contentLength = '0';
let contentType = 'none';

switch(statusCode) {
    case '200':
        contentLength = response.headers['content-length'][0].value;
        contentType = response.headers['content-type'][0].value;
        break;
    case '206':
        contentLength = response.headers['content-range'][0].value;
        contentLength = contentLength.split('/');
        contentLength = contentLength.pop();
        contentType = response.headers['content-type'][0].value;
        break;
    case '304':
        console.info(response);
        break;
    default:
        console.info(response);
        return response;
}
  • 通常,CloudFront采用OAI(Origin Access Identity)或者OAC(Origin Access Control)访问S3。在这种情况下,如果由Origin Request Lambda@Edge通过修改Host标头的方式,将请求再发给旧源站,那么,该请求并不是一个新的HTTP请求。这意味着,CloudFront发给旧源站的HTTP Request会携带本应发给S3的Signature,导致请求失败
    • 旧源站收到Lambda@Edge发来的Signature之后,会误认为这是一个Signed URL请求(或者其他名称,不同的对象存储有不同的名称),由于Signature无效,所以返回SignatureDoesNotMatch错误
    • 该Signature并不是Lambda@Edge Event的一部分,所以Lambda@Edge看不到它,自然也无法修改或删除它

在旧源站的HTTP响应中,还会携带特定的标头以标识存储服务商。例如,S3会返回Server: AmazonS3标头,Google Cloud Storage会返回Server: UploadServer标头。我们可以利用这些特定的标头作为判断条件来选择性地触发Lambda@Edge的工作流程。只有HTTP响应携带了特定的Header和特定的Value,才说明该文件在S3上不存在,现在从旧源站得到了响应,需要将该文件迁移至S3。然后,Lambda@Edge才会提取URI,Content-Type和Content-Length,向SQS队列发送消息。

还有一点需要注意,我们无法为Lambda@Edge设置环境变量。如果要实现自动化部署,怎样将SQS队列的URL传递给Lambda@Edge?

我们可以利用CloudFront Custom Origin Header来实现Lambda@Edge变量值的传递。

下面是Python CDK创建CloudFront Distribution的部分代码,我们将SQS队列所在的Region和Queue URL设置为Secondary Origin的自定义标头的Value:

origin=origins.OriginGroup(
    primary_origin=origins.S3Origin(
        s3_bucket,
        origin_shield_region=solution_region,
    ),
    fallback_origin=origins.HttpOrigin(
        gcs_domain_name,
        origin_path='/'+gcs_bucket_name.value_as_string,
        custom_headers={
            'x-back-to-origin': json.dumps({
                'region': solution_region,
                'queue_url': uri_list_queue.queue_url
            })
        },
        origin_shield_region=solution_region,
    ),
    fallback_status_codes=[403, 404]
),

CDK部署完成之后,检查Secondary Origin的配置,就能够看到自定义标头已经添加成功,并且包含了Region和Queue URL的信息。

Lambda@Edge从自定义的Origin标头即可获取到相应的变量值:

if (serverHeader === 'UploadServer') {
    
    const myHeader = request.origin.custom.customHeaders['x-back-to-origin'][0].value;
    const myVariables = JSON.parse(myHeader);
    
    const sqs = new AWS.SQS({
        region: myVariables.region,
        apiVersion: '2012-11-05'
    });
    const queueUrl = myVariables.queue_url;

    // 以下省略...
}

Lambda@Edge将URI,Content-Type和Content-Length发给一个Standard队列,这样可以保证大并发请求场景下的处理性能。

至于Lambda@Edge的成本,我做了一个简单的测试:384MB内存Lambda@Edge,处理每一个S3 Response的账单时间是1.18ms左右(因为直接向CloudFront返回Response,无需做其他的处理);处理797.1MB的大文件的账单时间是87ms左右(包括处理Response和发送SQS消息);处理0.3KB~12.3MB文件的账单时间是45ms左右(包括处理Response和发送SQS消息)。

另外,我们也测试了128MB Lambda@Edge,其账单时间是384MB Lambda@Edge的2.5~3倍。但是在测试的过程中,我们也发现一个现象:如果有一个Request复用了前一个Request的Lambda实例,则同样的任务(无论是计算密集型还是I/O密集型)可以在更短的时间内被完成(已经排除Lambda实例初始化的时间;请参见下文Multipart Upload的测试数据)。关于其背后的原理和影响,请参考本文末尾的补充阅读1:理解Lambda实例复用

针对静态文件下载的场景,我们认为客户会更关注Last Byte Latency而不是First Byte Latency,所以建议将Lambda@Edge的内存设置为128MB,但Lambda Timeout设置为30秒,理论上可以增加复用Lambda@Edge实例的次数,这样可以节省一些成本。

Lambda@Edge在发送SQS消息之后,立即将Response返回给CloudFront,最终用户也会马上开始接收文件。用户体验基本不会受到源站迁移的影响。

Lambda Function URL也曾是本方案的备选项,它可以作为Secondary Origin来实现比较复杂的逻辑。但是Lambda Function URL相比于Origin Response Lambda@Edge至少要多一次HTTP重定向和HTTP HEAD请求,Response Payload大小也有限制,所以它并不是最优解。

S3 Object Lambda则不适用于此方案,因为“文件不存在”并不是一个S3 Event,无法触发S3 Object Lambda。

相对于CloudFront工作流,数据迁移的工作流是异步进行的。SQS Standard队列消息会触发一个名为“Main”的Lambda。Lambda “Main”收到SQS消息之后,提取URL,对一个名为“URI_List_Table”的DynamoDB Table做一次Conditional Put Item。这样做的目的是:

  • 如果DDB Table里面没有该URI的条目,则说明之前没有创建过该对象的迁移任务,因此,Lambda “Main”向URI_List_Table写入该URI,然后继续下面的工作流程
  • 如果DDB Table里面已经存在该URI的条目,则说明之前已经创建过该对象的迁移任务,那么,不应该再重复迁移该对象。此时,DDB会返回一个Error,终止执行Lambda “Main”

后台数据同步解决方案架构

后台数据同步工作流的设计来源于分布式 Lambda 从海外到中国自动同步S3文件,但是对原方案做了一些改造,以适应CDN大并发请求的场景:

  • 为了实现更好的性能,可扩展性和可靠性,本方案通过SQS来触发Lambda,而不使用Asynchronous Invocation
    • Standard SQS 队列消息有一定概率被重复消费。重复下载小文件不是问题,但重复下载大文件会浪费较多的Lambda费用。为了避免这种情况,这两个SQS队列模式都是FIFO
    • Batch Size=1,每条消息分配1个任务,每个任务触发1个Lambda实例
    • Lambda “Main”在前面的步骤8已经通过Conditional Put Item的方式过滤了重复的任务,所以这两个FIFO的负载不会很重
    • 每百万个FIFO队列请求的价格只比Standard队列请求贵0.1 USD,但我们的迁移任务很难达到每个月百万请求的级别。与重复下载大文件所产生的Lambda费用相比,FIFO多出来的成本是很划算的投资
  • 本方案采用Streaming Transfer的模式迁移单个文件,无需先将文件下载到本地/tmp目录再上传到S3。这样可以提高数据迁移的效率,减少Lambda的并发实例数量和总运行时间,节省Lambda的成本。很多对象存储厂商都支持Streaming Download,如果客户的旧源站不支持Streaming Download,则需要修改这部分的代码,改成普通的文件下载模式
  • Lambda Timeout设置为15分钟,提高复用Lambda实例和TCP连接的概率

工作流程

首先,我们定义2个变量maxSizepartSizemaxSize是迁移单个文件的最大尺寸;partSize是采用S3 Multipart Upload迁移大文件时,每个Chunk的尺寸。

  • 因为CloudFront能够缓存的单个文件尺寸最大30GB,所以,如果maxSize > 30GB,则终止任务
  • 如果file size <= maxSize,则Lambda “Main”向“SingleQueue”队列发送任务消息,触发Lambda “Single”以Streaming的模式将文件从旧源站迁移至S3存储桶
  • 如果maxSize < file size <= 30GB,则Lambda “Main”:
    • 先创建一个S3 Multipart Upload的任务,将文件分割成若干个尺寸为partSize的Chunk
    • 然后分别为每一个Chunk创建一个迁移任务,分别向“MpuQueue”队列发送任务消息
    • 触发Lambda“MPU”向旧源站发送RangeGET请求,将Chunk下载到本地/tmp目录,然后将其读取到内存,以Multipart Upload的模式上传到S3存储桶

经测试,512MB内存ARM64架构的Lambda,以Streaming Transfer的模式从新加坡的Google Cloud Storage迁移797.1MB的Zip文件到新加坡的S3,耗时62.837秒,最大占用内存只有197MB。

根据这个测试数据,理论上,一个Lambda “Single”能够迁移的文件最大尺寸可以达到10GB(预计耗时14分钟)。

对于Multipart Upload文件迁移的场景,我们也做了一个简单的测试。384MB内存ARM64架构的Lambda,从新加坡的Google Cloud Storage迁移797.1MB的Zip文件到新加坡的S3,partSize为32MB。测试结果如下:

25个任务实际并发8个Lambda实例(同样传输32MB的Chunk,复用Lambda实例可以节省近2秒的Duration。请参考本文末尾的补充阅读1:理解Lambda实例复用)。整个迁移任务大概耗时17秒,小于Streaming传输同样尺寸文件的63秒。账单时间106.42秒,大于streaming传输同样尺寸文件的63秒。Multipart Upload模式完成文件传输任务的速度明显快于Streaming Transfer,但成本更高。即使考虑到内存大小的不同,Multipart Upload的成本也会更高一些。

此外,每个Region的Lambda并发实例的数量也有限制,虽然可以开Case提额,但客户的Lambda并发实例的额度不可能都分配给这个Solution,所以我们要尽量少用Multipart Upload,尽量避免出现大量并发Lambda实例的情况。

在综合考虑成本和传输效率两个因素之后,本方案提供以下2种设置maxSizepartSize的参考值:

  • 游戏和软件分发行业:新增的多是大尺寸安装包,单个文件最大尺寸可能会接近30GB
    • maxSize = 512MB。File size <= 512MB调用Lambda “Single”,用Streaming方式传输单个文件,最大文件迁移时间预计40.362秒
    • File size > 512MB调用Lambda “MPU”,用Multipart Upload方式分段传输,partSize = 32MB
  • 电商,视频,及其他行业:新增的多是小文件,html、js、css、图片、视频切片为主,单个文件尺寸一般不超过256MB
    • maxSize = 16MB。File size <= 16MB调用Lambda “Single”,用Streaming方式传输单个文件,最大文件迁移时间预计1.261秒
    • File size > 16MB调用Lambda “MPU”,用Multipart Upload方式分段传输,partSize = 5MB

静态内容在CDN上都会被缓存比较长的时间,如果客户认为迁移速度并不是关注的重点,可以适当地将maxSize设置得大一些,以降低成本。

DynamoDB的作用是维护任务状态。因为上面几个Lambda的Timeout都是15分钟,所以设置EventBridge每15分钟触发一次Lambda“Monitor”查询DDB Table,如果发现有超时15分钟的任务,则重新向SQS队列发送消息,重新触发Lambda迁移相应的文件或者Chunk。相关细节请阅读分布式 Lambda 从海外到中国自动同步S3文件,本文不再赘述。

最后,如果Lambda使用API的方式请求旧源站,可以利用Secrets Manager保存API Key。

部署解决方案

客户可以使用Python CDK快捷部署本方案的一个Demo。这个Demo使用Google Cloud Storage(GCS)作为旧源站。GCS提供3个月的免费试用,但不支持对象级别的访问控制。所以我的GCS Bucket是可以Public访问的,也就没有提供GCS API认证部分的代码。因此CDK部署的资源不包括Secrets Manager,也不包括SQS Dead Letter Queue。

客户需要自己申请一个GCS Bucket,记下Bucket Name和它所在的Region。

Lambda@Edge只能被部署到us-east-1 Region,存储桶则可能位于其他Region,所以CDK会自动创建2个Stack:一个用于在us-east-1 Region部署Lambda@Edge,另一个用于在客户指定的Region部署其他资源。因此Region 代号也是部署CDK所必需的。

部署的步骤:

git clone https://github.com/chenghit/cloudfront-s3-back-to-origin.git
python3 -m venv .venv
source .venv/bin/activate		# for MacOS and Linux
.venv\Scripts\activate.bat		# for Windows
pip install -r requirements.txt
npm uninstall -g aws-cdk		# AWS-CDK版本需要2.46或更高版本;如果之前已经安装了旧版本的CDK,则需要先将其卸载,再重新安装新版本
npm install -g aws-cdk
cdk bootstrap ACCOUNT_ID/REGION	# 必需指定账号ID和部署存储桶的Region代号
cdk synth
cdk deploy --all --parameters BackToOrigin:gcsBucketName=YOUR_GCS_BUCKET_NAME

本方案启用了Origin Shield,所以请确保在以下Region部署CDK,否则会报错。

[us-east-1, us-east-2, us-west-2, ap-south-1, ap-northeast-2, ap-southeast-1, ap-southeast-2, ap-northeast-1, eu-central-1, eu-west-1, eu-west-2, sa-east-1]

预期的测试效果

测试之前:

测试之后:

Cleaning up

进入Python环境,使用以下命令删除所有已经部署的Demo资源:

cdk destroy --all

总结

客户利用本方案可以保证对象存储源站的平滑迁移。虽然本方案的Demo采用了GCS作为旧源站,但也支持其他的对象存储源站。客户只需修改GCS下载文件的代码即可。

另外,客户可以根据Multipart Upload迁移文件的Lambda账单时间,以及需要迁移的总数据量,来预估整个迁移方案的成本。上文已经介绍过,Multipart Upload的成本比Streaming Transfer的成本要高一些,而大部分甚至绝大部分的文件都会通过Streaming Transfer的模式迁移。所以,预估的成本可以大致覆盖Lambda的请求费,以及SQS、EventBridge、Secrets Manger和DynamoDB等资源的费用。

补充阅读1:理解Lambda实例复用

如果有一个Request复用了前一个Request的Lambda实例,则同样的任务(无论是计算密集型还是I/O密集型)可以在更短的时间内被完成。从上面Multipart Upload的测试结果,我们可以观察到:同样传输32MB的Chunk,复用Lambda实例可以节省近2秒的Duration。为什么会这样呢?

原因在于:

  • Lambda每一次冷启动,在开始执行handler之前,都有免费和收费两个阶段的Duration:
    • 免费Duration:启动Container,下载代码,准备运行环境
    • 收费Duration:初始化编程语言的代码,加载Library,读取环境变量和Global变量
  • 完成代码的初始化之后,Lambda才能开始执行handler函数。如果复用Lambda实例,不仅无需初始化Container,也无需初始化代码,不仅可以加快处理速度,还能降低Lambda的运行成本
  • 复用Lambda实例也就意味着可以复用TCP连接。新的下载和上传任务不需要重新建立新的TCP连接,也不需要重新经历TCP慢启动的过程,可以直接使用较大的Congestion Window。所以我们可以看到复用Lambda实例下载32MB的Chunk比之前快了近2秒,速度提升非常明显

这也是为什么我们建议将Lambda@Edge的Timeout设置为最大的30秒,将Lambda的Timeout设置为最大的15分钟。在调用Lambda的频率满足持续复用条件的情况下,将Timeout从默认的3秒增加为30秒可以减少9次冷启动;将Timeout从5分钟增加为15分钟可以减少2次冷启动。对于文件迁移这种Duration比较长,I/O密集型的应用,每减少1次冷启动都能带来较大的收益。

但是需要注意:要为handler合理设置return和fallback,避免出现idle状态,否则一旦idle就有15分钟的Billing Duration,代价太大了。

我们还应该尽可能地在Lambda中使用全局变量。相关技术细节请阅读亚马逊云科技Global Blog:

补充阅读2:ETag对源站迁移的影响

文件被迁移之后,其ETag有可能会发生改变,有客户会担心ETag的变更可能影响最终用户体验。

通常,对象存储都会采用文件的md5(File).hexdigest()值作为ETag。对于那些通过Streaming Transfer迁移的文件,其迁移前后的ETag不会发生变化,不会对回源的行为产生影响。

但如果文件是通过Multipart Upload方式迁移的,其ETag的值就变成了(md5(Part1).digest()+md5(Part2).digest()…+md5(PartN).digest()).hexdigest()-{number of parts}。文件迁移前后的ETag值不相同。

如果CloudFront从旧源站缓存了一个大文件,然后该大文件通过Multipart Upload方式被迁移到S3,待Caching TTL过期,CloudFront会向S3发送一个Conditional GET或者Conditional RangeGET请求。因为S3保存的ETag与CloudFront保存的ETag不相同,所以S3会向CloudFront返回HTTP 200或者206,把文件或者Range重新发给CloudFront覆盖掉原来缓存的文件或者Range。

看上去,ETag的改变会影响到用户体验,但实际上并不会。

  • 源站的切换并不影响Clients和CloudFront之间已经建立的连接
  • S3向CloudFront发送文件免流量费
  • CloudFront采用Streaming模式回源,收到1个字节就向Client发送1个字节,而不需要等到文件从S3下载完成再发送给Client

也有客户曾提出过一种极端场景:

  • CloudFront从旧源站缓存了一个接近30GB的安装包
  • Client下载到99%,然后Client因为某种原因断开了网络连接
  • 此时该文件在CloudFront上面TTL过期
  • 当Client再次连接,继续下载安装包,因为ETag变更,是否会导致文件损坏?之前下载的99%是否就没用了?

答案是并不会。

  • 首先,我们要理解ETag和文件的MD5 Checksum是两个完全不同的概念,它们有完全不同的作用。ETag在某些情况下基于MD5 Checksum计算,但是它并不等同于MD5 Checksum
  • 其次,校验文件是否损坏,依据是MD5 Checksum,而不是ETag。ETag是CDN或者浏览器Caching的概念,作为下载文件的客户端并不关心ETag值是什么
  • 计算MD5 Checksum要等到文件下载完成之后才会计算,文件只下载了99%的时候不会计算Checksum。什么时候会出现MD5 Checksum校验失败呢?只有当Client不支持断点续传的时候,它认为99%已经下载完成,所以才会计算MD5 Checksum。这种情况已经和CDN没有关系了
  • 最后,Client是与CloudFront建立连接,而不是与S3建立连接;Client从CloudFront下载文件,而不是从S3下载文件。所以只要Client支持断点续传,只要CloudFront能把Client请求的Range Bytes发给它,就能够下载成功

客户可能会提出新的问题:CloudFront已经用旧源站的文件发送了99%的Bytes,现在又要用S3的文件发送1%的Bytes,那么CloudFront会不会出现文件校验错误而导致Client下载失败?

答案同样是不会。

上面已经提到,校验文件完整性采用的是MD5 Checksum而不是ETag。此外,CloudFront并不会为一个断开连接又重新连接的Client维护任何状态,所以CloudFront并不知道它之前已经给这个Client发送了99%的Bytes。CloudFront只会关心Client断点续传所请求的Range,然后从S3取回这个Range发给Client。

综上所述,客户无需担心ETag的变更会对用户体验造成影响。

本篇作者

陈程

亚马逊云科技边缘产品架构师,有多年OTT行业相关的工作经验,熟悉云网络、CDN和WAF。非工作时间忙于抚养一娃俩猫。