移动开发团队中 CI 的演变

如今,大多数软件产品都是团队开发的。 成功的团队发展的条件可以用简单的图表的形式表示。

移动开发团队中 CI 的演变

编写代码后,您需要确保:

  1. Работает。
  2. 它不会破坏任何东西,包括您同事编写的代码。

如果这两个条件都满足,那么您就走上了成功之路。 为了轻松检查这些条件并且不偏离盈利路径,我们提出了持续集成。

CI 是一种工作流程,您可以在其中尽可能频繁地将代码集成到整个产品代码中。 您不仅要集成,还要不断检查一切是否正常。 由于您需要经常进行大量检查,因此值得考虑自动化。 您可以手动检查所有内容,但您不应该这样做,原因如下。

  • 亲爱的人们。 任何程序员一小时的工作都比任何服务器一小时的工作更昂贵。
  • 人们会犯错误。 因此,当测试在错误的分支上运行或为测试人员编译了错误的提交时,可能会出现这种情况。
  • 人们很懒。 有时,当我完成一项任务时,我会想到:“有什么需要检查的? 我写了两行 - 一切正常! 我想你们有些人有时也会有这样的想法。 但你应该经常检查。

Nikolai Nesterov 表示,Avito 移动开发团队如何实施和开发持续集成,他们如何从每天 0 次构建到 450 次构建,以及构建机器每天组装 200 个小时。涅斯特罗夫)是 CI/CD Android 应用程序所有演进变化的参与者。

该故事基于 Android 命令示例,但大多数方法也适用于 iOS。


曾几何时,有一个人在 Avito Android 团队工作。 根据定义,他不需要持续集成的任何东西:没有人可以集成。

但应用程序不断增长,越来越多的新任务出现,团队也相应壮大。 在某些时候,是时候更正式地建立代码集成流程了。 决定使用 Git 流程。

移动开发团队中 CI 的演变

Git 流程的概念众所周知:一个项目有一个公共的开发分支,对于每一个新功能,开发人员切出一个单独的分支,提交到它,推送,当他们想要将代码合并到开发分支时,打开一个拉取请求。 为了分享知识和讨论方法,我们引入了代码审查,即同事必须检查并确认彼此的代码。

检查

用眼睛看到代码很酷,但还不够。 因此,正在引入自动检查。

  • 首先,我们检查 方舟组装.
  • 很多 联合测试.
  • 我们考虑代码覆盖率,因为我们正在运行测试。

要了解如何运行这些检查,让我们看看 Avito 中的开发过程。

它可以这样示意性地表示:

  • 开发人员在他的笔记本电脑上编写代码。 您可以在此处运行集成检查 - 使用提交挂钩,或者只是在后台运行检查。
  • 开发人员推送代码后,他会打开拉取请求。 为了使其代码包含在开发分支中,需要经过代码审查并收集所需数量的确认。 您可以在此处启用检查和构建:在所有构建成功之前,无法合并拉取请求。
  • 合并拉取请求并将代码包含到开发中后,您可以选择一个方便的时间:例如,在晚上,当所有服务器都空闲时,并运行任意数量的检查。

没有人喜欢在笔记本电脑上运行扫描。 当开发人员完成一个功能时,他希望快速推送它并打开拉取请求。 如果此时启动一些长时间的检查,这不仅不太令人愉快,而且还会减慢开发速度:当笔记本电脑正在检查某些东西时,就无法正常工作。

我们真的很喜欢在晚上运行检查,因为有很多时间和服务器,你可以四处闲逛。 但不幸的是,当功能代码进入开发阶段时,开发人员修复 CI 发现的错误的动力就少了很多。 当我查看早间报告中发现的所有错误时,我定期发现自己会想,有一天我会修复它们,因为现在 Jira 中有一个很酷的新任务,我只想开始做。

如果检查阻止拉取请求,那么就有足够的动力,因为在构建变绿之前,代码不会进入开发,这意味着任务将无法完成。

