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 就设置完了,然而事实远没有这么简单。
## 四、踩坑与修复
## 五、总结与反思