Acelerando desenvolvimento para Kubernetes com Tilt

É fácil testar localmente uma aplicação destinada a rodar num cluster Kubernetes?

Na maioria dos casos a resposta é não, e o motivo é simples: o Kubernetes é uma plataforma especialmente adequada para a arquitetura de microserviços, e microserviços geralmente vivem num ecossistema complexo, em meio a outros serviços, load balancers, ingress, mecanismos de auto-scaling e muito mais.

Testar um serviço ou alguns poucos serviços localmente pode ser feito facilmente com o auxílio do Docker Compose, mas a verdade é que este “ambiente local” não é nada parecido com a realidade de um cluster Kubernetes.

Se os nossos manifestos Kubernetes tiverem erros, é muito provável que só descubramos esse erro ao tentar fazer deploy de uma versão num cluster de testes. E o mesmo vale para templates escritos com as ferramentas Helm ou Skaffold.

Mas e se testar apps para Kubernetes localmente fosse fácil? E se fosse uma experiência agradável e super produtiva?

Essa é a proposta do Tilt.

Tilt

O Tilt é uma ferramenta de produtividade para desenvolvimento de apps para Kubernetes. E a proposta é bastante interessante: reinventar a experiência de desenvolver com Kubernetes, tomando vantagem das suas potencialidades de maneiras inovadoras.

Assim como o Garden, o Tilt permite a criação de workflows de desenvolvimento em ambientes Kubernetes individuais para cada desenvolvedor. Mas diferente do Garden, que tem o foco em clusters remoto, o time por trás do Tilt dá ênfase facilitar a configuração e uso de um cluster local para desenvolvimento.

Tiltfile

Os workflows do Tilt são definidos em arquivos chamados Tiltfile, e escritos em Starlark, um subset do Python originalmente criado para o sistema de build Bazel, e frequentemente utilizado como linguagem de configuração.

O time do Tilt desenvolveu uma DSL (linguagem de domínio específico) desenhada para facilitar a criação desses workflows.

A seguir vamos explorar rapidamente um exemplo da própria documentação do Tilt: uma simples aplicação WebAPI implementada em C#, que pode ser encontrada aqui.

Árvore de arquivos de uma solução .NET, com um arquivo hello-tilt.sln na raiz, e um Tiltfile

Árvore de arquivos do exemplo

Para que o Tilt seja capaz de rodar esta simples aplicação no nosso cluster, primeiro precisamos informar alguns detalhes importantes, como pode ser visto no Tiltfile abaixo:

docker_build('hello-tilt', './hello-tilt')
k8s_yaml('kubernetes.yaml')
k8s_resource('hello-tilt', port_forwards='8080:80')

Primeiro, precisamos declarar o contexto de build do Docker. Como podemos ver na árvore de arquivos mais acima, o Dockerfile fica na pasta hello-tilt, então esse é o contexto que declaramos com o comando docker_build.

Em seguida, com o comando k8s_yaml podemos declarar o manifesto Kubernetes que o Tilt deve aplicar no cluster. Como podemos ver abaixo, o conteúdo do arquivo kubernetes.yaml declara um Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-tilt
spec:
  selector:
    matchLabels:
      app: hello-tilt
  replicas: 1
  template:
    metadata:
      labels:
        app: hello-tilt
    spec:
      containers:
      - name: hello-tilt
        image: hello-tilt
        ports:
        - containerPort: 80

Por fim, usando o comando k8s_resource, dizemos para o Tilt que, dos recursos criados dentro do cluster, estamos interessados no recurso chamado hello-tilt.

Certo, mas o que o Tilt vai fazer com todas essas informações?

Vamos rodar o comando tilt up e descobrir.

A UI mágica do Tilt

Assim que rodamos o comando, nos deparamos com este menu:

Menu inicial do Tilt, com as opções para abrir a UI no browser, no console, ou fazer stream dos logs

Pressionando a tecla espaço, o Tilt abre uma UI bem bacana:

Tela inicial do Tilt, mostrando que os recursos Tiltfile e hello-tilt foram processados com sucesso

Clicando sobre um recurso, podemos ver mais detalhes sobre ele:

Detalhes do recurso hello-tilt, incluindo URL e nome do pod

E os logs de tudo que está rodando lá dentro, é fácil de ver? Sim. O Tilt não apenas exibe os logs dos contêineres rodando no cluster, como também formata de forma fácil de entender cada etapa de build da imagem docker, colore os logs por nível de severidade e indenta tudo de forma fácil de ler.

Logs do container

E é aqui que as coisas começam a ficar interessantes. Cada recurso declarado nos nossos tiltfiles podem ser disparados manualmente através da interface:

Botão para disparar manualmente um recurso declarado no Tiltfile

Podemos verificar que tudo está rodando corretamente no Kubernetes usando o comando kubectl get all:

Listagem dos pods, deployments e replica set do hello-tilt

E acessando http://localhost:8080 no navegador, podemos verificar que o serviço está rodando corretamente:

Página escrito Hello Cats!

Na próxima seção, vamos explorar formas de montar workflows avançados. Vamos rodar scripts localmente, e até implementar um live update de backend C#!

Live update de backend C#? (ou Go, Java, C++, etc)

Live update do backend para linguagens compiladas costuma ser algo bastante difícil de concretizar, e por isso não faz parte do dia-a-dia da maioria dos desenvolvedores.

Mas o Tilt oferece recursos para mudarmos essa realidade, e tudo começa com o conceito de recursos locais.

Assim como declaramos para o Tilt os recursos que nos interessam no cluster Kubernetes através do comando k8s_resource, também podemos declarar recursos locais. Para visualizar isso, vou utilizar um exemplo mais avançado da documentação do Tilt.

Árvore de arquivos do projeto, semelhante ao anterior, mas com a adição de um script Python

Árvore de arquivos do exemplo avançado

Recursos locais

Recursos do exemplo avançado, listando Tiltfile, hello-tilt, deploy e build

Árvore de arquivos do exemplo avançado

Como podemos ver na imagem acima, este exemplo avançado inclui dois novos recursos, listados como “Local Script”.

Esses recursos permitem a execução de scripts locais, tanto através da interface no navegador, quando como parte de uma árvore de build.

Esses recursos são declarados com o comando local_resource, como no exemplo abaixo:

local_resource(
    'deploy',
    'python ./record-start-time.py',
    deps=['./record-start-time.py'],
)

No exemplo acima, declaramos um recurso chamado “deploy”, que executa um script python presente na raiz do projeto. O parâmetro deps indica que o Tilt deve observar mudanças no arquivo record-start-time.py, e caso ele mude, disparar automaticamente o script.

O parâmetro deps é opcional, e se omitido, o recurso ainda poderá ser executado manualmente ou como parte da árvore de build. Só não vai executar automaticamente.

Se observarmos o Tiltfile deste exemplo avançado, nos deparamos com alguns conceitos novos:

# -*- mode: Python -*-

# For more on Extensions, see: https://docs.tilt.dev/extensions.html
load('ext://restart_process', 'docker_build_with_restart')

local_resource(
    'deploy',
    'python ./record-start-time.py',
    deps=['./record-start-time.py'],
)

local_resource(
    'build',
    'dotnet publish -c Release -o out',
    deps=['hello-tilt'],
    ignore=['hello-tilt/obj'],
    resource_deps=['deploy'],
)

docker_build_with_restart(
    'hello-tilt',
    'out',
    entrypoint=['dotnet', 'hello-tilt.dll'],
    dockerfile='Dockerfile',
    live_update=[
        sync('out', '/app/out'),
    ],
)

k8s_yaml('kubernetes.yaml')
k8s_resource('hello-tilt', port_forwards='8080:80', resource_deps=['build'])

O segundo local_resource, o “build”, publica a aplicação web numa pasta “out” rodando o comando dotnet publish:

local_resource(
    'build',
    'dotnet publish -c Release -o out',
    deps=['hello-tilt'],
    ignore=['hello-tilt/obj'],
    resource_deps=['deploy'],
)

Podemos ver através do parâmetro deps que ele vai executar automaticamente sempre que algo mudar dentro da pasta “hello-tilt”. Ou seja, sempre que o desenvolvedor mudar algo na aplicação, o recurso “build” será executado automaticamente.

O Tilt também é instruído a ignorar artefatos binários gerados na pasta “hello-tilt/obj”, a fim de evitar atualizações desnecessárias.

E um parâmetro muito importante aqui é o resource_deps. Ele indica que o recurso “build” depende do recurso “deploy”, o que implica uma cadeia de execução: sempre que o recurso “deploy” for executado, o recurso “build” deve ser executado na sequência.

Essas cadeias de execução estão presentes em todas as ferramentas de build, como MSBuild, Maven, Make, Fake e Bazel.

Ao inspecionar o Tiltfile, podemos ver que há duas declarações de dependência de recurso:

  • “build” depende de “deploy”
  • “hello-tilt” depende de “build”

Esta configuração implica a seguinte cadeia de execução:

Roda primeiro deploy, depois build, depois hello-tilt

E como podemos ver na imagem abaixo, ao clicar em deploy, toda a cadeia é executada, como esperado:

hello-tilt executando pendente de executar quando build terminar

Atualizando os pods rapidamente com live updates

O último elemento que faltou analisar é o comando docker_build_with_restart:

docker_build_with_restart(
    'hello-tilt',
    'out', # contexto de sincronização
    entrypoint=['dotnet', 'hello-tilt.dll'],
    dockerfile='Dockerfile',
    live_update=[
        sync('out', '/app/out'),
    ],
)

Como pode ser visto no começo do script, este comando é importado de uma extensão do Tilt:

load('ext://restart_process', 'docker_build_with_restart')

O que ele permite fazer, é atualizar o container Docker mais rapidamente. E para isso, o Tilt dispõe de um mecanismo bastante interessante: cada pod criado através do Tilt recebe um sidecar chamado Synclet.

Este Synclet pode atualizar o conteúdo de contêineres existentes, sem a necessidade de iniciar novos pods. Assim, o pod é atualizado em segundos, ao invés de minutos.

hello-tilt atualizado em 0,2 segundos depois do build local

Então, o comando docker_build_with_restart é executado quando a pasta “out” for atualizada. Como vimos mais acima, o recurso “build” publica a aplicação web na pasta out.

Como podemos ver abaixo, o Dockerfile desta aplicação copia o diretório para a pasta /app/out:

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-alpine
COPY . /app/out
WORKDIR /app/out
ENTRYPOINT ["dotnet", "hello-tilt.dll"]

O parâmetro live_update determina que a pasta “out” gerada pelo “build” seja sincronizada dentro no pod, na pasta “app/out”.

E por fim, após sincronizar os arquivos, ele vai sobrescrever o entrypoint do container, efetivamente reiniciando a aplicação quase que instantaneamente.

Vale notar que a extensão docker_build_with_restart só é necessária se houver necessidade de reiniciar a aplicação após atualizar os arquivos, como é o caso da maioria das linguagens de programação compiladas. Mas num projeto com linguagens interpretadas, como NodeJS ou Python, podemos usar o próprio comando docker_build com o parâmetro live_reload e obter o mesmo resultado.

Erros de compilação

Se eu remover o “;” de um comando C# como este abaixo, em menos de dois segundos o painel do Tilt vai identificar o problema:

private static DateTime end = DateTime.Now; // <- este ";" aqui
O recurso build aparece com status de erro na UI, e ao lado, o log de erro do C#, devidamente formato para fácil leitura

Corrigindo o problema, rapidamente, e o pod é atualizado:

Tudo verde novamente

Pensamentos finais

O Tilt como ferramenta, é fantástico. Permite criar workflows dinâmicos e acelera o loop de feedback de desenvolvimento.

Pontos de atenção

Em primeiro lugar, rodar um cluster Kubernetes localmente pode consumir muitos recursos de máquina, e a escolha do cluster pode influenciar bastante esta questão.

Já testei o MiniKube, o K3d e o Kind. O Kind, em particular, é o meu preferido: fácil de usar, e suficientemente leve para usar no notebook do trabalho.

No entanto, para que tudo rode rapidamente, precisamos de um registry do Docker local. E num projeto real, as imagens podem consumir disco rapidamente. Já me aconteceu de o registro do Docker alcançar 50GB e acabar o espaço em disco, corrompendo o cluster.

Mas se espaço em disco e memória não forem um problema, vá em frente e teste. Vale a pena.

Configurando um cluster local

Se você tiver interesse de ver como fiz a configuração de um cluster Kind local, confira este outro artigo, onde eu mostro em detalhes como configurar o ambiente.

Docker registries privados

Ao desenvolver um Tiltfile para uma aplicação real, muitas vezes as imagens Docker dos nossos serviços vão depender de uma imagem base armazenada num registry privado que exige autenticação.

Nesses casos, podemos tentar configurar essas credenciais no nosso cluster, mas não cheguei a explorar esta opção, pois parece bem difícil.

Ao invés disso, podemos carregar a imagem base de duas formas:

  1. Carregando a imagem no cluster: no caso do Kind, podemos rodar o comando kind load docker-image <nome-imagem-base:tag>
  2. Fazendo um push para o local registry usando o comando docker push localhost:<porta-registry>/nome-imagem-base:tag

Ganhos de produtividade

Para mim, o loop de feedback mais rápido que um workflow com Tilt pode proporcionar já justifica investir tempo em ao menos testar a ferramenta.

Hoje, no meu time na AmbevTech, ainda não usamos o Tilt no dia-a-dia, mas já comecei alguns testes com nossos serviços, e a ferramenta parece promissora.

Outras opções para compartilhar:
comments powered by Disqus