背景
Shopify 是一个面向中小型企业的多渠道商务平台,帮助用户创建商店并通过线上的网店或社交媒体以及线下的 POS 机随时随地销售产品。Shopify 为 60 万商家提供服务,在高峰期每秒处理 8 万个请求。
在为有抱负的商家提供在线商店服务的同时,Shopify 还是超级碗、Kylie 化妆品、Justin Bieber 和 Kanye West 等名人的商品销售站点。从工程的角度来看,应对这些“闪购”是个巨大挑战,因为它们的流量是不可预测的。
我是 Shopify 服务模式团队的一名高级工程师。我们的团队负责平台的分片、可扩展性和可靠性等方面的问题。我们为开发可扩展的软件提供指南和 API,因此 Shopify 的其他开发者就成了我们的用户。我们的团队座右铭是“把开发人员从可扩展性中解放出来”。
Shopify 的工程团队
在 2015 年之前,我们有一个运营和性能团队。大概就在这个时候,我们决定组建一个生产工程团队并将之前的运营团队合并在一起。新团队负责构建和维护通用基础设施,让其他产品开发团队能够正常运行他们的代码。
生产工程团队和其他产品开发团队共同负责最终的用户应用程序,保证它们能够持续运行。这意味着所有技术角色都需要关注监控和事件响应,在出现问题时提供必要的技能来恢复服务。
最初的架构
2004 年,Shopify 创始人兼首席执行官 Tobi Lütke 开始为滑雪板产品建立网店。他对市面上已有的电子商务产品不是太满意,所以决定使用 Ruby on Rails 构建自己的 SaaS 平台。
那时,Rails 的版本还不到 1.0,整个框架还只是通过电子邮件发送的.zip 存档文件。Tobi 加入了 Rails 作者 David Heinemeier Hansson(DHH)的行列,成为 Ruby on Rails 的贡献者,同时一边开发 Shopify。Shopify 现在是世界上最大最古老的 Rails 应用程序之一。它从未被重写过,仍然使用原始代码库,不过在过去十年中已经成熟了很多。Tobi 当初提交代码的历史记录依然保留在版本控制系统中。
押注 Rails 极大地影响了我们的思考方式,同时也让我们能够快速交付产品。虽然框架的某些部分难以扩展(例如 ActiveRecord 回调和代码结构),但大多数人仍然赞同 Tobi 的观点——能够让 Shopify 从车库创业公司变身上市公司的非 Rails 莫属。
Shopify 的核心应用仍然是 Rails 单体,除此之外,我们还有数百个其他 Rails 应用。它们不是微服务,而是特定于领域的应用程序:物流(与各种物流供应商交互)、身份(Shopify 网站的单点登录)和 App Store 等等。管理百来个应用程序并让它们保持安全更新是一项非常艰巨的任务,因此我们开发了 ServicesDB。ServicesDB 是一个内部应用程序,可用于跟踪所有的生产服务,并帮助开发人员确保他们不会遗漏任何重要的内容。
ServicesDB 为每个应用程序维护了一份检查清单:所有权、运行时间、日志、轮班待命、异常报告和 gem 安全更新。如果其中任何一个出现问题,ServicesDB 就会在 GitHub 上提交一个问题,并通知应用程序的所有者来解决问题。我们还可以通过 ServicesDB 了解如下一些问题:“有多少个应用程序使用了 Rails 4.2?有多少个应用程序使用了过时的 gem 版本?哪些应用程序正在调用这个服务?”
当前的技术栈
从一开始,我们就使用 MySQL 作为关系数据库,使用 memcached 作为键值存储,使用 Redis 作为队列和后台作业存储。
2014 年,我们无法再将所有数据都保存在单个 MySQL 实例中,即使升级更好的硬件也无济于事。我们决定使用分片,将所有数据分成几十个数据库分片。分片对我们来说很管用,因为 Shopify 的商家是彼此隔离的,所以我们可以将一部分商家放在一个分片中。如果商家之间需要共享数据,那就麻烦了,好在我们的业务没有这样的需求。
分片为我们解决了数据库容量问题,但很快我们就发现,我们的基础设施中存在单点故障问题。所有这些分片仍在使用单个 Redis。有一次,Redis 发生中断,导致整个 Shopify 瘫痪,我们后来称之为“Redismageddon(Redis 世界末日)”。这次事故给我们上了重要的一课,就是要避免让所有的 Shopify 服务共享单个资源。
多年来,我们从分片转向了“pod”的概念。pod 是一个完全独立的 Shopify 服务实例,它拥有自己的数据存储,如 MySQL、Redis、memcached。pod 实例可以在任何区域生成。这种方法帮我们消除了全局的中断事故。截止到现在,我们拥有超过一百个 pod,并且从转向新架构以来,没有哪个重大的中断会影响到整个 Shopify。现在的中断只会影响单个 pod 或区域。
当我们发展到数百个分片和 pod,很显然,我们需要一个编排部署解决方案。今天,我们使用 Docker、Kubernetes 和 Google Kubernetes Engine 为新的 pod 分配资源。在负载均衡器方面,我们使用了 Nginx、Lua 和 OpenResty,我们可以通过脚本来编写负载均衡器。
Shopify Admin 的客户端经历了一个漫长的旅程。我们最开始使用的是 HTML 模板、jQuery 和 prototype.js,并于 2013 年将其迁移到 Batman.js,Batman 是我们自己开发的单页面应用程序框架(SPA)。然后,经过重新评估,我们又回到了静态 HTML 和纯 JavaScript。随着前端生态系统的不断成熟,我们觉得是时候重新思考新的解决方案。去年,我们开始将 Shopify Admin 迁移到 React 和 TypeScript。
从使用 jQuery 和 Batman 以来,很多事情都发生了变化。JavaScript 的运行速度越来越快,我们可以轻松地在服务器上渲染应用程序,从而减少客户端的工作量。React 的资源和开发工具比 Batman 要好很多。另一个非常显著的区别是,我们现在有一个更好的解决方案可以确保业务逻辑不会泄漏到客户端——GraphQL。Admin 成为了另一个 GraphQL 客户端,遵循与移动应用相同的模式:没有数据持久化,不需要服务器处理需要在客户端之间共享的内容,以及极其高效的资源获取。
我们如何构建、测试和部署
Shopify 单体大概有 10 万个单元测试,其中大部分涉及到繁重的 ORM 调用,因此运行得不是很快。为了保持快速的交付管道,我们在 CI 基础设施上进行了大量投入。我们使用 BuildKite 作为 CI 平台。BuildKite 的独特之处在于,它负责编排构建并提供用户界面,而用户可以在自己的硬件上按照自己的方式运行测试。
我们使用数百个并行的 CI 工作线程来运行这 10 万个测试,耗时 15-20 分钟。并行测试加快了我们的交付过程,否则,单个构建可能需要数天时间。我们的数百名开发人员每天都要交付新的功能和改进,因此我们必须保持快速的持续集成管道。如果构建结果为绿色,就可以将变更部署到生产环境中。我们没有使用 staging 或金丝雀部署,在出现问题时我们主要依赖功能开关和快速回滚。
ShipIt 是我们的部署工具,是 Shopify 持续交付的核心。ShipIt 是一个编配器,负责运行和跟踪项目的部署脚本。它支持直接部署到 Rubygems、Pip、Heroku 和 Capistrano。对我们来说,主要是 kubernetes-deploy 或 Capistrano(用于遗留项目)。
我们使用稍微调整过的 GitHub 流程,功能开发在分支上进行,主分支作为生产发布的真实来源。PR 准备就绪后,将其添加到 ShipIt 的合并队列中。合并队列的作用是控制代码合并到主分支的速度。在繁忙时段,会有很多开发人员想要合并 PR,但我们又不希望同时往系统中引入太多变更。合并队列确保一次部署只包含 5-10 次代码提交,这样可以更轻松地识别问题,在部署后如果出现问题可以及时回滚。
我们开发了一个浏览器插件,让合并队列与 GitHub 上的合并按钮很好地结合在一起:
ShipIt 和 kubernetes-deploy 都是开源的,很多公司也采用了我们的流程并且取得了成功。
后续的挑战
在设计 Shopify 的每一个系统时都必须考虑到伸缩性,同时还要让人感觉是在开发经典的 Rails 应用程序。我们为此付出了令人难以置信的工作量。对于进行数据库迁移的开发人员来说,整个过程看起来就像迁移其他 Rails 应用程序一样,但实际上,系统将迁移作业异步应用到 100 多个数据库分片上,停机时间为零。我们的基础设施的其他方面也是类似,从 CI、测试到部署。
在生产工程方面,我们付出了很多努力将基础设施迁移到 Kubernetes。我们不得不对一些方法和设计决策做出评估,因为它们还没有为云环境做好准备。与此同时,我们在 Kubernetes 上的投入开始获得回报。因为之前我花了好几天写 Chef 手册,现在只需要再对 Kubernetes 的 YAML 配置文件做一些修改就可以了。我希望我们的 Kubernetes 基金会能够日趋成熟,为我们提供更多扩展的可能性。
通过使用 Semian 和 Toxiproxy 等工具,我们让单体应用具备了更高的可靠性和弹性。与此同时,我们还管理着公司的其他一百个服务——其中大多数使用了 Rails。我们借助 ServicesDB 来确保它们都使用了与单体相同的模式,让我们从大规模运行 Rails 应用程序中学到的经验教训发挥作用。
这些服务之间还需要以某种方式发生交互,至于使用何种方式由它们自行决定。有些服务使用了 Kafka,有些则使用基于 HTTP 的 REST API。最近,我们一直在寻找适用于整个 Shopify 的 RPC 和服务网格解决方案。我希望在接下来的一年中,应用程序能够以具备弹性和可扩展性的方式进行通信。
重要链接:
ShipIt: https://github.com/shopify/shipit-engine
kubernetes-deploy: https://github.com/shopify/kubernetes-deploy
合并队列: https://engineering.shopify.com/blogs/engineering/introducing-the-merge-queue
评论