Capítulo 15 – Específicos da configuração

Por Dave Cunningham e Misha Brukman com Christophe Kalt e Betsy Beyer 


Gerenciar sistemas de produção é uma das muitas maneiras pelas quais os SREs agregam valor a uma organização. A tarefa de configurar e executar aplicativos em produção requer conhecimento sobre como esses sistemas são montados e como funcionam. Quando as coisas dão errado, o engenheiro de sobreaviso precisa saber exatamente onde estão as configurações e como alterá-las. Essa responsabilidade pode se tornar um fardo se uma equipe ou organização não investiu em abordar o toil relacionado à configuração. 

Este livro aborda o tema do toil em detalhes (consulte o Capítulo 6). Se sua equipe de SRE está sobrecarregada com muito toil relacionado à configuração, esperamos que a implementação de algumas das ideias apresentadas neste capítulo o ajude a recuperar parte do tempo gasto fazendo alterações na configuração. 

Toil induzido pela configuração

No início do ciclo de vida de um projeto, a configuração geralmente é relativamente leve e direta. Você pode ter alguns arquivos em um formato apenas de dados como INI, JSON, YAML ou XML. Gerenciar esses arquivos requer pouco esforço (toil). À medida que o número de aplicativos, servidores e variações aumenta ao longo do tempo, a configuração pode se tornar muito complexa e detalhada. Por exemplo, você pode ter originalmente “alterado uma configuração” editando um arquivo de configuração, mas agora precisa atualizar arquivos de configuração em vários locais. Ler essa configuração também é difícil, pois diferenças importantes estão ocultas em um mar de detalhes duplicados irrelevantes. Podemos caracterizar esse toil relacionado à configuração como toil de replicação: a tarefa mundana de gerenciar a configuração replicada em um sistema. Esse tipo de toil não se limita a grandes organizações e sistemas enormes – é especialmente comum em arquiteturas de microsserviços com muitos componentes configurados independentemente. 

Os engenheiros frequentemente respondem ao toil de replicação construindo automação ou um framework de configuração. Eles visam remover a duplicação no sistema de configuração e tornar a configuração mais fácil de entender e manter. Reutilizando técnicas da engenharia de software, essa abordagem muitas vezes faz uso de uma “linguagem de configuração”. O Google SRE criou várias linguagens de configuração com o objetivo de reduzir o toil para nossos maiores e mais complexos sistemas de produção. 

Infelizmente, essa tática não necessariamente elimina o toil relacionado à configuração. Libertos de um número avassalador de configurações individuais, o projeto (e seu corpus de configuração) cresce com uma energia renovada. Inevitavelmente, você se depara com o toil de complexidade: a tarefa desafiadora e frustrante de lidar com os comportamentos emergentes e às vezes indesejáveis da automação complexa. Esse tipo de toil geralmente se materializa em organizações maiores (com mais de 10 engenheiros) e se agrava com o crescimento. Quanto mais cedo você puder lidar com o toil de complexidade, melhor; o tamanho e a complexidade da configuração só aumentarão com o tempo. 

Reduzindo o toil causado pela configuração

Se o seu projeto está cheio de toil relacionado à configuração, você tem algumas estratégias básicas para melhorar a situação. 

Em casos raros, e se o seu aplicativo for feito sob medida, você pode optar por remover completamente a configuração. O aplicativo pode ser naturalmente melhor do que uma linguagem de configuração em lidar com certos aspectos da configuração: pode fazer sentido para o aplicativo atribuir padrões porque tem acesso a informações sobre a máquina, ou variar alguns valores dinamicamente porque pode se ajustar de acordo com a carga. 

Se remover a configuração não é uma opção, e o toil de replicação está se tornando um problema, considere a automação para reduzir a duplicação em seu corpus de configuração. Você pode integrar uma nova linguagem de configuração, ou pode precisar melhorar ou substituir sua configuração existente. A próxima seção, “Propriedades Críticas e Armadilhas dos Sistemas de Configuração”, fornece algumas orientações sobre a escolha ou o design desse sistema.  

Se você optar por configurar um novo framework de configuração, precisará integrar a linguagem de configuração com o aplicativo que precisa ser configurado. “Integração de uma Aplicação Existente: Kubernetes” usa o Kubernetes como exemplo de uma aplicação existente a ser integrada, e “Integrando Aplicações Personalizadas (Software Interno)” oferece alguns conselhos mais gerais. Essas seções apresentam alguns exemplos usando o Jsonnet (que escolhemos como uma linguagem de configuração representativa para fins de ilustração). 

Depois de ter um sistema de configuração no lugar para ajudar com o toil de replicação – quer você já esteja comprometido com sua solução existente, ou escolha implementar uma nova linguagem de configuração – as melhores práticas em “Operando Efetivamente um Sistema de Configuração”, “Quando Avaliar a Configuração”, e “Protegendo-se contra Configurações Abusivas” devem ser úteis para otimizar sua configuração, não importa qual linguagem você esteja usando. A adoção desses processos e ferramentas pode ajudar a minimizar o toil de complexidade. 

Propriedades críticas e armadilhas dos sistemas de configuração

O Capítulo 14 delineou algumas propriedades críticas de qualquer sistema de configuração. Além dos requisitos ideais genéricos como leveza, facilidade de aprendizado, simplicidade e poder expressivo, um sistema de configuração eficiente deve: 

  • Suportar a saúde da configuração, a confiança do engenheiro e a produtividade por meio de ferramentas para gerenciar os arquivos de configuração (verificadores de código, depuradores, formatadores, integração IDE, etc.). 
  • Fornecer uma avaliação hermética da configuração para rollbacks e replay geral. 
  • Separar configuração e dados para permitir a análise fácil da configuração e uma variedade de interfaces de configuração. 

