原文首發(fā)與『程序員精進(jìn)』博客,原文鏈接:混沌工程實(shí)踐 - Spring Boot 微服務(wù)應(yīng)用使用 Chaos Monkey

我們中有多少人在生產(chǎn)環(huán)境下遇到了系統(tǒng)崩潰或是故障?當(dāng)然答案是所有人,暫時(shí)未遇到的日后也會遇到。如果我們不能避免故障,看起來可行的方案就是在肯定會故障的狀態(tài)下維護(hù)我們的系統(tǒng)。Netflix 基于這個(gè)概念發(fā)明了用于測試他們 IT 基礎(chǔ)設(shè)施韌性(可恢復(fù)能力)的工具——Chaos Monkey。
今天我們將在 Spring Boot 應(yīng)用中使用 Codecentric Chaos Monkey 庫,并且在一個(gè)由多個(gè)微服務(wù)構(gòu)成的示例項(xiàng)目中實(shí)現(xiàn)混沌工程。Chaos Monkey 庫目前與 Spring Boot 2.0 搭配的最新 release 版本是 1.0.1. 但在本次示例項(xiàng)目中將使用 2.0.0-SNAPSHOT 版本,因?yàn)樾碌倪@個(gè)版本有更多的有趣功能。
1. 應(yīng)用中啟用 Chaos Monkey
在 Spring Boot 應(yīng)用中開啟 Chaos Monkey 支持僅需要兩步,首先在項(xiàng)目依賴中添加在 chaos-monkey-spring-boot。
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>chaos-monkey-spring-boot</artifactId>
<version>2.0.0-SNAPSHOT</version>
</dependency>
再然后,我們在應(yīng)用啟動時(shí)激活 chaos-monkey 的 profile。
$ java -jar target/order-service-1.0-SNAPSHOT.jar --spring.profiles.active=chaos-monkey
2. 示例項(xiàng)目架構(gòu)
示例項(xiàng)目由三個(gè)微服務(wù)組成,每個(gè)微服務(wù)啟動兩個(gè)實(shí)例,另外再加一個(gè)服務(wù)發(fā)現(xiàn)服務(wù)。微服務(wù)在服務(wù)發(fā)現(xiàn)服務(wù)注冊自己,然后彼此間通過 HTTP API 通信。每個(gè)運(yùn)行中的微服務(wù)實(shí)例都引入了 Chaos Monkey 庫,服務(wù)發(fā)現(xiàn)服務(wù)不引入。下面架構(gòu)圖顯示了這個(gè)示例項(xiàng)目的組成。

