持续部署 Microservices 的实践和准则

本篇为 Thoughtworks 洞见博客的投稿: http://insights.thoughtworkers.org/the-practices-and-principles-of-continuous-deployment-microservices/。 感谢 TW 编辑们帮我校稿和排版

当我们讨论 Microservice 架构时,我们通常会和 Monolithic 架构(单体架构 ) 构进行比较。

Monolithic and Miroservice

在 Monolithic 架构中,一个简单的应用会随着功能的增加、时间的推移变得越来越庞大。当 Monoltithic App 变成一个庞然大物,就没有人能够完全理解它究竟做了什么。此时无论是添加新功能,还是修复 Bug ,都是一个非常痛苦、异常耗时的过程。

Microservices 架构渐渐被许多公司采用(AmazoneBayNetflix),用于解决 Monolithic 架构带来的问题。其思路是将应用分解为小的、可以相互组合的 Microservices。 这些 Microservices通过轻量级的机制进行交互,通常会采用基于 HTTP 协议的服务。

每个 Microservices 完成一个独立的业务逻辑,它可以是一个 HTTP API 服务,提供给其他服务或者客户端使用。也可以是一个 ETL 服务,用于完成数据迁移工作。每个 Microservices 除了在业务独立外,也会有自己独立的运行环境,独立的开发、部署流程。

这种独立性给服务的部署和运营带来很大的挑战。因此持续部署(Continuous Deployment)是 Microservices 场景下一个重要的技术实践。本文将介绍持续部署 Microservices 的实践和准则:

实践:

  1. 使用 Docker 容器化服务
  2. 采用 Docker Compose 运行测试

准则:

  1. 构建适合团队的持续部署流水线
  2. 版本化一切
  3. 容器化一切

使用 Docker 容器化服务

我们在构建和发布服务的时候,不仅要发布服务本身,还需要为其配置服务器环境。使用 Docker 容器化微服务,可以让我们不仅发布服务,同时还发布其需要的运行环境。容器化之后,我们可以基于 Docker 构建我们的持续部署流水线:

dockerize

上图描述了一个基于 Ruby on Rails (简称:Rails) 服务的持续部署流水线。我们用 Dockerfile 配置 Rails 项目运行所需的环境,并将 Dockerfile 和项目同时放在 Git 代码仓库中进行版本管理。下面 Dockerfile 可以描述一个 Rails 项目的基础环境:

FROM ruby:2.3.3
RUN apt-get update -y && \
    apt-get install -y libpq-dev nodejs git
WORKDIR /app
ADD Gemfile /app/Gemfile
ADD Gemfile.lock /app/Gemfile.lock
RUN bundle install
ADD . /app
EXPOSE 80
CMD ["bin/run"]

在持续集成服务器上会将项目代码和 Dockerfile 同时下载(git clone)下来进行构建(Build Image)、单元测试(Testing)、最终发布(Publish)。此时整个构建过程都基于 Docker 进行,构建结果为 Docker Image,并且将最终发布到 Docker Registry

在部署阶段,部署机器只需要配置 Docker 环境,从 Docker Registry 上 Pull Image 进行部署。

在服务容器化之后,我们可以让整套持续部署流水线只依赖 Docker,并不需要为环境各异的服务进行单独配置。

使用 Docker Compose 运行测试

在整个持续部署流水线中,我们需要在持续集成服务器上部署服务、运行单元测试和集成测试。Docker Compose 为我们提供了很好的解决方案。

Docker Compose 可以将多个 Docker Image 进行组合。在服务需要访问数据库时,我们可以通过 Docker Compose 将服务的 Image 和 数据库的 Image 组合在一起,然后使用 Docker Compose 在持续集成服务器上进行部署并运行测试。

docker-compose

上图描述了 Rails 服务和 Postgres 数据库的组装过程。我们只需在项目中额外添加一个 docker-compose.yml 来描述组装过程:

db:
  image: postgres:9.4
  ports:
    - "5432"
service:
  build: .
  command: ./bin/run
  volumes:
    - .:/app
  ports:
    - "3000:3000"
dev:
  extends:
    file: docker-compose.yml
    service: service
  links:
    - db
  environment:
    - RAILS_ENV=development
ci:
  extends:
    file: docker-compose.yml
    service: service
  links:
    - db
  environment:
    - RAILS_ENV=test

采用 Docker Compose 运行单元测试和集成测试:

docker-compose run -rm ci bundle exec rake

构建适合团队的持续部署流水线

当我们的代码提交到代码仓库后,持续部署流水线应该能够对服务进行构建、测试、并最终部署到生产环境。

为了让持续部署流水线更好的服务团队,我们通常会对持续部署流水线做一些调整,使其更好的服务于团队的工作流程。例如下图所示的,一个敏捷团队的工作流程:

agile team workflow

通常团队会有业务分析师(BA)做需求分析,业务分析师将需求转换成适合工作的用户故事卡(Story Card),开发人员(Dev)在拿到新的用户故事卡时会先做分析,之后和业务分析师、技术主管(Tech Lead)讨论需求和技术实现方案(Kick off)。

开发人员在开发阶段会在分支(Branch)上进行开发,采用 Pull Request 的方式提交代码,并且邀请他人进行代码评审(Review)。在 Pull Request 被评审通过之后,分支会被合并到 Master 分支,此时代码会被自动部署到测试环境(Test)

在 Microservices 场景下,本地很难搭建一整套集成环境,通常测试环境具有完整的集成环境,在部署到测试环境之后,测试人员(QA)会在测试环境上进行测试。