Não é amplamente compreendido que essas propriedades são críticas, e chegar ao nosso entendimento atual foi de fato uma jornada. Durante esta jornada, o Google inventou vários sistemas de configuração que não possuíam essas propriedades críticas. Não estávamos sozinhos, também. Apesar da grande variedade de sistemas de configuração populares, é difícil encontrar um que não caia em pelo menos uma das seguintes armadilhas. 

Armadilha 1: Não reconhecer a configuração como um problema de linguagem de programação

Se você não está projetando intencionalmente uma linguagem, é altamente improvável que a “linguagem” que você acabará tendo seja boa.  

Embora as linguagens de configuração descrevam dados em vez de comportamento, elas ainda têm as outras características das linguagens de programação. Se nossa estratégia de configuração começa com o objetivo de usar um formato apenas para dados, as características das linguagens de programação tendem a aparecer sorrateiramente. Em vez de permanecer uma linguagem apenas para dados, o formato se torna uma linguagem de programação esotérica e complexa. 

Por exemplo, alguns sistemas adicionam um atributo de contagem ao esquema de uma máquina virtual (VM) sendo provisionada. Este atributo não é uma propriedade da própria VM, mas indica que você deseja mais de uma delas. Embora útil, isso é uma característica de uma linguagem de programação, e não de um formato de dados, porque requer um avaliador ou interpretador externo. Uma abordagem clássica de linguagem de programação usaria lógica fora do artefato, como um loop for ou uma compreensão de lista, para gerar mais VMs conforme necessário. 

Outro exemplo é uma linguagem de configuração que acumula regras de interpolação de strings em vez de suportar expressões gerais. As strings parecem ser “apenas dados”, embora possam realmente conter código complexo, incluindo operações de estrutura de dados, checksums, codificação base64 e assim por diante. 

A solução popular YAML + Jinja também tem suas desvantagens. Formatos simples de dados puros, como XML, JSON, YAML e protocol buffers formatados em texto, são excelentes escolhas para casos de uso de dados puros. Da mesma forma, motores de modelagem textual como Jinja2 ou templates Go são excelentes para modelagem de HTML. Mas quando combinados em uma linguagem de configuração, eles se tornam difíceis tanto para humanos quanto para ferramentas manterem e analisarem. Em todos esses casos, essa armadilha nos deixa com uma “linguagem” esotérica e complexa que não é adequada para ferramentas. 

Armadilha 2: Projetando recursos de linguagem acidental ou ad hoc 

Os Engenheiros de Confiabilidade de Sistemas (SREs) geralmente percebem problemas de usabilidade de configuração ao operar sistemas em escala. Uma nova linguagem não terá um bom suporte de ferramentas (suporte de IDE, bons linters), e desenvolver ferramentas personalizadas é doloroso se a linguagem tiver semântica não documentada ou esotérica. 

Adicionar recursos ad hoc de linguagem de programação a um formato de configuração simples ao longo do tempo pode criar uma solução completa de recursos, mas as linguagens ad hoc são mais complexas e geralmente têm menos poder expressivo do que suas equivalentes formalmente projetadas. Elas também correm o risco de desenvolver armadilhas e idiossincrasias porque seus autores não puderam considerar a interação entre os recursos antecipadamente. 

Em vez de esperar que seu sistema de configuração não se torne complexo o suficiente para precisar de construções de programação simples, é melhor considerar esses requisitos na fase de design inicial. 

Armadilha 3: Construir muita otimização específica do domínio  

Quanto menor for a base de usuários de uma nova solução específica do domínio, mais tempo você terá que esperar para acumular usuários suficientes para justificar a construção de ferramentas. Os engenheiros estão relutantes em gastar tempo entendendo a linguagem corretamente porque ela tem pouca aplicabilidade fora desse domínio. Recursos de aprendizado como o Stack Overflow são menos propensos a estar disponíveis. 

Armadilha 4: Entrelaçando “Avaliação de Configuração” com “Efeitos Colaterais”

Efeitos colaterais incluem fazer alterações em sistemas externos ou consultar fontes de dados externas (DNS, IDs de VM, versões mais recentes de compilação) durante as execuções de configuração. 

Sistemas que permitem esses efeitos colaterais violam a hermeticidade e também impedem a separação da configuração dos dados. Em um caso extremo, é impossível depurar sua configuração sem gastar dinheiro reservando recursos na nuvem. Para permitir a separação de configuração e dados, primeiro avalie a configuração, em seguida, torne os dados resultantes disponíveis para o usuário analisar e somente então permita os efeitos colaterais. 

Armadilha 5: Utilizando uma linguagem de script de propósito geral existente como Python, Ruby ou Lua

Isso parece ser uma maneira trivial de evitar as quatro primeiras armadilhas, mas as implementações que utilizam uma linguagem de script de propósito geral são pesadas e/ou exigem sandboxing intrusivo para garantir a hermeticidade. Como as linguagens de propósito geral podem acessar o sistema local, considerações de segurança também podem exigir sandboxing. 

Além disso, não podemos presumir que as pessoas responsáveis pela manutenção da configuração estejam familiarizadas com todas essas linguagens. 

