gradle build 從 5 分鐘到 1 分鐘

本篇文章記錄的是 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 分鐘:

  1. 通過掛載 gradle cache,避免不必要的依賴下載時間
  2. 通過跨 agent 傳輸文件夾,實現(xiàn) gradle cache 在不同 agent 之間的傳遞
  3. 通過去除不需要的 gradle task,減少 gradle build 的耗時
  4. 通過盡可能地重用 spring test 中的 ApplicationContext,減少 spring boot 在集成測試中的啟動次數(shù)。

gradle 還提供了其他關(guān)于性能的 tips,感興趣的可以進一步閱讀 https://docs.gradle.org/current/userguide/performance.html

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容