一次使用 AWS 服务的心路历程

事情是这样的,几个月前有一个需求:用户在前端页面上输入查询条件调用 API 返回数据

很经典的简单 CRUD 对不对?我告诉你, 其实有点不同

因为这个查询除了其他可空条件外,有个必填的两个参数是开始时间和结束时间,而这个时间段,如果是当月(30 天内)的,API 要从数据库里面拿数据返回,而前面月份的则需要从 S3 的文件里拿,有个 Lambda 每天零点会定时执行任务,把数据库里超过 30 天的数据提取出来,生成 CSV 摆到 S3,然后删除数据库的数据

我们根据这个场景做过多轮压力测试,最终确定了每次从数据库里面拿 3000 是比较理想的,实现代码大概长这样:

1
2
3
4
5
6
7
8
9
10
cursor.execute('select count xxx')
row_count = cursor.rowcount
if row_count > 0:
while True:
fetch_rows = cursor.fetchmany(3000)
if len(fetch_rows) == 0:
break
else:
for row in fetch_rows:
# write csv

这是第一版的代码

测试过程中发现可能未来的数据量会有点大,并且这个 Athena 收费也不便宜,如果不设置合适的索引,每次都需要查询很多没意义的数据再过滤,相当烧钱

于是经过对页面几个查询条件和数据库原来的结构进行分析后,设置了 Athena 的索引,这个索引在 Athena 里面叫 partition key,我们选用的是 Hive 风格

因此在 Bucket 上的 CSV 大概长这样:bucket/year=2024/month=01/day=01/a=xxx/b=xxx(这里的 a 和 b 是页面上一些查询条件)

代码也改为了先按照查询条件 group by,好生成 S3 目录结构,再按照具体的条件去查询对应的数据

铺垫了这么多,下面开始正文

第一个坑:数据为什么查不出来?

相信有过大数据开发经验的同学很快就能看到问题,数据是有了,但是 Athena 并不认识这些数据,还要 “告诉” Athena 从那里拿数据回来

有两个步骤可以选择,要么 alter table add partition,要么 msck repair table,因为按照我们的设计,这个结构理论上是不会有什么大的变化的,所以我们选择了第二种方式

执行完 repair 后,会看到输出了一堆 S3 路径,意思就是 Athena 认识这些数据了

第二个坑:新的数据为什么查不出来?

好了,每天定时任务都能成功把数据从数据库转移到 Bucket 中,但是为什么只能查到老的数据?

想象一下数据库新增数据的时候,索引是不是也会发生变化,原来这个 Athena 也是如此,不过,这个重建索引的过程需要人为介入

考虑到我们的数据是每天零点同步过去的,而要重建索引后,新的数据才能被查到,所以给这个 Lambda 在同步完数据后再跑个 repair 语句,解决问题

项目开始上线并稳定运行了一段时间


上半部分结束,下半部分开始

新的需求过来了,和上面差不多,不过不是查数据显示,而是将查到的数据导出 CSV 然后下载下来

在这里需要先说明一下网络结构

有两个 AWS 账号,API 在账号 A,这个账号只有 EKS 服务,那些什么数据库、Lambda、Bucket、API Gateway 等等乱七八糟的全在另一个账号 B,API 通过 assume 账号 B 的 role 的方式来用账号 B 的服务

麻烦的方式不但在这里,还在明明在账号 B 创建好 role,赋予 S3 的增删改查和 Lambda 的执行权限,API 能够直接通过 assume role 来执行 Lambda 的情况下,API 还要通过私有 VPCE 来访问账号 B 的 API Gateway,API Gateway 再绑定 Lambda,这样蛋疼的方式来访问

问就是安全

人在江湖身不由己,也只好遵守他的游戏规则

在这个前提下,数据库那一部分很好处理,在 Athena 这里又出了幺蛾子

第三个坑:数据太大 Lambda 超时

用户点击下载 CSV,请求会这样走:API -> AWS API Gateway -> AWS Lambda -> AWS Athena / AWS RDS -> AWS S3

而 Lambda 默认是同步执行的,如果数据量太大,那么如果用户点击下载后一直在转,显然不现实,于是我们将 Lambda 改成异步执行

Lambda 在 General 那里最大限额可以配置 15 分钟超时,10G 内存,10G 硬盘,而在 Async 相关的设置里,则可以设置为最长跑 6 个小时并且最多 2 次失败重试(自动)

