Created At: [[2024-11-12]] 最近真的有一些麻。AWS Amplify 真的是我用过最为难用的框架。文档很糟糕,很多重要的东西在文档里没有写。社区很小,所以,当你遇到什么问题的时候,只能自己解决。即便现在有了 ChatGPT 或 Claude 这样的生成式 AI 工具,很多时候查阅相关资料的时候并不需要你知道精确的术语,这依然是一个比较棘手的问题。很多时候,一个问题一卡就能卡好几天,怎么也解决不了。这个时候只能努力去搜罗互联网上你能搜罗到的所有信息,去翻 Amplify 的官方 Discord 社群,以及跟着自己的直觉(Intuition)去做一些尝试。 最开始,我面对这样一个框架的时候,我的态度是急躁 + 抱怨。说真的,确实很少遇到如此难用的框架。但是逐渐地,我开始在里面寻找到一些规律,开始逐渐能使用它去达成一些我想要达成的东西。虽然每一次对后端的改动都可以用 “大动干戈” 一词来形容,每一次基本都要因为这样或者那样的原因卡上一两天,但不得不 “高情商” 地说,感谢 AWS Amplify 给了我这样一个磨练自己心性、变得更耐心的机会,也感谢它锻炼了我解决完全没有头绪的能力。但凡这个框架写的好那么一些些,我都不会有这样相关的经历。 在这篇博客中,我会介绍一个让我踩了一周多坑才解决的东西,具体来说就是如何给一个存储桶(Storage Bucket)设置一个内容分发网络(CDN),并允许任何人访问。希望能对看到这部分的你有一些帮助。同时,我也希望也借着这个机会,梳理一下亚马逊云的一些框架性的知识。 ## 一、动机与俯瞰 我有一份兼职工作,主要是帮瑞典的一个健身品牌写他们的健身软件。我加入的时候,这家公司正在把后端从自己写的 PHP 代码迁移到 [AWS Amplify](https://docs.amplify.aws/flutter/) 上。后来,由于一些原因,我自己一个人负责了整个软件的前端和后端。AWS 恰巧又推出了 Amplify V2,于是我开始把整个软件的后端迁移到 V2 的版本上。其中一个重要的工作就是一些文件的存储与分发。 Amplify 有一个对应的 Storage 的相关服务,主要用的底层是 [AWS S3](https://aws.amazon.com/s3/)(S3 是 Simple Storage Service)的简写。这个东西感觉与腾讯云的 [对象云存储](https://www.tencentcloud.com/zh/products/cos) 有点像。如果给一个没有计算机背景的人解释的话,这大概相当于是一个 Google Drive 或者是一个百度网盘,就是远程的这样一个存储器。你可以往里面上传东西,也可以从里面下载东西,当然也可以把不同的文件放在不同的文件夹下——当然,在 S3 的情况下,我们把一个文件的路径称为它的存储键(Storage Key)。 在我工作的这家公司的软件里,我们有一些图片和视频,是希望所有人都能看到的。同时,我们希望所有人能快速访问它。这就需要用到一个叫做 [内容分发网络](https://en.wikipedia.org/wiki/Content_delivery_network)(Content Delivery Network, CDN)的东西。什么是 CDN 呢?简单来说,你可以想象 S3 里的东西被存在远处的一个电脑里。我们假设它在加州吧。显然,如果你的电脑在加州,那么你的电脑去访问这样的一个东西速度就会快很多;反之,如果你的电脑在中国大陆,或者在新加坡,那么你去访问这样一个东西的速度就会有所下降。怎么办呢?CDN 就会在全球部署一系列的电脑,并把相应的数据通过一系列的规则拷贝到对应的位置。这样,如果你在新加坡,你就可以访问新加坡的电脑上的数据——这就比跑到美国访问数据要快的多。这其实是一种全球的缓存(Caching)机制,在大的想法上,和 CPU 里的 L2、L3 缓存的想法是相似的。 所以,我需要做的,就是给这些公开的内容放到一个 CDN 上,这样访问速度就会快很多。这么做还有一个动机:CDN 的读取价格往往比 S3 的读取价格便宜很多——虽然我觉得对于我们这样一家小企业来说,这一点点服务器的费用其实没有很显著的差距。 AWS 提供 CDN 的服务叫 [CloudFront](https://aws.amazon.com/cloudfront/)。不要和另一家叫 [CloudFlare](https://www.cloudflare.com/) 的公司搞错了,虽然那一家的主营业务感觉也是 CDN。我需要通过 CloudFront 来给我的存储桶配置相应的 CDN 服务。同时,我希望这个 CDN 是通过一个域名来访问的,而恰巧,我工作的公司正好有一个专门给这个 App 的域名托管在 AWS 上,并通过 DNS 解析将域名解析到对应的服务器上。AWS 用于托管域名和域名解析的服务叫 [Route53](https://aws.amazon.com/route53/),所以我也会用到这个服务。 > [!warning] 免责声明 > 请注意,我没有系统地学习过 AWS,所以很多的服务都是用的时候当场学,甚至很多本文介绍的东西都是一遍一遍试错的结果。或许,本文中所介绍的一些方法并不符合正确的 AWS 使用规范,如果有发现相关情况,欢迎 [邮件](mailto:[email protected]) 我批评指正。 ## 二、设置存储桶 ### 1)如何设置 在 AWS Amplify 这个框架中,我们是这么设置一个存储桶的: ```typescript export const storage = defineStorage({ name: "someStorage", // 1 isDefault: true, // 2 access: (allow) => ({ // 3 "public/*": [ allow.guest.to(["read"]), allow.groups(["admin"]).to(["read", "write", "delete"]), allow.resource(seedDataLambda).to(["read", "write"]), ], "protected/{entity_id}/*": [ allow.authenticated.to(["read"]), allow.entity("identity").to(["read", "write", "delete"]), allow.groups(["admin"]).to(["read", "write", "delete"]), ], "private/{entity_id}/*": [ allow.entity("identity").to(["read", "write", "delete"]), allow.groups(["admin"]).to(["read", "write", "delete"]), ], }), triggers: { // 4 onUpload: defineFunction({ entry: "./on-upload-handler.ts", }), }, }); ``` 我们注意这么几个东西: 1. `name`: 这个玩意儿表示了你存储桶的名字。Amplify 会给这个东西加上一些前缀和后缀,但是如果你成功部署了之后,在后台搜索这个名字,是能找到相应的桶的。对于同一个项目来说,不要使用同样的名字去创建两个不同的桶。 2. `isDefault`: 如果你创建了两个以上的桶的话,那么你需要在其中的一个里将这个设置为 `true`。 3. `access`: 这是 AWS Amplify 提供的访问控制的接口。这里,我允许所有人访问 `public/*`,允许 `staff` 和一个我自己写的 `seedDataLambda` 去读写这个存储桶中 `public/*` 的内容。**在写这部分代码的时候,我真的没有想到它在后来我做 CDN 的时候给我带来了最多的痛苦。** 4. `triggers`: 这里可以提供一些钩子(Hooks)函数。我最开始学编程是通过钻研 Anki 的代码库学的,当时 Anki 正在转型到一套基于钩子的 API。我当时不理解钩子是什么,其实这个词很形象。就是你可以把一些代码给勾到一些程序的内部中,当一定条件到达的时候,对应的钩子函数就会被触发。在这里,我提供了一个 `onUpload` 的钩子——每当有一个文件被上传的时候,其对应的函数就会被触发。 ### 2)工作原理 Amplify 其实是在 AWS 的 [CDK](https://aws.amazon.com/cdk/) (Cloud Development Kit)上额外套了一层代码。CDK 是 AWS 开发的一套 “基础设置即代码” ([Infrastructure as Code](https://en.wikipedia.org/wiki/Infrastructure_as_code),IaC)的框架。IaC 的好处是,传统的硬件、资源配置,往往需要专业的 IT 人员去手工操作——这带来了大量的人工错误。在知道 CDK 之前,我对 AWS 的了解基本上是如果我需要开一个服务,我去登陆它的后台,去点来点去。我在做 [Diary Plus Plus](https://github.com/TeamCoffeeWithOatMilk/diary-plus-plus) (一个很无聊的根据日记生成猫猫图片的 App,当时是和朋友一起参加 Hackathon 的时候做的),其实并不知道 IaC 是什么。导致的结果就是在 AWS 的 Console 上点来点去——如果这个不行那就调整那个,然后反复地尝试。到最后,这个 24 小时的 Hackathon 结束的时候,对应的 API 都没有调通。试想,如果所有的 IT 部署都这么麻烦的话,那估计大公司每一次后端的部署都会是灾难性的。所以,就有了一个很好的想法:**为什么不把部署的流程写成代码呢?** 这就是 IaC。如果你听说过 [Terraform](https://www.terraform.io/) 的话,它其实就是一个非常典型的 IaC 框架。 #### a)[`defineStorage`](https://docs.amplify.aws/flutter/build-a-backend/storage/set-up-storage/) 我们如果跳进 [`defineStorage` 的代码](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend-storage/src/factory.ts#L76) ,我们会发现这样一个定义: ```ts /** * Include storage in your Amplify backend. * @see https://docs.amplify.aws/gen2/build-a-backend/storage/ */ export const defineStorage = ( props: AmplifyStorageFactoryProps ): ConstructFactory<ResourceProvider<StorageResources> & StackProvider> => new AmplifyStorageFactory(props, new Error().stack); ``` 这说明,如果要理解 `defineStorage` 到底干了什么,我们需要去理解 `AmplifyStorageFactory` 这个类型到底干了什么。 #### b)[`AmplifyStorageFactory`](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend-storage/src/factory.ts#L18) 这里创建了一个 `AmplifyStorageFactory`。 > [!tip]- 跑题:为什么是工厂(Factory)? > 这个 `AmplifyStorageFactory` 类型实践了(implement)一个 [`ConstructFactory`](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/plugin-types/src/construct_factory.ts#L18) 的类型。如果按照 Amplify 的 Quick Start Guide 走一遍的话,我们会发现整个项目的入口其实是一个 `backend.ts` 的文件,这个文件中我们能看到类似于这样的代码: > ```ts > export const backend = defineBackend({ > auth, > data, > storage, > }); >``` >如果我们进一步去看 [`defineBackend`]() 的代码库,其中有 [这样一块代码](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend/src/backend_factory.ts#L113): >```ts >Object.entries(constructFactories).forEach( > ([resourceName, constructFactory]) => { > this.resources[resourceName as keyof T] = constructFactory.getInstance( > { > constructContainer, > outputStorageStrategy, > importPathVerifier, > resourceNameValidator, > } > ) as any; > } > ); >``` >这样就能看明白了——所有的不同的内容,比如 Storage,或者 Auth,其实都是一个工厂。只有调用了 `defineBackend` 函数,这些工厂才会生成对应的资源(Resources)。关于这一点在 API 上的一些细微的影响,如果我写到后面还记得的话,我们会在后面进行讨论。 ^b05c47 而这个工厂类型最重要的一个函数是 [`getInstance`](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend-storage/src/factory.ts#L34) 函数(可以见上面跑题部分知道为什么是这个函数)。我们关注这个函数的中间部分,有这样一段代码: ```ts if (!this.generator) { this.generator = new StorageContainerEntryGenerator( this.props, getInstanceProps ); } const amplifyStorage = constructContainer.getOrCompute( this.generator ) as AmplifyStorage; ``` 这个 `constructContainer` (构造容器,构造这个概念一会儿讲)是这个函数从外面拿进来的一个东西,定义在 [这里](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/plugin-types/src/construct_container.ts#L35)。说实话,定义我其实也没有特别看懂每一个部分具体是干什么的,但是这不要紧。从上面的一段逻辑代码以及命名中,我们其实可以大概推断出一二。 1. 首先,我们有一个 `generator`。如果这个 `generator` 没有被设置的话,我们就给它设置成一个新的 `StorageContainerEntryGenerator`。 2. 其次,当我们确保了一定有 `generator` 之后(因为上一点的逻辑),我们调用 `constructContainer` 的 `getOrCompute` 函数。 所以,我们大概可以推断,这个 `getOrCompute` 函数的作用大概,如果外面的容器里已经存了一个相应的 `AmplifyStorage` 了,那么我们返回已经存了的实例(instance)。否则,我们大概是通过这个 `generator` 去生成一个新的实例。 如果我们去看了 [`StorageContainerEntryGenerator` 的源代码](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend-storage/src/storage_container_entry_generator.ts#L17),发现它其实比较平平无奇。主要的作用就是去创建了一个 [`AmplifyStorage`](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend-storage/src/construct.ts#L80),其余的代码和我们没有太大的关系。 #### c)[`AmplifyStorage`](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend-storage/src/construct.ts#L80) 终于到了最重要的一部分代码。其实如果我们看到这个类型 [所在的文件(construct.ts)](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend-storage/src/construct.ts),我们就能意识到这个文件涉及到了与 CDK 的交互,因为构造(Construct)在 AWS 的 CDK 中是一个非常重要的概念。[引用 AWS 官方文档](https://docs.aws.amazon.com/cdk/v2/guide/constructs.html): > Constructs are the basic building blocks of AWS Cloud Development Kit (AWS CDK) applications. A construct is a component within your application that represents one or more AWS CloudFormation resources and their configuration. You build your application, piece by piece, by importing and configuring constructs. 所以这里终于设计相应的 CDK 相关的内容了。我们进一步看,看这个类型的 [构造函数(Constructor)](https://github.com/aws-amplify/amplify-backend/blob/38d69861d400b41976fb2a7d149359afcc5f80ef/packages/backend-storage/src/construct.ts#L92): ```ts constructor(scope: Construct, id: string, props: AmplifyStorageProps) { // ... const bucketProps: BucketProps = { versioned: props.versioned || false, cors: [ { maxAge: 3000, exposedHeaders: [ 'x-amz-server-side-encryption', 'x-amz-request-id', 'x-amz-id-2', 'ETag', ], allowedHeaders: ['*'], allowedOrigins: ['*'], allowedMethods: [ HttpMethods.GET, HttpMethods.HEAD, HttpMethods.PUT, HttpMethods.POST, HttpMethods.DELETE, ], }, ], autoDeleteObjects: true, removalPolicy: RemovalPolicy.DESTROY, enforceSSL: true, }; const bucket = new Bucket(this, 'Bucket', bucketProps); // ... } ``` 其实就是创建了一个从 `aws-cdk-lib/aws-s3` 导入的 Bucket。看到这里,如果熟悉 AWS-CDK 的朋友可能就要开始说:啊,终于看到熟悉的东西了。对于不熟悉的朋友:[这里](https://github.com/aws-samples/aws-cdk-examples/blob/7f2d778bd01692755d6a8e1347b6161d7e979802/typescript/backup-s3/lib/aws-backup-s3-stack.ts#L12C3-L43C4) 是一个官方的 AWS S3 的示例: ```ts constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // ... const bucket = new aws_s3.Bucket(this, "testBucket", { blockPublicAccess: { blockPublicAcls: true, blockPublicPolicy: true, ignorePublicAcls: true, restrictPublicBuckets: true, }, enforceSSL: true, publicReadAccess: false, encryption: aws_s3.BucketEncryption.S3_MANAGED, versioned: true, }); // ... } ``` 是不是很像? 所以,Amplify 实际上就是 CDK 上所搭建的一层抽象。 原理的东西就讲到这里,主要是记录了一下我研究和分析 AWS 这部分源代码的一些思路。**一会儿再说为什么会看到这里,因为这边有一个巨坑。** ## 三、CDN 设置 ### 1)如何配置 设置好 AWS SDK 的 CDN,我们大概需要用到这么几个东西: 1. [AWS Certificate Manager](https://aws.amazon.com/certificate-manager/): 这个玩意儿是提供 SSL/TLS 证书的服务。SSL/TLS 的作用是对在网络上传输的东西进行加密。如果传输的内容在中途被修改了,这个传输也会作废。 2. [AWS CloudFront](https://aws.amazon.com/cloudfront/):这个是 AWS 的内容分布网络(Content Delivery Network,CDN)服务,也就是在全球对你的内容进行一定的缓存(caching),让用于能访问更近的缓存。 3. [AWS Route53](https://aws.amazon.com/route53/):这个是 AWS 的域名解析服务(Domain Name Service,DNS),就是把一个域名转换成对应的服务器 IP 地址。 4. [AWS S3](https://aws.amazon.com/s3/):这个就是 AWS 的存储服务,S3 代表 Simple Storage Service,正好三个 S。 #### a)获取我们的存储桶 在 backend.ts 里,我们有这样的代码: ```ts export const backend = defineBackend({ auth, data, storage, }); ``` 而我们获得存储的方式则是通过这个 `backend` 的实例来获取: ```ts const bucket = backend.storage.resources.bucket; ``` 这里的 bucket 就是一个 AWS CDK 里 S3 的 Bucket,它的类型就是 [`IBucket`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3.IBucket.html)。之前在 [[#^b05c47|跑题]] 中提到后面会说这个 `defineBackend` 的细微影响,其实说的就是这里。——你想要获取实际的 Bucket,你不能直接从 `storage` 获取,而要通过 `backend`。如果你仔细阅读跑题里所讲的内容的话,你就会明白为什么。 #### b)获取 Route53 中域名的托管区域(HostedZone) 一个托管区域是一个负责管理一个域名的 DNS 的这样一个构建。如果你有一个域名,比如假设我把我的博客域名(polarzone.me)丢到了 Route53 里去托管,那么所有的字域名都可以放在同一个托管区域里——比如把博客就可以放在 blog.polarzone.me 这样的域名上。当然,我没有这么做,有点懒。 由于我工作的公司通过 AWS 已经购买过了一个域名,并且已经在 Route53 上有了一个 Zone,所以这里我使用的是 [`HostedZone.fromHostedZoneAttributes`](fromHostedZoneAttributes) 这个函数。其实有几个其他的函数看上去也能用,但是我用了就是部署不了,我怀疑是哪里的文档没有认真读。目前看来,最有可能的是这个函数的文档中写了 > Imports a hosted zone from another stack. 而其他的函数没有这么写。我的 `HostedZone` 确实和我自己 Amplify 不在同一个栈(Stack,大概可以理解为被放在一起的一系列云基础设施,这个大概相当于一个 App,而不同的构建则相当于这个 App 的不同的模块)。 ![[20241113001404.png]] 从 Route53 的后台复制下来 1 和 2 的信息,然后就可以这么写: ```ts // Lookup existing hosted zone const zone = route53.HostedZone.fromHostedZoneAttributes(scope, 'Zone', { hostedZoneId: <2>, zoneName: <1>, }); ``` #### c)通过 Certificate Manager 创建一个 SSL/TLS 证书 ```ts // Request a public certificate const certificate = new certificatemanager.Certificate(scope, 'SiteCertificate', { domainName: siteDomain, validation: certificatemanager.CertificateValidation.fromDns(zone), }); ``` 这里的 `domainName` 就是你想要分发证书的域名。比如,我就可以写 `blog.polarzone.me`,这样就可以给这个网站分发证书。 Validation(核验)方式有很多中,这里会使用 `fromDns`,即这个 SSL/TLS 协议会通过试图访问并修改你的 DNS 服务器的方式,来验证签发这个证书的人确实是有权限修改这个域名的人。也有一些其他的方式,比如 `fromEmail` 等,大概是通过发邮件的方式来确定你有这个权限签发这样的证书。 #### d)创建一个 CloudFront 的内容分发网络 ```ts // CloudFront distribution const distribution = new cloudfront.Distribution(scope, 'SiteDistribution', { defaultBehavior: { origin: origins.S3BucketOrigin.withBucketDefaults(bucket), allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, }, domainNames: [siteDomain], certificate: certificate, priceClass: cloudfront.PriceClass.PRICE_CLASS_100, }); ``` 这里我们注意这么几件事: 1. `origin`: 这里我们希望我们的 CDN 的 “源” 是我们之前在 a)里定义 `bucket` 2. `allowedMethods`:因为我们只希望公开的 CDN 是只读的,所以我们只允许 `HEAD`(测一个 Object 存不存在)以及 `GET` 的操作。 #### e)通过 Route53 添加 DNS 的 Aliases ```ts // Route53 alias record for the CloudFront distribution new route53.ARecord(scope, 'CdnAliasRecord', { recordName: siteDomain, target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)), zone, }); new route53.AaaaRecord(scope, 'CdnAliasRecord2', { recordName: siteDomain, target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)), zone, }); ``` 这里相当于是把我们的域名给指向我们的 CDN 分发网络。到这一步,**理论上来说**,我们的 CDN 就设置完了,然而事实远没有这么简单。 ## 四、踩坑与修复 ## 五、总结与反思