Seção 4: Configuração Base das VMs Ubuntu (Docker, Portainer, Cliente NFS)¶
Com as máquinas virtuais (core-services-vm e, opcionalmente, ai-desktop-vm) devidamente provisionadas (Seção 3), o próximo passo é realizar a configuração base essencial dentro delas. Este processo é automatizado pelo Ansible e inclui:
- Instalação de pacotes comuns e utilitários.
- Configuração do cliente NFS para montar os volumes de dados ZFS compartilhados pelo host Proxmox.
- Criação de um usuário e grupo dedicados (
dockeruser,dockergroup) para possuir os arquivos nos volumes NFS e para os containers Docker utilizarem (via PUID/PGID). - Configuração do firewall UFW (Uncomplicated Firewall).
- Instalação do Grafana Alloy, coletor de logs e métricas (seção 4.4).
- Instalação do Docker Engine e Docker Compose na
core-services-vm. - Deploy do container Portainer na
core-services-vmpara gerenciamento gráfico do Docker. - (Opcional) Instalação de um ambiente desktop na
ai-desktop-vmseGUI_ENABLED=true.
Utilizaremos o playbook Ansible setup-base-ubuntu.yml. Este playbook orquestra a aplicação de três roles principais:
1. common: Aplicado a todas as VMs no grupo ubuntu_vms.
2. infra/docker: Aplicado especificamente à core-services-vm.
3. infra/alloy: Aplicado a todas as VMs, depois do Docker (o Alloy lê os containers pelo socket e pelo /var/lib/docker).
E, condicionalmente, o role apps/desktop-environment.
4.1. Role Ansible common (ansible/roles/common/)¶
Este role é fundamental, pois aplica configurações e instala pacotes que são comuns e necessários para todas as nossas VMs baseadas em Ubuntu.
tasks/main.yml¶
Este arquivo contém as tarefas principais do role common.
# ansible/roles/common/tasks/main.yml
- name: "Aguardar cloud-init finalizar completamente na VM"
ansible.builtin.command: cloud-init status --wait
changed_when: false # Este comando não altera o estado, apenas espera
tags: always # Garante que esta task sempre rode
- name: "Atualizar cache APT e instalar pacotes essenciais (incluindo nfs-common)"
ansible.builtin.apt:
name:
- git
- curl
- wget
- htop
- tmux # Multiplexador de terminal, muito útil
- ufw # Uncomplicated Firewall
- python3-apt # Necessário para alguns módulos Ansible de gerenciamento de pacotes
- qemu-guest-agent # Já deve ter sido instalado no template, mas garante
- nfs-common # Cliente NFS, essencial para montar volumes do Proxmox
- unattended-upgrades # Para atualizações automáticas de segurança
- apt-listchanges # Mostra o changelog de pacotes durante upgrades
# Adicione outros utilitários comuns que você usa
state: present
update_cache: yes
notify: Configure unattended-upgrades # Handler para configurar atualizações automáticas
- name: "Garantir que o serviço qemu-guest-agent está rodando e habilitado"
ansible.builtin.systemd_service:
name: qemu-guest-agent
state: started
enabled: yes
- name: "Configurar fuso horário da VM"
community.general.timezone:
name: "{{ system_timezone }}" # Variável do vault.yml
# --- Configuração do Cliente NFS para montar volumes ZFS do Proxmox Host ---
- name: "Criar ponto de montagem base para volumes NFS do Proxmox Host na VM"
ansible.builtin.file:
path: "{{ vm_nfs_mount_base_path }}" # Ex: /mnt/pve_data_zfs (de group_vars/all/main.yml)
state: directory
owner: root
group: root
mode: '0755'
- name: "Montar datasets ZFS do Proxmox Host via NFS na VM"
ansible.posix.mount:
src: "{{ hostvars[groups['proxmox_hosts'][0]]['ansible_host'] }}:{{ zfs_base_pool_path }}/{{ item.dataset_name }}" # Ex: 192.168.15.66:/data/docker-volumes
path: "{{ vm_nfs_mount_base_path }}/{{ item.dataset_name }}" # Ex: /mnt/pve_data_zfs/docker-volumes
fstype: nfs
# Opções de montagem NFS otimizadas. vers=4.2 é recomendado se suportado pelo host Proxmox.
# rsize e wsize maiores podem melhorar a performance em redes gigabit.
# Um item pode trocar as opções (ex.: 'files' só leitura na VM de IA).
# _netdev + x-systemd.mount-timeout (e não 'bg', que o systemd trata como nofail): a montagem fica ordenada
# antes do remote-fs.target, então o Docker (After=remote-fs.target) espera o NFS; com o NFS fora, ela
# desiste em 60 s e o boot segue.
opts: "{{ item.opts | default('rw,sync,hard,intr,_netdev,x-systemd.mount-timeout=60s,noatime,nodiratime,rsize=1048576,wsize=1048576,vers=4.2') }}"
state: mounted # Garante que está montado e adiciona a entrada correspondente ao /etc/fstab
loop: # Itera sobre os datasets ZFS definidos em group_vars/all/main.yml
- { dataset_name: "{{ zfs_docker_volumes_dataset_name }}" }
- { dataset_name: "{{ zfs_media_dataset_name }}" }
# Arquivos do Nextcloud também na VM de IA, só leitura: origem do conversor de documentos (página 12).
- { dataset_name: "{{ zfs_files_dataset_name }}", when_condition: "'ai_desktop' in group_names", opts: "ro,hard,intr,_netdev,x-systemd.mount-timeout=60s,noatime,nodiratime,rsize=1048576,vers=4.2" }
# Fotos (Immich) e arquivos (Nextcloud) na core-services-vm, onde rodam as stacks photos e cloud.
- { dataset_name: "{{ zfs_photos_dataset_name }}", when_condition: "'core_services' in group_names" }
- { dataset_name: "{{ zfs_files_dataset_name }}", when_condition: "'core_services' in group_names" }
# Vaults do Obsidian (segundo cérebro, página 12): só na VM de IA (grupo 'ai_desktop' do inventário).
- { dataset_name: "{{ zfs_notes_dataset_name }}", when_condition: "'ai_desktop' in group_names" }
when: item.when_condition is not defined or item.when_condition # Permite loop condicional
# NOTA: O dataset de backups (zfs_backups_dataset_name) geralmente não precisa ser montado nas VMs.
# --- Criação de Usuários e Grupos Docker ---
# Este usuário e grupo serão donos dos arquivos nos volumes NFS,
# e os containers Docker rodarão com estes PUID/PGID.
- name: "Criar grupo '{{ docker_group_name_on_vm }}' com GID específico"
ansible.builtin.group:
name: "{{ docker_group_name_on_vm }}" # De group_vars/all/main.yml
gid: "{{ docker_pgid }}" # Do vault.yml
state: present
- name: "Criar usuário '{{ docker_user_name_on_vm }}' com UID e grupo específicos"
ansible.builtin.user:
name: "{{ docker_user_name_on_vm }}" # De group_vars/all/main.yml
uid: "{{ docker_puid }}" # Do vault.yml
group: "{{ docker_group_name_on_vm }}" # Grupo criado acima
groups: sudo # Opcional: Adiciona ao grupo sudo para conveniência. Pode ser removido se não desejado.
append: yes
shell: /bin/bash
comment: "Usuário para containers Docker e proprietário de volumes NFS"
# Define uma senha (pode ser a mesma do usuário Ansible da VM ou outra do vault)
# A senha é hasheada com SHA512.
password: "{{ vm_ansible_user_password_plain | password_hash('sha512') }}"
state: present
- name: "Adicionar usuário Ansible da VM ('{{ vm_ansible_user_name }}') ao grupo '{{ docker_group_name_on_vm }}'"
ansible.builtin.user:
name: "{{ vm_ansible_user_name }}" # Usuário Ansible criado via Cloud-Init
groups: "{{ docker_group_name_on_vm }}" # Adiciona a este grupo
append: yes
# O usuário Ansible já é sudo por padrão via Cloud-Init.
# --- Firewall UFW (Uncomplicated Firewall) ---
# Em todas as VMs: entrada negada por padrão, saída liberada. Vale para os serviços do próprio host (SSH,
# Alloy na 12345, VNC). Portas publicadas pelo Docker não passam pelo UFW: elas são limitadas pela cadeia
# HOMESERVER na DOCKER-USER (firewall_docker_portas, role infra/docker; página 14) e pela autenticação de cada serviço.
- name: "UFW: liberar SSH (antes de ligar, para não perder a conexão)"
community.general.ufw:
rule: allow
port: '22'
proto: tcp
comment: "SSH"
- name: "UFW: liberar HTTP e HTTPS do Traefik na core-services-vm"
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
comment: "Traefik"
loop: ['80', '443']
when: inventory_hostname in groups['core_services']
- name: "UFW: liberar o VNC na VM com ambiente gráfico (GUI_ENABLED)"
community.general.ufw:
rule: allow
port: '5901'
proto: tcp
comment: "VNC :1"
when: hostvars[inventory_hostname]['GUI_ENABLED'] | default(false) | bool
- name: "UFW: negar entrada por padrão"
community.general.ufw:
default: deny
direction: incoming
- name: "UFW: liberar saída por padrão"
community.general.ufw:
default: allow
direction: outgoing
- name: "UFW: ligar"
community.general.ufw:
state: enabled
handlers/main.yml¶
Handlers para o role common.
# ansible/roles/common/handlers/main.yml
- name: Configure unattended-upgrades
ansible.builtin.copy:
content: |
// Configurações para atualizações automáticas.
// Arquivo: /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
dest: /etc/apt/apt.conf.d/20auto-upgrades
owner: root
group: root
mode: '0644'
# Para uma configuração mais detalhada de unattended-upgrades (e.g., Allowed-Origins, Blacklist, Email),
# você pode usar um template para /etc/apt/apt.conf.d/50unattended-upgrades.
# Exemplo:
# - name: Configure unattended-upgrades details (50unattended-upgrades)
# ansible.builtin.template:
# src: 50unattended-upgrades.j2
# dest: /etc/apt/apt.conf.d/50unattended-upgrades
# owner: root
# group: root
# mode: '0644'
- Principais Tarefas do Role
common: - Aguardar a finalização completa do
cloud-init. - Atualizar o cache APT e instalar pacotes essenciais como
git,curl,htop,ufw,nfs-common,unattended-upgrades,qemu-guest-agent. - Garantir que o serviço
qemu-guest-agentesteja ativo. - Configurar o fuso horário da VM usando a variável
{{ system_timezone }}. - Configurar Cliente NFS:
- Criar o ponto de montagem base na VM (e.g.,
/mnt/pve_data_zfs, definido por{{ vm_nfs_mount_base_path }}). - Montar os datasets ZFS compartilhados pelo host Proxmox (
docker-volumesemediaem todas;photosefilesna core;notesefilessó leitura na VM de IA) nos subdiretórios correspondentes, adicionando entradas ao/etc/fstabpara persistência.
- Criar o ponto de montagem base na VM (e.g.,
- Criar Usuário e Grupo Docker:
- Criar o grupo
{{ docker_group_name_on_vm }}com o GID{{ docker_pgid }}. - Criar o usuário
{{ docker_user_name_on_vm }}com o UID{{ docker_puid }}, associado ao grupo acima, e com uma senha definida. - Adicionar o usuário Ansible da VM (
{{ vm_ansible_user_name }}) também ao grupo{{ docker_group_name_on_vm }}.
- Criar o grupo
- Configurar Firewall UFW: em todas as VMs, entrada negada por padrão; libera SSH (antes de ligar o firewall), HTTP/S do Traefik só na
core-services-vme VNC só ondeGUI_ENABLEDé verdadeiro. A porta 12345 do Alloy é liberada pelo roleinfra/alloy. Portas publicadas pelo Docker não passam pelo UFW: quem as limita é o firewall do roledocker(abaixo).
4.2. Role Ansible docker (ansible/roles/infra/docker/)¶
Este role roda em todas as VMs Ubuntu (ubuntu_vms). A variável docker_portainer_mode (em ansible/roles/infra/docker/defaults/main.yml) decide o que cada uma recebe além do Docker: server na core-services-vm (o Portainer completo, a rede proxy e o diretório no NFS) e agent nas demais (o Portainer Agent na porta 9001, publicada só no IP da LAN, pelo qual o Portainer central gerencia as stacks daquela VM). As portas publicadas pelo Docker não passam pelo UFW. O agente tem duas proteções: o firewall do role (cadeia HOMESERVER na DOCKER-USER, gerada de firewall_docker_portas), que só deixa a core-services-vm alcançar a 9001 pela LAN (seção 14), e o segredo portainer_agent_secret do vault (AGENT_SECRET), que o Portainer central também recebe. Suas principais responsabilidades são:
- Instalar o Docker Engine e o plugin Docker Compose.
- Adicionar os usuários relevantes (
{{ vm_ansible_user_name }},{{ docker_user_name_on_vm }}) ao grupodockerdo sistema para permitir a execução de comandos Docker semsudo. - Na
core-services-vm: criar o diretório NFS para os dados persistentes do Portainer. - Na
core-services-vm: criar a rede Dockerproxy(que será usada pelo Traefik e outros serviços expostos). - Nas demais VMs: fazer o deploy do Portainer Agent (
portainer_agent_image), com o socket do Docker e oAGENT_SECRET. - Na
core-services-vm: fazer o deploy do container Portainer CE, configurando-o com: - Mapeamento do socket Docker.
- Volume persistente para seus dados (no diretório NFS preparado).
- Conexão à rede
proxy. - Labels Traefik para que o Portainer seja automaticamente descoberto e exposto via
https://portainer.{{ base_domain }}e protegido por Authelia.
tasks/main.yml¶
# ansible/roles/infra/docker/tasks/main.yml
# Instala Docker Engine em todas as VMs Ubuntu; Portainer CE na core-services-vm e Portainer Agent nas demais.
- name: "Adicionar chave GPG oficial do Docker"
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.gpg # Novo caminho recomendado para chaves GPG
mode: '0644'
force: true # Sobrescreve se já existir, para garantir a chave mais recente
- name: "Adicionar repositório Docker"
ansible.builtin.apt_repository:
repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
filename: docker # Nome do arquivo .list em /etc/apt/sources.list.d/
update_cache: yes # Atualiza o cache após adicionar o repo
# Antes da instalação: o daemon já sobe assim, sem imagens a migrar. Com o image store do containerd
# (padrão no Docker 29) o cAdvisor do Alloy não encontra os containers (seção 4.4).
- name: "Garantir o diretório /etc/docker"
ansible.builtin.file:
path: /etc/docker
state: directory
mode: '0755'
# default-address-pools: as redes Docker saem só de 172.16.0.0/12 (blocos /24). As regras de UFW que confiam
# nessa faixa valem por construção, e o Docker nunca usa 192.168.0.0/16, onde está a LAN.
- name: "Configurar o daemon do Docker sem o image store do containerd"
ansible.builtin.copy:
dest: /etc/docker/daemon.json
content: |
{
"features": {
"containerd-snapshotter": false
},
"default-address-pools": [
{"base": "172.16.0.0/12", "size": 24}
]
}
owner: root
group: root
mode: '0644'
notify: Reiniciar Docker # numa VM já instalada, a configuração nova só vale com o daemon reiniciado
# Ordem de boot: o Docker sobe depois das montagens NFS (remote-fs.target), para nenhum container nascer com a
# pasta vazia por baixo. Wants (e não Requires): com o NFS fora, a core ainda sobe Traefik e Authelia.
- name: "Garantir o drop-in do serviço Docker"
ansible.builtin.file:
path: /etc/systemd/system/docker.service.d
state: directory
mode: '0755'
- name: "Docker depois das montagens de rede"
ansible.builtin.copy:
dest: /etc/systemd/system/docker.service.d/10-montagens.conf
content: |
[Unit]
After=remote-fs.target
Wants=remote-fs.target
owner: root
group: root
mode: '0644'
notify: Reiniciar Docker
- name: "Instalar Docker Engine, CLI, Containerd e plugins Compose/Buildx"
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin # Para `docker compose` (v2)
state: present
- name: "Garantir que o serviço Docker está rodando e habilitado"
ansible.builtin.systemd_service:
name: docker
state: started
enabled: yes
daemon_reload: true # lê o drop-in de montagens
- name: "Adicionar usuários ao grupo 'docker' do sistema para permitir execução de comandos Docker sem sudo"
ansible.builtin.user:
name: "{{ item }}"
groups: docker # Grupo 'docker' é criado automaticamente pela instalação do Docker CE
append: yes
loop:
- "{{ vm_ansible_user_name }}" # Usuário Ansible da VM
- "{{ docker_user_name_on_vm }}" # Usuário dedicado para volumes Docker
notify: Reiniciar VM para aplicar mudanças de grupo Docker # Ou pedir logout/login do usuário
- name: "Criar diretório para dados persistentes do Portainer (via NFS)"
ansible.builtin.file:
# Caminho no host (VM core-services-vm) onde o volume do Portainer será montado.
# Este caminho DEVE existir DENTRO da VM e ser um ponto de montagem NFS do Proxmox Host.
# {{ vm_nfs_mount_base_path }}/{{ zfs_docker_volumes_dataset_name }} é o caminho base NFS na VM.
path: "{{ vm_nfs_mount_base_path }}/{{ zfs_docker_volumes_dataset_name }}/portainer/data"
state: directory
owner: "{{ docker_puid }}" # PUID do vault.yml (usuário que rodará o container Portainer, ou root)
group: "{{ docker_pgid }}" # PGID do vault.yml
mode: '0755' # Permissões para o container Portainer escrever.
# A criação do diretório base no dataset ZFS do Proxmox host (e.g., /data/docker-volumes/portainer/data)
# pode ser feita aqui também ou assumir que o dataset pai já existe.
# Para simplicidade, esta task garante o subdiretório no montado NFS.
when: docker_portainer_mode == 'server'
- name: "Criar rede Docker 'proxy' se não existir (para Traefik e serviços expostos)"
community.docker.docker_network:
name: proxy
state: present
# Outras opções como 'driver: bridge' são padrão.
when: docker_portainer_mode == 'server' # O Traefik só roda na core-services-vm
- name: "Deploy Portainer CE container"
community.docker.docker_container:
name: portainer
image: "{{ portainer_ce_image }}" # versão fixada em defaults/main.yml (a mesma do agente)
# HTTP na 9000 só dentro da rede Docker 'proxy' (não é publicada): é por onde o MCP ops (página 13) fala com a
# API do Portainer. Publicá-la mandaria senha e chave de API em texto claro pela LAN.
command: ["--http-enabled"]
restart_policy: unless-stopped
security_opt: ["no-new-privileges:true"] # Boa prática de segurança
ports:
# Mapeia portas apenas no IP da VM 'core-services-vm' para evitar conflitos e melhor controle.
# Acesso principal via Traefik na porta 443. Estas são para acesso direto se necessário.
- "{{ hostvars[inventory_hostname]['ansible_host'] }}:9443:9443" # HTTPS principal do Portainer
- "{{ hostvars[inventory_hostname]['ansible_host'] }}:8000:8000" # Porta para o Edge Agent do Portainer (opcional)
env:
# Segredo compartilhado com o Portainer Agent das outras VMs: o agente só atende quem o conhece.
AGENT_SECRET: "{{ portainer_agent_secret }}"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock" # Permite Portainer gerenciar o Docker local
# Caminho no host (VM core-services-vm) onde o volume NFS do Portainer está montado
- "{{ vm_nfs_mount_base_path }}/{{ zfs_docker_volumes_dataset_name }}/portainer/data:/data" # Dados persistentes do Portainer
networks:
- proxy # Conecta Portainer à rede 'proxy' para ser descoberto pelo Traefik
labels: # Labels para Traefik descobrir e rotear para Portainer
traefik.enable: "true"
# Roteador para Portainer
traefik.http.routers.portainer.rule: "Host(`portainer.{{ base_domain }}`)" # base_domain do vault.yml
traefik.http.routers.portainer.entrypoints: "websecure" # Usa o entrypoint HTTPS do Traefik
traefik.http.routers.portainer.tls.certresolver: "letsencrypt" # Usa Let's Encrypt para SSL
traefik.http.routers.portainer.service: "portainer-svc" # Nome do serviço Traefik para Portainer
# Serviço Traefik para Portainer
traefik.http.services.portainer-svc.loadbalancer.server.port: "9443" # Porta interna do Portainer (HTTPS)
traefik.http.services.portainer-svc.loadbalancer.server.scheme: "https" # Esquema para o backend
# Middleware de Autenticação Authelia
# Refere-se ao middleware Authelia que será definido no docker-compose do Authelia (Seção 5).
traefik.http.routers.portainer.middlewares: "authelia-forwardauth@docker"
when: docker_portainer_mode == 'server'
# Nas demais VMs (ai-desktop-vm), o Portainer Agent: o Portainer central gerencia as stacks dela por aqui.
# Porta só no IP da LAN, aceita só da core (firewall_docker_portas, abaixo) e protegida pelo AGENT_SECRET
# (o mesmo do Portainer server): sem ele, o agente recusa a conexão.
- name: "Deploy Portainer Agent container"
community.docker.docker_container:
name: portainer_agent
image: "{{ portainer_agent_image }}"
restart_policy: unless-stopped
security_opt: ["no-new-privileges:true"]
ports:
- "{{ hostvars[inventory_hostname]['ansible_host'] }}:9001:9001"
env:
AGENT_SECRET: "{{ portainer_agent_secret }}"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "/var/lib/docker/volumes:/var/lib/docker/volumes"
when: docker_portainer_mode == 'agent'
# Firewall das portas publicadas pelo Docker (página 14): o UFW não vê esse tráfego. As regras ficam na cadeia
# DOCKER-USER e são refeitas antes de cada início do Docker (Before= e PartOf=docker.service): quando os
# containers sobem, as regras já estão lá.
- name: "Script do firewall das portas publicadas pelo Docker"
ansible.builtin.template:
src: firewall-docker.sh.j2
dest: /usr/local/sbin/firewall-docker.sh
owner: root
group: root
mode: '0750'
notify: Aplicar firewall do Docker
- name: "Unit do firewall das portas publicadas pelo Docker"
ansible.builtin.copy:
dest: /etc/systemd/system/firewall-docker.service
owner: root
group: root
mode: '0644'
content: |
[Unit]
Description=Regras da cadeia DOCKER-USER (portas publicadas pelo Docker; página 14)
Before=docker.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=true
ExecStart=/usr/local/sbin/firewall-docker.sh
[Install]
WantedBy=docker.service
notify: Aplicar firewall do Docker
- name: "Habilitar o firewall das portas publicadas pelo Docker"
ansible.builtin.systemd_service:
name: firewall-docker.service
enabled: true
state: started
daemon_reload: true
when: not ansible_check_mode # no --check a unit só foi simulada
handlers/main.yml¶
Handler para o role docker.
# ansible/roles/infra/docker/handlers/main.yml
# daemon.json ou drop-in mudados numa VM já instalada. Os containers com restart policy voltam sozinhos.
- name: Reiniciar Docker
ansible.builtin.systemd_service:
name: docker
state: restarted
daemon_reload: true
when: not ansible_check_mode # numa VM nova, no --check, o Docker ainda não existe
# Regras novas em firewall_docker_portas (página 14).
- name: Aplicar firewall do Docker
ansible.builtin.systemd_service:
name: firewall-docker.service
state: restarted
daemon_reload: true
when: not ansible_check_mode
- name: Reiniciar VM para aplicar mudanças de grupo Docker
ansible.builtin.reboot:
msg: "Reiniciando a VM para que as mudanças de associação ao grupo 'docker' tenham efeito para os usuários."
connect_timeout: 5
reboot_timeout: 300 # Tempo máximo para a VM reiniciar e SSH estar disponível
pre_reboot_delay: 0
post_reboot_delay: 30 # Espera após o reboot para garantir que SSH esteja pronto
test_command: uptime # Comando para verificar se a VM está de volta
# Este handler é chamado se a task de adicionar usuário ao grupo 'docker' fizer alguma mudança.
# É necessário para que o usuário Ansible e dockeruser possam rodar comandos Docker sem sudo
# em sessões SSH subsequentes sem precisar fazer logout/login manual.
4.3. Playbook ansible/playbooks/setup-base-ubuntu.yml¶
Este playbook orquestra a aplicação dos roles common e docker (e apps/desktop-environment condicionalmente) às VMs apropriadas.
Certifique-se de que o arquivo ansible/playbooks/setup-base-ubuntu.yml exista com o seguinte conteúdo:
# ansible/playbooks/setup-base-ubuntu.yml
# Configura a base para todas as VMs Ubuntu e instala Docker/Portainer na VM de containers.
- hosts: ubuntu_vms # Alvo: todas as VMs definidas no grupo 'ubuntu_vms' do inventário
become: yes # Tarefas nos roles 'common' e 'docker' exigem privilégios de root
vars_files:
- ../inventories/home/group_vars/all/vault.yml
# Variáveis de group_vars/all/main.yml são carregadas automaticamente
roles:
- role: common # Aplica configurações base, cliente NFS e UFW a todas as VMs Ubuntu
# Docker em todas as VMs Ubuntu. O Portainer completo fica na core-services-vm; nas demais (ai-desktop-vm)
# roda o Portainer Agent, que o Portainer central usa para gerenciar as stacks dela (docker_portainer_mode).
- hosts: ubuntu_vms
become: yes
vars_files:
- ../inventories/home/group_vars/all/vault.yml # base_domain nas labels do Portainer e portainer_agent_secret
roles:
- role: infra/docker # Instala Docker Engine e o Portainer (server) ou o Portainer Agent (agent)
# Coletor de logs e métricas (seção 4.4). Depois do Docker, cujos containers ele lê.
- hosts: ubuntu_vms
become: yes
roles:
- role: infra/alloy
# Opcional: Instalar ambiente gráfico em VMs específicas
# A variável GUI_ENABLED deve ser definida no inventário para os hosts desejados.
# Exemplo em ansible/inventories/home/hosts.ini:
# ai-desktop-vm ansible_host=192.168.15.12 GUI_ENABLED=true
- hosts: ubuntu_vms # Verifica todas as VMs Ubuntu que podem ter a flag
become: yes
tasks:
- name: "Incluir role para ambiente desktop (se GUI_ENABLED for true para o host)"
ansible.builtin.include_role:
name: apps/desktop-environment # Role definido na Seção 3.5
vars:
# Passa a flag para o role, convertendo para booleano e tratando ausência da variável
install_gui_flag: "{{ hostvars[inventory_hostname]['GUI_ENABLED'] | default(false) | bool }}"
when: hostvars[inventory_hostname]['GUI_ENABLED'] | default(false) | bool # Só executa se a flag for true
Executando o Playbook setup-base-ubuntu.yml¶
- Verifique os Roles: Certifique-se de que os roles
common,infra/docker,infra/alloy(seção 4.4) eapps/desktop-environment(se for usar a funcionalidade de GUI) estão criados e preenchidos com o código correto nos seus respectivos diretórios dentro deansible/roles/. - Execute a Partir da Máquina de Controle: Na sua máquina de controle Ansible, entre na pasta
server/ansibledo repositório. -
Execute o Playbook:
Você será solicitado a fornecer a senha do Ansible Vault.
Possível Reinício das VMs
O role infra/docker inclui um handler que reiniciará a VM (core-services-vm) se o usuário Ansible ({{ vm_ansible_user_name }}) ou o {{ docker_user_name_on_vm }} forem adicionados ao grupo docker do sistema pela primeira vez. Isso é para garantir que as novas associações de grupo tenham efeito imediato para esses usuários em sessões SSH subsequentes, permitindo que eles executem comandos docker sem sudo.
Após a execução bem-sucedida deste playbook:
- Todas as suas VMs Ubuntu terão as configurações base (NFS client, UFW, Alloy, etc.).
- A
core-services-vmterá o Docker Engine instalado e o container Portainer CE rodando. - A
ai-desktop-vm, seGUI_ENABLED=truefoi definido para ela no inventário, terá o ambiente XFCE e TightVNCServer instalados.
4.4. Coleta com Grafana Alloy (ansible/roles/infra/alloy/)¶
O Grafana Alloy é o coletor único da infraestrutura: roda instalado no sistema (pacote alloy do repositório apt da Grafana, serviço systemd) no host Proxmox e nas duas VMs. Ele substitui o Promtail (fim da vida em 2026-03-02) e o node_exporter, e acrescenta métricas por container:
| Bloco | O que coleta | Para onde | Etiquetas |
|---|---|---|---|
| Journal | journal do systemd | Loki | job="system_journal", unit, host |
/var/log |
messages, syslog, auth.log, kern.log |
Loki | job="var_log", host |
| Docker (só VMs) | logs de todos os containers, pela API do Docker | Loki | job="docker_logs", container_name, host |
| Host | métricas do sistema (node_*, inclusive node_zfs_* no host Proxmox) |
Prometheus (remote_write) |
job="node", instance |
| Containers (só VMs) | cAdvisor embutido (container_*, etiqueta name) |
Prometheus (remote_write) |
job="cadvisor", instance |
host e instance são o nome do host no inventário (proxmox_main_host, core-services-vm, ai-desktop-vm). O Prometheus também raspa o /metrics do próprio Alloy em cada host (job alloy, porta 12345): é desse up que sai o alerta host-sem-resposta.
O role é aplicado pelo setup-base-ubuntu.yml (VMs) e pelo setup-proxmox-host.yml (host). A versão fica fixada em alloy_version no group_vars/all/main.yml; para atualizar, troque o valor e rode os dois playbooks.
Alloy como root nas VMs
Para ler os logs e as métricas dos containers, o Alloy roda como root nas VMs (drop-in /etc/systemd/system/alloy.service.d/override.conf): o cAdvisor lê /var/lib/docker, que só o root acessa, e o grupo docker sozinho não basta (e já equivaleria a root). No host Proxmox, sem Docker, ele roda como o usuário alloy (grupos adm e systemd-journal). O Docker é instalado sem o image store do containerd (/etc/docker/daemon.json, seção 4.2): com ele, padrão no Docker 29, o cAdvisor não encontra os containers. A interface do Alloy (http://<host>:12345) não tem autenticação e mostra a configuração (sem segredos); o firewall libera a porta só para a core-services-vm (UFW nas VMs; PVE Firewall no host). Na core-services-vm a origem liberada é 172.16.0.0/12, porque o Prometheus em container chega ao IP do próprio host vindo da rede Docker.
defaults/main.yml¶
# ansible/roles/infra/alloy/defaults/main.yml
# Endereço da interface/API do Alloy (o Prometheus raspa o /metrics daqui).
alloy_listen_addr: "0.0.0.0:12345"
# Destinos na core-services-vm (portas publicadas só na LAN).
alloy_loki_push_url: "http://{{ core_services_vm_ip_var }}:3100/loki/api/v1/push"
alloy_remote_write_url: "http://{{ core_services_vm_ip_var }}:9090/api/v1/write"
# Logs e métricas de containers só onde há Docker (as VMs Ubuntu).
alloy_docker_enabled: "{{ inventory_hostname in groups['ubuntu_vms'] }}"
# Origem liberada no UFW para a porta do Alloy: na core, a rede Docker do Prometheus; nas demais, o IP da core.
alloy_scrape_allowed_from: "{{ '172.16.0.0/12' if inventory_hostname == 'core-services-vm' else core_services_vm_ip_var }}"
tasks/main.yml¶
# ansible/roles/infra/alloy/tasks/main.yml
# Instala o Grafana Alloy (repositório apt da Grafana) e gera /etc/alloy/config.alloy.
- name: "Garantir o diretório de chaves do apt"
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: "Adicionar a chave do repositório da Grafana"
ansible.builtin.get_url:
url: https://apt.grafana.com/gpg.key
dest: /etc/apt/keyrings/grafana.asc
mode: '0644'
- name: "Adicionar o repositório apt da Grafana"
ansible.builtin.apt_repository:
repo: "deb [signed-by=/etc/apt/keyrings/grafana.asc] https://apt.grafana.com stable main"
state: present
filename: grafana
update_cache: true
# O pin vale também para 'apt upgrade' manual e para o upgrade geral do host Proxmox.
- name: "Fixar a série do Alloy (alloy_version) no apt"
ansible.builtin.copy:
dest: /etc/apt/preferences.d/alloy
content: |
# Gerenciado pelo Ansible (role infra/alloy): instalação e upgrade ficam na série fixada.
Package: alloy
Pin: version {{ alloy_version }}
Pin-Priority: 1001
owner: root
group: root
mode: '0644'
- name: "Instalar o Alloy (a versão mais nova da série fixada)"
ansible.builtin.apt:
name: alloy
state: latest
update_cache: true
cache_valid_time: 3600
notify: Restart alloy
# journal (systemd-journal) e /var/log (adm). No host Proxmox o Alloy roda como o usuário alloy.
- name: "Dar ao usuário alloy acesso ao journal e ao /var/log"
ansible.builtin.user:
name: alloy
groups: [adm, systemd-journal]
append: true
notify: Restart alloy
# Onde há Docker, o cAdvisor lê /var/lib/docker (0710 root:root) e o socket: o grupo docker não basta,
# então o serviço roda como root (o grupo docker já equivaleria a root).
- name: "Criar o diretório de drop-ins do serviço alloy"
ansible.builtin.file:
path: /etc/systemd/system/alloy.service.d
state: directory
mode: '0755'
when: alloy_docker_enabled | bool
- name: "Rodar o Alloy como root onde há Docker (cAdvisor)"
ansible.builtin.copy:
dest: /etc/systemd/system/alloy.service.d/override.conf
content: |
# Gerenciado pelo Ansible (role infra/alloy)
[Service]
User=root
owner: root
group: root
mode: '0644'
when: alloy_docker_enabled | bool
notify: Restart alloy
- name: "Configurar /etc/default/alloy (arquivo de configuração e endereço de escuta)"
ansible.builtin.copy:
dest: /etc/default/alloy
content: |
# Gerenciado pelo Ansible (role infra/alloy)
CONFIG_FILE="/etc/alloy/config.alloy"
CUSTOM_ARGS="--server.http.listen-addr={{ alloy_listen_addr }}"
RESTART_ON_UPGRADE=true
owner: root
group: root
mode: '0644'
notify: Restart alloy
- name: "Gerar /etc/alloy/config.alloy (só grava se a configuração for válida)"
ansible.builtin.template:
src: config.alloy.j2
dest: /etc/alloy/config.alloy
owner: root
group: alloy
mode: '0640'
validate: alloy validate %s
notify: Reload alloy
- name: "Garantir que o Alloy está habilitado e rodando"
ansible.builtin.systemd_service:
name: alloy
state: started
enabled: true
- name: "UFW: liberar a porta do Alloy só para o Prometheus da core-services-vm"
community.general.ufw:
rule: allow
port: '12345'
proto: tcp
from_ip: "{{ alloy_scrape_allowed_from }}"
comment: "Alloy: Prometheus da core-services-vm"
when: inventory_hostname in groups['ubuntu_vms']
handlers/main.yml¶
# ansible/roles/infra/alloy/handlers/main.yml
- name: Reload alloy
ansible.builtin.systemd_service:
name: alloy
state: reloaded
- name: Restart alloy
ansible.builtin.systemd_service:
name: alloy
state: restarted
daemon_reload: true # aplica o drop-in de /etc/systemd/system/alloy.service.d
templates/config.alloy.j2¶
// ansible/roles/infra/alloy/templates/config.alloy.j2
// Gerado pelo Ansible (role infra/alloy) para {{ inventory_hostname }}.
// ---------- Destinos ----------
loki.write "default" {
endpoint {
url = "{{ alloy_loki_push_url }}"
}
}
prometheus.remote_write "default" {
endpoint {
url = "{{ alloy_remote_write_url }}"
}
}
// ---------- Logs: journal do systemd ----------
loki.relabel "journal" {
forward_to = []
rule {
source_labels = ["__journal__systemd_unit"]
target_label = "unit"
}
}
loki.source.journal "system" {
max_age = "12h"
relabel_rules = loki.relabel.journal.rules
labels = { job = "system_journal", host = "{{ inventory_hostname }}" }
forward_to = [loki.write.default.receiver]
}
// ---------- Logs: /var/log ----------
local.file_match "var_log" {
path_targets = [{ "__path__" = "/var/log/{messages,syslog,auth.log,kern.log}", job = "var_log", host = "{{ inventory_hostname }}" }]
}
loki.source.file "var_log" {
targets = local.file_match.var_log.targets
forward_to = [loki.write.default.receiver]
}
// ---------- Métricas do host ----------
// O coletor systemd (além dos padrões) expõe o estado das units notes-* (timers do segundo cérebro, página 12)
// e backup-* (dumps diários dos bancos), usado pelo alerta timer-falhou. Restrito a elas para não gerar uma
// série por unit do sistema.
prometheus.exporter.unix "host" {
enable_collectors = ["systemd"]
systemd {
unit_include = "(notes|backup)-.+\\.service"
}
}
discovery.relabel "host" {
targets = prometheus.exporter.unix.host.targets
rule {
target_label = "job"
replacement = "node"
}
rule {
target_label = "instance"
replacement = "{{ inventory_hostname }}"
}
}
prometheus.scrape "host" {
targets = discovery.relabel.host.output
forward_to = [prometheus.remote_write.default.receiver]
}
{% if alloy_docker_enabled | bool %}
// ---------- Logs dos containers (API do Docker) ----------
discovery.docker "local" {
host = "unix:///var/run/docker.sock"
}
// O Docker entrega o nome como "/traefik"; a etiqueta fica sem a barra.
discovery.relabel "docker_logs" {
targets = []
rule {
source_labels = ["__meta_docker_container_name"]
regex = "/(.*)"
target_label = "container_name"
}
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.local.targets
relabel_rules = discovery.relabel.docker_logs.rules
labels = { job = "docker_logs", host = "{{ inventory_hostname }}" }
forward_to = [loki.write.default.receiver]
}
// ---------- Métricas por container (cAdvisor embutido) ----------
prometheus.exporter.cadvisor "containers" {
docker_host = "unix:///var/run/docker.sock"
docker_only = true
store_container_labels = false
}
discovery.relabel "cadvisor" {
targets = prometheus.exporter.cadvisor.containers.targets
rule {
target_label = "job"
replacement = "cadvisor"
}
rule {
target_label = "instance"
replacement = "{{ inventory_hostname }}"
}
}
prometheus.scrape "cadvisor" {
targets = discovery.relabel.cadvisor.output
forward_to = [prometheus.remote_write.default.receiver]
}
{% endif %}
Verificação depois do deploy¶
- Nos três hosts:
systemctl status alloy; a interfacehttp://<host>:12345mostra os componentes saudáveis. - No Prometheus:
up{job="alloy"}vale 1 nos três hosts;node_zfs_zpool_statechega doproxmox_main_host;container_last_seen{name="traefik"}chega dacore-services-vm. - No Grafana (Explore → Loki):
{job="docker_logs", host="ai-desktop-vm"}e{job="system_journal", host="proxmox_main_host"}trazem linhas; o alertaauthelia-logins-falhostem dados. - Com o PVE Firewall ligado, as montagens NFS das VMs continuam funcionando (
df -h | grep /mnt).
Dashboards
Nenhum dashboard é provisionado. No Grafana, Dashboards → New → Import aceita os dashboards da comunidade para node exporter e cAdvisor (busque por "Node Exporter Full" e "cAdvisor" em grafana.com/grafana/dashboards); escolha o datasource Prometheus ao importar.
Com esta base sólida, suas VMs estão prontas para o próximo passo crucial: a Implementação da Rede Segura, que configurará Traefik, Cloudflare Tunnel e Authelia.