O desejo de evitar essas armadilhas levou ao desenvolvimento de linguagens de domínio específico reutilizáveis (DSLs) para configuração, como HOCON, Flabbergast, Dhall e Jsonnet. Recomendamos o uso de uma DSL existente para configuração. Mesmo que uma DSL pareça ser muito poderosa para suas necessidades, você pode precisar da funcionalidade adicional em algum momento, e sempre pode restringir a funcionalidade da linguagem usando um guia de estilo interno. 

Uma breve introdução ao Jsonnet 

Jsonnet é uma DSL hermética de código aberto que pode ser usada como uma biblioteca ou ferramenta de linha de comando para fornecer configuração para qualquer aplicativo. É amplamente utilizado tanto dentro quanto fora do Google. 

A linguagem é projetada para ser familiar aos programadores: ela usa uma sintaxe semelhante ao Python, orientação a objetos e construções funcionais. É uma extensão do JSON, o que significa que um arquivo JSON é simplesmente um programa Jsonnet que produz a si mesmo. Jsonnet é mais permissivo com aspas e vírgulas do que JSON e suporta comentários. Mais importante ainda, ele adiciona construções computacionais. 

Embora você não precise estar particularmente familiarizado com a sintaxe do Jsonnet para acompanhar o restante deste capítulo, dedicar apenas alguns momentos para ler o tutorial online pode ajudá-lo a se orientar. 

Não há uma linguagem de configuração dominante no Google ou entre nossos leitores, mas precisávamos escolher alguma linguagem que nos permitisse fornecer exemplos. Este capítulo usa o Jsonnet para mostrar exemplos práticos das recomendações que fornecemos no Capítulo 14. 

Se você ainda não estiver comprometido com uma linguagem de configuração específica e quiser usar o Jsonnet, pode aplicar diretamente os exemplos deste capítulo. Em todos os casos, fizemos o possível para tornar o mais fácil possível para você abstrair a lição subjacente dos exemplos de código. 

Além disso, alguns dos exemplos exploram conceitos (como a completude de Turing) que você pode esperar encontrar em um livro de programação. Tomamos muito cuidado para mergulhar apenas o necessário para explicar uma sutileza que realmente nos afetou na produção. Na maioria dos sistemas complexos – e certamente no que diz respeito às configurações – as falhas estão nas bordas. 

Integrando uma Linguagem de Configuração

Esta seção utiliza o Jsonnet para discutir como integrar uma linguagem de configuração com a aplicação que você precisa configurar, mas as mesmas técnicas também se aplicam a outras linguagens de configuração. 

Gerando configurações em formatos específicos 

Uma linguagem de configuração pode gerar nativamente no formato correto. Por exemplo, o Jsonnet gera JSON, que é compatível com muitas aplicações. JSON também é suficiente para consumidores de linguagens que estendem o JSON, como JavaScript, YAML, ou a Linguagem de Configuração da HashiCorp. Se esta for a sua situação, você não precisa realizar mais nenhum trabalho de integração. 

Para outros formatos de configuração que não são suportados nativamente: 

  1. Você precisa encontrar uma maneira de representar os dados de configuração dentro da linguagem de configuração. Normalmente, isso não é difícil porque valores de configuração como mapas, listas, strings e outros valores primitivos são genéricos e disponíveis em todas as linguagens. 
  2. Uma vez que esses dados estejam representados na linguagem de configuração, você pode usar os construtores dessa linguagem para reduzir a duplicação (e, assim, o esforço). 
  3. Você precisa escrever (ou reutilizar) uma função de serialização para o formato de saída necessário. Por exemplo, a biblioteca padrão do Jsonnet possui funções para produzir saídas em INI e XML a partir de sua representação interna semelhante a JSON. Se os dados de configuração resistirem à representação dentro da linguagem de configuração (por exemplo, um script Bash), você pode usar técnicas básicas de formatação de strings como último recurso. 

Gerenciando múltiplas aplicações 

Uma vez que você consiga controlar aplicativos existentes arbitrários a partir da linguagem de configuração, você talvez consiga direcionar diversos aplicativos a partir da mesma configuração. Se seus aplicativos usam diferentes formatos de configuração, você precisará realizar algum trabalho de conversão. Uma vez que você seja capaz de gerar configurações nos formatos necessários, você pode facilmente unificar, sincronizar e eliminar repetições em todo o seu corpus de configuração. Dada a prevalência de JSON e formatos baseados em JSON, você pode nem mesmo precisar gerar diferentes formatos, por exemplo, isso é verdadeiro se você usar uma arquitetura de implantação que utilize o GCP Deployment Manager, AWS Cloud Formation, ou Terraform para infraestrutura base, além do Kubernetes para contêineres. 

Neste ponto, você pode: 

  • Gerar uma configuração de servidor web Nginx e uma configuração de firewall Terraform a partir de uma única avaliação Jsonnet que define a porta apenas uma vez. 
  • Configurar seus painéis de monitoramento, políticas de retenção e pipelines de notificação de alerta a partir dos mesmos arquivos. 
  • Gerenciar o trade-off de desempenho entre scripts de inicialização de VM e scripts de construção de imagem de disco movendo comandos de inicialização de uma lista para outra. 

Depois de unir configurações díspares em um só lugar, você tem muitas oportunidades para refinar e abstrair a configuração. As configurações podem até ser aninhadas – por exemplo, uma configuração do Cassandra pode estar incorporada dentro da configuração do Deployment Manager de sua infraestrutura base ou dentro de um ConfigMap do Kubernetes. Uma boa linguagem de configuração pode lidar com qualquer tipo de formatação de strings complicada e geralmente tornar essa operação natural e simples. 

