再见蒙戈,你好Postgres(2018)

2020-12-27 22:46:59

在《卫报》上,大多数内容(包括文章,实时博客,画廊和视频内容)都是由我们内部的CMS工具Composer制作的。直到最近,这都由运行在AWS上的Mongo DB数据库支持。该数据库实质上是所有在线发布的Guardian内容(约230万个内容项)的“真理来源”。从Mongo到Postgres SQL的迁移已经完成。

Composer及其数据库最初是在Guardian Cloud中开始的,Guardian Cloud是我们位于Kings Cross附近办公室地下室的数据中心,并在伦敦其他地方进行了故障转移。我们的故障转移程序在2015年7月的一个炎热天气中经受了严峻的考验

此后,Guardian迁移到AWS变得更加紧急。我们决定购买Mons的数据库管理软件OpsManager以及Mongo的支持合同,以帮助进行云迁移。我们使用OpsManager来管理备份,处理业务流程并提供对数据库集群的监视。

由于编辑方面的要求,我们需要在我们自己的AWS基础架构中运行数据库集群和OpsManager,而不是使用Mongo的托管数据库产品。这是不平凡的,因为Mongo没有提供任何工具来轻松地在AWS上进行设置-我们需要手工编写cloudformation来定义所有基础架构,最重要的是,我们编写了数百行ruby脚本来处理安装监视/自动化代理并编排新的数据库实例。我们最终不得不在团队中运行有关数据库管理的知识共享会议-我们希望OpsManager可以使事情变得简单。

我们的目标是在招聘过程中尽可能做到公平和透明。与其他组织类似,有简历筛选,电话采访,编码练习和面对面采访。在此处阅读更多关于期望和应用的信息。

自迁移到AWS以来,由于数据库问题,我们发生了两次重大停机,每次停机至少一个小时都无法在theguardian.com上发布。在这两种情况下,OpsManager和Mongo的支持代理都无法提供帮助,我们最终自己解决了问题–在一个案例中,这要归功于团队中的一员从阿布扎比郊区的沙漠中拿起电话。每个问题都可以自己撰写整篇博文,但总的要点是:

时钟很重要-不要将VPC锁定得太多,以免NTP停止工作。

数据库管理很重要而且很艰辛-我们宁愿不要自己做。

OpsManager并未真正实现其无忧数据库管理的承诺。例如,实际管理OpsManager本身(特别是从OpsManager 1升级到2)非常耗时,并且需要有关OpsManager设置的专业知识。由于不同版本的Mongo DB之间的身份验证架构发生了变化,因此它也没有实现“一键升级”的承诺。一年下来,我们至少花费了两个月的工程时间来进行数据库管理工作。

所有这些问题,再加上我们为支持合同和OpsManager支付的高额年费,使我们不得不寻找具有以下要求的替代数据库选项:

由于我们所有其他服务都在AWS中运行,因此显而易见的选择是DynamoDB – Amazon的NoSQL数据库产品。不幸的是,当时Dynamo不支持静态加密。在等待大约九个月的时间来添加此功能之后,我们最终放弃了,并寻找其他东西,最终选择在AWS RDS上使用Postgres。

“但是postgres不是文件存储!”我听到你哭了。嗯,不,不是,但是它确实具有JSONB列类型,并且支持JSON Blob中字段的索引。我们希望通过使用JSONB类型,可以对Mongo进行迁移,而对数据模型的更改最少。此外,如果我们将来想改用更相关的模型,则可以选择。 Postgres的另一个优点是它的成熟程度:在大多数情况下,我们想问的每个问题都已经在Stack Overflow上得到了回答。

从性能的角度来看,我们有信心Postgres可以应付-Composer是一个写大量工具(每次记者停止键入时都会将其写到数据库中)-通常只有几百个并发用户-并非完全是高性能计算!

大多数数据库迁移都涉及相同的步骤,我们也不例外。以下是我们迁移数据库所采取的步骤:

