Docker 和 Jenkins 持续交付(四)
原文:
zh.annas-archive.org/md5/7C44824F34694A0D5BA0600DC67F15A8译者:飞龙
第九章:高级持续交付
在上一章中,我们介绍了服务器集群的工作原理以及如何与 Docker 和 Jenkins 一起使用。在本章中,我们将看到一系列不同方面的内容,这些内容在持续交付过程中非常重要,但尚未被描述。
本章涵盖以下要点:
-
解释如何在持续交付的背景下处理数据库更改
-
介绍数据库迁移及相关工具的概念
-
探索向后兼容和不向后兼容的数据库更新的不同方法
-
在 Jenkins 流水线中使用并行步骤
-
创建 Jenkins 共享库
-
介绍回滚生产更改的方法
-
为传统系统引入持续交付
-
探索如何准备零停机部署
-
介绍持续交付的最佳实践
管理数据库更改
到目前为止,我们已经专注于应用于 Web 服务的持续交付过程。其中一个简单的部分是 Web 服务本质上是无状态的。这意味着它们可以很容易地更新、重新启动、在许多实例中克隆,并且可以从给定的源代码重新创建。然而,Web 服务通常与其有状态部分——数据库相关联,这给交付过程带来了新的挑战。这些挑战可以分为以下几类:
-
兼容性:数据库架构和数据本身必须始终与 Web 服务兼容
-
零停机部署:为了实现零停机部署,我们使用滚动更新,这意味着数据库必须同时与两个不同的 Web 服务版本兼容
-
回滚:数据库的回滚可能很困难,受限制,有时甚至是不可能的,因为并非所有操作都是可逆的(例如,删除包含数据的列)
-
测试数据:与数据库相关的更改很难测试,因为我们需要与生产环境非常相似的测试数据
在本节中,我将解释如何解决这些挑战,以便持续交付过程尽可能安全。
理解模式更新
如果你考虑交付过程,实际上并不是数据本身造成了困难,因为当我们部署一个应用程序时,我们通常不会改变数据。数据是在系统在生产环境中运行时收集的;而在部署过程中,我们只是改变了存储和解释这些数据的方式。换句话说,在持续交付过程的背景下,我们对数据库的结构感兴趣,而不是它的内容。这就是为什么这一部分主要涉及关系数据库(及其模式),并且对其他类型的存储,如 NoSQL 数据库,关注较少,因为它们没有结构定义。
为了更好地理解这一点,我们可以想象一下 Redis,我们在本书中已经使用过。它存储了缓存数据,因此实际上它是一个数据库。然而,从持续交付的角度来看,它不需要任何努力,因为它没有任何数据结构。它存储的只是键值条目,这些条目随时间不会发生变化。
NoSQL 数据库通常没有任何限制模式,因此简化了持续交付过程,因为不需要额外的模式更新步骤。这是一个巨大的好处;然而,这并不一定意味着使用 NoSQL 数据库编写应用程序更简单,因为我们在源代码中需要更多的努力来进行数据验证。
关系数据库具有静态模式。如果我们想要更改它,例如向表中添加新列,我们需要编写并执行 SQL DDL(数据定义语言)脚本。为每个更改手动执行这个操作需要大量的工作,并且会导致易出错的解决方案,运维团队必须保持代码和数据库结构同步。一个更好的解决方案是以增量方式自动更新模式。这样的解决方案称为数据库迁移。
引入数据库迁移
数据库模式迁移是对关系数据库结构进行增量更改的过程。让我们看一下以下图表,以更好地理解它:
版本v1的数据库由V1_init.sql文件定义。此外,它还存储与迁移过程相关的元数据,例如当前模式版本和迁移日志。当我们想要更新模式时,我们以 SQL 文件的形式提供更改,比如V2_add_table.sql。然后,我们需要运行迁移工具,它会在数据库上执行给定的 SQL 文件(还会更新元表)。实际上,数据库模式是所有随后执行的 SQL 迁移脚本的结果。接下来,我们将看一个迁移的例子。
迁移脚本应该存储在版本控制系统中,通常与源代码存储在同一个仓库中。
迁移工具及其使用的策略可以分为两类:
-
升级和降级:这种方法,例如 Ruby on Rails 框架使用的方法,意味着我们可以向上迁移(从 v1 到 v2)和向下迁移(从 v2 到 v1)。它允许数据库模式回滚,这有时可能导致数据丢失(如果迁移在逻辑上是不可逆的)。
-
仅升级:这种方法,例如 Flyway 工具使用的方法,只允许我们向上迁移(从 v1 到 v2)。在许多情况下,数据库更新是不可逆的,例如从数据库中删除表。这样的更改无法回滚,因为即使我们重新创建表,我们已经丢失了所有数据。
市场上有许多数据库迁移工具,其中最流行的是 Flyway、Liquibase 和 Rail Migrations(来自 Ruby on Rails 框架)。为了了解这些工具的工作原理,我们将以 Flyway 工具为例进行介绍。
还有一些商业解决方案专门针对特定的数据库,例如 Redgate(用于 SQL Server)和 Optim Database Administrator(用于 DB2)。
使用 Flyway
让我们使用 Flyway 为计算器 Web 服务创建数据库模式。数据库将存储在服务上执行的所有操作的历史记录:第一个参数、第二个参数和结果。
我们展示如何在三个步骤中使用 SQL 数据库和 Flyway。
-
配置 Flyway 工具与 Gradle 一起工作。
-
定义 SQL 迁移脚本以创建计算历史表。
-
在 Spring Boot 应用程序代码中使用 SQL 数据库。
配置 Flyway
为了将 Flyway 与 Gradle 一起使用,我们需要将以下内容添加到build.gradle文件中:
buildscript {
dependencies {
classpath('com.h2database:h2:1.4.191')
}
}
…
plugins {
id "org.flywaydb.flyway" version "4.2.0"
}
…
flyway {
url = 'jdbc:h2:file:/tmp/calculator'
user = 'sa'
}
以下是对配置的快速评论:
-
我们使用了 H2 数据库,这是一个内存(和基于文件的)数据库。
-
我们将数据库存储在
/tmp/calculator文件中 -
默认数据库用户称为
sa(系统管理员)
对于其他 SQL 数据库(例如 MySQL),配置将非常相似。唯一的区别在于 Gradle 依赖项和 JDBC 连接。
应用此配置后,我们应该能够通过执行以下命令来运行 Flyway 工具:
$ ./gradlew flywayMigrate -i
该命令在文件/tmp/calculator.mv.db中创建了数据库。显然,由于我们还没有定义任何内容,它没有模式。
Flyway 可以作为命令行工具、通过 Java API 或作为流行构建工具 Gradle、Maven 和 Ant 的插件来使用。
定义 SQL 迁移脚本
下一步是定义 SQL 文件,将计算表添加到数据库模式中。让我们创建src/main/resources/db/migration/V1__Create_calculation_table.sql文件,内容如下:
create table CALCULATION (
ID int not null auto_increment,
A varchar(100),
B varchar(100),
RESULT varchar(100),
primary key (ID)
);
请注意迁移文件的命名约定,<version>__<change_description>.sql。SQL 文件创建了一个具有四列ID、A、B、RESULT的表。ID列是表的自动递增主键。现在,我们准备运行 Flyway 命令来应用迁移:
$ ./gradlew flywayMigrate -i
…
Successfully applied 1 migration to schema "PUBLIC" (execution time 00:00.028s).
:flywayMigrate (Thread[Daemon worker Thread 2,5,main]) completed. Took 1.114 secs.
该命令自动检测到迁移文件并在数据库上执行了它。
迁移文件应始终保存在版本控制系统中,通常与源代码一起。
访问数据库
我们执行了第一个迁移,因此数据库已准备就绪。为了查看完整的示例,我们还应该调整我们的项目,以便它可以访问数据库。
首先,让我们配置 Gradle 依赖项以使用 Spring Boot 项目中的 H2 数据库。我们可以通过将以下行添加到build.gradle文件中来实现这一点:
dependencies {
compile("org.springframework.boot:spring-boot-starter-data-jpa")
compile("com.h2database:h2")
}
下一步是在src/main/resources/application.properties文件中设置数据库位置和启动行为:
spring.datasource.url=jdbc:h2:file:/tmp/calculator;DB_CLOSE_ON_EXIT=FALSE
spring.jpa.hibernate.ddl-auto=validate
第二行意味着 Spring Boot 不会尝试从源代码模型自动生成数据库模式。相反,它只会验证数据库模式是否与 Java 模型一致。
现在,让我们在新的src/main/java/com/leszko/calculator/Calculation.java文件中为计算创建 Java ORM 实体模型:
package com.leszko.calculator;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Calculation {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Integer id;
private String a;
private String b;
private String result;
protected Calculation() {}
public Calculation(String a, String b, String result) {
this.a = a;
this.b = b;
this.result = result;
}
}
实体类在 Java 代码中表示数据库映射。一个表被表示为一个类,每一列被表示为一个字段。下一步是创建用于加载和存储Calculation实体的存储库。
让我们创建src/main/java/com/leszko/calculator/CalculationRepository.java文件:
package com.leszko.calculator;
import org.springframework.data.repository.CrudRepository;
public interface CalculationRepository extends CrudRepository<Calculation, Integer> {}
最后,我们可以使用Calculation和CalculationRepository类来存储计算历史。让我们将以下代码添加到src/main/java/com/leszko/calculator/CalculatorController.java文件中:
...
class CalculatorController {
...
@Autowired
private CalculationRepository calculationRepository;
@RequestMapping("/sum")
String sum(@RequestParam("a") Integer a, @RequestParam("b") Integer b) {
String result = String.valueOf(calculator.sum(a, b));
calculationRepository.save(new Calculation(a.toString(), b.toString(), result));
return result;
}
}
现在,当我们启动服务并执行/sum端点时,每个求和操作都会记录到数据库中。
如果您想浏览数据库内容,那么您可以将spring.h2.console.enabled=true添加到application.properties文件中,然后通过/h2-console端点浏览数据库。
我们解释了数据库模式迁移的工作原理以及如何在使用 Gradle 构建的 Spring 项目中使用它。现在,让我们看看它如何在持续交付过程中集成。
在持续交付中更改数据库
在持续交付管道中使用数据库更新的第一种方法可能是在迁移命令执行中添加一个阶段。这个简单的解决方案对许多情况都能正确工作;然而,它有两个重大缺点:
-
回滚:如前所述,不总是可能回滚数据库更改(Flyway 根本不支持降级)。因此,在服务回滚的情况下,数据库变得不兼容。
-
停机时间:服务更新和数据库更新并非完全同时执行,这会导致停机时间。
这导致我们需要解决的两个约束:
-
数据库版本需要始终与服务版本兼容
-
数据库模式迁移是不可逆的
我们将针对两种不同情况解决这些约束:向后兼容的更新和非向后兼容的更新。
向后兼容的更改
向后兼容的更改更简单。让我们看一下以下图表,看看它们是如何工作的:
假设模式迁移“数据库 v10”是向后兼容的。如果我们需要回滚“服务 v1.2.8”版本,那么我们部署“服务 v1.2.7”,并且不需要对数据库做任何操作(数据库迁移是不可逆的,所以我们保留“数据库 v11”)。由于模式更新是向后兼容的,“服务 v.1.2.7”可以完美地与“数据库 v11”配合使用。如果我们需要回滚到“服务 v1.2.6”,等等,也是一样的。现在,假设“数据库 v10”和所有其他迁移都是向后兼容的,那么我们可以回滚到任何服务版本,一切都会正常工作。
停机时间也不是问题。如果数据库迁移本身是零停机的,那么我们可以先执行它,然后对服务使用滚动更新。
让我们看一个向后兼容更改的例子。我们将创建一个模式更新,向计算表添加一个created_at列。迁移文件src/main/resources/db/migration/V2__Add_created_at_column.sql如下所示:
alter table CALCULATION
add CREATED_AT timestamp;
除了迁移脚本,计算器服务还需要在Calculation类中添加一个新字段:
...
private Timestamp createdAt;
...
我们还需要调整它的构造函数,然后在CalculatorController类中使用它:
calculationRepository.save(new Calculation(a.toString(), b.toString(), result, Timestamp.from(Instant.now())));
运行服务后,计算历史记录将与created_at列一起存储。请注意,这个更改是向后兼容的,因为即使我们恢复 Java 代码并保留数据库中的created_at列,一切都会正常工作(恢复的代码根本不涉及新列)。
不向后兼容的更改
不向后兼容的更改要困难得多。看看前面的图,如果数据库更改 v11 是不向后兼容的,那么将无法将服务回滚到 1.2.7 版本。在这种情况下,我们如何处理不向后兼容的数据库迁移,以便回滚和零停机部署是可能的呢?
长话短说,我们可以通过将不向后兼容的更改转换为在一定时间内向后兼容的更改来解决这个问题。换句话说,我们需要额外努力并将模式迁移分为两部分:
-
现在执行的向后兼容更新通常意味着保留一些冗余数据
-
在回滚期限之后执行的不向后兼容更新定义了我们可以回滚代码的时间范围
为了更好地说明这一点,让我们看一下以下图片:
让我们考虑一个删除列的例子。一个提议的方法包括两个步骤:
-
停止在源代码中使用该列(v1.2.5,向后兼容的更新,首先执行)。
-
从数据库中删除列(v11,不向后兼容的更新,在回滚期之后执行)。
直到Database v11的所有服务版本都可以回滚到任何以前的版本,从Service v1.2.8开始的服务只能在回滚期内回滚。这种方法可能听起来很琐碎,因为我们所做的一切只是延迟了从数据库中删除列。但是,它解决了回滚问题和零停机部署问题。因此,它减少了与发布相关的风险。如果我们将回滚期调整为合理的时间,例如,每天多次发布到两周,则风险可以忽略不计。我们通常不会回滚很多版本。
删除列是一个非常简单的例子。让我们看一个更困难的情景,并在我们的计算器服务中重命名结果列。我们将在几个步骤中介绍如何做到这一点:
-
向数据库添加新列。
-
更改代码以使用两列。
-
合并两列中的数据。
-
从代码中删除旧列。
-
从数据库中删除旧列。
向数据库添加新列
假设我们需要将result列重命名为sum。第一步是添加一个将是重复的新列。我们必须创建一个src/main/resources/db/migration/V3__Add_sum_column.sql迁移文件:
alter table CALCULATION
add SUM varchar(100);
因此,在执行迁移后,我们有两列:result和sum。
更改代码以使用两列
下一步是在源代码模型中重命名列,并将两个数据库列用于设置和获取操作。我们可以在Calculation类中进行更改:
public class Calculation {
...
private String sum;
...
public Calculation(String a, String b, String sum, Timestamp createdAt) {
this.a = a;
this.b = b;
this.sum = sum;
this.result = sum;
this.createdAt = createdAt;
}
public String getSum() {
return sum != null ? sum : result;
}
}
为了 100%准确,在getSum()方法中,我们应该比较类似最后修改列日期的内容(不一定总是首先使用新列)。
从现在开始,每当我们向数据库添加一行时,相同的值将被写入result和sum列。在读取sum时,我们首先检查它是否存在于新列中,如果不存在,则从旧列中读取。
可以通过使用数据库触发器来实现相同的结果,触发器会自动将相同的值写入两列。
到目前为止,我们所做的所有更改都是向后兼容的,因此我们可以随时回滚服务,到任何我们想要的版本。
合并两个列中的数据
这一步通常在发布稳定后的一段时间内完成。我们需要将旧的result列中的数据复制到新的sum列中。让我们创建一个名为V4__Copy_result_into_sum_column.sql的迁移文件:
update CALCULATION
set CALCULATION.sum = CALCULATION.result
where CALCULATION.sum is null;
我们仍然没有回滚的限制;然而,如果我们需要部署在第 2 步之前的版本,那么这个数据库迁移需要重复执行。
从代码中删除旧列
此时,我们已经将所有数据存储在新列中,因此我们可以在数据模型中开始使用它,而不需要旧列。为了做到这一点,我们需要删除Calculation类中与result相关的所有代码,使其如下所示:
public class Calculation {
...
private String sum;
...
public Calculation(String a, String b, String sum, Timestamp createdAt) {
this.a = a;
this.b = b;
this.sum = sum;
this.createdAt = createdAt;
}
public String getSum() {
return sum;
}
}
在此操作之后,我们不再在代码中使用result列。请注意,此操作仅向后兼容到第 2 步。如果我们需要回滚到第 1 步,那么我们可能会丢失此步骤之后存储的数据。
从数据库中删除旧列
最后一步是从数据库中删除旧列。这个迁移应该在回滚期之后执行,当我们确定在第 4 步之前不需要回滚时。
由于我们不再使用数据库中的列,回滚期可能会很长。这个任务可以被视为一个清理任务,因此即使它不向后兼容,也没有相关的风险。
让我们添加最终的迁移,V5__Drop_result_column.sql:
alter table CALCULATION
drop column RESULT;
在这一步之后,我们终于完成了列重命名的过程。请注意,我们所做的一切只是稍微复杂了操作,以便将其延长。这减少了向后不兼容的数据库更改的风险,并允许零停机部署。
将数据库更新与代码更改分开
到目前为止,在所有的图表中,我们都提到数据库迁移是与服务发布一起运行的。换句话说,每个提交(意味着每个发布)都包括数据库更改和代码更改。然而,推荐的方法是明确分离存储库的提交是数据库更新还是代码更改。这种方法在下图中呈现:
数据库服务变更分离的好处是,我们可以免费获得向后兼容性检查。想象一下,更改 v11 和 v1.2.7 涉及到一个逻辑更改,例如,向数据库添加一个新列。然后,我们首先提交数据库 v11,这样持续交付流水线中的测试就会检查数据库 v11 是否与服务 v.1.2.6 正常工作。换句话说,它们检查数据库更新 v11 是否向后兼容。然后,我们提交 v1.2.7 的更改,这样流水线就会检查数据库 v11 是否与服务 v1.2.7 正常工作。
数据库-代码分离并不意味着我们必须有两个单独的 Jenkins 流水线。流水线可以始终同时执行,但我们应该将其作为一个良好的实践,即提交要么是数据库更新,要么是代码更改。
总之,数据库架构的更改不应该手动完成。相反,我们应该始终使用迁移工具自动化它们,作为持续交付流水线的一部分执行。我们还应该避免非向后兼容的数据库更新,确保这一点的最佳方法是将数据库和代码更改分别提交到存储库中。
避免共享数据库
在许多系统中,我们可以发现数据库成为了多个服务之间共享的中心点。在这种情况下,对数据库的任何更新都变得更加具有挑战性,因为我们需要在所有服务之间进行协调。
例如,想象一下我们开发了一个在线商店,我们有一个包含以下列的 Customers 表:名字,姓氏,用户名,密码,电子邮件和折扣。有三个服务对客户数据感兴趣:
-
个人资料管理器:这使得用户数据可以进行编辑
-
结账处理器:这个处理结账(读取用户名和电子邮件)
-
折扣管理器:这个分析客户的订单并设置合适的折扣
让我们看一下下面的图片,展示了这种情况:
它们依赖于相同的数据库架构。这种方法至少存在两个问题:
-
当我们想要更新架构时,它必须与这三个服务兼容。虽然所有向后兼容的更改都没问题,但任何非向后兼容的更新都变得更加困难甚至不可能。
-
每个服务都有一个独立的交付周期和独立的持续交付管道。那么,我们应该使用哪个管道进行数据库架构迁移?不幸的是,对于这个问题没有一个好的答案。
基于之前提到的原因,每个服务应该有自己的数据库,并且服务应该通过它们的 API 进行通信。根据我们的例子,我们可以应用以下的重构:
-
结账处理器应该与档案管理器的 API 通信,以获取客户的数据
-
折扣列应该被提取到一个单独的数据库(或架构)中,并且折扣管理器应该负责
重构后的版本如下图所示:
这种方法与微服务架构的原则一致,应该始终被应用。通过 API 的通信比直接访问数据库更加灵活。
在单体系统的情况下,数据库通常是集成点。由于这种方法会引起很多问题,被认为是一种反模式。
准备测试数据
我们已经介绍了保持数据库架构一致的数据库迁移,这是一个副作用。这是因为如果我们在开发机器上、在暂存环境中或者在生产环境中运行相同的迁移脚本,我们总是会得到相同的架构结果。然而,表内的数据值是不同的。我们如何准备测试数据,以有效地测试我们的系统?这就是本节的主题。
这个问题的答案取决于测试的类型,对于单元测试、集成/验收测试和性能测试是不同的。让我们分别来看每种情况。
单元测试
在单元测试的情况下,我们不使用真实的数据库。我们要么在持久化机制的层面(仓库、数据访问对象)模拟测试数据,要么用内存数据库(例如 H2 数据库)伪造真实的数据库。由于单元测试是由开发人员创建的,确切的数据值通常是由开发人员虚构的,而且并不重要。
集成/验收测试
集成和验收测试通常使用测试/暂存数据库,该数据库应尽可能与生产环境相似。许多公司采取的一种方法是将生产数据快照到暂存,以确保两者完全相同。然而,出于以下原因,这种方法被视为反模式:
-
测试隔离:每个测试都在同一个数据库上操作,因此一个测试的结果可能会影响其他测试的输入
-
数据安全性:生产实例通常存储敏感信息,因此更容易得到保护。
-
可重现性:每次快照后,测试数据都是不同的,这可能导致测试不稳定
基于上述原因,首选的方法是通过与客户或业务分析师一起手动准备测试数据,选择生产数据的子集。当生产数据库增长时,值得重新审视其内容,看是否有任何应该添加的合理情况。
将数据添加到暂存数据库的最佳方法是使用服务的公共 API。这种方法与通常是黑盒的验收测试一致。而且,使用 API 可以保证数据本身的一致性,并通过限制直接数据库操作简化数据库重构。
性能测试
性能测试的测试数据通常类似于验收测试。一个重要的区别是数据量。为了正确测试性能,我们需要提供足够数量的输入数据,至少与生产环境(在高峰时段)的数据量一样大。为此,我们可以创建数据生成器,通常在验收和性能测试之间共享。
管道模式
我们已经知道启动项目并使用 Jenkins 和 Docker 设置持续交付管道所需的一切。本节旨在通过一些推荐的 Jenkins 管道实践来扩展这些知识。
并行化管道
在整本书中,我们总是按顺序执行流水线,一步一步地进行。这种方法使得很容易理清构建的状态和结果。如果首先是验收测试阶段,然后是发布阶段,这意味着在验收测试成功之前,发布永远不会发生。顺序流水线易于理解,通常不会引起任何意外。这就是为什么解决任何问题的第一种方法是按顺序进行。
然而,在某些情况下,阶段是耗时的,值得并行运行它们。一个很好的例子是性能测试。它们通常需要很长时间,因此假设它们是独立和隔离的,将它们并行运行是有意义的。在 Jenkins 中,我们可以在两个不同的级别上并行化流水线:
-
并行步骤:在一个阶段内,并行进程在同一代理上运行。这种方法很简单,因为所有与 Jenkins 工作区相关的文件都位于一台物理机器上,但是,与垂直扩展一样,资源仅限于该单一机器。
-
并行阶段:每个阶段可以在提供资源水平扩展的单独代理机器上并行运行。如果需要在另一台物理机器上使用前一阶段创建的文件,我们需要注意环境之间的文件传输(使用
stashJenkinsfile 关键字)。
在撰写本书时,声明性流水线中并行阶段是不可用的。该功能应该在 Jenkins Blue Ocean v1.3 中添加。与此同时,唯一的可能性是使用基于 Groovy 的脚本流水线中的弃用功能,如此处所述:jenkins.io/doc/book/pipeline/jenkinsfile/#executing-in-parallel。
让我们看看实际操作中是什么样子。如果我们想要并行运行两个步骤,Jenkinsfile 脚本应该如下所示:
pipeline {
agent any
stages {
stage('Stage 1') {
steps {
parallel (
one: { echo "parallel step 1" },
two: { echo "parallel step 2" }
)
}
}
stage('Stage 2') {
steps {
echo "run after both parallel steps are completed"
}
}
}
}
在阶段 1中,使用parallel关键字,我们执行两个并行步骤,one和two。请注意,只有在两个并行步骤都完成后,才会执行阶段 2。这就是为什么这样的解决方案非常安全地并行运行测试;我们始终可以确保只有在所有并行测试都已通过后,才会运行部署阶段。
有一个非常有用的插件叫做并行测试执行器,它可以帮助自动拆分测试并并行运行它们。在jenkins.io/doc/pipeline/steps/parallel-test-executor/上阅读更多。
前面的描述涉及到并行步骤级别。另一个解决方案是使用并行阶段,因此在单独的代理机器上运行每个阶段。选择使用哪种类型的并行通常取决于两个因素:
-
代理机器的强大程度
-
给定阶段需要多少时间
作为一般建议,单元测试可以并行运行,但性能测试通常最好在单独的机器上运行。
重用管道组件
当 Jenkinsfile 脚本变得越来越复杂时,我们可能希望在相似的管道之间重用其部分。
例如,我们可能希望为不同的环境(开发、QA、生产)拥有单独但相似的管道。在微服务领域的另一个常见例子是,每个服务都有一个非常相似的 Jenkinsfile。那么,我们如何编写 Jenkinsfile 脚本,以便不重复编写相同的代码?为此有两种好的模式,参数化构建和共享库。让我们逐一描述它们。
构建参数
我们已经在第四章中提到,持续集成管道,管道可以有输入参数。我们可以使用它们来为相同的管道代码提供不同的用例。例如,让我们创建一个带有环境类型参数的管道:
pipeline {
agent any
parameters {
string(name: 'Environment', defaultValue: 'dev', description: 'Which
environment (dev, qa, prod)?')
}
stages {
stage('Environment check') {
steps {
echo "Current environment: ${params.Environment}"
}
}
}
}
构建需要一个输入参数,环境。然后,在这一步中,我们所做的就是打印参数。我们还可以添加一个条件,以执行不同环境的不同代码。
有了这个配置,当我们开始构建时,我们将看到一个输入参数的提示,如下所示:
参数化构建可以帮助重用管道代码,适用于只有少许不同的情况。然而,不应该过度使用这个功能,因为太多的条件会使 Jenkinsfile 难以理解。
共享库
重用管道的另一个解决方案是将其部分提取到共享库中。
共享库是作为单独的源代码控制项目存储的 Groovy 代码。此代码可以稍后用作许多 Jenkinsfile 脚本的管道步骤。为了明确起见,让我们看一个例子。共享库技术始终需要三个步骤:
-
创建一个共享库项目。
-
在 Jenkins 中配置共享库。
-
在 Jenkins 文件中使用共享库。
创建一个共享库项目
我们首先创建一个新的 Git 项目,在其中放置共享库代码。每个 Jenkins 步骤都表示为位于vars目录中的 Groovy 文件。
让我们创建一个sayHello步骤,它接受name参数并回显一个简单的消息。这应该存储在vars/sayHello.groovy文件中:
/
* Hello world step.
*/
def call(String name) {
echo "Hello $name!"
}
共享库步骤的可读性描述可以存储在*.txt文件中。在我们的例子中,我们可以添加带有步骤文档的vars/sayHello.txt文件。
当库代码完成时,我们需要将其推送到存储库,例如,作为一个新的 GitHub 项目。
在 Jenkins 中配置共享库
下一步是在 Jenkins 中注册共享库。我们打开“管理 Jenkins | 配置系统”,找到全局管道库部分。在那里,我们可以添加一个选择的名称的库,如下所示:
我们指定了库注册的名称和库存储库地址。请注意,库的最新版本将在管道构建期间自动下载。
我们介绍了将 Groovy 代码导入为全局共享库,但也有其他替代解决方案。更多信息请阅读jenkins.io/doc/book/pipeline/shared-libraries/。
在 Jenkinsfile 中使用共享库
最后,我们可以在 Jenkinsfile 脚本中使用共享库。
让我们看一个例子:
pipeline {
agent any
stages {
stage("Hello stage") {
steps {
sayHello 'Rafal'
}
}
}
}
如果在 Jenkins 配置中没有选中“隐式加载”,那么我们需要在 Jenkinsfile 脚本的开头添加"@Library('example') _"。
正如您所看到的,我们可以使用 Groovy 代码作为管道步骤sayHello。显然,在管道构建完成后,在控制台输出中,我们应该看到Hello Rafal!。
共享库不限于一个步骤。实际上,借助 Groovy 语言的强大功能,它们甚至可以作为整个 Jenkins 管道的模板。
回滚部署
我记得我的一位资深架构师同事说过:“你不需要更多的质量保证人员,你需要更快的回滚。”尽管这种说法过于简化,而且质量保证团队通常是非常有价值的,但这句话中有很多真理。想想看;如果你在生产环境引入了一个 bug,但在第一个用户报告错误后很快回滚,通常不会发生什么坏事。另一方面,如果生产错误很少,但没有进行回滚,那么通常会导致在长时间的失眠夜晚和一些不满意的用户中调试生产过程。这就是为什么我们在创建 Jenkins 流水线时需要事先考虑回滚策略。
在持续交付的背景下,有两个可能发生失败的时刻:
-
在发布过程中,在流水线执行中
-
流水线构建完成后,在生产环境中
第一种情况非常简单且无害。它涉及到应用程序已经部署到生产环境,但接下来的阶段失败,例如,冒烟测试失败。那么,我们需要做的就是在“失败”情况的“后”流水线部分执行一个脚本,将生产服务降级到较旧的 Docker 镜像版本。如果我们使用蓝绿部署(如本章后面描述的),那么任何停机时间的风险都是最小的,因为通常我们会在冒烟测试之后的最后一个流水线阶段执行负载均衡器切换。
第二种情况是,当我们在成功完成流水线后注意到生产 bug 时,情况更加困难,需要一些评论。在这种情况下,规则是我们应该始终使用与标准发布完全相同的流程来发布回滚的服务。否则,如果我们尝试以更快的方式手动操作,那么我们就是在自找麻烦。任何非重复性的任务都是有风险的,尤其是在压力下,当生产环境处于无序状态时。
顺便说一句,如果流水线顺利完成但出现了生产 bug,那么这意味着我们的测试还不够好。因此,在回滚之后的第一件事是扩展单元/验收测试套件,以涵盖相应的场景。
最常见的持续交付流程是一个完全自动化的流水线,从检出代码开始,以发布到生产结束。
以下图表显示了这是如何工作的:
在本书中,我们已经介绍了经典的持续交付管道。如果回滚应该使用完全相同的流程,那么我们需要做的就是从存储库中恢复最新的代码更改。结果,管道会自动构建、测试,最后发布正确的版本。
存储库回滚和紧急修复不应跳过管道中的测试阶段。否则,我们可能会因为其他问题导致发布仍然无法正常工作,使得调试变得更加困难。
解决方案非常简单而优雅。唯一的缺点是我们需要花费时间在完整的管道构建上。如果使用蓝绿部署或金丝雀发布,可以避免这种停机时间,在这种情况下,我们只需更改负载均衡器设置以解决健康环境。
在编排发布的情况下,回滚操作变得更加复杂,因为在此期间许多服务同时部署。这是编排发布被视为反模式的原因之一,特别是在微服务世界中。正确的方法是始终保持向后兼容,至少在一段时间内(就像我们在本章开头为数据库所介绍的那样)。然后,可以独立发布每个服务。
添加手动步骤
一般来说,持续交付管道应该是完全自动化的,由对存储库的提交触发,并在发布后结束。然而,有时我们无法避免出现手动步骤。最常见的例子是发布批准,这意味着流程是完全自动化的,但有一个手动步骤来批准新发布。另一个常见的例子是手动测试。其中一些可能是因为我们在传统系统上操作;另一些可能是因为某些测试根本无法自动化。无论原因是什么,有时除了添加手动步骤别无选择。
Jenkins 语法提供了一个关键字input用于手动步骤:
stage("Release approval") {
steps {
input "Do you approve the release?"
}
}
管道将在“输入”步骤上停止执行,并等待手动批准。
请记住,手动步骤很快就会成为交付过程中的瓶颈,这就是为什么它们应该始终被视为次于完全自动化的解决方案的原因。
有时设置输入的超时时间是有用的,以避免无限等待手动交互。在配置的时间过去后,整个管道将被中止。
发布模式
在上一节中,我们讨论了用于加快构建执行(并行步骤)、帮助代码重用(共享库)、限制生产错误风险(回滚)和处理手动批准(手动步骤)的 Jenkins 流水线模式。本节介绍了下一组模式,这次与发布过程有关。它们旨在减少将生产环境更新到新软件版本的风险。
我们已经在《使用 Docker Swarm 进行集群化》的第八章中描述了一个发布模式,即滚动更新。在这里,我们介绍另外两种:蓝绿部署和金丝雀发布。
蓝绿部署
蓝绿部署是一种减少发布相关停机时间的技术。它涉及拥有两个相同的生产环境,一个称为绿色,另一个称为蓝色,如下图所示:
在图中,当前可访问的环境是蓝色的。如果我们想进行新的发布,那么我们将所有内容部署到绿色环境,并在发布过程结束时将负载均衡器切换到绿色环境。结果,用户突然开始使用新版本。下次我们想进行发布时,我们对蓝色环境进行更改,并在最后将负载均衡器切换到蓝色。每次都是这样进行,从一个环境切换到另一个环境。
蓝绿部署技术在两个假设条件下能够正常工作:环境隔离和无编排发布。
这种解决方案带来了两个重要的好处:
-
零停机时间:从用户角度来看,所有停机时间都是可以忽略不计的负载平衡切换时刻
-
回滚:为了回滚一个版本,只需将负载平衡切换回蓝绿部署包括:
-
数据库:模式迁移在回滚时可能会很棘手,因此值得使用本章开头介绍的模式。
-
事务:运行数据库事务必须移交给新数据库
-
冗余基础设施/资源:我们需要双倍的资源
有技术和工具可以克服这些挑战,因此蓝绿部署模式在 IT 行业中被高度推荐和广泛使用。
您可以在优秀的 Martin Fowler 博客martinfowler.com/bliki/BlueGreenDeployment.html中阅读有关蓝绿部署技术的更多信息。
金丝雀发布
金丝雀发布是一种减少引入新软件版本风险的技术。与蓝绿部署类似,它使用两个相同的环境,如下图所示:
与蓝绿部署技术类似,发布过程始于在当前未使用的环境中部署新版本。然而,相似之处到此为止。负载均衡器不是切换到新环境,而是仅将选定的用户组链接到新环境。其余所有用户仍然使用旧版本。这样,一些用户可以测试新版本,如果出现错误,只有一小部分用户受到影响。测试期结束后,所有用户都切换到新版本。
这种方法有一些很大的好处:
-
验收和性能测试:如果在暂存环境中难以进行验收和性能测试,那么可以在生产环境中进行测试,最大程度地减少对一小群用户的影响。
-
简单回滚:如果新更改导致失败,那么通过将所有用户切换回旧版本来进行回滚。
-
A/B 测试:如果我们不确定新版本在 UX 或性能方面是否比旧版本更好,那么可以将其与旧版本进行比较。
金丝雀发布与蓝绿部署具有相同的缺点。额外的挑战是我们同时运行两个生产系统。尽管如此,金丝雀发布是大多数公司用来帮助发布和测试的一种优秀技术。
您可以在优秀的 Martin Fowler 博客martinfowler.com/bliki/CanaryRelease.html中阅读有关金丝雀发布技术的更多信息。
与遗留系统一起工作
到目前为止,我们所描述的一切都顺利适用于全新项目,为这些项目设置持续交付流水线相对简单。
然而,遗留系统要困难得多,因为它们通常依赖于手动测试和手动部署步骤。在本节中,我们将逐步介绍将持续交付应用于遗留系统的推荐方案。
作为第零步,我建议阅读 Michael Feathers 的一本优秀书籍Working Effectively with Legacy Code。他关于如何处理测试、重构和添加新功能的想法清楚地解决了如何为传统系统自动化交付流程的大部分问题。
对于许多开发人员来说,完全重写传统系统可能比重构更具诱惑力。虽然这个想法从开发人员的角度来看很有趣,但通常这是一个导致产品失败的不良商业决策。您可以在 Joel Spolsky 的博客文章Things You Should Never Do中阅读更多关于重写 Netscape 浏览器的历史,网址为www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i。
应用持续交付流程的方式在很大程度上取决于当前项目的自动化、使用的技术、硬件基础设施和当前发布流程。通常,它可以分为三个步骤:
-
自动化构建和部署。
-
自动化测试。
-
重构和引入新功能。
让我们详细看一下。
自动化构建和部署
第一步包括自动化部署过程。好消息是,在我所使用的大多数传统系统中,已经存在一些自动化,例如以 shell 脚本的形式。
无论如何,自动化部署的活动包括以下内容:
-
构建和打包:通常已经存在一些自动化,例如 Makefile、Ant、Maven、任何其他构建工具配置或自定义脚本。
-
数据库迁移:我们需要以增量方式开始管理数据库架构。这需要将当前架构作为初始迁移,并使用 Flyway 或 Liquibase 等工具进行所有进一步的更改,如本章中已经描述的。
-
部署:即使部署过程完全手动,通常也有一个需要转换为自动化脚本的文本/维基页面描述。
-
可重复配置:在传统系统中,配置文件通常是手动更改的。我们需要提取配置并使用配置管理工具,如第六章中描述的 Ansible 配置管理。
在前面的步骤之后,我们可以将所有内容放入部署流水线,并将其用作手动 UAT(用户验收测试)周期后的自动化阶段。
从流程的角度来看,已经值得开始更频繁地发布。例如,如果发布是每年一次,尝试将其改为每季度一次,然后每月一次。对这一因素的推动将最终导致更快的自动化交付采用。
自动化测试
下一步,通常更加困难,是为系统准备自动化测试。这需要与 QA 团队沟通,以了解他们目前如何测试软件,以便我们可以将所有内容移入自动验收测试套件。这个阶段需要两个步骤:
-
验收/理智测试套件:我们需要添加自动化测试,以取代 QA 团队的一些回归活动。根据系统的不同,它们可以作为黑盒 Selenium 测试或 Cucumber 测试提供。
-
(虚拟)测试环境:此时,我们应该已经考虑到我们的测试将在哪些环境中运行。通常,为了节省资源并限制所需机器的数量,最好的解决方案是使用 Vagrant 或 Docker 虚拟化测试环境。
最终目标是拥有一个自动化验收测试套件,它将取代开发周期中的整个 UAT 阶段。然而,我们可以从一个理智的测试开始,它将简要检查系统是否从回归的角度正确。
在添加测试场景时,请记住测试套件应该在合理的时间内执行。对于理智测试,通常不到 10 分钟。
重构和引入新功能
当我们至少拥有了基本的回归测试套件时,我们就可以开始添加新功能并重构旧代码。最好一步一步地进行,因为一次性重构通常会导致混乱,从而导致生产故障(与任何特定更改都没有明显关系)。
这个阶段通常包括以下活动:
-
重构:重构旧代码的最佳地方是新功能预期的地方。以这种方式开始,我们就为新的功能请求做好了准备。
-
重写:如果我们计划重写部分旧代码,我们应该从最难测试的代码开始。这样,我们不断增加项目中的代码覆盖率。
-
引入新功能:在实施新功能时,值得使用功能切换模式。然后,如果发生任何不良情况,我们可以快速关闭新功能。实际上,在重构过程中也应该使用相同的模式。
在这个阶段,值得阅读Martin Fowler的一本优秀书籍,重构:改善现有代码的设计。
在触及旧代码时,最好遵循先添加通过的单元测试的规则,然后再更改代码。通过这种方法,我们可以依赖自动化来检查我们不会意外改变业务逻辑。
理解人的因素
在向遗留系统引入自动交付过程时,您可能会感到人的因素比其他任何地方都更重要。为了自动化构建过程,我们需要与运维团队进行良好的沟通,他们必须愿意分享他们的知识。同样的情况也适用于手动 QA 团队;他们需要参与编写自动化测试,因为只有他们知道如何测试软件。如果您仔细想想,运维和 QA 团队都需要为以后自动化他们的工作做出贡献。在某个时候,他们可能会意识到他们在公司的未来不稳定,并变得不那么乐于助人。许多公司在引入持续交付过程时遇到困难,因为团队不愿意投入足够的精力。
在本节中,我们讨论了如何应对遗留系统以及它们带来的挑战。如果您正在将项目和组织转变为持续交付方法,那么您可能希望查看持续交付成熟度模型,该模型旨在为采用自动交付的过程提供结构。
可以在developer.ibm.com/urbancode/docs/continuous-delivery-maturity-model/找到持续交付成熟度模型的良好描述。
练习
在本章中,我们已经涵盖了持续交付过程的各个方面。由于熟能生巧,我们建议进行以下练习:
- 使用 Flyway 在 MySQL 数据库中创建一个不向后兼容的更改:
-
使用官方的 Docker 镜像
mysql来启动数据库 -
使用正确的数据库地址、用户名和密码配置 Flyway
-
创建一个初始迁移,创建一个包含三列的
users表:id、email和password -
向表中添加示例数据
-
将
password列更改为hashed_password,用于存储散列密码 -
按照本章的描述,将不向后兼容的更改分成三个迁移
-
您可以使用 MD5 或 SHA 进行散列
-
检查结果,确保数据库不以明文存储密码
- 创建一个 Jenkins 共享库,其中包含构建和单元测试 Gradle 项目的步骤:
-
为库创建一个单独的存储库
-
在库中创建两个文件:
gradleBuild.groovy和gradleTest.groovy -
编写适当的
call方法 -
将库添加到 Jenkins
-
在流水线中使用库中的步骤
总结
本章混合了以前未涉及的各种持续交付方面。本章的主要要点如下:
-
数据库是大多数应用程序的重要组成部分,因此应包含在持续交付过程中。
-
数据库模式更改存储在版本控制系统中,并由数据库迁移工具管理。
-
数据库模式更改有两种类型:向后兼容和向后不兼容。虽然第一种类型很简单,但第二种类型需要一些额外的工作(分成多个迁移,分散在时间上)。
-
数据库不应成为整个系统的中心点。首选解决方案是为每个服务提供其自己的数据库。
-
交付过程应始终准备好回滚场景。
-
始终考虑三种发布模式:滚动更新、蓝绿部署和金丝雀发布
-
传统系统可以分步骤转换为持续交付过程,而不是一次性全部转换。
最佳实践
感谢阅读本书。我希望您已经准备好将持续交付方法引入您的 IT 项目中。作为本书的最后一部分,我提出了前 10 个持续交付实践清单。祝您愉快!
实践 1 - 在团队内部拥有流程!
团队内部拥有整个流程,从接收需求到监控生产。正如有人曾经说过:在开发者的机器上运行的程序是赚不到钱的。
这就是为什么有一个小的 DevOps 团队完全拥有产品是很重要的。实际上,这就是 DevOps 的真正含义 - 从开始到结束的开发和运营:
-
拥有持续交付管道的每个阶段:如何构建软件,验收测试中的需求以及如何发布产品。
-
避免拥有管道专家!团队的每个成员都应参与创建管道。
-
找到一种良好的方式在团队成员之间共享当前管道状态(以及生产监控)。最有效的解决方案是团队空间中的大屏幕。
-
如果开发人员,质量保证和 IT 运维工程师是独立的专家,那么确保他们在一个敏捷团队中共同工作。基于专业知识的分开团队会导致对产品不负责任。
-
记住,给团队自主权会带来高度的工作满意度和异常的参与度。这将带来出色的产品!
实践 2-自动化一切!
自动化一切,从业务需求(以验收测试的形式)到部署过程。手动描述,带有指导步骤的 wiki 页面,它们很快就会过时,并导致部落知识,使流程变得缓慢,繁琐和不可靠。这反过来又导致需要发布排练,并使每次部署都变得独特。不要走上这条路!一般来说,如果你做某件事第二次,就自动化它:
-
消除所有手动步骤;它们是错误的根源!整个过程必须是可重复和可靠的。
-
绝对不要直接在生产环境中进行任何更改!使用配置管理工具代替。
-
使用完全相同的机制部署到每个环境。
-
始终包括自动化的冒烟测试,以检查发布是否成功完成。
-
使用数据库模式迁移来自动化数据库更改。
-
使用自动维护脚本进行备份和清理。不要忘记删除未使用的 Docker 镜像!
实践 3-对一切进行版本控制!
对一切进行版本控制:软件源代码,构建脚本,自动化测试,配置管理文件,持续交付管道,监控脚本,二进制文件和文档。简直就是一切。让你的工作基于任务,每个任务都会导致对存储库的提交,无论是与需求收集,架构设计,配置还是软件开发有关。任务从敏捷看板开始,最终进入存储库。这样,你就可以保持一个真实的历史和更改原因的单一真相:
-
严格控制版本控制。一切都意味着一切!
-
将源代码和配置存储在代码仓库中,将二进制文件存储在构件仓库中,将任务存储在敏捷问题跟踪工具中。
-
将持续交付管道作为代码开发。
-
使用数据库迁移并将其存储在仓库中。
-
以可版本控制的 markdown 文件形式存储文档。
实践 4 - 使用业务语言进行验收测试!
使用面向业务的语言进行验收测试,以改善双方的沟通和对需求的共同理解。与产品负责人密切合作,创建埃里克·埃文所说的“普遍语言”,即业务和技术之间的共同方言。误解是大多数项目失败的根本原因:
-
创建一个共同的语言,并在项目内使用它。
-
使用接受测试框架,如 Cucumber 或 FitNesse,帮助业务团队理解并参与其中。
-
在验收测试中表达业务价值,并在开发过程中不要忘记它们。很容易在无关的主题上花费太多时间!
-
改进和维护验收测试,使其始终作为回归测试。
-
确保每个人都知道,通过验收测试套件意味着业务方同意发布软件。
实践 5 - 准备好回滚!
准备好回滚;迟早你会需要这样做。记住,你不需要更多的质量保证人员,你需要更快的回滚。如果在生产环境出现问题,你想做的第一件事就是确保安全,并回到上一个可用版本:
-
制定回滚策略以及系统宕机时的处理流程
-
将不兼容的数据库更改拆分为兼容的更改
-
始终使用相同的交付流程进行回滚和标准发布
-
考虑引入蓝绿部署或金丝雀发布
-
不要害怕错误,如果你能快速反应,用户不会离开你!
实践 6 - 不要低估人的影响力
不要低估人的影响力。他们通常比工具更重要。如果 IT 运维团队不帮助你,你就无法自动化交付。毕竟,他们对当前流程有了解。同样适用于质量保证人员、业务人员和所有相关人员。让他们变得重要并参与其中:
-
让质量保证人员和 IT 运维成为 DevOps 团队的一部分。你需要他们的知识和技能!
-
为当前正在进行手动操作的成员提供培训,以便他们可以转向自动化。
-
更青睐非正式沟通和扁平化的组织结构,而不是等级制度和命令。没有善意,你什么都做不了!
实践 7 - 构建可追溯性!
为交付过程和工作系统构建可追溯性。没有比没有日志消息的失败更糟糕的了。监控请求数量、延迟、生产服务器的负载、持续交付管道的状态,以及您能想到的任何能帮助您分析当前软件的东西。要主动!在某个时候,您将需要检查统计数据和日志:
-
记录管道活动!在失败的情况下,用信息丰富的消息通知团队。
-
实施对运行系统的适当记录和监控。
-
使用 Kibana、Grafana 或 Logmatic.io 等专业工具进行系统监控。
-
将生产监控集成到您的开发生态系统中。考虑在团队共享空间中放置大屏幕,显示当前生产统计数据。
实践 8 - 经常集成!
经常集成,实际上,一直都在!正如有人所说:“持续性比你想象的更频繁”。没有什么比解决合并冲突更令人沮丧的了。持续集成更多关乎团队实践而非工具。每天至少将代码集成到一个代码库中几次。忘掉持续存在的特性分支和大量的本地更改。基于主干的开发和功能切换才是胜利之道!
-
使用基于主干的开发和功能切换,而不是特性分支。
-
如果您需要分支或本地更改,请确保您至少每天与团队集成一次。
-
始终保持主干的健康;确保在合并到基线之前运行测试。
-
在每次提交到存储库后运行管道,以获得快速反馈循环。
实践 9 - 仅构建一次二进制文件!
仅构建一次二进制文件,并在每个环境中运行相同的二进制文件。无论它们是 Docker 镜像还是 JAR 包的形式,仅构建一次可以消除各种环境引入的差异风险。这也可以节省时间和资源:
-
仅构建一次,并在各个环境之间传递相同的二进制文件。
-
使用工件存储库存储和版本化二进制文件。绝对不要使用源代码存储库来做这个目的。
-
外部化配置并使用配置管理工具在环境之间引入差异。
实践 10-经常发布!
经常发布,最好是在每次提交到存储库后。俗话说,“如果痛苦,就更频繁地做。”每天发布使过程变得可预测和平静。远离陷入罕见的发布习惯。那只会变得更糟,最终你将以每年一次的频率发布,需要三个月的准备期!
-
重新定义你的完成标准为完成意味着发布。承担整个过程的责任!
-
使用功能切换来隐藏(对用户)仍在进行中的功能。
-
使用金丝雀发布和快速回滚来减少生产中的错误风险。
-
采用零停机部署策略以实现频繁发布。