因此,我们选择了以下策略:我们在晚上运行尽可能多的检查,并启动其中最关键的检查,最重要的是,根据拉取请求启动最快的检查。 但我们并没有就此止步——同时,我们优化了检查速度,以便将它们从夜间模式转移到拉取请求检查。

当时,我们所有的构建都很快完成了,因此我们只是将 ARK 构建、Junit 测试和代码覆盖率计算作为拉取请求的拦截器。 我们打开它,思考它,然后放弃了代码覆盖率,因为我们认为我们不需要它。

我们花了两天的时间才完成了基本的CI(以下时间估计是近似的,需要规模化)。

之后,我们开始进一步思考——我们检查是否正确? 我们是否正确地基于拉取请求运行构建?

我们在打开拉取请求的分支的最后一次提交上开始构建。 但此提交的测试只能表明开发人员编写的代码有效。 但他们并不能证明他没有破坏任何东西。 事实上,当一个功能合并到develop分支后,你需要检查它的状态。

移动开发团队中 CI 的演变

为此,我们编写了一个简单的 bash 脚本 预合并.sh:

#!/usr/bin/env bash

set -e

git fetch origin develop

git merge origin/develop

在这里,所有来自开发的最新更改都被简单地拉出并合并到当前分支中。 我们添加了 premerge.sh 脚本作为所有构建的第一步,并开始检查我们想要的内容,即 积分.

花了三天时间定位问题,找到解决方案,写出这个脚本。

应用程序不断开发,越来越多的任务出现,团队不断壮大,而 premerge.sh 有时开始让我们失望。 开发中有冲突的更改破坏了构建。

这是如何发生的一个例子:

移动开发团队中 CI 的演变

两个开发人员同时开始开发功能 A 和 B。功能 A 的开发人员发现项目中有一个未使用的功能 answer() 然后,像一个优秀的童子军一样,将其删除。 同时,功能 B 的开发人员在他的分支中添加了对此函数的新调用。

开发人员完成工作并同时打开拉取请求。 构建已启动,premerge.sh 检查有关最新开发状态的两个拉取请求 - 所有检查都是绿色的。 之后,功能 A 的拉取请求被合并,功能 B 的拉取请求被合并......繁荣! 开发中断是因为开发代码包含对不存在函数的调用。

移动开发团队中 CI 的演变

不发展的时候就发展了 局部灾害。 整个团队无法收集任何东西并提交进行测试。

碰巧我最常从事基础设施任务:分析、网络、数据库。 也就是说,是我编写了其他开发人员使用的那些函数和类。 正因为如此,我发现自己经常遇到类似的情况。 我什至把这张照片挂了一段时间。

移动开发团队中 CI 的演变

由于这不适合我们,我们开始探索如何防止这种情况发生。

如何不破坏开发

第一个选项: 更新开发时重建所有拉取请求。 在我们的示例中,如果功能 A 的拉取请求是第一个包含在开发中的,则功能 B 的拉取请求将被重建,相应地,检查将由于编译错误而失败。

要了解这需要多长时间,请考虑一个具有两个 PR 的示例。 我们打开两个 PR:两个构建,两次运行检查。 第一个PR合并到develop后,需要重建第二个PR。 总共,两个 PR 需要运行三轮检查:2 + 1 = 3。

原则上是没问题的。 但是我们看了一下统计,我们团队中典型的情况是10个开放的PR,那么检查的数量就是级数的总和:10 + 9 + ... + 1 = 55。即接受10个PR,你需要重建 55 次。 这是在理想的情况下,当所有检查第一次通过时,当没有人在处理这十几个请求时打开额外的拉取请求。

想象一下自己是一名开发人员,需要第一个单击“合并”按钮,因为如果邻居这样做,那么您将不得不等到所有构建再次完成...不,那行不通,这会严重减慢开发速度。