创建一个代理,该代理使用旧数据库作为主数据库将流量发送到旧数据库和新数据库。

鉴于我们要迁移的数据库为CMS提供了强大的支持,因此迁移对新闻记者造成的干扰应尽可能小。毕竟,新闻永远不会停止。

新的Postgres支持的API的工作已于2017年7月底开始。因此,我们的旅程开始了。但是要了解旅程,我们首先需要了解我们从哪里开始。

我们简化的CMS体系结构是这样的:一个数据库,一个API,以及与其通信的多个应用程序(例如Web前端)。该堆栈曾经(现在仍然)是使用Scala,Scalatra Framework和Angular.js构建的,大约有4年的历史了。

经过一些调查,我们得出的结论是,在我们可以迁移现有内容之前,我们需要一种与新的PostgreSQL数据库进行通信的方法,并且仍然可以照常运行旧的API。毕竟,Mongo数据库是我们的真实来源。在试用新API时,它为我们提供了安全毯。

这是为什么不能在旧API上进行构建的原因之一。原始API与关注的问题几乎没有分离,甚至在控制器级别也可以找到MongoDB的细节。结果,在现有API中添加另一种数据库类型的任务太冒险了。

我们改而走了另一条路,并复制了旧的API。 APIV2就是这样诞生的。它或多或少是Mongo的精确副本,并且包含相同的端点和功能。我们使用了doobie(Scala的纯功能JDBC层),添加了Docker以在本地运行和测试,并改善了问题的记录和分离。 APIV2将成为一种快速而现代的API。

截至2017年8月,我们已经部署了一个新的API,该API使用PostgreSQL作为其数据库。但这仅仅是开始。 Mongo数据库中有一些文章是二十多年前首次创建的,所有这些文章都需要移到Postgres数据库中。

无论何时发布,我们都需要能够编辑该网站上的任何文章,因此所有文章都作为唯一的“真理来源”存在于我们的数据库中。

尽管所有文章都包含在Guardian的Content API(CAPI)中,该API为应用程序和网站提供支持,但正确地进行迁移是关键,因为我们的数据库是“真理之源”。如果CAPI的Elasticsearch集群发生任何事情,那么我们将从Composer的数据库中为其重新编制索引。

因此,在关闭Mongo之前,我们必须确信,对Postgres支持的API和Mongo支持的API的相同请求将返回相同的响应。

为此,我们需要将所有内容复制到新的Postgres数据库中。这是通过直接与新旧API通讯的脚本完成的。这样做的好处是,API已经提供了一个经过良好测试的接口,用于与数据库之间读写文章,而不是编写直接访问相关数据库的东西。

如果您的最终用户完全不知道数据库迁移已经发生,那么数据库迁移的进展就非常顺利,并且良好的迁移脚本始终将是其中的重要部分。

我们开始使用亚mon石。 Ammonite允许您使用Scala(我们团队的主要语言)编写脚本。这是一个很好的机会,尝试一下我们以前从未使用过的东西,看看它是否对我们有用。尽管Ammonite允许我们使用一种熟悉的语言,但仍有缺点。尽管Intellij现在支持Ammonite,但当时还不支持,这意味着我们丢失了自动完成和自动导入功能。也无法长时间运行Ammonite脚本。

最终,Ammonite不是适合该工作的工具,我们改用了sbt项目来执行迁移。我们采用的方法使我们能够以自己有信心的语言进行工作,并执行多次“测试迁移”,直到我们有信心在生产中运行它。

出乎意料的是,这对于测试Postgres API很有用。我们在以前未发现的新API中发现了一些细微的错误和极端情况。

快进到2018年1月,是时候在我们的预生产环境CODE中测试完整的迁移了。

与我们的大多数系统类似,CODE和PROD之间的唯一相似之处在于它们正在运行的应用程序版本。支持CODE环境的AWS基础设施远没有PROD强大,这仅仅是因为它获得的使用量少得多。

