Construindo e aplicando IaC no meu homelab
Este é o primeiro hands-on de uma série sobre a construção do meu homelab. Quero registrar a evolução do ambiente desde o servidor físico até as camadas de plataforma, observabilidade, segurança e aplicações.
A proposta é documentar as decisões de arquitetura, as ferramentas escolhidas, os problemas encontrados e o que precisei mudar pelo caminho. Meu objetivo é automatizar o máximo possível da infraestrutura e manter um ambiente no qual eu possa experimentar Kubernetes, observabilidade, segurança e comunicação entre aplicações.
O primeiro desafio: como o GitHub Actions chega ao meu servidor?
O primeiro desafio foi descobrir como um workflow do GitHub conseguiria acessar o servidor que está aqui em casa para executar um simples terraform apply.
Atualmente, uso o Tailscale para conectar meus dispositivos por meio de uma rede privada. Resumindo, ele cria uma tailnet na qual os equipamentos autorizados conseguem se comunicar sem que eu precise expor diretamente o SSH ou outros serviços do servidor para a internet.
Mas como fazer o GitHub chegar até lá?
Para resolver isso, usei a action tailscale/github-action@v4. A partir do TS_OAUTH_CLIENT_ID e do TS_AUDIENCE, configurados como secrets no GitHub, o runner recebe permissão para entrar na minha rede privada com a tag tag:ci.
- name: Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
audience: ${{ secrets.TS_AUDIENCE }}
tags: tag:ci
Depois de entrar na tailnet, o workflow verifica se o servidor andrelab está acessível. Estando tudo certo, ele abre uma sessão com Tailscale SSH e executa o Terraform diretamente no servidor.
O processo atualiza o repositório local com a versão mais recente da branch main, executa o terraform init, gera o plano e aplica as mudanças:
cd ~/homelab
git fetch --all --prune
git reset --hard origin/main
cd infra
terraform init -input=false
terraform plan -input=false
terraform apply -auto-approve -input=false
A chave usada para adicionar as VMs ao Tailscale fica nos secrets do GitHub. Ela é enviada para o processo remoto pela entrada padrão e chega ao Terraform por meio da variável sensível TF_VAR_tailscale_authkey, sem ser gravada no repositório.
Com isso, o fluxo ficou assim:
- Um push é feito na branch
main. - O GitHub Actions conecta o runner à tailnet.
- O workflow verifica a conectividade com o servidor físico.
- O runner acessa o servidor usando Tailscale SSH.
- O Terraform cria ou atualiza a infraestrutura local.
Arquitetura AS IS
Este é o retrato da arquitetura nesta etapa do laboratório. Vou atualizar a série conforme o ambiente evoluir.
+------------------------------+ +---------------------------------------------------------------------+ | GitHub | | Tailscale Network | | | | | | | | +--------------------------+ | | | | | K8s Cluster | | | +------------------------+ | | | | | | | Workflow | |--- SSH --------->| | +------+ +------+ | +------------+ +------------+ | | +------------------------+ | Terraform Apply | | | VM | | VM | | | | | | | | | | | +------+ +------+ | | Laptop | | Smartphone | | | | | | | | | | | | | | | | +------+ +------+ | +------------+ +------------+ | | | | | | VM | | VM | | | | | | | +------+ +------+ | | | | | | | | | | | +--------------------------+ | | | | | +------------------------------+ +---------------------------------------------------------------------+
O objetivo final é ter um cluster Kubernetes para os meus laboratórios, com alguns microsserviços, um API Gateway para o tráfego norte-sul, um service mesh para o tráfego leste-oeste, bancos de dados, event broker e Vault.
Neste primeiro momento, meu foco foi automatizar a criação do cluster. Até aqui, o Terraform já provisiona:
- uma VM para o control plane do K3s;
- duas VMs para os workers;
- Ubuntu Server 24.04 em todas as máquinas;
- Tailscale em todos os nós;
- instalação e configuração automática do cluster K3s;
- geração de um kubeconfig que usa o IP do Tailscale.
Como estou fazendo tudo em bare metal, instalei o Ubuntu Server 24.04 no servidor físico. Nele, uso o Multipass para criar as VMs, enquanto o Terraform fica responsável por declarar e manter toda essa infraestrutura.
Conhecendo o cloud-init
Para provisionar as VMs, estou usando o provider todoroff/multipass:
multipass = {
source = "todoroff/multipass"
version = "~> 1.4"
}
Com esse provider, consigo passar uma configuração de cloud-init para cada instância. Assim, logo no primeiro boot, a VM instala os pacotes necessários, entra na tailnet e instala o K3s sem que eu precise configurar tudo manualmente.
No control plane, o template recebe dois valores gerados e fornecidos pelo Terraform:
cloud_init = templatefile("${path.module}/cloud_init/master.yaml.tpl", {
k3s_token = random_password.k3s_token.result
tailscale_authkey = var.tailscale_authkey
})
O tailscale_authkey é usado somente durante o provisionamento. Dentro da VM, ele é gravado temporariamente em /run/tailscale-authkey, com a permissão 0600. Depois de ser usado pelo comando tailscale up, o arquivo é removido.
Já o k3s_token é uma senha aleatória de 48 caracteres criada pelo Terraform. O mesmo token é enviado para o control plane e para os workers, permitindo que todos os nós entrem no mesmo cluster.
resource "random_password" "k3s_token" {
length = 48
special = false
}
Criando o control plane
Bom, a primeira receita de cloud-init é a do control plane. Nela, instalo as dependências, configuro o Tailscale e só depois instalo o servidor K3s:
#cloud-config
package_update: true
packages:
- apt-transport-https
- ca-certificates
- curl
- containerd
write_files:
- path: /run/tailscale-authkey
permissions: '0600'
owner: root:root
content: "${tailscale_authkey}"
runcmd:
- curl -fsSL https://tailscale.com/install.sh -o /tmp/tailscale-install.sh
- sh /tmp/tailscale-install.sh
- systemctl enable --now tailscaled
- tailscale up --authkey="$(cat /run/tailscale-authkey)" --hostname=$(hostname) --accept-dns=false
- rm -f /run/tailscale-authkey
- curl -sfL https://get.k3s.io -o /tmp/k3s-install.sh
- K3S_TOKEN=${k3s_token} INSTALL_K3S_EXEC="--tls-san=$(tailscale ip -4)" sh /tmp/k3s-install.sh
Essa ordem é importante porque o IP do Tailscale precisa ser incluído como tls-san no certificado da API do Kubernetes. Com isso, consigo acessar a API do cluster pela tailnet sem receber erro de certificado.
A VM do control plane tem 2 CPUs, 4 GB de memória e 20 GB de disco:
resource "multipass_instance" "k8s-master" {
name = "k8s-master"
cpus = "2"
memory = "4G"
image = "24.04"
disk = "20G"
cloud_init = templatefile("${path.module}/cloud_init/master.yaml.tpl", {
k3s_token = random_password.k3s_token.result
tailscale_authkey = var.tailscale_authkey
})
wait_for_cloud_init = true
}
O wait_for_cloud_init = true faz o Terraform esperar o fim da configuração inicial antes de seguir para os recursos que dependem do cluster.
Adicionando os workers
Depois de criar o control plane, chegou a hora de adicionar os workers. O Terraform cria duas VMs com a mesma quantidade de CPU, memória e disco:
resource "multipass_instance" "k8s-worker" {
count = 2
name = "k8s-worker-${count.index}"
cpus = "2"
memory = "4G"
image = "24.04"
disk = "20G"
cloud_init = templatefile("${path.module}/cloud_init/worker.yaml.tpl", {
master_ip = multipass_instance.k8s-master.ipv4[0]
k3s_token = random_password.k3s_token.result
tailscale_authkey = var.tailscale_authkey
})
depends_on = [multipass_instance.k8s-master]
}
Cada worker recebe o IP privado da VM master e o token compartilhado. No cloud-init, esses dados são usados para instalar o agente do K3s e conectá-lo ao cluster:
K3S_URL=https://${master_ip}:6443 K3S_TOKEN=${k3s_token} sh /tmp/k3s-install.sh
Aqui, o depends_on garante que o Terraform só comece a criar os workers depois que o control plane estiver disponível.
Gerando o kubeconfig pela tailnet
Depois que o master está pronto, ainda preciso conseguir acessar o cluster. Para isso, o Terraform lê o arquivo /etc/rancher/k3s/k3s.yaml de dentro da VM. Como o kubeconfig original aponta para 127.0.0.1, substituo esse endereço pelo IP do Tailscale no master.
data "external" "kubeconfig" {
depends_on = [multipass_instance.k8s-master]
program = ["bash", "-c", <<-EOT
set -euo pipefail
TS_IP=$(multipass exec k8s-master -- tailscale ip -4 | tr -d '\r\n')
[ -n "$TS_IP" ] || { echo "tailscale nao retornou IPv4 no master" >&2; exit 1; }
KC=$(multipass exec k8s-master -- sudo cat /etc/rancher/k3s/k3s.yaml \
| sed "s#127\.0\.0\.1#$TS_IP#")
jq -n --arg raw "$KC" '{raw: $raw}'
EOT
]
}
resource "local_sensitive_file" "kubeconfig" {
content = data.external.kubeconfig.result.raw
filename = "${path.module}/${var.kubeconfig_path}"
file_permission = "0600"
}
O resultado é salvo como um arquivo sensível, com a permissão 0600. Além de permitir o acesso ao cluster pela rede privada, esse kubeconfig poderá ser usado pelos providers Kubernetes e Helm nas próximas etapas do projeto.
Estado do Terraform
O projeto usa um backend remoto em um bucket da Oracle Cloud Infrastructure (OCI), no objeto homelab/terraform.tfstate. Optei por usar essa cloud porque atualmente trabalho com ela.
A importância de deixar esse estado fora da máquina é não depender apenas do disco do meu servidor e também evitar que ele seja salvo no repositório. Isso ajuda a tornar as execuções mais consistentes.
Resultado desta primeira etapa
Até aqui, saí de um servidor Ubuntu vazio para um cluster K3s com três nós, conectividade privada pelo Tailscale e provisionamento automatizado com GitHub Actions e Terraform.
A base está pronta. No próximo hands-on da série, vou entrar na camada de observabilidade e mostrar como estou instalando e configurando os primeiros componentes dentro do cluster.
Conforme a arquitetura evoluir, vou registrar por aqui as decisões que funcionaram, os erros que exigiram mudanças e os aprendizados de cada etapa.