本示例項(xiàng)目的代碼已經(jīng)放在了 GitHub 上,代碼倉庫名為 sample-spring-chaosmonkey ( https://github.com/piomin/sample-spring-chaosmonkey.git )。在克隆 git 代碼倉庫到本地工作副本后,使用 mvn clean install 命令進(jìn)行示例項(xiàng)目的編譯打包。先運(yùn)行服務(wù)發(fā)現(xiàn)服務(wù),然后給每個(gè)微服務(wù)啟動兩個(gè)實(shí)例,當(dāng)然運(yùn)行時(shí)需要通過 -Dserver.port 參數(shù)來指定服務(wù)端口,以下是運(yùn)行服務(wù)的參考命令:
$ java -jar target/discovery-service-1.0-SNAPSHOT.jar
$ java -jar target/order-service-1.0-SNAPSHOT.jar --spring.profiles.active=chaos-monkey
$ java -jar -Dserver.port=9091 target/order-service-1.0-SNAPSHOT.jar --spring.profiles.active=chaos-monkey
$ java -jar target/product-service-1.0-SNAPSHOT.jar --spring.profiles.active=chaos-monkey
$ java -jar -Dserver.port=9092 target/product-service-1.0-SNAPSHOT.jar --spring.profiles.active=chaos-monkey
$ java -jar target/customer-service-1.0-SNAPSHOT.jar --spring.profiles.active=chaos-monkey
$ java -jar -Dserver.port=9093 target/customer-service-1.0-SNAPSHOT.jar --spring.profiles.active=chaos-monkey
3. Chaos Monkey 庫配置
在 chaos-monkey-spring-boot 庫的 2.0.0-SNAPSHOT 版本中,Chaos Monkey 在引入后是默認(rèn)開啟的,可以通過設(shè)置 chaos.monkey.enabled 屬性來開啟或關(guān)閉。默認(rèn)的襲擊方式是延遲,延遲襲擊方式是在每個(gè)請求處理時(shí)添加隨機(jī)的時(shí)延,其中隨機(jī)時(shí)延取值于 chaos.monkey.assaults.latencyRangeStart 和 chaos.monkey.assaults.latencyRangeEnd 兩個(gè)屬性之間的區(qū)間。襲擊的請求數(shù)量由屬性 chaos.monkey.assaults.level 來設(shè)置,這個(gè)屬性取值范圍為 1-10,數(shù)值為 1 時(shí)意味著每個(gè)請求,數(shù)值為 10 時(shí)意味著每第 10 個(gè)請求。另外我們可以開啟另外兩種襲擊方式:異常和 appKiller。在示例項(xiàng)目微服務(wù)中我們關(guān)于 Chaos Monkey 的配置如下:
chaos:
monkey:
assaults:
level: 8
latencyRangeStart: 1000
latencyRangeEnd: 10000
exceptionsActive: true
killApplicationActive: true
watcher:
repository: true
restController: true
在每個(gè)微服務(wù)的 application.yml 文件中設(shè)置如上內(nèi)容,理論上,我們會開啟 Chaos Monkey 三種襲擊方式。但實(shí)際上,如果我們開啟了延時(shí)和異常的襲擊方式,appKiller 襲擊方式就不會發(fā)生;并且,當(dāng)我們同時(shí)開啟了延時(shí)和異常的襲擊方式,每個(gè)請求都會被攻擊,將與我們設(shè)置的 chaos.monkey.assaults.level 屬性無關(guān)。另外需要注意的是,我們將 restController watcher 開啟了,默認(rèn)情況下是關(guān)閉的。
4. 啟用 Spring Boot Actuator 訪問端口
在 Codecentric Chaos Monkey 庫的 2.0 版本中有個(gè)新的特性:Spring Boot Actuator 訪問端口,通過 management.endpoint.chaosmonkey.enabled 屬性來設(shè)置是否開啟,在應(yīng)用啟動后,就可以通過 HTTP 訪問端口來訪問了。
management:
endpoint:
chaosmonkey:
enabled: true
endpoints:
web:
exposure:
include: health,info,chaosmonkey
示例項(xiàng)目 chaos-monkey-spring-boot 提供了幾個(gè)訪問端口進(jìn)行配置查看和修改,通過 GET 請求訪問 /chaosmonkey 可以獲取 Chaos Monkey 庫的所有配置,另外可以通過 POST 請求訪問 /chaosmonkey/disable 來關(guān)閉 Chaos Monkey。完整的訪問端口列表詳見:https://codecentric.github.io/chaos-monkey-spring-boot/2.0.0-SNAPSHOT/#endpoints 。
5. 運(yùn)行項(xiàng)目
示例項(xiàng)目中所有的微服務(wù)都將數(shù)據(jù)存儲到了 MySQL,這里我們將使用 Docker 鏡像來跑 MySQL 數(shù)據(jù)庫,運(yùn)行命令如下:
$ docker run -d --name mysql -e MYSQL_DATABASE=chaos -e MYSQL_USER=chaos -e MYSQL_PASSWORD=chaos123 -e MYSQL_ROOT_PASSWORD=123456 -p 33306:3306 mysql
在示例項(xiàng)目中的應(yīng)用都運(yùn)行后(每個(gè)微服務(wù)運(yùn)行兩個(gè)實(shí)例),我們的運(yùn)行環(huán)境架構(gòu)應(yīng)該跟下圖一致:

當(dāng)應(yīng)用啟動時(shí)我們可以在日志中看到 Chaos Monkey 的啟動信息,大致如下:

可以通過 actuator 的 HTTP 訪問端口來查看每個(gè)運(yùn)行中實(shí)例的 Chaos Monkey 配置。

6. 測試示例項(xiàng)目
我們這里使用性能測試庫 Gatling 來進(jìn)行測試,將創(chuàng)建 20 個(gè)并發(fā)線程,將通過 API 網(wǎng)關(guān)服務(wù)來調(diào)用 order-service,每個(gè)線程調(diào)用 500 次。
class ApiGatlingSimulationTest extends Simulation {
val scn = scenario("AddAndFindOrders").repeat(500, "n") {
exec(
http("AddOrder-API")
.post("http://localhost:8090/order-service/orders")
.header("Content-Type", "application/json")
.body(StringBody("""{"productId":""" + Random.nextInt(20) + ""","customerId":""" + Random.nextInt(20) + ""","productsCount":1,"price":1000,"status":"NEW"}"""))
.check(status.is(200), jsonPath("$.id").saveAs("orderId"))
).pause(Duration.apply(5, TimeUnit.MILLISECONDS))
.
exec(
http("GetOrder-API")
.get("http://localhost:8090/order-service/orders/${orderId}")
.check(status.is(200))
)
}
setUp(scn.inject(atOnceUsers(20))).maxDuration(FiniteDuration.apply(10, "minutes"))
}
測試的 POST 請求訪問端口是 OrderController 的 add(...) 方法,這個(gè)方法將通過 OpenFeign 客戶端來調(diào)用 customer-service 和 product-service。如果顧客用足夠的資金并且相應(yīng)商品有庫存的情況下,就接受訂單通過 PUT 方法對顧客和商品服務(wù)進(jìn)行修改。下面是相應(yīng)的方法實(shí)現(xiàn):
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
OrderRepository repository;
@Autowired
CustomerClient customerClient;
@Autowired
ProductClient productClient;
@PostMapping
public Order add(@RequestBody Order order) {
Product product = productClient.findById(order.getProductId());
Customer customer = customerClient.findById(order.getCustomerId());
int totalPrice = order.getProductsCount() * product.getPrice();
if (customer != null && customer.getAvailableFunds() >= totalPrice && product.getCount() >= order.getProductsCount()) {
order.setPrice(totalPrice);
order.setStatus(OrderStatus.ACCEPTED);
product.setCount(product.getCount() - order.getProductsCount());
productClient.update(product);
customer.setAvailableFunds(customer.getAvailableFunds() - totalPrice);
customerClient.update(customer);
} else {
order.setStatus(OrderStatus.REJECTED);
}
return repository.save(order);
}
@GetMapping("/{id}")
public Order findById(@PathVariable("id") Integer id) {
Optional order = repository.findById(id);
if (order.isPresent()) {
Order o = order.get();
Product product = productClient.findById(o.getProductId());
o.setProductName(product.getName());
Customer customer = customerClient.findById(o.getCustomerId());
o.setCustomerName(customer.getName());
return o;
} else {
return null;
}
}
// ...
}
如前面第三步所示,我們將 Chaos Monkey 庫設(shè)置了隨機(jī)延時(shí)為 1000 到 10000 毫秒之間,因此我們要設(shè)置下 Feign 和 Ribbon 客戶端的默認(rèn)超時(shí),這里我們設(shè)定讀超時(shí)為 5000 毫秒,這樣有些請求將會引起客戶端超時(shí)。
feign:
client:
config:
default:
connectTimeout: 5000
readTimeout: 5000
hystrix:
enabled: false
下面是訪問 API 網(wǎng)關(guān)的 Ribbon 客戶端超時(shí)設(shè)置,同時(shí)我們需要更改下 Hystrix 設(shè)置,將 zuul 的斷路器給關(guān)閉。
ribbon:
ConnectTimeout: 5000
ReadTimeout: 5000
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 15000
fallback:
enabled: false
circuitBreaker:
enabled: false
接下來我們將運(yùn)行 Gatling 性能測試,到 performance-test 目錄中運(yùn)行 gradle loadTest 命令,測試結(jié)果跟 Chaos Monkey 襲擊設(shè)置以及 Ribbon、Feign 客戶端的超時(shí)設(shè)置都相關(guān),下圖是我們運(yùn)行后的結(jié)果:

下面是 Gatling 繪制平均響應(yīng)時(shí)間的圖表,需要注意的是示例項(xiàng)目 order-service 的 add 方法將調(diào)用 product-service 和 customer-service。

下面是 Gatling 繪制請求結(jié)果的圖表,這里顯示了成功和失敗響應(yīng)的情況。Gatling 生成 HTML 報(bào)告在目錄 performance-test/build/gatling-results 。

譯注:混沌工程擴(kuò)展閱讀
想了解更多關(guān)于混沌工程相關(guān)內(nèi)容,以下資料會有幫助: