本篇文章記錄的是 java/kotlin + spring boot 的服務(wù)端項目,在持續(xù)集成(CI)流水線(pipeline)上執(zhí)行 gradle build 的優(yōu)化過程。
1. 時間超長的 gradle build
由于執(zhí)行流水線的機器 (ci agent) 是幾個團隊共用的,而各服務(wù)用到的技術(shù)棧不盡相同,甚至同為 java 項目,java 版本也不同。為了避免在每一個 ci agent 上重復(fù)安裝不同版本的 java,又想保證執(zhí)行測試時所用的 java 版本與最終部署時所用的 java 運行時版本一致,我們使用 docker 容器作為打包運行時,進行 gradle build,于是使用最簡單直白的指令如下:
docker run -v $(pwd):/app -w /app eclipse-temurin:11-jre ./gradlew clean build
如果你真的用這個指令跑每一次 ci,你就會發(fā)現(xiàn)它慢得令人發(fā)指,因為近乎一個空的 spring-boot repo,跑完 build 這一步驟都要花費5分鐘。給上述 gradle 指令加上調(diào)試參數(shù) -i 后,我們在日志中不難發(fā)現(xiàn),大量的時間花費在了下載依賴上。而且由于每次都在容器中跑 gradle build,跑完以后的依賴并不能被下一次 build 重用。
2. 依賴下載緩存
2.1 嘗試一:利用 docker build cache
為了達到重用的目的,我們第一步想到了 docker build cache。簡而言之,就是在構(gòu)建 docker image 的時候是逐層構(gòu)建,如果前面幾層的文件和指令都相同,那么 docker 并不會做重復(fù)工作,而是會使用緩存。官網(wǎng)還舉了一個 node 環(huán)境的例子,為我們提供了思路。
優(yōu)化前:
FROM node
WORKDIR /app
COPY . . # Copy over all files in the current directory
RUN npm install # Install dependencies
RUN npm build # Run build
優(yōu)化后
FROM node
WORKDIR /app
COPY package.json yarn.lock . # Copy package management files
RUN npm install # Install dependencies
COPY . . # Copy over project files
RUN npm build # Run build
例中優(yōu)化前的版本,由于易變更的業(yè)務(wù)代碼過早引入,導(dǎo)致每次 npm install 都需要重新拉取依賴,非常的耗時。而優(yōu)化后的 Dockerfile 通過單獨拷貝兩個不易變更的 package.json 和 yarn.lock 兩個文件,而后先行安裝依賴的方式,讓依賴不變的情況下,前四層能夠利用 docker build 的緩存,避免重復(fù)拉取依賴。然后才將易變更的業(yè)務(wù)代碼拷貝,進行 build,這樣一來,如果 ci agent 之前執(zhí)行過當(dāng)前 repo 的流水線,那么有很大概率能節(jié)省掉拉取依賴的時間。
道理懂了,我們?nèi)绶ㄅ谥?,?gòu)建出 dockerfile 如下:
FROM eclipse-temurin:11-jre as builder
WORKDIR /app/
COPY build.gradle.kts settings.gradle.kts gradle.properties gradlew ./
COPY gradle ./gradle
RUN ./gradlew clean build
COPY . .
RUN ./gradlew clean build
FROM eclipse-temurin:11-jre as app
WORKDIR /app/
COPY --from=builder /app/build/libs/*.jar .
COPY ./entrypoint.sh .
ENTRYPOINT ["./entrypoint.sh"]
CMD [""]
道理也很簡單,先把 gradle 自身的文件,以及其定義依賴的文件拷貝,然后先行安裝依賴,讓這些層最大限度地使用緩存,然后再引入業(yè)務(wù)代碼,再進行 build,可以為我們節(jié)省很多時間。同時結(jié)合 .dockerignore 文件將與 build 不相干的文件全部排除,甚至可以在業(yè)務(wù)代碼不變的情況下,連測試和打包都利用緩存,使 ci 更加迅速。然后利用分段構(gòu)建,只取 jar 包和 base image,減小 image 體積,從而縮短部署時拉鏡像的時長,進一步縮短 ci 耗時。
經(jīng)此優(yōu)化后,普通的業(yè)務(wù)代碼變更,在有緩存的 agent 上,ci 只需要1分50秒,而且從日志可以看出,在業(yè)務(wù)代碼之前的部分都使用了 cache。
[14:45:03] $ docker build .
[14:45:03] Sending build context to Docker daemon 403.5kB
[14:45:03] Step 1/13 : FROM eclipse-temurin:11-jre as builder
[14:45:03] ---> 6c1d7cfd8f3c
[14:45:03] Step 2/13 : WORKDIR /app/
[14:45:03] ---> Using cache
[14:45:03] ---> 34bd2aa46830
[14:45:03] Step 3/13 : COPY build.gradle.kts settings.gradle.kts gradle.properties gradlew ./
[14:45:03] ---> Using cache
[14:45:03] ---> 4e2748bad63f
[14:45:03] Step 4/13 : COPY gradle ./gradle
[14:45:03] ---> Using cache
[14:45:03] ---> 831fd4b1b6ff
[14:45:03] Step 5/13 : RUN ./gradlew clean build
[14:45:03] ---> Using cache
[14:45:03] ---> 9b08d16abb7e
[14:45:03] Step 6/13 : COPY . .
[14:45:03] ---> 40b37551d8a7
[14:45:03] Step 7/13 : RUN ./gradlew -w clean build
[14:45:03] ---> Running in e24187b324eb
當(dāng)然,這樣的收益,是有條件的,它要求 ci agent 近期執(zhí)行過當(dāng)前 repo 的流水線。如果這是一個不經(jīng)常開發(fā)的 repo,可能幾乎享受不到收益,因為 agent 可能會定期清理或更換。沒有緩存的收益,這樣的流水線執(zhí)行仍然需要5分鐘。
2.2 轉(zhuǎn)機:跨 agent 共享 cache
此時我們能想到的是,如果 build cache 能夠被 pipeline 隨身攜帶就好了,任何一個 agent 都有相關(guān)的 build cache,將大幅提升效率。幸運的是,export docker build cache 提供了這樣的可能。
在 cache 導(dǎo)出以后,再通過各 pipeline 的互傳 artifact 機制,或者利用可以上傳和下載文件的插件,進行跨 agent 的傳輸即可實現(xiàn)。至于互傳 cache 的指令或插件,它的責(zé)任很簡單,就是在每個 pipeline step 執(zhí)行前,將之前上傳的 cache 文件夾下載到本地,并在 step 執(zhí)行以后,將 cache 繼續(xù)上傳即可。上傳和下載 cache 的目的地可能都是在內(nèi)網(wǎng),或在同一網(wǎng)絡(luò)環(huán)境內(nèi),如果速度明顯超過 gradle build 從 maven repository 下載依賴的速度,那么這種方法著實可行。需要注意的是,在 repo 較多的團隊中,這些依賴可能要根據(jù)種類或 repo 進行區(qū)分,如當(dāng)前 repo 有其專用的 cache,否則下載不相關(guān)的依賴也會消耗不必要的時間。
但是我們并沒有按照這一方法繼續(xù)定制化,因為我們得知,其他團隊已經(jīng)可以通過攜帶和掛載 gradle cache 的方式加速 ci,且大團隊內(nèi)部已經(jīng)實現(xiàn)了互傳 cache 的相關(guān)插件,我們只需要引用該插件即可。
2.3 嘗試二:攜帶和掛載 gradle dependency cache
對呀,既然都想到在各 agent 之間傳遞 cache 了,為什么不直接傳遞 gradle dependency cache 呢?相比之下,docker build cache 會在上述幾個依賴定義文件內(nèi)容發(fā)生改變時失效,如 build.gradle,gradle.properties 等,添加一個依賴會導(dǎo)致整層緩存失效,所有依賴需要重新下載,甚至格式的改變也會如此。而利用 gradle dependency cache 的方式則更有優(yōu)勢,因為 gradle 會對其下載的依賴進行判斷,來決定是重用還是需要下載新的依賴,讓緩存發(fā)揮最大作用,從而減少不必要的時間消耗。
現(xiàn)在 ci agent 有了 gradle cache,只需要在 docker gradle build 時將 gradle cache 掛載到容器中即可。
docker run -v $(pwd):/app -v "$HOME/.gradle:/root/.gradle" -w /app eclipse-temurin:11-jre ./gradlew clean build
使用這種辦法,我們 gradle build 的執(zhí)行時間縮短到了1分7秒,加上平均15秒的上傳和下載 cache 的時間,總體略快于上述利用 docker build cahce 的方法,但是這種方法可以使每次 ci 都充分利用緩存的便利,效率更高。在調(diào)整了 build.gradle 文件的格式以后,ci 執(zhí)行時間幾乎沒有改變。甚至在我們添加了一個依賴以后,因為只需要下載新加的依賴,時間也僅僅是1分36秒,加上加上平均15秒的上傳和下載 cache 的時間,也遠(yuǎn)遠(yuǎn)好于需要下載所有依賴的5分鐘。
3. 精簡 gradle tasks
關(guān)于縮短依賴?yán)〉臅r間,我們已經(jīng)滿意了??墒菫槭裁催€要1分鐘多呢?明明只有3個測試類,10個測試用例呀!以后代碼多了會不會成倍增長?為了進一步縮短 build 的時間,我們開始了對 gradle tasks 的分析。gradle 提供了profile report 的功能,可以詳細(xì)展示每一步工作的耗時情況。通過在本地執(zhí)行 ./gradlew clean build --profile,我們得到了下列結(jié)果(本小節(jié)都在本地對比):
| Task | Duration | Result |
|---|---|---|
| : | 26.870s | (total) |
| :test | 16.078s | |
| :compileKotlin | 2.087s | |
| :distZip | 1.875s | |
| :compileTestKotlin | 1.735s | |
| :bootDistZip | 1.518s | |
| :bootStartScripts | 1.037s | |
| :jacocoTestReport | 0.852s | |
| :bootJar | 0.693s | |
| :distTar | 0.254s | |
| :bootDistTar | 0.249s | |
| :bootJarMainClassName | 0.118s | |
| :clean | 0.084s | |
| :lintKotlinMain | 0.080s | |
| :jacocoTestCoverageVerification | 0.078s | |
| :startScripts | 0.062s | |
| :lintKotlinTest | 0.055s | |
| :processResources | 0.006s | |
| :inspectClassesForKotlinIC | 0.004s | |
| :processTestResources | 0.003s | |
| :compileJava | 0.001s | NO-SOURCE |
| :compileTestJava | 0.001s | NO-SOURCE |
| :assemble | 0s | Did No Work |
| :build | 0s | Did No Work |
| :check | 0s | Did No Work |
| :classes | 0s | Did No Work |
| :jar | 0s | SKIPPED |
| :lintKotlin | 0s | Did No Work |
| :testClasses | 0s | Did No Work |
其中很明顯有一些我們并不需要的,比如 distTar, bootDistTar, distZip, bootDistZip 等。通過仔細(xì)地對比和理解每一個 task 所做的工作,我們最終留下了 lintKotlin, test, jacocoTestCoverageVerification, jacocoTestReport, bootJar,通過在 build.gradle 中定義 task 之間的依賴,我們只需要執(zhí)行 ./gradlew clean test bootJar 就可以達到我們的期望了。同時精簡之后本地的 profile report 如下,直接減少了不必要的 8 秒。PS:減少生成不必要的測試報告也能提供一點優(yōu)化。
| Task | Duration | Result |
|---|---|---|
| : | 18.644s | (total) |
| :test | 13.418s | |
| :compileKotlin | 1.866s | |
| :compileTestKotlin | 1.494s | |
| :jacocoTestReport | 0.752s | |
| :bootJar | 0.690s | |
| :bootJarMainClassName | 0.141s | |
| :clean | 0.096s | |
| :jacocoTestCoverageVerification | 0.071s | |
| :lintKotlinTest | 0.054s | |
| :lintKotlinMain | 0.052s | |
| :processResources | 0.005s | |
| :processTestResources | 0.004s | |
| :compileTestJava | 0.001s | NO-SOURCE |
| :classes | 0s | Did No Work |
| :compileJava | 0s | NO-SOURCE |
| :lintKotlin | 0s | Did No Work |
| :testClasses | 0s | Did No Work |
4. 提升測試速度
現(xiàn)在就剩下測試的執(zhí)行了。真正的單元測試執(zhí)行得很快,反觀需要啟動 spring 的集成測試,花在啟動 spring boot 上的時間占了很大一部分。除了保證測試策略的正確性和按照測試金字塔安排測試數(shù)量以外,在相同測試數(shù)量的情況下盡量減少 spring boot 在整個測試中的啟動時間,能大大優(yōu)化測試的執(zhí)行效率。
你還在喜于使用了 @WebMvcTest、@DataJpaTest 而減少了 spring context 的啟動加載類的數(shù)量,從而減少了整體測試時間?你可能用錯了哦。如果你了解過 spring boot test context caching,你會發(fā)現(xiàn) spring test 是足夠聰明的,如果 ApplicationContext 沒有發(fā)生改變,在執(zhí)行下一個需要啟動 spring 的測試類時,spring 會重用原來的上下文。
那么怎樣能讓我們盡可能地受益于 context caching?其實就是要盡可能地避免讓測試覺得 ApplicationContext 變過,如避免 @MockBean, @SpyBean, @DirtiesContext, @TestPropertySource, @DynamicPropertySource 等等。
再次審查我們的測試,其中有 @SpringBootTest, @WebMvcTest, @DataJpaTest,執(zhí)行一下,發(fā)現(xiàn) spring 啟動了 3 次,比較浪費時間。本著盡可能共用 ApplicationContext 的原則,把他們都換成 @SpringBootTest (change 1),畢竟另外兩個就算包含的 bean 再少,也不如直接不重新啟動 spring 來的實在。此外我們還發(fā)現(xiàn)測試中有 @MockBean 存在,優(yōu)化的方式是將他們以 bean 的形式放到測試的 @Configuration 中 (change 2),供所有測試使用,來保證上下文的一致性。以下為示例代碼:
優(yōu)化前:
@WebMvcTest
class ATest(@Autowired val mvc: MockMvc) {
@MockBean
lateinit var bService: BService
@Test
fun `blahblah`() {
...
}
}
優(yōu)化后
@SpringBootTest // change 1
@AutoConfigureMockMvc // change 1
class ATest(
@Autowired val mvc: MockMvc,
@Autowired val bService: BService // change 2
) {
@Test
fun `blahblah`() {
...
}
}
@Configuration // change 2
class TestConfiguration {
@Bean
fun bService(): BService {
return Mockito.mock(BService::class.java)
}
}
改好以后,通過觀察測試日志,發(fā)現(xiàn)在執(zhí)行所有10個測試的過程中, spring 只啟動了一次,達到了目的。再在本地執(zhí)行 profile report,發(fā)現(xiàn) test 的時間又減少了3秒,變成了10秒。
現(xiàn)在,我們把優(yōu)化好的 pipeline 在 ci agent 上執(zhí)行一下,gradle build 的執(zhí)行時間來到了 58 秒。加上 cache 操作時間15秒,一共1分13秒。
5. 總結(jié)
通過以下方式,我們將 ci 上的 gradle build 時間從 5 分鐘縮短到了 1 分鐘:
- 通過掛載 gradle cache,避免不必要的依賴下載時間
- 通過跨 agent 傳輸文件夾,實現(xiàn) gradle cache 在不同 agent 之間的傳遞
- 通過去除不需要的 gradle task,減少 gradle build 的耗時
- 通過盡可能地重用 spring test 中的 ApplicationContext,減少 spring boot 在集成測試中的啟動次數(shù)。
gradle 還提供了其他關(guān)于性能的 tips,感興趣的可以進一步閱讀 https://docs.gradle.org/current/userguide/performance.html