本文最初发表于Medium,作者 Kareem Ayesh 和 Yasser El-Sayed 授权由 InfoQ 中文站翻译分享。
Meddy 成立于 2016 年,随着其规模的扩展,它取得很大成功。在 2019 年,我们庆祝达成 100000 个预定和服务于 300 万用户,并获得A轮融资。
在过去四年中,Meddy 经历很多技术方面的变化。本文为成长中的科技初创公司提供基础设施方面的建议。我们会讨论四年前最初的基础设施,这些年来我们面临的所有问题以及为了解决这些问题采用的增量式解决方案。
我记得 CEO 告诉我,他的代码比我写的任何代码都好。当我问他为什么,他说“因为我的代码已经被成千上万的人使用了”。 ……他是对的。
使用单体结构本身并不是什么问题。只是,对于大多数单体应用来讲,随着它不断演化,最终将会作茧自缚,而这恰好是我们经历的。也就是说,这意味着在项目的生命周期中会出现很多问题。
但是,我们所担心的并不是技术本身的问题,而是最终无法解决它们的“宿命”。
单体的存在就意味着问题就像进入了死胡同。请求添加的特性越多,出现的问题也就越多,解决这些问题就像创建新特性一样自然。
基础设施最初的重构和更新对应用的增长至关重要,这反过来又为可能出现的问题提供了潜在的解决方案。这样一来,对新功能的要求就不会引发一连串的推诿扯皮或更糟的“我认为现在这是不可能的”。
在当前的单体架构中,数据持久化是一个令人头疼的大问题。上传的镜像、数据库和日志都必须备份到根文件系统中,当新实例启动的时候会用到它们。当时没有 staging 实例,所以测试都是在生产环境进行的,服务器频繁出现故障,从而危及我们的数据。
另外一个需要解决的大问题就是部署。部署是通过 SSH 和直接从 Github origin 上git pull
代码完成的,没有任何形式的脚本来自动化新进程启动、运行测试以及报告错误的过程。
显然,我们需要有一些变化,我们决定除了应用代码的变更外,还要对基础设施进行一些小的变更,即将数据库和上传的文件放到单独的服务中。
通过使用 AWS 的三个服务,解决了如下三个问题:
重申一下,我们在这个项目中所做的整体变更并没有对基础设施带来很大改善。这种分离只是保证应用程序中未来的问题不会损害任何敏感的东西。此外,由于在主 EC2 实例上只有一个应用程序,这意味着任何额外的资源都可以存在于独立的服务器或服务上而没有任何问题。
但这并不完美:
当时,业务量比较低,我们并没有太多的服务甚至太多的特性,所以这些问题都是可以容忍的。
不过,这个项目提供了一个很重要的敲门砖,并为以后奠定了坚实的基础。
搜索是一个令人沮丧的功能,它会耗费很长时间,但并不是十分精准。我们以前使用 Postgres 的Trigram Similarity来实现搜索功能。针对大量记录进行搜索的话,它并不是最快的方案,而且针对多字段的搜索也并不准确。
最重要的是,我们无法真正控制这些搜索的行为。我们希望跟踪所有在搜索查询中遇到的问题。
我们用 Google Sheet 记录了所有想要对搜索功能进行的改善
搜索会占用其所在实例的大量资源。与其扩展实例并继续在实例中运行搜索,我们决定采用一个不同的方案,那就是启用一个单独的服务,在 EC2 实例上运行ElasticSearch。每当有新的记录创建或更新的时候,我们就会更新该 EC2 实例中的一个索引。另外,还有一个 cronjob 定时更新完整的索引。
添加 ElasticSearch
经过我们反复迭代,最终形成了更好以及更加可控的搜索功能,这个过程中没有对 ElasticSearch 服务上运行的镜像进行太大的修改。
在每次部署的时候,我们都会经历 10 秒钟的停机时间。部署是由 ElasticBeanstalk 负责的。在底层,EB 会并行运行新的(ondeck)和旧的(current)应用,并且会在所有的执行脚本成功时进行一个符号链接(symlink)。因为我们使用的是一个服务器,所以符号链接会导致 5 到 10 秒的停机时间,这是我们需要解决的。
为了解决这个问题,我们需要在多个服务器上进行滚动部署。这是一个相对比较简单的过程,只需要在我们的 ElasticBeanstalk 管理的自动扩展组(Autoscaling group)前面添加一个应用负载均衡器(Application Load Balancer )就可以了。
添加负载均衡器
滚动部署增加了测试开销,也带来了更长的部署时间,但是彻底消除了停机的问题。
过去,我们采用了混合渲染的方式,其中 Django 应用会部分渲染 HTML,而在客户端会运行 AngularJS 应用。这导致了很多问题:
我们的渲染非常慢并且会困住开发团队和服务器。我们的服务器需要很多的处理能力才能进行渲染,响应时间出现了难以避免的瓶颈。甚至开发都变得非常困难,因为一个变更我们就需要编码很多次。在某些情况下,改变一个跟踪事件都是很困难的。
糟糕的构建系统导致了高负载时间。我们过去使用自己的构建系统,因为对于混合渲染来说,没有 AngularJS 构建管理器,我们自己的系统有很多打包不一致和缓存问题,这导致了低效的构建和高负载时间。这也是一个需要改变和改进的问题。
网站偶尔会卡住 5 到 10 分钟。当我们收到来自用户或机器人的大量请求时,服务器会打开太多与数据库的连接,导致数据库被挂起。A 轮融资为 Meddy 带来更多的业务之后,在 2019 年底,这种情况每周都会发生。原因在于 Django 会打开一个数据库连接,该连接会一直处于打开状态,直到 HTML 渲染结束。因为打开了太多的数据库连接,这会导致服务器卡住,最终生成 504 响应。
在 A 轮融资后,我们开始将代码从混合渲染转向纯客户端渲染。为实现这一点,必须对后端服务请求的方式进行大量更改。
我们决定使用 S3 的桶托管前端资源,并使用CloudFront分发响应。CloudFront 还允许我们附加Lambda函数,从而能够非常细粒度地对请求进行所需的控制。
我们进行混合渲染的一个重要原因是担心动态渲染会影响 SEO,而我们网站卡住的一个重要原因是机器人导致的请求暴增。通过预先渲染页面以及为机器人提供预先渲染的页面,这两个问题都得到了解决。
为了实现这一点,我们使用了一个名为prerender.io的服务,它能预渲染请求页面、缓存这些页面并将这些页面提供给机器人。基于 HTTP 请求的 User-agent,我们在所使用的 CloudFront 分发版本的 Lambda Edge 函数中新增了一个机器人探测机制。如果探测到机器人的话,它们就会被重定向到prerender.io,从而得到一个缓存的页面。
添加预渲染和机器人探测
很重要的一点需要注意,一旦我们达到 300,000 个页面,prerender.io 的成本就会变得非常高,所以必须要有自己的预渲染服务。于是,我们使用 S3 实现了自己的缓存机制,并使用ECS Fargate来托管服务。
缓存资源无法在服务器之间共享。缓存资源无法在服务器之间共享,这意味着有些请求是被缓存的,而有些则是没有被缓存的。另外,我们也遇到了服务器上的 Redis 问题。Redis 在服务器上有调度的数据转储,当磁盘空间被其他服务占用的时候,这会导致停机,这样的情况曾经出现过两次。
调度任务需要在一台服务器上运行。Celery Beat具有内置的功能以便于在特定的日期和时间运行任务,并且会生成事件流,该事件流会以表的形式存储在数据库中。对我们来说,这是非常有用的,因为我们可以使用它在预约前后的特定时间间隔发送提醒短信。假如将它保存在服务器上,将会导致任务重复执行,这主要会在我们的通知模块中引发冲突。
因为我们要基于负载均衡器使用多台服务器,后端必须要共享类似的缓存服务器。为了实现这一点,我们使用了ElastiCache。初始环境的搭建非常容易,更具挑战性的方面是我们的任务管理器,它在每台服务器上由 Celery Beat 进行管理。
为了实现解耦,我们会在一个新服务上创建事件,该服务通过向后端发送请求调用这些事件。事件只会存储它需要调用的函数、函数的参数以及它需要调用的时间。这里会有自己的数据库,当请求后端的时候,后端将会填充事件。
将 Redis 和 Celery 移到单独的服务中
这会带来一个很明显的不足:那就是不能使用 pickle,因为请求是通过 HTTP 调用的。但是,鉴于Celery不推荐使用Pickle,所以我们没有在代码中使用它。
短链接的重定向会在客户端应用中出现。转向纯客户端应用意味着服务器无法在某个连接上提供 301 响应,这是因为所有的请求都转到了 S3 的桶中。当某个重定向要从/x
转向/y
时,客户端侧的应用必须要向服务器检查/x
,并通过向 DOM 中添加一个额外的参数告知 Prerenderer 该页面需要进行重定向。为了确保 Prerenderer 能够注意到这一点,我们创建一个页面,该页面显示“重定向中,请稍候”并添加了这个额外的参数。
对于内部重定向来讲,这还是可行的,因为我们没有太多这样的场景。但是对于短链接来讲,这种方式就不理想了,因为我们想要用户直接看到这些短链接,不需要进行任何的等待,尤其是当我们需要重定向到 Meddy 外部时尤为如此。
为了解决这一点,我们创建了两个 Lambda 函数,这两个函数会从一个包含 hash 和链接的 Dynamo 表中读取数据。其中一个 Lambda 函数用来生成短链接,另外一个函数则负责重定向短链接。
除此之外,针对这些 URL,我们使用 API 网关作为重定向 Lambda 函数的入口,并将其关联到一个新的域中。