测试完成后,测试人员会跟业务分析师、技术主管进行验收测试(User Acceptance Test),确认需求的实现和技术实现方案,进行验收。验收后的用户故事卡会被部署到生产环境(Production)

在上述团队工作的流程下,如果持续部署流水线仅对 Master 分支进行打包、测试、发布。在开发阶段 (即:代码还在分支) 时,无法从持续集成上得到反馈,直到代码被合并到 Master 并运行构建后才能得到反馈,通常会造成“本地测试成功,但是持续集成失败”的场景。

因此,团队对仅基于 Master 分支的持续部署流水线做一些改进。使其可以支持对 Pull Request 代码的构建:

team workflow

如上图所示:

  • 持续部署流水线区分 Pull Request 和 Master。 Pull Request 上只运行单元测试, Master 运行完成全部构建并自动将代码部署到测试环境。
  • 为生产环境部署引入手动操作,在验收测试完成之后再手动触发生产环境部署。

经过调整后的持续部署流水线可以使团队在开发阶段快速从持续集成上得到反馈,并且对生产环境的部署有更好的控制。

版本化一切

版本化一切,即将服务开发、部署相关的系统都版本化控制。我们不仅将项目代码纳入版本管理,同时将项目相关的服务、基础设施都进行版本化管理。

对于一个服务,我们一般会为它单独配置持续部署流水线,为它配置独立的用于运行的基础设施。此时会涉及两个非常重要的技术实践:

  • 构建流水线即代码
  • 基础设施即代码

构建流水线即代码。通常我们使用 Jenkins 或者 Bamboo 来搭建配置持续部署流水线,每次创建流水线需要手动配置,这些手动操作不易重用,并且可读性很差,每次对流水线配置的改动并不会保存在历史记录中,也就是说我们无从追踪配置的改动。

在今年上半年,团队将所有的持续部署流水线从 Bamboo 迁移到了 BuildKite,BuildKite 对构建流水线即代码有很好的支持。下图描述了 BuildKite 的工作方式:

build pipeline as code

在 BuildKite 场景下,我们会在每个服务代码库中新增一个 pipeline.yml 来描述构建步骤。构建服务器(CI Service)会从项目的 pipeline.yml 中读取配置,生成构建步骤。例如,我们可以使用如下代码描述流水线:

steps:
  -
    name: "Run my tests"
    command: "shared_ci_script/bin/test"
    agents:
      queue: test
  - wait
  -
    name: "Push docker image"
    command: "shared_ci_script/bin/docker-tag"
    branches: "master"
    agents:
      queue: test
  - wait
  -
    name: "Deploy To Test"
    command: "shared_ci_script/bin/deploy"
    branches: "master"
    env:
      DEPLOYMENT_ENV: test
    agents:
      queue: test
  - block
  - name: "Deploy to Production"
    command: "shared_ci_script/bin/deploy"
    branches: "master"
    env:
      DEPLOYMENT_ENV: prod
    agents:
      queue: production

在上述配置中, command 中的步骤 ( 即:test、docker-tag、deploy ) 分别是具体的构建脚本,这些脚本被放在一个公共的 shared_ci_script 代码库中,shared_ci_script 会以 git submodule 的方式被引入到每个服务代码库中。

经过构建流水线即代码方式的改造,对于持续部署流水线的任何改动都会在 Git 中被追踪,并且有很好的可读性。

基础设施即代码。对于一个基于 HTTP 协议的 API 服务基础设施可以是:

  • 用于部署的机器
  • 机器的 IP 和网络配置
  • 设备硬件监控服务(CPU,Memory 等)
  • 负载均衡(Load Balancer)
  • DNS 服务
  • AutoScaling Service (自动伸缩服务)
  • Splunk 日志收集
  • NewRelic 性能监控
  • PagerDuty 报警

这些基础设施我们可以使用代码进行描述,AWS Cloudformation 在这方面提供了很好的支持。我们可以使用 AWS Cloudformation 设计器或者遵循 AWS Cloudformation 的语法配置基础设施。下图为一个服务的基础设施构件图,图中构建了上面提到的大部分基础设施:

infrastruture as code

在 AWS Cloudformation 中,基础设施描述代码可以是 JSON 文件,也可以是 YAML 文件。我们将这些文件也放到项目的代码库中进行版本化管理。

所有对基础设施的操作,我们都通过修改 AWS Cloudformation 配置进行修改,并且所有修改都应该在 Git 的版本化控制中。

由于我们采用代码描述基础设施,并且大部分服务遵循相通的部署流程和基础设施,基础设施代码的相似度很高。 DevOps 团队会为团队创建属于自己的部署工具来简化基础设施配置和部署流程。

容器化一切

通常在部署服务时,我们还需要一些辅助服务,这些服务我们也将其容器化,并使用 Docker 运行。下图描述了一个服务在 AWS EC2 Instance 上面的运行环境:

deploy via docker

在服务部署到 AWS EC2 Instance 时,我们需要为日志配置收集服务,需要为服务配置 Nginx 反向代理。

按照 12-factors 原则,我们基于 fluentd,采用日志流的方式处理日志。其中 logs-router 用来分发日志、splunk-forwarder 负责将日志转发到 Splunk

在容器化一切之后,我们的服务启动只需要依赖 Docker 环境,相关服务的依赖也可以通过 Docker 的机制运行。

总结

Microservices 给业务和技术的扩展性带来了极大的便利,同时在组织和技术层面带来了极大的挑战。由于在架构的演进过程中,会有很多新服务产生,持续部署是技术层面的挑战之一,好的持续部署实践和准则可以让团队从基础设施抽离出来,关注与产生业务价值的功能实现。

推荐阅读更多精彩内容