@levinzhang
2020-12-13T10:38:43.000000Z
字数 5144
阅读 556
Meddy是一家专注于医患预约的平台,随着其规模的扩展,取得了巨大的成功,本文阐述了随着其用户群体的增加,所带来的技术挑战,以及技术团队是如何借助AWS等相关技术解决这些挑战的。
本文最初发表于Medium网站,由作者Kareem Ayesh和Yasser El-Sayed授权由InfoQ中文站翻译分享。
Meddy成立于2016年,随着其规模的扩展,它取得了很大的成功。在2019年,我们庆祝了达成100000个预定和服务于300万用户,并获得了A轮融资。
在过去的四年中,Meddy经历了很多技术方面的变化。本文为成长中的科技初创公司提供了基础设施方面的建议。我们将会讨论四年前最初的基础设施,这些年来我们面临的所有问题以及为了解决这些问题按顺序采用的增量式解决方案。
如果你有志于成为新兴公司的CTO的话,那么本文会给你带来很多帮助。
我记得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函数的入口,并将其关联到一个新的域中。
使用Lambda添加链接重定向
这种方式能够很好地运行,Lambda几乎没有提供任何维护和使用图表,我们的数据库中不需要维护一个持有这些重定向的大表。
对更好日志的需求是相当迫切的。服务器请求数量的增加意味着我们不能再依赖于在文件系统中轮流切换日志文件,因为文件会轮转地非常快。除此之外,我们所使用的服务数量也在增长,从不同的文件系统拉取文件是一个噩梦。为所有的服务提供一个中心化的日志系统是一项挑战。
我们决定使用Grafana Loki构建一个服务实现日志和监控功能。Loki是一个开源的、可水平伸缩的、支持多租户的日志管理聚合系统,其灵感来源于Prometheus。而Grafana是一个开源的分析和交互式可视化软件。实际上,我们使用Loki作为日志聚合工具,使用Grafana可视化这些日志。
这里的理念是让我们的不同服务推送日志到Loki服务所打开的一个HTTP端点上。Loki将会对它们进行索引并以二进制格式存储到S3和DynamoDB表的索引中。这样的话,我们就能够在日志中查找特定的标记,比如特定的状态码和特定的日志级别。通过这种方式,我们也能查询日志,甚至对它们使用聚合功能,比如计算过去5分钟内具有特定标记的日志的数量。
使用Grafana Loki添加日志和监控
在事件监控方面,我们依然使用Grafana,但是,这次它搭配的是AWS CloudWatch。Grafana作为CloudWatch的客户端,按需获取数据并对其进行可视化。
如上所述,为了将日志推送至Loki,我们必须为不同的服务采用不同的技术,比如在应用服务器上构建处理程序、为数据转储添加Lambda函数。Lambda函数用来解析来自S3的Load Balancer和CloudFront日志,并将它们推送至Loki。
通过混合使用数据处理程序和CloudWatch,我们就能够为基础设施中的所有内容构建一个中心化的日志和监控系统,我们可以在一个地方监控所有的东西。
这可能是到目前为止我们最喜欢的文章,因为它这展示了我们在经历这些挑战时所带来的成长。最令人兴奋的是,这对Meddy来说仅仅是一个开始。
感谢您的阅读!