为了获得对这些指标的准确度量,我们必须匹配两个环境。这涉及将PROD mongo数据库的备份还原到CODE中以及更新AWS支持的基础架构。

迁移超过200万个内容项将花费很长时间,当然还要花更多的办公时间。因此,我们在一夜之间在屏幕会话中运行了脚本。

为了衡量迁移的进度,我们将结构化日志(使用标记)运送到了ELK堆栈中。从这里,我们可以创建详细的仪表板,跟踪成功迁移的文章数,失败数和总体进度。此外,这些内容还显示在团队附近的大屏幕上,以提供更大的可见性。

迁移完成后,我们将使用相同的技术来检查与Postgres匹配的Mongo中的每个文档。

现在,新的基于Postgres的API正在运行,我们需要使用实际流量和数据访问模式对其进行测试,以确保其可靠和稳定。有两种方法可以实现此目的:更新与Mongo API对话的每个客户端以与这两个API对话;或运行将执行此操作的代理。我们使用Akka Streams在Scala中编写了一个代理。

在开始时,代理记录了两个API响应之间的许多差异,从而暴露了需要修复的API中一些非常细微但重要的行为差异。

我们在Guardian上进行记录的方式是使用ELK堆栈。使用Kibana使我们能够以对我们最有用的方式灵活地记录原木。 Kibana使用相当容易学习的lucene查询语法。但是我们很快意识到,在当前设置中无法过滤出日志或将其分组。例如,我们无法过滤由于GET请求而发送的日志。

我们的解决方案是向Kibana发送更多结构化日志,而不是仅发送消息。一个日志条目包含多个字段,例如时间戳记,发送日志或堆栈的应用程序的名称。以编程方式添加新字段非常容易。这些结构化字段称为标记,可以使用logstash-logback-encoder库实现它们。对于每个请求,我们都提取了有用的信息(例如,路径,方法,状态代码),并创建了带有我们需要记录的其他信息的地图。看下面的例子。