第二种可能的方式: 代码审查后收集拉取请求。 也就是说,您打开一个拉取请求,从同事那里收集所需数量的批准,更正所需的内容,然后启动构建。 如果成功,拉取请求将合并到开发中。 在这种情况下,没有额外的重启,但反馈速度大大减慢。 作为一名开发人员,当我打开拉取请求时,我立即想看看它是否会起作用。 例如,如果测试失败,您需要快速修复它。 在延迟构建的情况下,反馈会减慢,因此整个开发也会减慢。 这也不适合我们。

结果,只剩下第三种选择—— 自行车。 我们所有的代码、所有源都存储在 Bitbucket 服务器上的存储库中。 因此,我们必须为 Bitbucket 开发一个插件。

移动开发团队中 CI 的演变

该插件覆盖拉取请求合并机制。 开始是标准的:PR 打开,所有程序集启动,代码审查完成。 但是,在代码审查完成并且开发人员决定单击“合并”后,插件会检查运行检查的开发状态。 如果在构建后更新了开发,则插件将不允许此类拉取请求合并到主分支中。 它只会重新启动相对较新的开发的构建。

移动开发团队中 CI 的演变

在我们的具有冲突更改的示例中,此类构建将由于编译错误而失败。 因此,功能 B 的开发人员必须更正代码,重新启动检查,然后插件将自动应用拉取请求。

在实现此插件之前,我们平均每个拉取请求运行 2,7 次审核。 该插件发布了 3,6 版。 这适合我们。

值得注意的是,这个插件有一个缺点:它只会重新启动一次构建。 也就是说,仍然存在一个小窗口,通过这个窗口,冲突的变更可以进入开发阶段。 但这种可能性很低,我们在启动次数和失败可能性之间进行了权衡。 两年之内只发射了一次,所以应该没有白费。

我们花了两周时间编写了 Bitbucket 插件的第一个版本。

新支票

与此同时,我们的团队也在不断壮大。 添加了新的检查。

我们想:如果可以避免错误,为什么还要犯错误呢? 这就是为什么他们实施 静态代码分析。 我们从 lint 开始,它包含在 Android SDK 中。 但当时他根本不知道如何使用 Kotlin 代码,而我们已经有 75% 的应用程序是用 Kotlin 编写的。 因此,内置的被添加到 lint 中 Android Studio 检查。

为了做到这一点,我们必须做很多变态:使用 Android Studio,将其打包在 Docker 中,并使用虚拟监视器在 CI 上运行,以便它认为它是在真正的笔记本电脑上运行。 但它奏效了。

也是在这段时间,我们开始写很多东西 仪器测试 并实施了 截图测试。 这是为单独的小视图生成参考屏幕截图时,测试包括从视图中获取屏幕截图并将其与标准直接逐像素进行比较。 如果有差异,则意味着布局有问题或者样式有问题。

但仪器测试和屏幕截图测试需要在设备上运行:在模拟器上或在真实设备上。 考虑到测试很多并且运行频繁,所以需要整个农场。 建立自己的农场太费力了,所以我们找到了一个现成的选择 - Firebase Test Lab。

火力基地测试实验室

之所以选择它,是因为 Firebase 是 Google 产品,这意味着它应该可靠且不会消亡。 价格合理:真实设备运行每小时 5 美元,模拟器运行每小时 1 美元。

将 Firebase 测试实验室实施到我们的 CI 中大约花了三周时间。

但团队不断成长,不幸的是,Firebase 开始让我们失望。 那时,他没有任何 SLA。 有时,Firebase 会让我们等到所需数量的设备可以自由进行测试,而不是像我们想要的那样立即开始执行它们。 排队等候的时间长达半个小时,这是一个非常漫长的时间。 每个 PR 都要进行仪器测试,延迟确实减慢了开发速度,然后每月的账单会带来一笔总金额。 总的来说,由于团队已经足够壮大,因此决定放弃 Firebase 并在内部工作。

Docker + Python + bash

我们使用 Docker,将模拟器塞进其中,用 Python 编写了一个简单的程序,该程序会在适当的时候在所需版本中启动所需数量的模拟器,并在必要时停止它们。 当然,还有几个 bash 脚本 - 没有它们我们会怎样?

创建我们自己的测试环境花了五周时间。

因此,对于每个拉取请求,都有一个广泛的合并阻止检查列表:

  • 方舟组装;
  • 联合测试;
  • 皮棉;
  • Android Studio 检查;
  • 仪器仪表测试;
  • 截图测试。

这避免了许多可能的故障。 从技术上讲,一切正常,但开发人员抱怨等待结果的时间太长。

多长时间才算太长呢? 我们将Bitbucket和TeamCity的数据上传到分析系统中,发现: 平均等待时间 45 分钟。 也就是说,开发人员在打开拉取请求时,平均等待构建结果 45 分钟。 在我看来,这已经很多了,你不能那样工作。

当然,我们决定加快所有构建速度。

让我们加快速度

看到构建经常排在队列中,我们做的第一件事是 购买了更多硬件 ——粗放式开发是最简单的。 构建停止排队,但等待时间仅略有减少,因为某些检查本身需要很长时间。

删除耗时过长的检查

我们的持续集成可以捕获这些类型的错误和问题。

  • 不会。 当由于冲突的更改而无法构建某些内容时,CI 可以捕获编译错误。 正如我已经说过的,那么没有人可以组装任何东西,开发就会停止,每个人都会感到紧张。
  • 行为错误。 例如,当应用程序已构建时,但按下按钮时崩溃,或者根本没有按下按钮。 这很糟糕,因为这样的错误可能会影响到用户。
  • 布局错误。 例如,单击一个按钮,但向左移动了 10 个像素。
  • 技术债务增加.

看完这个列表后,我们意识到只有前两点是关键的。 我们想首先抓住这样的问题。 布局中的错误是在设计审核阶段发现的,然后可以轻松纠正。 处理技术债务需要单独的流程和规划,因此我们决定不对拉取请求进行测试。

根据这种分类,我们调整了整个检查清单。 划掉 Lint 并推迟了一夜的启动:只是为了就该项目存在多少问题生成一份报告。 我们同意单独处理技术债务,并且 Android Studio 检查被完全放弃。 Android Studio 在 Docker 中运行检查听起来很有趣,但在支持方面造成了很多麻烦。 Android Studio 版本的任何更新都意味着要与难以理解的错误作斗争。 支持截图测试也很困难,因为库不是很稳定,存在误报。 屏幕截图测试已从检查列表中删除.

结果,我们留下了:

  • 方舟组装;
  • 联合测试;
  • 仪器测试。

Gradle 远程缓存

没有严格的检查,一切都变得更好。 但完美无极限!

我们的应用程序已经被分成大约 150 个 gradle 模块。 Gradle 远程缓存通常在这种情况下效果很好,因此我们决定尝试一下。

Gradle 远程缓存是一项可以缓存各个模块中各个任务的构建工件的服务。 Gradle 并没有实际编译代码,而是使用 HTTP 敲击远程缓存并询问是否有人已经执行了此任务。 如果是,它只是下载结果。

运行 Gradle 远程缓存很容易,因为 Gradle 提供了 Docker 镜像。 我们在三个小时内成功完成了这件事。

您所要做的就是启动 Docker 并在项目中写入一行。 不过虽然可以很快启动,但要让一切顺利进行还需要相当长的时间。

下面是缓存未命中图。

移动开发团队中 CI 的演变

一开始,缓存未命中的百分比约为 65。三周后,我们设法将该值提高到 20%。 事实证明,Android 应用程序收集的任务具有奇怪的传递依赖关系,因此 Gradle 错过了缓存。

通过连接缓存,我们大大加快了构建速度。 但除了组装之外,还有仪器测试,而且需要很长的时间。 也许并非每个拉取请求都需要运行所有测试。 为了找到答案,我们使用影响分析。

影响分析

在拉取请求中,我们收集 git diff 并找到修改后的 Gradle 模块。

移动开发团队中 CI 的演变

仅运行检查更改的模块以及依赖于它们的所有模块的仪器测试是有意义的。 对相邻模块运行测试是没有意义的:那里的代码没有改变,也不会破坏任何东西。

仪器测试没那么简单,因为它们必须位于顶层应用程序模块中。 我们使用启发式和字节码分析来了解每个测试属于哪个模块。

升级仪器测试的操作,使其仅测试涉及的模块,花费了大约八周的时间。

加快检查速度的措施已取得成功。 我们从 45 分钟增加到大约 15 分钟。等待一刻钟的构建已经很正常了。

但现在开发者开始抱怨他们不明白正在启动哪些构建、在哪里查看日志、为什么构建是红色的、哪个测试失败等等。

移动开发团队中 CI 的演变

反馈问题会减慢开发速度,因此我们尝试提供有关每个 PR 和构建的尽可能清晰详细的信息。 我们首先在 Bitbucket 中对 PR 进行评论,指出哪个构建失败了以及原因,并在 Slack 中编写了有针对性的消息。 最后,我们为该页面创建了一个 PR 仪表板,其中包含当前正在运行的所有构建及其状态的列表:已排队、正在运行、崩溃或已完成。 您可以单击构建并获取其日志。

移动开发团队中 CI 的演变

我们花了六周的时间来提供详细的反馈。

计划

让我们继续看看最近的历史。 解决了反馈问题后,我们达到了一个新的水平 - 我们决定建立自己的模拟器农场。 当测试和模拟器很多时,它们很难管理。 结果,我们所有的模拟器都迁移到了具有灵活资源管理的 k8s 集群。

此外,还有其他计划。

  • 返回皮棉 (和其他静态分析)。 我们已经在朝这个方向努力。
  • 在 PR 拦截器上运行所有内容 端到端测试 在所有 SDK 版本上。

那么,我们追溯了Avito中持续集成的发展历史。 现在我想从经验丰富的角度给一些建议。

Советы

如果我只能给出一条建议,那就是:

请小心 shell 脚本!

Bash是一个非常灵活和强大的工具,编写脚本非常方便和快捷。 但你可能会因此陷入陷阱,不幸的是,我们也陷入了其中。

这一切都始于在我们的构建机器上运行的简单脚本:

#!/usr/bin/env bash
./gradlew assembleDebug

但是,如您所知,随着时间的推移,一切都会发展并变得更加复杂 - 让我们从另一个脚本运行一个脚本,让我们在其中传递一些参数 - 最后我们必须编写一个函数来确定我们现在处于哪个级别的 bash 嵌套插入必要的引号,让一切开始。

移动开发团队中 CI 的演变

开发这样的脚本的人力成本可想而知。 我建议你不要陷入这个陷阱。

什么可以替换?

  • 任何脚本语言。 写给 Python 或 Kotlin 脚本 更方便,因为它是编程,而不是脚本。
  • 或者在表单中描述所有的构建逻辑 自定义 gradle 任务 为您的项目。

我们决定选择第二个选项,现在我们正在系统地删除所有 bash 脚本并编写大量自定义 gradle 任务。

技巧#2:将基础设施存储在代码中。

当持续集成设置不是存储在 Jenkins 或 TeamCity 等的 UI 界面中,而是以文本文件的形式直接存储在项目存储库中时,会很方便。 这提供了版本控制能力。 在另一个分支上回滚或构建代码并不困难。

脚本可以存储在项目中。 环境怎么办?

提示#3:Docker 可以帮助改善环境。

它肯定会对 Android 开发者有所帮助;不幸的是,iOS 还没有。

这是一个包含 jdk 和 android-sdk 的简单 docker 文件的示例:

FROM openjdk:8

ENV SDK_URL="https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip" 
    ANDROID_HOME="/usr/local/android-sdk" 
    ANDROID_VERSION=26 
    ANDROID_BUILD_TOOLS_VERSION=26.0.2

# Download Android SDK
RUN mkdir "$ANDROID_HOME" .android 
    && cd "$ANDROID_HOME" 
    && curl -o sdk.zip $SDK_URL 
    && unzip sdk.zip 
    && rm sdk.zip 
    && yes | $ANDROID_HOME/tools/bin/sdkmanager --licenses

# Install Android Build Tool and Libraries
RUN $ANDROID_HOME/tools/bin/sdkmanager --update
RUN $ANDROID_HOME/tools/bin/sdkmanager "build-tools;${ANDROID_BUILD_TOOLS_VERSION}" 
    "platforms;android-${ANDROID_VERSION}" 
    "platform-tools"

RUN mkdir /application
WORKDIR /application

编写完这个 Docker 文件(我告诉你一个秘密,你不必编写它,只需从 GitHub 上拉取现成的文件即可)并组装镜像,你将获得一个可以在其上构建应用程序的虚拟机并运行 Junit 测试。

这样做有意义的两个主要原因是可扩展性和可重复性。 使用 docker,您可以快速创建十几个构建代理,它们将具有与前一个完全相同的环境。 这使得 CI 工程师的生活变得更加轻松。 将 android-sdk 推送到 docker 中非常容易,但是使用模拟器就有点困难了:你必须更加努力(或者再次从 GitHub 下载完成的版本)。

提示四:不要忘记,检查不是为了检查而检查,而是为了人。

最重要的是,快速且清晰的反馈对于开发人员来说非常重要:什么出了问题,什么测试失败了,在哪里可以看到构建日志。

提示#5:开发持续集成时要务实。

清楚地了解您想要防止哪些类型的错误,您愿意花费多少资源、时间和计算机时间。 例如,花费太长时间的检查可以推迟过夜。 而那些发现不太重要错误的错误应该被完全放弃。

提示#6:使用现成的工具。

现在有很多公司提供云CI。

移动开发团队中 CI 的演变

对于小型团队来说,这是一个很好的解决方案。 您不需要支持任何东西,只需支付一点钱,构建您的应用程序,甚至运行仪器测试。

提示#7:在大型团队中,内部解决方案更有利可图。

但随着团队的成长,内部解决方案迟早会变得更有利可图。 这些决定有一个问题。 经济学有一个收益递减规律:任何项目,后续的每一次改进都会变得越来越困难,需要越来越多的投入。

经济学描述了我们的整个生活,包括持续集成。 我为持续集成的每个发展阶段制定了劳动力成本表。

移动开发团队中 CI 的演变

显然,任何改进都变得越来越困难。 看看这张图,你就可以明白,持续集成是需要随着团队规模的增长而发展的。 对于一个两人团队来说,花费 50 天开发一个内部模拟器农场是一个平庸的想法。 但同时,对于一个大团队来说,根本不进行持续集成也是一个坏主意,因为集成问题、修复沟通等都可能是一个坏主意。 这将需要更多的时间。

我们最初的想法是,需要自动化,因为人力成本昂贵,他们会犯错误并且很懒。 但人们也会实现自动化。 因此,所有相同的问题都适用于自动化。

  • 自动化成本高昂。 记住劳动时间表。
  • 当谈到自动化时,人们会犯错误。
  • 有时人们很懒于自动化,因为一切都是这样的。 为什么要改进其他东西,为什么要进行持续集成?

但我有统计数据:20% 的程序集中发现了错误。 这并不是因为我们的开发人员写的代码很糟糕。 这是因为开发人员有信心,如果他们犯了一些错误,它不会最终出现在开发中,而是会被自动检查发现。 因此,开发人员可以花更多的时间编写代码和有趣的事情,而不是在本地运行和测试某些内容。

实践持续集成。 但要适度。

顺便说一下,尼古拉·涅斯特罗夫不仅亲自做了精彩的报告,而且还是程序委员会的成员 应用大会 并帮助其他人为您准备有意义的演讲。 下一次会议议程的完整性和实用性可以通过以下主题来评估 日程。 如需了解详情,请于 22 月 23 日至 XNUMX 日访问 Infospace。

来源: habr.com

添加评论