本文共 7701 字,大约阅读时间需要 25 分钟。
为了获得一个全托管的解决方案,英国卫报在 2018 年将 CMS 的数据存储从一个自托管的 MongoDB 集群迁移到了 Amazon RDS 上的 PostgreSQL。团队在没有停机的情况下进行了基于 API 的迁移。
Guardian(英国卫报)网站的大部分内容——包括文章、博客、图集和视频——都是通过我们的内部 CMS 工具 Composer 生成的。用于存储这些内容的是运行在 AWS 上的 MongoDB 数据库。这个数据库实际上是 Guardian 发布的所有在线内容的“事实来源”——大约有 230 万项内容,而在不久前,我们完成了从 MongoDB 到 Postgres SQL 的迁移。
Composer 及其数据库最初起源于 Guardian Cloud——位于 Kings Cross 办公室地下室的数据中心里,并在伦敦的其他地方进行了失效备援。我们的失效备援机制在 2015 年 7 月一个炎热的夏日经受了一次严峻的考验。
从那之后,将 Guardian 迁移到 AWS 这一需求变得更加紧迫。我们决定购买 OpsManager(MongoDB 的数据库管理软件)和 MongoDB 支持合同,希望给云迁移带来帮助。我们使用 OpsManager 来管理备份、处理编排问题,并对我们的数据库集群进行监控。
由于编辑部的要求,我们需要在 AWS 上运行数据库集群和 OpsManager,而不是使用 MongoDB 托管数据库产品。这个很有难度,因为 MongoDB 并没有提供任何可用于在 AWS 上搭建数据库的工具——我们需要手动编写 cloudformation 来定义所有的基础设施。我们编写了数百行 ruby 脚本,用来安装监控和自动化代理以及编排新数据库实例。我们不得不在团队中进行与数据库管理相关的知识分享——而我们原本希望 OpsManager 能够轻松实现这些。
自从迁移到 AWS 以来,由于数据库问题,我们经历了两次严重的中断,每次都有一个小时的时间无法在 上发布任何内容。在这两次事故中,OpsManager 和 MongoDB 的支持代理都没能够帮到我们,我们最终都是自己解决了这些问题——其中一次要感谢我们的一个同事,他在阿布扎比郊区的沙漠中接听了我们打给他的救急电话。每个问题都可以写成一篇博文,不过我们可以将这些问题概括如下。
OpsManager 并没有真正兑现无障碍数据库管理的承诺。例如,OpsManager 本身的管理就很费时——特别是从 OpsManager 1 升级到 OpsManager 2 时,并且需要掌握 OpsManager 的相关知识。由于不同版本 MongoDB 之间的身份验证模式发生了变化,它也没有实现“一键升级”的承诺。我们每年至少要花费两个月的工程时间来完成数据库管理工作。
所有这些问题,加上我们每年为支持合同和 OpsManager 支付的高额费用,我们开始寻找替代数据库方案,并提出了以下要求。
因为其他服务都运行在 AWS 上,所以最明显的选择应该是 DynamoDB——亚马逊的 NoSQL 数据库产品。然而,Dynamo 当时还不支持静态加密。在等待亚马逊开发这个特性大约 9 个月后,我们放弃了,最终选择在 AWS RDS 上使用 Postgres。
你可能会说:“但是 Postgres 不是文档数据库!”。是的,它不是文档数据库,但它确实支持 JSONB 列类型,而且支持 JSON Blob 字段索引。我们希望通过使用 JSONB 类型将 MongoDB 迁移到 Postgres,只需要对数据模型做出最小的更改。此外,如果我们希望将来转向关系型模型,还可以继续使用 Postgres。另外,Postgres 已经很成熟了:我们遇到的大部分问题都能够在 Stack Overflow 上找到解答。
从性能的角度来看,我们有信心 Postgres 可以应对 Composer,尽管 Composer 是一个写入密集型的工具(每次用户停止输入时就会将内容写入数据库),但通常只有几百个并发用户,不需要高性能并行计算!
大多数数据库迁移都涉及相同的步骤,我们的也不例外。以下是我们迁移数据库的步骤。
因为迁移的数据库正在为我们的 CMS 提供支持,所以在迁移过程中应该尽可能减小对用户造成的影响。毕竟,新闻不能一刻停止更新。
2017 年 7 月下旬,我们开始开发基于 Postgres 的新 API。所以,我们的迁移旅程开始了。但要了解整个旅程,首先需要了解我们是从哪里开始的。
我们的 CMS 架构大概是这样的:一个数据库、一组 API 和与 API 交互的一些应用程序(例如 Web 前端)。技术栈一直是 Scala、Scalatra Framework 和 Angular.js,四年来没有变过。
经过一些调查,我们得出结论,在迁移现有内容之前,我们需要找到一种方法与新的 PostgreSQL 数据库通信,并且仍然可以像往常一样使用旧 API。毕竟,MongoDB 仍然是事实来源。在试验新 API 时,它为我们提供了一张安全毯。
这就是为什么基于旧 API 开发不是一个好的选择。在旧 API 中几乎没有关注点分离,甚至在控制器级别都能够找到 MongoDB 相关的东西。因此,基于现有 API 添加另一个数据库存风险太大。
我们另辟蹊径,复制了旧 API,于是就有了 APIV2。它包含了与旧 API 相同的端点和功能。我们使用了 doobie( Scala JDBC 层,并使用 Docker 在本地运行和测试,改进了日志和关注点分离。APIV2 将成为更快的现代 API。
到了 2017 年 8 月底,我们部署了一组新 API,使用 PostgreSQL 作为后端数据库。但这只是一个开始。保存在 MongoDB 中的文章最早是在二十年前创建的,所有这些文章都需要迁移到 Postgres 数据库中。
我们需要能够编辑网站上的文章,无论它们是什么时候发布的,因此所有文章都作为单一的“事实来源”存在数据库中。
虽然通过 Guardian 的 Content API(CAPI)可以操作所有的文章,为应用程序和网站提供支持,但迁移才是关键,因为数据库是“事实来源”。如果 CAPI 的 Elasticsearch 集群出现任何问题,可以从 Composer 的数据库重新索引数据。
因此,在关闭 MongoDB 之前,我们必须确信发给基于 Postgres 的 API 和基于 MongoDB 的 API 的相同请求能够返回相同的响应。
为此,我们需要将所有内容复制到新的 Postgres 数据库中。这一步使用了直接调用新旧 API 的脚本来完成。这样做的好处是,API 已经提供了一组经过良好测试的接口,用于从数据库读取文章和写入文章,而不是编写直接访问数据库的代码。
迁移的基本流程是这样的:
如果最终用户完全没有意识到后端正在进行迁移,这说明迁移进行得很顺利,而一个好的迁移脚本是进行顺利迁移的重要组成部分。
因此,我们需要一个脚本,它可以:
我们开始使用 Ammonite( 允许你使用 Scala 编写脚本,Scala 是我们团队使用的主要语言。这是一个很好的机会,我们可以尝试之前没有用过的东西,看看它对我们是否有用。不过,虽然 Ammonite 允许我们使用熟悉的语言,但它也有一些缺点。虽然 Intellij 现在支持 Ammonite,但在当时是不支持的,所以当时我们无法使用自动完成和自动导入功能,而且也不可能长时间运行 Ammonite 脚本。
最终,我们放弃使用 Ammonite,并选择了一个 sbt 项目来进行迁移。这种方法让我们可以使用的我们认为可靠的语言,并可以进行多次“测试迁移”,直到我们有信心在生产环境中运行。
让我们感到惊喜的是,这对测试 Postgres API 有很大好处。我们在新 API 中发现了一些之前没有发现的细微错误和边缘情况。
快进到 2018 年 1 月,是时候在我们的预生产环境 CODE 中测试完整的迁移了。
与我们的大多数系统类似,CODE 和 PROD 之间唯一相同的地方是应用程序的版本。用于 CODE 环境的 AWS 基础设施远没有 PROD 那么强大,因为它的使用率要低得多。
在 CODE 上运行迁移将有助于我们:
为了准确衡量这些指标,我们必须让这两个环境相匹配。我们需要将 PROD 环境的 MongoDB 备份还原到 CODE 环境,并更新 AWS 基础设施。
迁移 200 多万项内容需要很长时间,我们的脚本运行了一整夜。
为了衡量迁移的进度,我们将结构化日志发送到 ELK。我们可以创建仪表盘,跟踪成功迁移的文章数量、失败的数量和总体进度。此外,仪表盘显示在离团队很近的大屏幕上,让大家都能看到。
迁移完成后,我们采用相同的技术检查 Postgres 与 MongoDB 中的每个文档是否匹配。
现在,基于 Postgres 的新 API 已经在运行,我们需要使用真实的流量和数据访问模式对其进行测试,以确保它的可靠性和稳定性。我们可以使用两种方法:修改客户端,让原先的 Mongo API 客户端与新旧两个 API 通信,或者使用代理。我们使用 Akka Streams 开发了一个代理。
代理的操作相当简单:
一开始,代理记录了两个 API 响应之间的很多差异,这说明 API 有一些细小但却很重要的行为差异,我们需要修补这些差异。
在 Guardian,我们使用 ELK 来记录日志。Kibana 为我们提供了灵活显示日志的方式。Kibana 采用了简单易学的 lucene 查询语法。但我们很快就发现无法过滤掉日志或对其进行分组。例如,我们无法过滤出与 GET 请求相关的日志。
我们的解决方案是向 Kibana 发送更多结构化日志,而不只是发送消息。一个日志条目包含了多个字段,例如时间戳、发送日志的应用程序名。我们可以非常容易地通过编程的方式来添加新字段。这些结构化字段被称为标记(marker),可以使用 logstash-logback-encoder 库( map,将它们记录到日志中。请看下面的例子。
import akka.http.scaladsl.model.HttpRequestimport ch.qos.logback.classic.{Logger =\u0026gt; LogbackLogger}import net.logstash.logback.marker.Markersimport org.slf4j.{LoggerFactory, Logger =\u0026gt; SLFLogger}import scala.collection.JavaConverters._object Logging {val rootLogger: LogbackLogger = LoggerFactory.getLogger(SLFLogger.ROOT_LOGGER_NAME).asInstanceOf[LogbackLogger]private def setMarkers(request: HttpRequest) = { val markers = Map( \u0026quot;path\u0026quot; -\u0026gt; request.uri.path.toString(), \u0026quot;method\u0026quot; -\u0026gt; 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.gztar -xzf gor_0.16.0_x64.tar.gz gorsudo gor --input-raw :80 --output-http http://apiv2.code.co.uk
一切都运行良好,但一段时间之后,出现代理会在几分钟内不可用的情况,导致生产环境出现中断。经过调查,我们发现运行代理的三台服务器同时重启。我们怀疑 gor 使用了太多资源,导致代理出现故障。经过进一步调查,我们在 AWS 控制台中发现这些服务器会定期重启,但不是在同一时间。
我们试图找到一种方法,即在运行 gor 的同时不给代理施加任何压力,于是我们使用了第二个技术栈。这套方案只在发生紧急时使用,而且我们的生产环境监控工具会不断对其进行测试。这次,将流量从这个技术栈以双倍的速度复制到 CODE,没有出现任何问题。
但新发现带来了很多问题。我们在设计代理时只是将它作为暂时方案,所以它可能没有像其他应用程序那样经过精心设计。此外,它是用 Akka Http 构建的,而团队成员之前并没有使用过它。代码很混乱,到处都是临时修复代码。我们决定启动一项重大的重构工作,以提高代码可读性,并添加更多的日志标记。
我们希望通过简化代码逻辑来防止服务器崩溃重启,但并没有奏效。经过大约两个星期的尝试,我们感觉越陷越深。我们不得不痛下决心放弃,因为将时间花在实际的迁移工作上比试图修复一个月之后就会消失的软件要好得多。做出这个决定的代价就是我们又经历了两次生产环境中断,每次中断持续大约两分钟,但总体来说这是正确的做法。
快进到 2018 年 3 月,我们现在已经完成了 CODE 迁移,对 API 的性能或 CMS 的用户体验没有任何不利影响。我们现在可以开始考虑停用 CODE 中的代理。
第一阶段是改变 API 的优先级,让代理先与 Postgres 通信。虽然这一步是可配的,但仍然有一点复杂。
Composer 会在一个文档被更新后向 Kinesis 流发送消息。为了避免消息重复,只应该有一个 API 发送消息。API 为此提供了一个配置标志,值为 true 表示基于 MongoDB 的 API,值为 false 表示基于 Postgres 的 API。但只是简单地让代理与 Postgres 通信还不够,因为在请求到达 MongoDB 之前,消息不会被发到 Kinesis 流,这样延迟太严重了。
为了解决这个问题,我们创建了 HTTP 端点,用于即时修改负载均衡器所有实例的内存配置。这样我们就能非常快地切换 API,而无需编辑配置文件并重新部署。此外,这个可以通过脚本来实现,减少人为干预和人为错误。
现在,所有的请求都先到达 Postgres,我们可以通过配置和重新部署让更改永久生效。
下一步是完全移除代理,让客户端直接与 Postgres API 通信。由于我们有很多客户端,逐个更新每个客户端并不是个好办法。因此,我们考虑在 DNS 层面做一些修改。我们最开始在 DNS 中创建了一个 CNAME,指向了代理的 ELB,现在将其改为指向 API 的 ELB。这样就可以只在一个地方做出更改,而不是更新每一个客户端。
现在到了 PROD 迁移的时候了。这个过程相对简单,因为一切都是基于配置的。此外,因为我们在日志中添加了 stage 标记,所以我们可以通过更新 Kibana 过滤器来重用之前构建的仪表盘。
历经 10 个月,迁移了 240 万篇文章,我们终于可以关闭所有与 MongoDB 相关的基础设施。但首先是我们一直在等待的那一刻:关掉代理。
代理给我们带来了很多问题,我们迫不及待想要把它关掉!我们所要做的就是更新 CNAME 记录,将它直接指向 APIV2 负载均衡器。
出乎意料的是,删除旧的 MongoDB API 成了我们的另一项挑战。在疯狂删除旧代码时,我们发现集成测试并没有使用新 API。所运的是,大多数问题都只与配置相关,因此这些问题很容易就解决了。
之后发生的一切都很顺利。我们将所有 MongoDB 实例从 OpsManager 中分离出来,然后关掉它们。剩下的事情就是庆祝,然后睡个好觉。
英文原文
转载地址:http://dvpmx.baihongyu.com/