既然写得这么明白,那么我们的数据也没这么大,不用跑几个小时,配置肯定是够用的,但是保险起见,先 sleep 个 16 分钟验证下?

不出意外的话,意外发生了

经过测试,虽然这个异步设置最长可以设置 6 小时,但是其实无论同步还是异步,Lambda 最长还是只能执行 15 分钟,这个 6 小时只是 Lambda 执行失败后会放在队列里的最长等待时间

就是说,就算用异步的方式执行耗时任务,在 15 分钟内没跑完,没关系,重试,还没跑完,也没关系,再重试,但在我们这个需求就不适用,因为是从头开始重试的,该拿不回来数据还是拿不回来

文档写得又乱,版本 V1、V2 又混着来,反正我是很蛋疼

15 分钟也行吧,Athena 查询东西本来就比较快,查询数据库都能 2-3 分钟跑完,Athena 不见得要超过这个时间吧?

现实又啪啪打脸

由于有之前上线生产环境的经验,那么我们从 Athena 里导出数据生成 CSV 下载,肯定还是沿用之前的老路:

1
2
3
4
5
6
athena.start_query_execution()
athena.get_query_execution()
while True:
query_execution_status == 'SUCCEED':
break
athena.get_query_results()

问题来了,Athena 查询快是快,但是每次只能从中最多拿 1000 条数据回来,同时会返回一个叫 NextToken 这样的东西,后续请求需要带上它,才能获取后面的数据

那这就有问题了

经过测试,每次拿回来 1000 条数据大概花费 2-3 秒左右,那么 15 分钟极限最多拿不到 30W 条数据,一天的数据量远远超过这个

正当一筹莫展准备限定页面搜索条件,缩小总数据集,或者分拆成多个子任务再合并 CSV 时,猛然想到,其实 Athena 查询的时候往往需要指定一个结果输出路径,无论在 AWS Console 还是 boto3,并且是必填的,这个路径其实就是保存了本次查询的数据集和元数据

艹,怎么之前看漏了眼

结果就很美丽了,100W 数据查询才 10 秒,并且 CSV 都生成好了,只需移动下位置即可

第四个坑:API 通过 API Gateway 调用 Lambda 不是异步的

前面说过,不能让用户点击下载后在干等,所以需要异步执行这个 Lambda

好了,参考文档 Set up asynchronous invocation of the backend Lambda function 进行设置,在 Lambda 里简单 time.sleep(33) 看看,因为 API 调用 API Gateway 超过 10 秒就要报错了,而 API Gateway 调用 Lambda 超过 30 秒也会报错,所以这样能试到两种情况

在没设置 X-Amz-Invocation-Type 请求头前,在 AWS Console 上面测试 API Gateway 调用 Lambda 是超时的,而设置了这个请求头后,正常了,但是 API 通过 API Gateway 来调用 Lambda,还是超时

这又是什么破玩意?

百思不得其解

Header 传得不对吗?

Lambda 没有设置好吗?

链接用错了吗?

有鬼

最后发现其实是忘记了重新部署 API Gateway

虽然是我们自己的锅,但是没有重新部署却在 AWS Console 上面测试也能通过,就很误导人

第五个坑:无法利用自定义域名来生成 presignedUrl

下载 CSV 这个动作,考虑到 API 性能问题,没有将数据再经过 API 一遍流式返回前端徒增功耗,而是直接生成 presignedUrl,前端直接调用这个链接下载文件就完事了

这个链接,包含了 AWS 账号信息和 Bucket 信息,想将其隐藏掉,于是考虑到了利用自定义域名的方式来完成

最后配置好了路由,生成 presignedUrl,替换自定义域名,等等都完成后,通过新链接来访问文件,却说签名不匹配

也尝试过在代码里重写 endpointUrl 或者修改参与计算签名的 Host 字段,让它和我们的自定义域名一样,结果还是不行

查阅了相关文章,似乎也说,这其实就是 S3 的规范

感觉是没直接方法实现的,有的话,麻烦告知一下我

或者我们可以通过在 API 里映射的方式来实现?新增接口来专门处理这个,暴露 API 信息,但是可以有效隐藏掉 AWS 相关信息,这就是后话了


TBC