这篇文章介绍了英国《卫报Guardian》为什么和如何从Mongo迁移到Postgres,英国卫报大部分内容 - 包括文章,实时博客,画廊和视频内容 - 都是内部CMS工具Composer中制作的。直到最近一直得到了在AWS上运行的Mongo DB数据库的支持。这个Mongo DB数据库是Guardian所有在线发布内容的“真实来源” - 大约230万内容项。
当初为了迁移到AWS,决定购买OpsManager- Mongo的数据库管理软件,使用OpsManager来管理备份,处理编排并为我们的数据库集群提供监控。
因为Mongo没有提供任何工具来轻松在AWS上进行设置 - 我们需要手工编写cloudformation来定义所有基础架构,最重要的是我们编写了数百行ruby脚本来处理安装监视/自动化代理和新数据库实例的编排。
自从迁移到AWS 以来,由于Mongo DB数据库问题,我们发生了两次严重的中断,每次在theguardian.com上被阻止发布内容至少一个小时。在这两种情况下,OpsManager和Mongo的支持服务人员都没有能够帮助我们,我们最终解决了这个问题 :
- 时钟非常重要 - 不要将VPC锁定到NTP停止工作的程度。
- 在应用程序启动时自动生成数据库索引可能是一个坏主意。
- 数据库管理很重要而且很难 - 我们宁愿不自己做。
OpsManager并没有真正兑现其无障碍数据库管理的承诺。例如,实际管理OpsManager本身 - 特别是从OpsManager 1升级到2 - 非常耗时,并且需要有关我们的OpsManager设置的专业知识。
由于不同版本的Mongo DB之间的身份验证架构发生了变化,它也没有实现其“一键升级”承诺。我们每年至少花费两个月的工程时间来完成这项数据库管理工作。
所有这些问题,加上我们为支持合同和OpsManager支付的高额年费,让我们寻找替代数据库选项,具有以下要求:
- 需要最少的数据库管理。
- 支持静态加密。
- Mongo的可行迁移路径。
由于我们所有其他服务都在AWS中运行,因此显而易见的选择是DynamoDB - 亚马逊的NoSQL数据库产品。不幸的是,当时Dynamo不支持静态加密。在等待大约9个月后才添加此功能,我们最终放弃并寻找其他东西,最终选择在AWS RDS上使用Postgres。
“但是postgres不是文件存储!”我听到你哭了。嗯,不,它不是,但它确实有一个JSONB列类型,支持JSON blob中字段的索引。
我们希望通过使用JSONB类型,我们可以将Mongo迁移到Postgres,只需对我们的数据模型进行最小的更改。此外,如果我们希望将来转向更多的关系模型,我们就有了这个选择。关于Postgres的另一个好处是它有多成熟:在大多数情况下,我们想要提出的每个问题都已经在Stack Overflow上得到了解答。
从性能的角度来看,我们有信心Postgres可以应对 - 而Composer是一个写作繁重的工具(每次记者停止打字时都会写入数据库) - 通常只有几百个并发用户 - 而不是高性能计算!
第二部分 - 二十年的内容迁移,没有停机时间
以下是我们迁移数据库的步骤:
- 创建新数据库。
- 创建一种写入新数据库的方法(新API)。
- 创建一个代理,使用旧的数据库作为主数据库,将流量发送到旧数据库和新数据库。
- 将记录从旧数据库迁移到新数据库。
- 使新数据库成为主数据库。
- 删除旧数据库。
鉴于我们正在迁移的数据库为我们的CMS提供支持,因此迁移对我们的记者造成的影响很小。毕竟,新闻永远不会停止。
新API:
2017年7月底,新的Postgres驱动的API开始工作。所以我们的旅程开始了。
我们简化的CMS架构是这样的:数据库,API和与之交互的几个应用程序(例如Web前端)。堆栈是,现在仍然是使用Scala,Scalatra Framework和Angular.js构建的,它大约有四年的历史。
经过一些调查,我们得出结论,在我们可以迁移现有内容之前,我们需要一种方法来与新的PostgreSQL数据库进行对话,并且仍然像往常一样运行旧的API。毕竟,Mongo数据库是我们的真相来源。它在试验新API时为我们提供了安全毯。
这是为什么在旧API之上构建不是一个选项的原因之一。在原始API中几乎没有关注点分离,甚至在控制器级别也可以找到MongoDB细节。因此,在现有API中添加另一个数据库类型的任务风险太大。
我们采用了不同的路线,并复制了旧的API。这就是APIV2诞生的方式。它或多或少是Mongo的复制品,包含相同的端点和功能。我们使用doobie,一个用于Scala的纯功能JDBC层,添加了Docker以便在本地运行和测试,并改进了日志记录和关注点分离。APIV2将成为一个快速而现代的API。
到2017年8月底,我们部署了一个使用PostgreSQL作为其数据库的新API。但这只是一个开始。Mongo数据库中的文章最初是在二十年前创建的,所有这些文章都需要移到Postgres数据库中。
迁移
我们需要能够编辑网站上的任何文章,无论它们何时发布,因此所有文章都作为单一的“事实来源”存在于我们的数据库中。
虽然所有文章都存在于Guardian的内容API(CAPI)中,它为应用程序和网站提供支持,但正确的迁移是关键,因为我们的数据库是“真相的来源”。如果CAPI的Elasticsearch集群发生任何事情,那么我们将从Composer的数据库中重新索引它。
因此,在关闭Mongo之前,我们必须确信在Postgres驱动的API和Mongo驱动的API上的相同请求将返回相同的响应。
为此,我们需要将所有内容复制到新的Postgres数据库。这是使用直接与新旧API对话的脚本完成的。这样做的好处是,API已经提供了一个经过良好测试的界面,用于从数据库读取和写入文章,而不是直接编写访问相关数据库的内容。
迁移的基本流程是:
- 从Mongo获取内容。
- 将内容发布到Postgres。
- 从Postgres获取内容。
- 检查一个和三个的响应是否相同
如果您的最终用户完全没有意识到它已经发生并且一个好的迁移脚本始终是这个的重要组成部分,那么数据库迁移真的很顺利。
考虑到这一点,我们需要一个脚本,可以:
- 发出HTTP请求。
- 确保在迁移一段内容后,两个API的响应都匹配。
- 如果出现错误则停止。
- 生成详细日志以帮助诊断问题。
- 发生错误后从正确的点重新启动。
我们开始使用Ammonite,Ammonite允许您在Scala中编写脚本,Scala是我们团队的主要语言。这是一个很好的机会,可以尝试我们之前没有用过的东西,看看它对我们是否有用。虽然Ammonite允许我们使用熟悉的语言,但也有缺点。虽然Intellij现在支持Ammonite,但当时它没有,这意味着我们失去了自动完成和自动导入。也不可能长时间运行Ammonite脚本。
最终,Ammonite不是正确的工具,我们使用sbt项目来执行迁移。我们采用的方法允许我们使用我们自信的语言工作并执行多次“测试迁移”,直到我们有信心在生产中运行它。
快进到2017年1月,是时候在我们的预生产环境CODE中测试完整的迁移了。
与我们的大多数系统类似,CODE和PROD之间***的相似之处是它们运行的应用程序的版本。支持CODE环境的AWS基础架构远没有PROD那么强大,因为它的使用率要低得多。
在CODE上运行迁移将有助于我们:
- 估计PROD上的迁移需要多长时间。
- 评估迁移对性能的影响(如果有的话)。
为了准确衡量这些指标,我们必须匹配这两个环境。这包括将PROD mongo数据库的备份还原到CODE并更新AWS支持的基础架构。
迁移超过200万项内容需要很长时间,当然比办公时间更长。所以我们一夜之间在屏幕上运行脚本。
为了衡量迁移的进度,我们将结构化日志(使用标记)发送到ELK堆栈。从这里,我们可以创建详细的仪表板,跟踪成功迁移的文章数量,失败次数和总体进度。此外,这些显示在团队附近的大屏幕上,以提供更大的可见性。
迁移完成后,我们采用相同的技术检查Postgres匹配的Mongo中的每个文档。
第三部分 - 代理和生产中的运行
现在,新的Postgres驱动的API正在运行,我们需要使用实际流量和数据访问模式对其进行测试,以确保其可靠和稳定。有两种可能的方法可以实现这一点:更新与Mongo API通信的每个客户端以与两个API通信; 或运行代理,这样做。我们使用Akka Streams在Scala中编写了一个代理。
代理操作相当简单:
- 接受来自负载均衡器的流量。
- 将流量转发到主api并返回。
- 异步将相同的流量转发到辅助api。
- 计算两个响应之间的任何差异并记录它们。
一开始,代理在两个API的响应之间记录了很多差异,在需要修复的API中出现了一些非常微妙但重要的行为差异。
结构化日志记录
我们在Guardian上进行日志记录的方式是使用ELK堆栈。使用Kibana使我们能够灵活地以对我们最有用的方式显示日志。Kibana使用相当容易学习的lucene查询语法。但我们很快意识到,在当前设置中无法过滤掉日志或对其进行分组。例如,我们无法过滤掉因GET请求而发送的日志。
我们的解决方案是向Kibana发送更多结构化日志,而不是仅发送消息。一个日志条目包含多个字段,例如时间戳,发送日志或堆栈的应用程序的名称。以编程方式添加新字段非常简单。这些结构化字段称为标记,可以使用logstash-logback-encoder库实现。对于每个请求,我们提取了有用的信息(例如路径,方法,状态代码),并创建了一个包含我们记录所需的附加信息的地图。看看下面的例子。
- object Logging {
- val rootLogger: LogbackLogger = LoggerFactory.getLogger(SLFLogger.ROOT_LOGGER_NAME).asInstanceOf[LogbackLogger]
- private def setMarkers(request: HttpRequest) = {
- val markers = Map(
- "path" -> request.uri.path.toString(),
- "method" -> request.method.value
- )
- Markers.appendEntries(markers.asJava)
- }
- def infoWithMarkers(message: String, akkaRequest: HttpRequest) =
- rootLogger.info(setMarkers(akkaRequest), message)
- }
我们日志中的附加结构允许我们构建有用的仪表板并在我们的差异中添加更多上下文,这有助于我们识别API之间的一些较小的不一致。
复制流量和代理重构:
将内容迁移到CODE数据库后,我们最终得到了几乎完全相同的PROD数据库副本。主要区别是CODE没有流量。为了将实际流量复制到CODE环境中,我们使用了一个名为GoReplay(gor)的开源工具。它的设置非常简单,可根据您的要求进行定制。
由于进入我们的API的所有流量首先达到了代理,因此在代理服务器上安装gor是有意义的。请参阅下文,了解如何在您的盒子上下载gor以及如何开始捕获端口80上的流量并将其发送到另一台服务器。
- wget https://github.com/buger/goreplay/releases/download/v0.16.0.2/gor_0.16.0_x64.tar.gz
- tar -xzf gor_0.16.0_x64.tar.gz gor
- sudo gor --input-raw :80 --output-http http://apiv2.code.co.uk
一切都运行良好一段时间,但很快我们的代理几分钟时就遇到了生产中断。经过调查,我们发现代理运行的所有三个盒子同时循环。我们怀疑gor使用了太多资源并导致代理失败。在进一步调查中,我们在AWS控制台中发现这些盒子已经定期循环,但不是在同一时间。
在深入研究之前,我们试图找到一种方法来继续运行gor,但这次没有对代理施加任何压力。解决方案来自Composer的二级堆栈。该堆栈仅用于紧急情况,并且我们的生产监控工具会不断对其进行测试。将流量从此堆栈重新映射到CODE,速度加倍,这次没有任何问题。
新发现提出了很多问题。该代理的构建可能没有像其他应用程序那样精心设计。此外,它是使用Akka Http构建的,之前没有任何团队成员使用过。代码很混乱,并且快速修复。我们决定开始一项重大的重构工作,以提高可读性,包括使用理解而不是我们之前增长的嵌套逻辑,并添加更多的日志记录标记。
我们希望通过花时间了解一切是如何运作的,并通过简化逻辑,我们能够阻止骑行。但这没效果。经过大约两个星期的尝试使代理更可靠,我们开始觉得我们越来越深入兔子洞了。必须作出决定。我们同意冒险并留下风险,因为花在实际迁移上的时间比试图修复一个月内将会消失的软件更好。我们通过再经历两次生产中断来验证这个决定,每次中断持续大约两分钟,但总体来说这是正确的做法。
快进到2017年3月,我们现在已经完成了迁移CODE,对API的性能或CMS中的用户体验没有任何不利影响。我们现在可以开始考虑在CODE中停用代理。
***阶段是改变API的优先级,以便代理首先与Postgres交谈。如前所述,这是基于配置的。然而,有一个复杂性。
更新文档后,Composer会在Kinesis流上发送消息。为了避免重复消息,只有一个API应该发送这些消息。API为此配置了一个标志; 对于Mongo支持的API,该值为true,对于Postgres支持的API,该值为false。简单地更改代理与Postgres交谈是不够的,因为在请求到达Mongo之前,消息不会在Kinesis流上发送。这太迟了。
为了解决这个问题,我们创建了HTTP端点,以便瞬时更改负载均衡器中所有实例的内存配置。这使我们能够非常快速地切换哪个API是主要的,而无需编辑配置文件并重新部署。此外,这可以编写脚本,减少人为干预和错误。
现在所有的请求都是Postgres,而API2正在与Kinesis交谈,可以通过配置和重新部署来***更改。
下一步是完全删除代理,让客户单独与Postgres API交谈。由于有许多客户,单独更新每个客户并不是真的可行。因此,我们将其推向了DNS。也就是说,我们在DNS中创建了一个CNAME,它首先指向代理的ELB,然后更改为指向API ELB。这允许进行单个更改,而不是更新API的每个单独客户端。
现在是迁移PROD的时候了。虽然有点可怕,因为它是生产。这个过程相对简单,因为一切都基于配置。此外,当我们向日志添加舞台标记时,还可以通过更新Kibana过滤器来重新调整先前构建的仪表板。
关闭代理和MongoDB
在10个月和240万个迁移文章之后,我们终于可以关闭所有与Mongo相关的基础设施。但首先,我们一直在等待的那一刻:杀死代理
这个小软件给我们带来了很多问题,我们迫不及待想要把它关掉!我们需要做的就是更新CNAME记录以直接指向APIV2负载均衡器。
该团队聚集在一台电脑周围。只需点击一下即可切换开关。没有人再呼吸了。完全沉默。点击!而且改变了。什么都没发生!我们都放松了。
出乎意料的是,删除旧的MongoDB API是另一项挑战。在疯狂删除旧代码时,我们发现我们的集成测试从未更改为使用新API。一切都很快变红了。幸运的是,大多数问题都与配置相关,因此很容易修复。但是测试捕获的PostgreSQL查询存在一些问题。为了避免这个错误,我们想到了我们可以做的事情,我们意识到,在开始大量工作时你也必须接受你会犯错误。
之后发生的一切都很顺利。我们从OpsManager中分离了所有Mongo实例,然后终止它们。剩下要做的***事情就是庆祝。