Para facilitar a escrita de vários arquivos diferentes para várias aplicações, o Jsonnet possui um modo que espera que a execução da configuração produza um único objeto JSON que mapeia nomes de arquivo para conteúdo de arquivo (formatado conforme necessário). Você pode simular essa facilidade em outras linguagens de configuração emitindo um mapa de string para string e usando uma etapa de pós-processamento ou script de wrapper para escrever os arquivos. 

Integrar uma aplicação existente: Kubernetes

O Kubernetes é um estudo de caso interessante por algumas razões: 

  • Os trabalhos executados no Kubernetes precisam ser configurados, e sua configuração pode se tornar complexa. 
  • O Kubernetes não vem com uma linguagem de configuração integrada (nem mesmo uma ad hoc, felizmente). 

Usuários do Kubernetes com objetos minimamente complexos simplesmente usam YAML. Usuários com infraestrutura maior estendem seu fluxo de trabalho do Kubernetes com linguagens como o Jsonnet para fornecer as facilidades de abstração necessárias nessa escala. 

O que o Kubernetes oferece

O Kubernetes é um sistema de código aberto para orquestrar cargas de trabalho contêinerizadas em um cluster de máquinas. Sua API permite gerenciar os próprios contêineres e muitos detalhes importantes, como comunicação entre contêineres, comunicação dentro/fora do cluster, balanceamento de carga, armazenamento, implantação progressiva e dimensionamento automático. Cada item de configuração é representado por um objeto JSON que pode ser gerenciado por meio de um endpoint da API. A ferramenta de linha de comando kubectl permite ler esses objetos do disco e enviá-los para a API. 

No disco, os objetos JSON são realmente codificados como fluxos YAML. YAML é facilmente legível e converte facilmente para JSON por meio de bibliotecas comumente disponíveis. A experiência do usuário pronta envolve escrever arquivos YAML que representam objetos Kubernetes e executar kubectl para implantá-los em um cluster. 

Para aprender sobre as melhores práticas para configurar o Kubernetes, consulte a documentação do Kubernetes sobre esse tópico. 

Exemplo de configuração do Kubernetes

O YAML, a interface do usuário para a configuração do Kubernetes, oferece algumas características simples, como comentários, e possui uma sintaxe concisa que a maioria das pessoas prefere ao JSON bruto. No entanto, o YAML fica aquém quando se trata de abstração: ele fornece apenas âncoras, que raramente são úteis na prática e não são suportadas pelo Kubernetes. 

Suponha que você queira replicar um objeto do Kubernetes quatro vezes com diferentes namespaces, rótulos e outras variações menores. Seguindo as melhores práticas de infraestrutura imutável, você armazena a configuração de todas as quatro variantes, duplicando os outros aspectos idênticos da configuração. O trecho de código a seguir apresenta uma variante (por questão de brevidade, omitimos os outros três arquivos): 

# example1.yaml  
apiVersion: v1  
kind: Service  
metadata:  
  labels:  
    app: guestbook  
    tier: frontend  
  name: frontend  
  namespace: prod  
spec:  
   externalTrafficPolicy: Cluster  
   ports:  
   - port: 80  
     protocol: TCP  
     targetPort: 80  
   selector:  
     app: guestbook  
     tier: frontend  
   sessionAffinity: None  
   type: NodePort 

As variantes são difíceis de ler e manter porque as diferenças importantes estão obscurecidas. 

Integrando a linguagem de configuração

Como discutido em “Toil Induzido pela Configuração”, gerenciar um grande número de arquivos YAML pode levar uma quantidade significativa de tempo. Uma linguagem de configuração pode ajudar a simplificar essa tarefa. A abordagem mais direta é emitir um único objeto do Kubernetes a partir de cada execução do Jsonnet e, em seguida, enviar o JSON resultante diretamente para o kubectl, que processa o JSON como se fosse YAML. Alternativamente, você poderia emitir um fluxo YAML (uma sequência desses objetos) ou um único objeto de lista do kubectl, ou ter o Jsonnet emitir vários arquivos a partir da mesma configuração. 

Desenvolvedores devem estar cientes de que, em geral, o YAML permite escrever configurações que não são expressíveis em JSON (e, portanto, não podem ser geradas pelo Jsonnet). As configurações YAML podem conter valores excepcionais de ponto flutuante IEEE, como NaN, ou objetos com campos não string, como arrays, outros objetos ou nulos. Na prática, esses recursos são muito raramente usados, e o Kubernetes não os permite porque a configuração deve ser codificada em JSON ao ser enviada para a API. 

O trecho a seguir mostra como nossa configuração de exemplo do Kubernetes ficaria no Jsonnet: 

// templates.libsonnet  
 
  MyTemplate:: {  
    local service = self,  
    tier:: error 'Needs tier',  
    apiVersion: 'v1',  
    kind: 'Service',  

    local selector_labels = { app: 'guestbook', tier: service.tier },  

    metadata: {  
      labels: selector_labels,  
      name: 'guestbook-' + service.tier,  
      namespace: 'default',  
    },  

    spec: {  
      externalTrafficPolicy: 'Cluster',  
      ports: [{  
        port: 80,  
        protocol: 'TCP',  
        targetPort: 80,  
      }],  
      selector: selector_labels,  
      sessionAffinity: 'None',  
      type: 'NodePort',  
    },  
  },  
 

// example1.jsonnet  
local templates = import 'templates.libsonnet';  