导入akka.http.scaladsl.model.HttpRequest导入ch.qos.logback.classic。{Logger => LogbackLogger}导入net.logstash.logback.marker.Markers导入org.slf4j。{LoggerFactory,Logger => SLFLogger}导入scala.collection.JavaConverters._object日志记录{val rootLogger:LogbackLogger = LoggerFactory.getLogger(SLFLogger.ROOT_LOGGER_NAME).asInstanceOf [LogbackLogger] private def setMarkers(request:HttpRequest)= {val markers = Map(" path&path #34;-> request.uri.path.toString(),"方法"-> request.method.value)Markers.appendEntries(markers.asJava)} def infoWithMarkers(消息:String,akkaRequest :HttpRequest)= rootLogger.info(setMarkers(akkaRequest),消息)}

日志中的其他结构使我们能够构建有用的仪表板,并在差异之间添加更多上下文,这有助于我们识别API之间的一些较小的不一致之处。

将内容迁移到CODE数据库后,我们最终获得了几乎与PROD数据库完全相同的副本。主要区别是CODE没有流量。为了将实际流量复制到CODE环境中,我们使用了称为GoReplay(gor)的开源工具。设置非常容易,并且可以根据您的要求进行自定义。

由于进入我们的API的所有流量都首先到达了代理服务器,因此在代理服务器上安装gor很有意义。参见下文,了解如何在包装盒上下载gor以及如何开始在端口80上捕获流量并将其发送到另一台服务器。

一段时间后一切正常,但是很快,当代理服务器在几分钟内变得不可用时,我们就停产了。经过调查,我们发现在其上运行代理的所有三个框同时循环。我们的怀疑是gor使用了过多的资源并导致代理崩溃。在进一步调查中,我们在AWS控制台中发现这些盒子定期循环运行,但不是同时循环。

在深入探讨之前,我们尝试找到一种仍可以运行gor的方法,但是这次没有对代理施加任何压力。该解决方案来自于Composer的辅助堆栈。该堆栈仅在紧急情况下使用,并且我们的生产监控工具会不断对其进行测试。这次以两倍的速度将流量从此堆栈重播到CODE,而没有任何问题。

新的发现提出了很多问题。代理的构建是因为它只能临时存在,因此可能没有像其他应用那样精心设计。而且,它是使用Akka Http构建的,该团队成员以前都没有使用过。代码杂乱无章,并且快速修复。我们决定开始一项大型的重构​​工作,以提高可读性,其中包括用于理解,而不是使用以前增长的嵌套逻辑,并添加更多的日志记录标记。

我们希望通过花一些时间来了解一切工作原理,并简化逻辑,以使这些包装盒无法循环使用。但这没用。在尝试使代理更可靠的大约两周后,我们开始感到自己越来越陷入困境。必须做出决定。我们同意承担风险,因为将时间花在实际的迁移上比尝试修复一个月后将要消失的软件要好得多。我们通过两次以上的生产中断来支付此决定,每次生产中断持续约两分钟,但总的来说,这是正确的做法。

快进到2018年3月,我们现在已经完成了CODE的迁移,对API的性能或CMS中的用户体验没有不利影响。现在,我们可以开始考虑停用CODE中的代理了。

第一步是更改API的优先级,以便代理首先与Postgres对话。如前所述,这是基于配置的。但是,这里有一个复杂性。

文档更新后,Composer会在Kinesis流上发送消息。为了避免消息重复,只有一个API应该发送这些消息。 API为此配置了一个标志。对于Mongo支持的API,该值为true;对于Postgres支持的API,该值为false。仅更改代理以首先与Postgres对话是不够的,因为直到请求也到达Mongo之前,消息不会在Kinesis流上发送。为时已晚。

为了解决这个问题,我们创建了HTTP端点,以在负载均衡器中的所有实例之间即时更改内存中的配置。这使我们能够快速切换哪个API是主要的API,而无需编辑配置文件并重新部署。此外,可以编写脚本,以减少人为交互和错误。

现在所有请求都首先发送给Postgres,而API2正在与Kinesis进行通信,可以通过config和reeploy来使更改永久生效。

下一步是完全删除代理,并让客户仅与Postgres API对话。由于有许多客户,因此单独更新每个客户实际上并不可行。因此,我们将此推送到DNS。也就是说,我们在DNS中创建了一个CNAME,该CNAME首先指向代理的ELB,然后更改为指向API ELB。这允许进行单个更改,而不是更新API的每个客户端。

现在该迁移PROD了。虽然有点吓人,因为它是生产的。该过程相对简单,因为一切都基于配置。另外,当我们在日志中添加阶段标记时,还可以通过更新Kibana过滤器来重新调整先前构建的仪表板的用途。

十个月后,迁移了240万篇文章,我们终于可以关闭与Mongo相关的所有基础架构。但是首先,我们所有人都在等待的时刻:杀死代理。

这个小小的软件给我们带来了很多问题,我们迫不及待想要关闭它!我们需要做的就是更新CNAME记录以直接指向APIV2负载平衡器。

团队聚集在一台计算机周围。一键切换。没人呼吸了。完全沉默。点击!改变就出来了。没事!我们都放松了。

出乎意料的是,删除旧的MongoDB API是另一个挑战。在疯狂删除旧代码时,我们发现我们的集成测试从未更改为使用新API。一切很快变红。幸运的是,大多数问题与配置有关,因此很容易解决。但是测试发现了PostgreSQL查询的一些问题。尝试思考为避免该错误而可以做的事情,我们意识到在开始大量工作时,您还必须接受自己会犯错误。

此后出现的所有内容均运行正常。我们从OpsManager分离了所有Mongo实例,然后终止了它们。剩下要做的就是庆祝。睡一下