OpenTofu Além do Básico: Providers Dinâmicos e Variáveis no Backend
Introdução
Duas limitações acompanharam o Terraform por quase toda a existência dele, e quem já operou infraestrutura em escala esbarrou nas duas.
A primeira aparece quando você tenta usar variável na configuração do backend e leva um erro dizendo que aquele valor precisa ser literal. A segunda aparece quando você precisa provisionar a mesma infraestrutura em oito regiões e acaba copiando o mesmo bloco provider oito vezes, mudando uma linha em cada.
Essas duas restrições explicam boa parte da razão de existir de ferramentas de orquestração ao redor do Terraform. O OpenTofu removeu as duas, e este artigo mostra como.
Por que Existia a Restrição
Vale entender a causa, porque ela explica os limites que sobraram.
O init acontece antes de qualquer avaliação de configuração. É nesse momento que a ferramenta precisa saber onde fica o estado e de onde baixar os módulos, e nesse ponto ela ainda não processou expressão, variável ou dependência nenhuma.
A saída de sempre era exigir valor literal nesses pontos. Quem precisava de flexibilidade recorria a arquivo de backend parcial, script que gerava configuração ou uma camada externa de orquestração.
O OpenTofu resolveu isso com o que chama de avaliação antecipada: uma etapa que processa variável e local antes do resto, desde que não dependam de nada que só existe depois.
Variáveis no Backend e no Módulo
A partir da versão 1.8, variável e local funcionam na configuração de backend, na origem de módulos e na configuração de criptografia.
O caso mais útil é o backend parametrizado por ambiente:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
variable "projeto" {
type = string
default = "minhaapp"
}
variable "ambiente" {
type = string
}
variable "regiao" {
type = string
default = "us-east-1"
}
locals {
bucket_estado = "${var.projeto}-estado-${var.regiao}"
chave_estado = "${var.ambiente}/${var.projeto}/terraform.tfstate"
}
terraform {
backend "s3" {
bucket = local.bucket_estado
key = local.chave_estado
region = var.regiao
encrypt = true
}
}
O mesmo vale para a origem de um módulo, o que permite fixar a versão por variável:
1
2
3
4
5
6
7
8
9
10
11
12
variable "versao_vpc" {
type = string
default = "5.4.0"
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = var.versao_vpc
name = "producao"
cidr = "10.0.0.0/16"
}
Um padrão prático que isso abre é alternar entre módulo local e módulo do registry durante o desenvolvimento:
1
2
3
locals {
origem_vpc = var.usar_modulo_local ? "./modules/vpc" : "terraform-aws-modules/vpc/aws"
}
Para isso funcionar, o init passou a consumir o arquivo de variáveis e aceitar os parâmetros de linha de comando correspondentes:
1
2
tofu init -var="ambiente=producao"
tofu init -var-file=producao.tfvars
O limite que sobra
A avaliação antecipada não é mágica. Só resolve o que dá pra saber antes de qualquer execução.
Variável e local funcionam. Referência a recurso, fonte de dado ou saída de módulo, não. Se o valor depende de algo que a ferramenta só descobre durante o plano, ele segue indisponível nesse ponto.
Na prática, é restrição razoável. O nome do bucket de estado raramente depende de um recurso já provisionado.
Providers Dinâmicos com for_each
A segunda limitação caiu na versão 1.9, com a possibilidade de iterar bloco provider.
O cenário clássico é multi-região. Antes, você declarava um bloco por região. Agora, declara um só:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
variable "regioes" {
type = map(object({
cidr = string
}))
default = {
us-east-1 = { cidr = "10.0.0.0/16" }
eu-west-1 = { cidr = "10.1.0.0/16" }
sa-east-1 = { cidr = "10.2.0.0/16" }
}
}
provider "aws" {
alias = "por_regiao"
for_each = var.regioes
region = each.key
}
Para usar essas instâncias, o módulo recebe a instância correspondente pelo mapa de providers:
1
2
3
4
5
6
7
8
9
10
11
12
module "vpc_regional" {
source = "./modules/vpc"
for_each = { for regiao, config in var.regioes : regiao => config }
providers = {
aws = aws.por_regiao[each.key]
}
name = "vpc-${each.key}"
cidr = each.value.cidr
}
Adicionar uma região passa a ser só acrescentar uma entrada no mapa. O código não cresce.
Três restrições que vale conhecer
A primeira: o bloco provider precisa ter alias estático. Ele identifica a configuração, e por isso a referência no módulo é fixa, com a instância escolhida na hora entre colchetes.
A segunda: a expressão do for_each do provider precisa ser resolvível na etapa de avaliação antecipada. Vale variável e local, não vale fonte de dado nem recurso.
A terceira é a mais sutil, e a que mais gera erro. A expressão de for_each do módulo ou do recurso precisa ser diferente da expressão usada no provider, mesmo que as duas produzam o mapa idêntico. É por isso que, no exemplo acima, o módulo usa uma compreensão sobre a variável em vez de repetir var.regioes direto.
O motivo é ordem de destruição. A ferramenta precisa garantir que as instâncias de provider sobrevivam aos recursos que dependem delas durante um destroy. Expressões diferentes permitem estabelecer essa ordem.
O Que Isso Muda na Relação com o Terragrunt
Cabe aqui uma observação honesta, porque essas duas funcionalidades atingem justo algumas das razões históricas para adotar camada de orquestração.
Backend parametrizado por ambiente e origem de módulo versionada por variável foram, por anos, argumento direto a favor do Terragrunt. Hoje o próprio OpenTofu resolve os dois casos.
Isso não torna a orquestração desnecessária. O Terragrunt continua entregando o que a ferramenta base não faz: isolamento de estado por unidade, grafo de dependências entre componentes, execução em lote sobre dezenas de units e filtro do que mudou.
O que mudou foi a fronteira. Projeto pequeno e médio, que adotava Terragrunt só pra parametrizar o backend, hoje resolve isso na própria ferramenta. Projeto com muita unidade interdependente segue se beneficiando da camada de orquestração.
Reavaliar essa fronteira de tempos em tempos é mais saudável do que assumir que a decisão tomada há dois anos ainda vale.
Cuidados
Três pontos que merecem atenção antes de sair usando.
Variável no backend afeta quem executa. Se o nome do bucket depende de uma variável, quem rodar a ferramenta precisa passá-la também no init. Documente isso, ou o próximo do time vai perder tempo descobrindo.
Valor sensível é bloqueado na origem de módulos. Desde a 1.9, a ferramenta impede que valor marcado como sensível apareça em caminho de módulo, porque ficaria visível durante a inicialização.
Flexibilidade cobra clareza. Origem de módulo definida por expressão condicional é poderosa e também mais difícil de rastrear. Use quando resolver um problema real, não só porque agora dá.
Conclusão
Essas duas funcionalidades têm algo em comum: devolvem à ferramenta base coisa que a comunidade resolvia por fora, com script, arquivo parcial e camada extra.
Vale registrar que essa vantagem não é permanente. O Terraform vem fechando parte dessa distância nas versões recentes, e é esperado que continue. A diferença entre os dois projetos é retrato em movimento, não estado final.
O que fica é o critério pra avaliar qualquer uma das duas ferramentas: menos a lista de funcionalidade exclusiva de cada versão, e mais a pergunta de quais camadas externas você mantém hoje só porque a ferramenta não dava conta. Toda vez que uma delas deixa de ser necessária, sobra menos código pra manter, e é isso que de fato conta no fim de um ano de operação.