Minha Jornada migrando um projeto iOS para o Bazel
Em grandes empresas de tecnologia, um problema recorrente são os tempos de build altos, que trazem consigo toda uma cascata de problemas como: diminuição de produtividade dos engenheiros ao construir e/ou testar softwares localmente, grandes filas no CI, ocasionando morosidade de feedback dos testes e integração de pull requests, entre outros.
Algumas empresas investem em super máquinas para os engenheiros e CI a fim de tentar solucionar, mas esse tipo de saída costuma ser a mais cara e não é escalável, pois dependendo da velocidade de crescimento do software, essa solução pode durar apenas alguns meses.
No contexto de iOS, temos disponíveis algumas soluções bem mais baratas e escaláveis (mas as vezes também não tão rápidas para implementar). Pensando nisso, resolvi detalhar neste artigo a minha jornada ao utilizar a ferramenta Bazel para solucionar esse problema em uma big tech.
Por que Bazel e não outro build system?
Bazel já possui uma comunidade iOS bem consolidada e bastante colaborativa, e para o projeto a qual estava avaliando, o bazel se encaixava perfeitamente por ter a estrutura ideal (monorepo + modularização por frameworks).
Outros build system que poderiam ser utilizados seriam o Buck (inclusive já existem empresas brasileiras que obtiveram sucesso com esse build system), porém, existe um movimento em big techs, que visa migrar do Buck para o Bazel por "n" fatores, o que levantou uma bandeira em relação a adoção dele e como seria a sustentabilidade do mesmo.
Além disso, uma parte da implementação do Bazel já havia sido iniciada dentro do projeto, mas ficou parada por alguns meses e sem manutenção. Logo, não estava funcionando.
Desafio de manutenabilidade
"Automatize o máximo que der primeiro e depois foque na evolução". Foi com essa premissa que se iniciei a migração. Pensando nisso, mostro alguns exemplos de automação que usei, para que a própria ferramenta se sustentasse com a evolução do projeto:
Criação de novos módulos (frameworks): A cada novo framework, era necessário criar um arquivo BUILD, para possibilitar a construção pelo Bazel. Para isso, foi adotada a abordagem de copiar um arquivo de template para o novo módulo, alterando apenas os placeholders do arquivo BUILD copiado.
Exemplo template:
load("@build_bazel_rules_ios//rules:framework.bzl", "apple_framework")
load("@build_bazel_rules_ios//rules:test.bzl", "ios_unit_test")
apple_framework(
name = "<framework_name>",
srcs = glob([
"<framework_name>/**/*.swift"
]),
deps = []
)
ios_unit_test(
name = "<framework_name>Tests",
deps = [
":<framework_name>"
],
srcs = glob([
"<framework_name>Tests/**/*.swift"
]),
size = "small"
)
Exemplo de script:
cp Templates/BUILD.bazel Modules/NewModule/
find . ! -name ".bazel" -type f -exec sh -c "sed -i '' 's/<framework_name>/NewModule/g' '{}'" \;
Resolução de dependências: A cada import de um framework, é necessário adicioná-lo como dependência no arquivo BUILD do framework ou application dependente, forçando o engenheiro a visitar esses arquivos para adicionar manualmente.
A solução desenvolvida foi criar um script de resolução automática de dependências, baseado no algoritmo de busca em profundidade e, após coletar as dependências de cada framework, reescreve a sessão de dependências de um arquivo BUILD. Isso é útil tanto para a inclusão de uma nova dependência com base nos imports nos arquivos swift, quanto para a exclusão, quando não existe mais imports para tal framework.
Erros de compilação
Um dos grandes problemas que encontrei ao realizar a migração, foi compilar targets do tipo application e que possuíam código fonte misto (Objective-c + Swift). Já era um problema esperado devido a relatos de outras empresas que tiveram complicações de interoperabilidade usando o Bazel. Para minha sorte, o rules_ios já resolvia boa parte dos problemas com interoperabilidade, mas alguns casos específicos persistiram, como o exemplo abaixo:
bazel-out/ios-applebin/bin/app/Workout-Swift.h:217:9 fatal error: '/var/
folders/2sdfs/Workout-Bridging-Header-1.pch' file not found
#import "/var/folders/2sdfs/Workout-Bridging-Header-1.pch"
1 error generated
Error in child process '/usr/bin/xcrun'. 1
Eu não fazia ideia de como resolver e o por que isso acontecia. Depois de pesquisar e fazer POCs separadas, consegui reproduzir o mesmo problema, que acontecia nos seguintes tipos de implementação de código:
- Extension de uma class objective-c escrita em Swift, e essa extension tinha a notação
@objc
para torna-las visíveis em códigos escritos em objective-c por meio do arquivo NomeDoApp-Swift.h alto gerado durante compilação. - Class escritas em Swift que herdam de uma class objective-c.
Nessas duas ocorrências, o Xcode conseguia se virar bem. Mas o Bazel ainda não conseguia resolver. Então eu tinha que resolver por ele e foi isso que decidi fazer, já que ao fazer o levantamento de esforço não seria tão complexo.
Implementação do Bazel cache
Após fazer o Bazel funcionar corretamente, era hora de usá-lo como build system oficial no CI, a fim de testar as Pull requests. Após alguns dias, a métrica de tempo build&tests P50 foi de 52 minutos (rodando com Xcode) para 42 minutos (rodando com Bazel).
No entanto, ainda não utilizávamos uma das features principais do Bazel, o seu mecanismo de cache. Para isso, foi necessário subir um serviço do Bazel-remote para gerenciar os artefatos de cache gerados em builds no CI e poder reaproveitar em builds subsequentes.
Feitos os ajustes, a métrica P50, antes observada, passou a ser de 28 minutos. Existia a possibilidade de reduzir mais ainda trabalhando em ajustes finos na infra em que o serviço de cache estava inserido, mas algumas limitações de infraestrutra precisariam ser trabalhadas antes para suportar a grande transferência de dados. Então, busquei outros pontos de melhoria que pareciam ser mais palpáveis e resolveriam de uma forma mais barata, como testar somente o necessário, que detalho a seguir.
Testar somente o necessário
A forma como o cache do Bazel é gerado auxilia no implementar de algumas soluções performáticas para minimizar o tempo de build&test sem afetar a qualidade dos feedbacks. Uma dessas soluções é o bazel-diff, que busca a diferença entre commits e forma uma lista contendo somente targets que foram afetados com as alterações em código fonte no intervalo analisado.
Porém, da forma como o CI estava implementado no referido projeto, essa ferramenta não funcionaria corretamente. Para isso, criei uma versão do bazel-diff de uma forma mais simplificada, construída da seguinte forma:
- Utilização do github CLI para identificar quais módulos (frameworks) estavam sendo modificados, algo como:
pr_diff=$(gh pr view ${PULL_REQUEST_NUMBER} --json files --jq '.files[].path')
- Além de testar os módulos que foram modificados, usar bazel query com a combinação de rdeps para identificar no grafo de dependências quais são os módulos que foram afetados de forma implícita, para garantimos a qualidade do feedback do CI.
bazel query 'kind("ios_unit_test", rdeps(//app/..., //Modules/ModifiedModule))'
- Por fim, passar o resultado dessa query para o comando bazel test.
bazel test //Module1:Module1Tests //Module2:Module2Tests //Module3:Module3Tests
Após inclusão desse script, observamos uma redução de transferência de dados (artefatos de cache) entre o bazel-client e bazel-remote. A vantagem dessa solução é que deixou a execução semelhante ao comportamento de algoritmos logaritmos, pois, mesmo aumentando a base de código, o tempo de execução se mantem estável e tem curva de crescimento baixíssimo. Abaixo segue os ganhos alcançados:
RIP: SDKs legados
Projetos de larga escala e com certa tempo de existência, possuem algumas tecnologias ou implementações defasadas. Isso dificulta a implementação de ferramentas recentes, e por isso, irei explanar como SDKs legados podem ser uma pedra no sapato para o Bazel.
Algumas bibliotecas externas pré-compiladas que são inseridas em projetos iOS, ainda não possuem suporte para Simuladores arm64. Nesses tipos de projetos, não é possível usar o binário do Bazel arm64 para compilar. Na verdade até tem como…
Buscando soluções para utilizar o Bazel arm64, passei a usar o arm-to-sim, que tinha o objetivo de, ao se deparar com bibliotecas que não tinham suporte sim-arm64, pegava o slice arm64 disponibilizado para iPhoneOS na biblioteca em questão e manipulava para que fosse vista pelo compilador como um binário arm64 para Simuladores. Deixarei ao final referências para o artigos que detalham como esse processo acontece.
Essa solução era muito boa para cerca de 95% de bibliotecas sem suporte que existia no projeto. Os 5% restantes não poderiam se beneficiar com ela. Então foi montado um plano para que os times responsáveis por tais bibliotecas pudessem atualizar as mesmas.
Após atualizar todas as bibliotecas para conter suporte para simuladores arm64 e não utilizar nenhum tipo de “hack”, realizei a mudança do executável para o Bazel arm64. Com essa mudança tivemos ganhos incríveis. Até o momento da escrita desse artigo, a métrica de tempo de build P50, se concentra em 11min.
Alguns aprendizados extraídos
Nem tudo com o Bazel são maravilhas. A comunidade Bazel iOS enfrenta alguns erros que acontecem em comum, como timeout de simuladores ao executar testes, travamentos de GPU em MacOS virtualizados, e empresas que costumam desenvolver suas próprias soluções para contornar esses problemas e se mantendo internamente, sem divulgar para a comunidade.
Além disso, ao tomar decisão de utilizar um outro build system que não seja o recomendado pela Apple (xcodebuild), traz boa parte da manutenabilidade e poder de customizações para as nossas mãos, não somente em relação ao tempo de build, mas também para redução de tamanho do app ou coisas afins. Por um lado isso é bom, mas por outro lado isso pode gerar dores de cabeça, sendo necessário existirem pessoas dedicadas para esses tipos de demanda.
Outro ponto a se levar em consideração é que existem poucos profissionais que se interessam por essa área de manipulação de build system e afins. Com isso, há um ponto negativo dentro de empresas onde esse conhecimento se concentra em poucos indivíduos e a saída dos mesmos da empresa causam um alerta vermelho. Então, se vai implementar recomendo fomentar o conhecimento sobre a ferramenta para vários engenheiros da equipe.
Bazel, Buck e outros podem não ser a solução para o seu produto. Talvez você pode estar resolvendo um problema que nem exista, ou até mesmo desperdiçando tempo com a implementação de ferramentas complexas, quando existem alternativas bem mais simples e rápidas e que já resolveriam o seu problema. Então é interessante realizar um estudo com empresas e desenvolvedores que já usam essas ferramentas, coletar os prós e contras, e realizar experimentos antes de bater o martelo.