templates.MyTemplate {  
  tier: 'frontend',  
 

// example2.jsonnet  
local templates = import 'templates.libsonnet';  

templates.MyTemplate {  
  tier: 'backend',  
  metadata+: {  
    namespace: 'prod',  
  },  
} 

// example3.jsonnet  
local templates = import 'templates.libsonnet';  

templates.MyTemplate {  
  tier: 'frontend',  
  metadata+: {  
    namespace: 'prod',  
    labels+: { foo: 'bar' },  
  },  
 

// example4.jsonnet  
local templates = import 'templates.libsonnet';  

templates.MyTemplate {  
  tier: 'backend',  
} 

Observe o seguinte: 

  • Expressamos todas as quatro variantes instanciando um modelo abstrato quatro vezes, mas também poderíamos usar abstrações funcionais. 
  • Enquanto usamos um arquivo Jsonnet separado para cada instância, também poderíamos consolidá-los em um único arquivo. 
  • No modelo abstrato, o namespace padrão é default e o tier deve ser substituído. 
  • À primeira vista, o Jsonnet é ligeiramente mais verbose, mas reduz a carga de trabalho à medida que o número de instanciações do modelo aumenta. 

Dentro de MyTemplate, a palavra-chave local define uma variável service, que é inicializada como self (uma referência ao objeto mais próximo envolvente). Isso permite que você se refira ao objeto de dentro de objetos aninhados, onde self é redefinido. 

O campo tier tem dois pontos (em vez do único ponto regular do JSON) e está oculto (não é gerado) no JSON gerado. Caso contrário, o Kubernetes rejeitará tier como um campo não reconhecido. Campos ocultos ainda podem ser substituídos e referenciados – neste caso, como service.tier. 

O modelo não pode ser usado sozinho porque fazer referência a service.tier aciona o erro construct, que gera um erro de tempo de execução com o texto fornecido. Para evitar o erro, cada instância do modelo substitui o campo tier por alguma outra expressão. Em outras palavras, esse padrão expressa algo semelhante a um método virtual/abstrato puro. 

Usar funções para abstração significa que a configuração só pode ser parametrizada. Em contraste, os modelos permitem que você substitua qualquer campo do pai. Como descrito no Capítulo 14, enquanto a simplicidade deve ser fundamental para o seu design, a capacidade de escapar da simplicidade é importante. As substituições de modelo fornecem uma útil saída de emergência para alterar detalhes específicos que normalmente poderiam ser considerados muito detalhados. Por exemplo: 

templates.MyTemplate {  
  tier: 'frontend',  
  spec+: {  
    sessionAffinity: 'ClientIP',  
  },  
} 

Aqui está um fluxo de trabalho típico para converter um modelo existente para Jsonnet: 

  • Converter uma das variantes YAML para JSON. 
  • Passar o JSON resultante pelo formatador de Jsonnet. 
  • Adicionar manualmente construções de Jsonnet para abstrair e instanciar o código (como mostrado no exemplo). 

O exemplo mostrou como remover a duplicação mantendo certos campos que eram diferentes. O uso de uma linguagem de configuração se torna mais convincente à medida que as diferenças se tornam mais sutis (por exemplo, strings são ligeiramente diferentes) ou desafiadoras de expressar (por exemplo, a configuração tem diferenças estruturais como elementos adicionais em arrays, ou a mesma diferença aplicada a todos os elementos de um array). 

Em geral, abstrair as similaridades entre diferentes configurações promove a separação de preocupações e tem os mesmos benefícios da modularidade em linguagens de programação. Você pode aproveitar as capacidades de abstração para diversos casos de uso diferentes: 

  • Uma única equipe pode precisar criar várias versões de sua configuração que são quase (mas não completamente) iguais, por exemplo, ao gerenciar implantações em ambientes variados (prod/estágio/dev/teste), ajustar implantações em arquiteturas diferentes ou ajustar a capacidade em diferentes geografias. 
  • Uma organização pode ter uma equipe de infraestrutura que mantém componentes reutilizáveis, como frameworks de serviço de API, servidores de cache ou MapReduces, que são usados pelas equipes de aplicativos. Para cada componente, a equipe de infraestrutura pode manter um modelo que define os objetos do Kubernetes necessários para executar esse componente em escala. Cada equipe de aplicativos pode instanciar esse modelo para adicionar os detalhes específicos de sua aplicação. 

Integrando aplicações personalizadas (software interno)  

Se sua infraestrutura utiliza algum aplicativo personalizado (ou seja, software desenvolvido internamente, em oposição a soluções prontas), então você pode projetar esses aplicativos para coexistir com uma linguagem de configuração reutilizável. As sugestões nesta seção devem melhorar a experiência geral de configuração do usuário ao escrever arquivos de configuração ou interagir com os dados de configuração gerados (por exemplo, para fins de depuração, ou ao integrar com outras ferramentas). Elas também devem simplificar o design do aplicativo e separar a configuração dos dados. 

Sua estratégia geral para lidar com aplicativos personalizados deve ser: 

  • Deixar a linguagem de configuração lidar com o que ela foi projetada para: o aspecto de linguagem do problema. 
  • Deixar seu aplicativo lidar com todas as outras funcionalidades. 

As seguintes melhores práticas incluem exemplos que usam Jsonnet, mas as mesmas recomendações se aplicam a outras linguagens: 

  • Consuma um único arquivo de dados puros e deixe a linguagem de configuração dividir a configuração em arquivos usando importações. Isso significa que a implementação da linguagem de configuração só precisa emitir (e o aplicativo só precisa consumir) um único arquivo. Além disso, como os aplicativos podem combinar arquivos de diferentes maneiras, essa estratégia delimita explicitamente e claramente como os arquivos são combinados para formar a configuração do aplicativo. 
  • Represente coleções de entidades nomeadas usando objetos, onde o campo contém o nome do objeto e o valor contém o restante da entidade. Evite usar uma matriz de objetos onde cada elemento tenha um campo de nome.  

Exemplo ruim em JSON: 

 
  { "name": "cat", ... },  
  { "name": "dog", ... }  
 

Exemplo bom em JSON:   

 
   "cat": { ... }, 
   "dog": { ... }  
} 

Essa estratégia torna a coleção (e os animais individuais) mais fáceis de estender, e você pode referenciar entidades pelo nome (por exemplo, animals.cat) em vez de referenciar índices frágeis (por exemplo, animals[0]). 

  • Evite agrupar entidades por tipo no nível superior. Estruture o JSON de forma que a configuração logicamente relacionada seja agrupada na mesma subárvore. Isso permite que a abstração (no nível da linguagem de configuração) siga limites funcionais.  

Exemplo ruim em JSON: 

 
  "pots": { "pot1": { ... }, "pot2": { ... } },  
  "lids": { "lid1": { ... }, "lid2": { ... } }  
} 

Exemplo bom em JSON: 

 
  "pot_assembly1": { "pot": { ... }, "lid": { ... } },  
  "pot_assembly2": { "pot": { ... }, "lid": { ... } }  
} 

No nível da linguagem de configuração, essa estratégia permite abstrações como as seguintes: 

  • Mantenha geralmente simples o design da representação de dados:  
    • Evite incorporar recursos da linguagem na representação de dados (como mencionado em “Armadilha 1: Não reconhecer a configuração como um problema de linguagem de programação”). Esses tipos de abstrações serão subdimensionados e apenas criarão confusão, pois forçam os usuários a decidir se devem usar os recursos de abstração na representação de dados ou na linguagem de configuração.  
    • Não se preocupe com representações de dados excessivamente verbosas. Soluções para reduzir a verbosidade introduzem complexidade, e o problema pode ser gerenciado na linguagem de configuração. 
    • Evite interpretar sintaxe personalizada de interpolação de string, como condicionais ou referências de espaços reservados em strings, em sua aplicação. Às vezes, a interpretação é inevitável, por exemplo, quando você precisa descrever ações que são executadas após a versão de dados pura da configuração ser gerada (alertas, manipuladores, etc.). Mas, caso contrário, deixe que a linguagem de configuração faça o máximo possível do trabalho de nível de linguagem. 

Como mencionado anteriormente, se você puder eliminar completamente a configuração, essa é sempre a melhor opção. Embora a linguagem de configuração possa ocultar a complexidade do modelo subjacente usando modelos com valores padrão, os dados de configuração gerados não são completamente ocultos—eles podem ser processados por ferramentas, inspecionados por humanos ou carregados em bancos de dados de configuração. Por esse motivo, não confie na linguagem de configuração para corrigir nomes inconsistentes, plurais ou erros no modelo subjacente—corrija-os no próprio modelo. Se você não puder corrigir inconsistências no modelo, é melhor conviver com elas no nível da linguagem para evitar ainda mais inconsistência. 

Em nossa experiência, as mudanças de configuração tendem a dominar as causas raiz das interrupções ao longo do tempo em um sistema (consulte nossa lista das principais causas de interrupções no Apêndice C). Validar suas alterações de configuração é uma etapa chave para manter a confiabilidade. Recomendamos validar os dados de configuração gerados imediatamente após a execução da configuração. A validação sintática sozinha (ou seja, verificar se o JSON é analisável) não encontrará muitos bugs. Após a validação de esquema genérica, verifique propriedades específicas do domínio de aplicativo—por exemplo, se os campos obrigatórios estão presentes, se os nomes de arquivo referenciados existem e se os valores fornecidos estão dentro dos intervalos permitidos. 

Você pode validar o JSON do Jsonnet com JSONschema. Para aplicativos que usam protocol buffers, você pode gerar facilmente a forma JSON canônica desses buffers a partir do Jsonnet, e a implementação do protocol buffer fará a validação durante a desserialização. 

Independentemente de como você decidir validar, não ignore nomes de campos não reconhecidos, pois eles podem indicar um erro de digitação no nível da linguagem de configuração. O Jsonnet pode mascarar campos que não devem ser incluídos usando a sintaxe ::. Também é uma boa ideia realizar a mesma validação em um gancho de pré-compromisso. 

Operar efetivamente um sistema de configuração

Ao implementar “configuração como código” em qualquer linguagem, recomendamos seguir a disciplina e os processos que auxiliam a engenharia de software de maneira geral. 

Versionamento

As linguagens de configuração geralmente incentivam os engenheiros a escreverem bibliotecas de modelos e funções de utilidade. Muitas vezes, uma equipe mantém essas bibliotecas, mas muitas outras equipes podem consumi-las. Quando você precisa fazer uma alteração significativa na biblioteca, tem duas opções: 

  • Realizar uma atualização global de todo o código do cliente, refatorando o código para que ele ainda funcione (isso pode não ser possível organizacionalmente). 
  • Versionar a biblioteca para que diferentes consumidores possam usar versões diferentes e migrar independentemente. Os consumidores que optarem por usar versões obsoletas não obterão os benefícios das novas versões e incorrerão em dívida técnica – algum dia, eles terão que refatorar seu código para usar a nova biblioteca. 

A maioria das linguagens, incluindo o Jsonnet, não oferece suporte específico para versionamento; em vez disso, você pode facilmente usar diretórios. Para um exemplo prático em Jsonnet, consulte o repositório ksonnet-lib, onde a versão é o primeiro componente do caminho importado:  

local k = import 'ksonnet.beta.2/k.libsonnet'; 

Controle de Origem

O Capítulo 14 defende a manutenção de um registro histórico de alterações de configuração (incluindo quem as fez) e garantir que os retornos sejam fáceis e confiáveis. O controle de origem da configuração traz todas essas capacidades, além da possibilidade de revisão de código das alterações de configuração. 

Ferramentas

Considere como você irá impor estilo e realizar lint em suas configurações, e investigue se há um plug-in de editor que integra essas ferramentas ao seu fluxo de trabalho. Seus objetivos aqui são manter um estilo consistente entre todos os autores, melhorar a legibilidade e detectar erros. Alguns editores suportam ganchos pós-escrita que podem executar formatadores e outras ferramentas externas para você. Você também pode usar ganchos pré-commit para executar as mesmas ferramentas e garantir que as configurações verificadas estejam com alta qualidade. 

Testes

Recomendamos implementar testes unitários para bibliotecas de modelos upstream. Certifique-se de que as bibliotecas gerem a configuração concreta esperada ao serem instanciadas de várias maneiras. Da mesma forma, as bibliotecas de funções devem incluir testes unitários para que possam ser mantidas com confiança.  

No Jsonnet, você pode escrever testes como arquivos Jsonnet que: 

  1. Importe a biblioteca a ser testada. 
  2. Exercite a biblioteca. 
  3. Use a declaração assert ou a função assertEqual da biblioteca padrão para validar sua saída. Esta última apresenta quaisquer valores divergentes em suas mensagens de erro.  

O exemplo a seguir testa a função joinName e MyTemplate: 

// utils_test.jsonnet  
local utils = import 'utils.libsonnet'; 

std.assertEqual(utils.joinName(['foo', 'bar']), 'foo-bar') && 
std.assertEqual(utils.MyTemplate { tier: 'frontend' }, { ... }) 

Para conjuntos de testes maiores, você pode aproveitar um framework de teste de unidade mais abrangente desenvolvido por membros da comunidade Jsonnet. Você pode usar este framework para definir e executar conjuntos de testes de maneira estruturada, por exemplo, para relatar o conjunto de todos os testes falhando em vez de abortar a execução na primeira asserção falha. 

Quando avaliar a configuração

Nossas propriedades críticas incluem a hermeticidade; ou seja, as linguagens de configuração devem gerar os mesmos dados de configuração independentemente de onde ou quando forem executadas. Como descrito no Capítulo 14, um sistema pode ser difícil ou impossível de reverter se depender de recursos que possam mudar fora de seu ambiente hermético. Geralmente, a hermeticidade significa que o código Jsonnet é sempre intercambiável com o JSON expandido que ele representa. Consequentemente, você pode gerar JSON a partir do Jsonnet a qualquer momento entre a atualização do Jsonnet e quando precisar do JSON – até mesmo cada vez que precisar do JSON. 

Recomendamos armazenar a configuração no controle de versão. Então, sua primeira oportunidade de validar a configuração é antes do check-in. No extremo oposto, um aplicativo pode avaliar a configuração quando precisa dos dados JSON. Como uma opção intermediária, você pode avaliar no momento da construção. Cada uma dessas opções tem vários trade-offs, e você deve otimizar de acordo com os detalhes do seu caso de uso. 

Muito cedo: Verificando o JSON

Você pode gerar JSON a partir do código Jsonnet antes de fazer o check-in de ambos no controle de versão. O fluxo de trabalho típico é o seguinte: 

  1. Modificar os arquivos Jsonnet. 
  2. Executar a ferramenta de linha de comando Jsonnet (talvez envolvida em um script) para regenerar os arquivos JSON. 
  3. Usar um gancho pré-commit para garantir que o código Jsonnet e a saída JSON estejam sempre consistentes. 
  4. Empacotar tudo em uma solicitação de pull para uma revisão de código. 

Prós 

  • O revisor pode verificar a sanidade das alterações concretas – por exemplo, uma refatoração não deve afetar o JSON gerado de forma alguma. 
  • Você pode inspecionar anotações de linha por vários autores em diferentes versões em ambos os níveis gerados e abstraídos. Isso é útil para auditoria de mudanças. 
  • Você não precisa executar o Jsonnet em tempo de execução, o que pode ajudar a limitar a complexidade, o tamanho binário e/ou a exposição ao risco. 

Contras 

  • O JSON gerado não necessariamente é legível – por exemplo, se ele incorporar strings longas. 
  • O JSON pode não ser adequado para ser verificado no controle de versão por outros motivos – por exemplo, se for muito grande ou contiver segredos. 
  • Conflitos de merge podem surgir se muitas edições concorrentes em arquivos Jsonnet separados convergirem para um único arquivo JSON. 

Meio do caminho: Avaliar no momento da compilação 

Você pode evitar verificar o JSON no controle de origem executando a utilidade de linha de comando Jsonnet no momento da compilação e incorporando o JSON gerado ao artefato de lançamento (por exemplo, como um arquivo tarball). O código da aplicação simplesmente lê o arquivo JSON do disco no momento da inicialização. Se estiver usando o Bazel, você pode facilmente alcançar isso usando as regras Bazel do Jsonnet. No Google, geralmente preferimos essa abordagem por causa dos prós listados a seguir. 

Prós 

  • Você tem a capacidade de controlar a complexidade em tempo de execução, o tamanho binário e a exposição ao risco sem precisar reconstruir os arquivos JSON em cada solicitação de pull. 
  • Não há risco de dessincronização entre o código Jsonnet original e o JSON resultante. 

Contras 

  • A compilação é mais complexa. 
  • É mais difícil avaliar a alteração concreta durante a revisão de código. 

Tarde: Avaliar em tempo de execução

Vincular a biblioteca Jsonnet permite que o próprio aplicativo interprete a configuração em qualquer momento, resultando em uma representação em memória do JSON de configuração gerado. 

Prós 

  • É mais simples, pois você não precisa de uma avaliação prévia. 
  • Você ganha a capacidade de avaliar o código Jsonnet fornecido pelo usuário durante a execução. 

Contras 

  • Qualquer biblioteca vinculada aumenta a pegada e a exposição ao risco. 
  • Erros de configuração podem ser descobertos durante a execução, o que é tarde demais. 
  • Se o código Jsonnet não for confiável, você precisa ter cuidado especial. 

Para seguir nosso exemplo em execução, quando você deve executar o Jsonnet se estiver gerando objetos do Kubernetes? 

A resposta depende da sua implementação. Se você está construindo algo semelhante ao ksonnet (uma ferramenta de linha de comando do lado do cliente que executa código Jsonnet do sistema de arquivos local), a solução mais fácil é vincular a biblioteca Jsonnet na ferramenta e avaliar o Jsonnet no processo. Fazer isso é seguro porque o código é executado na própria máquina do autor. 

A infraestrutura da Box.com usa ganchos do Git para enviar alterações de configuração para produção. Para evitar a execução do Jsonnet no servidor, os ganchos do Git atuam no JSON gerado que é mantido no repositório. Para um daemon de gerenciamento de implantação como o Helm ou o Spinnaker, sua única opção é avaliar o Jsonnet no servidor durante a execução (com as ressalvas descritas na próxima seção). 

Protegendo-se contra configurações abusivas

Ao contrário de serviços de longa duração, a execução da configuração deve ser encerrada rapidamente com a configuração resultante. Infelizmente, devido a bugs ou ataques deliberados, a configuração pode consumir uma quantidade arbitrária de tempo de CPU ou memória. Para ilustrar o motivo, considere o seguinte programa Jsonnet que não termina: 

local f(x) = f(x + 1); f(0) 

Um programa que utiliza memória ilimitada é semelhante: 

local f(x) = f(x + [1]); f([]) 

Você pode escrever exemplos equivalentes usando objetos em vez de funções, ou em outras linguagens de configuração.  

Você pode tentar evitar o consumo excessivo de recursos restringindo a linguagem para que ela não seja mais Turing completa. No entanto, fazer com que todas as configurações terminem não necessariamente impede o consumo excessivo de recursos. É fácil escrever um programa que consome tempo ou memória suficientes para ser praticamente não terminante. Por exemplo: 

local f(x) = if x == 0 then [] else [f(x - 1), f(x - 1)]; f(100) 

De fato, tais programas existem mesmo com formatos de configuração simples como XML e YAML. 

Na prática, o risco desses cenários depende da situação. No lado menos problemático, suponha que uma ferramenta de linha de comando use Jsonnet para construir objetos do Kubernetes e, em seguida, implante esses objetos. Nesse caso, o código Jsonnet é confiável: acidentes que resultam em não terminação são raros, e você pode usar Ctrl-C para mitigá-los. O esgotamento acidental de memória é extremamente improvável. No outro extremo, com um serviço como Helm ou Spinnaker, que aceita código de configuração arbitrário de um usuário final e o avalia em um manipulador de solicitações, você deve ter muito cuidado para evitar ataques de negação de serviço que possam sobrecarregar os manipuladores de solicitações ou esgotar a memória. 

Se você avaliar código Jsonnet não confiável em um manipulador de solicitações, pode evitar tais ataques ao isolar a execução do Jsonnet. Uma estratégia simples é usar um processo separado e ulimit (ou seu equivalente não-UNIX). Tipicamente, você precisa bifurcar para o executável de linha de comando em vez de vincular a biblioteca Jsonnet. Como resultado, programas que não são concluídos dentro dos recursos fornecidos falham com segurança e informam o usuário final. Para uma defesa adicional contra explorações de memória C++, você pode usar a implementação nativa de Go do Jsonnet. 

Conclusão 

Independentemente de você usar o Jsonnet, adotar outra linguagem de configuração ou desenvolver a sua própria, esperamos que você possa aplicar essas melhores práticas para gerenciar a complexidade e a carga operacional necessárias para configurar seu sistema de produção com confiança. 

No mínimo, as propriedades críticas de uma linguagem de configuração são boas ferramentas, configurações herméticas e separação de configuração e dados. 

Seu sistema pode não ser complexo o suficiente para precisar de uma linguagem de configuração. A transição para uma linguagem específica do domínio, como o Jsonnet, é uma estratégia a ser considerada quando sua complexidade aumenta. Fazê-lo permitirá que você forneça uma interface consistente e bem estruturada e liberará o tempo da sua equipe de SRE para trabalhar em outros projetos importantes. 

Rolar para cima