Tutoriais

Elastic Stack

Tutorial completo de Elasticsearch, Kibana, Filebeat e Fleet — instalação, segurança, ILM, ingest pipelines, dashboards, alertas e APM.

Tutorial completo para subir uma stack de observabilidade de logs do zero até dashboards e alertas funcionando, integrando aplicações Spring Boot, frontends Vue, nginx-proxy, Keycloak e Postgres.

Versão alvo da Elastic Stack: 8.16.x (linha 8.x estável atual em 2026). Sempre que ver ${STACK_VERSION} nos exemplos, substitua pela tag exata que você quer fixar.


1. Visão geral

A Elastic Stack (antiga “ELK”) é um conjunto de componentes para coletar, processar, armazenar, buscar e visualizar dados — principalmente logs, métricas e traces.

ComponenteFunçãoOnde roda
ElasticsearchBanco de dados de busca distribuído (storage + query engine). Indexa documentos JSON.Servidor central
KibanaFrontend web para Elasticsearch. Discover, dashboards, ILM UI, Stack Management, Alerts.Mesmo host do ES
LogstashPipeline pesada de ETL. Lê de N inputs (Beats, Kafka, syslog, JDBC), aplica filters (grok, mutate, ruby), envia pra N outputs.Opcional, host central
BeatsFamília de shippers leves (Go). Filebeat para logs de arquivo, Metricbeat para métricas de host, Packetbeat, Auditbeat, Heartbeat.Em cada host produtor de log
Elastic AgentSubstituto unificado dos Beats. Um único binário que faz log + metrics + APM + endpoint security. Gerenciado centralmente via Fleet.Em cada host produtor
Fleet ServerPlano de controle dos Elastic Agents. Roda como subprocesso dentro de um Elastic Agent dedicado. Distribui policies e coleta status.Host central, ao lado do ES

Logstash vs Ingest Pipeline

Você não precisa de Logstash. A maioria dos casos práticos é resolvida com Ingest Pipelines — pipelines executadas dentro do próprio Elasticsearch (nó coordinador), configuradas via API ou Kibana. Use Logstash quando:

  • Precisar de input não-Elastic (Kafka, JDBC, syslog UDP, IMAP).
  • Tiver fan-out complexo (mesmo evento vai pra ES + S3 + webhook).
  • Quiser buffer persistente em disco (Logstash persistent queue) entre Beats e ES.
  • Usar plugins ruby/aggregate que ingest pipeline não tem.

Para logs JSON estruturados do Spring Boot + nginx, ingest pipeline cobre 100%.

Basic license vs Gold/Platinum

A partir da 8.x quase tudo importante está na basic license (gratuita, código fonte aberto sob Elastic License v2):

  • Security (TLS, RBAC, API keys, file/PKI realms)
  • Alerting básico (rule type “Elasticsearch query”, “Index threshold”)
  • ILM, SLM, snapshots
  • Canvas, Lens, Maps
  • APM Server + agentes
  • Fleet + Elastic Agent

Fica para Gold/Platinum/Enterprise: machine learning anomaly detection, LDAP/SAML/OIDC realms (no Kibana), cross-cluster replication, searchable snapshots, alerting webhook “connector” pre-built avançado, suporte. Para a maioria dos ambientes self-hosted a basic é mais que suficiente.

Alternativas

StackQuando faz sentido
OpenSearch (fork AWS)Quer licença Apache 2.0 pura, sem Elastic License v2. APIs compatíveis até ES 7.10.
Grafana LokiJá tem Grafana e quer só logs (sem busca full-text indexada — Loki indexa só labels). Muito mais barato em disco.
VictoriaLogsSingle binary, performático, simples. Sem ML/APM.
GraylogUI mais simples para audit/SIEM. Usa Elasticsearch/OpenSearch por baixo.

Para esse tutorial seguimos com Elastic Stack 8.x porque integra logs + métricas + traces (APM) num único produto, e Kibana > Grafana para investigação de logs (Discover, Lens, runtime fields).


2. Arquitetura

flowchart LR
    subgraph Apps[Aplicações]
        SF[api-backend<br/>Spring Boot + logback JSON]
        FE[frontends Vue<br/>nginx access/error]
        NP[nginx-proxy<br/>access/error logs]
        KC[keycloak<br/>stdout JSON]
        PG[postgres<br/>csvlog]
    end

    subgraph Coletor[Coletor por host]
        FB[Filebeat<br/>autodiscover docker]
        EA[Elastic Agent<br/>gerenciado por Fleet]
    end

    subgraph Pipeline[Processamento opcional]
        LS[Logstash<br/>se precisar]
    end

    subgraph Storage[Storage + Query]
        ES[(Elasticsearch<br/>single-node)]
        KB[Kibana 5601]
    end

    subgraph Control[Plano de controle]
        FS[Fleet Server]
    end

    User[Usuário via VPN] --> NX[nginx-proxy<br/>kibana.example.com]
    NX --> KB

    SF -- docker logs --> FB
    FE -- docker logs --> FB
    NP -- docker logs --> FB
    KC -- docker logs --> FB
    PG -- docker logs --> FB

    FB -- bulk API HTTPS --> ES
    FB -. opcional .-> LS
    LS --> ES

    EA -- HTTPS --> FS
    FS -- policies --> EA
    EA -- bulk API --> ES

    KB <--> ES
    FS --> ES

O caminho recomendado para começar é: docker logs (stdout) → Filebeat com autodiscover → Elasticsearch (com ingest pipeline) → Kibana

Quando crescer e quiser gerenciamento central de agentes (atualizar policies sem mexer em arquivo em cada host), migra para Elastic Agent + Fleet.


3. Sizing

RAM

  • Elasticsearch heap: mínimo 2 GB, ideal 4 GB. Regra: heap = 50% da RAM disponível do container, nunca passar de 32 GB (compressed oops).
  • Resto da RAM (não-heap) é usado para o file system cache do Lucene — crítico para performance de busca. Reserve pelo menos heap × 1 para cache.
  • Kibana: 1 GB suficiente para uso individual.
  • Filebeat: 100-200 MB por host.

Mínimo prático: 6 GB RAM dedicados à stack (4 ES + 1 Kibana + 1 buffer). Confortável: 8 GB.

Disco

  • Logs JSON do Spring Boot comprimem na ordem de 8:1 a 12:1 depois de indexados (depende muito da cardinalidade dos campos).
  • Regra grossa: cada GB de log “bruto” vira ~150-250 MB indexado.
  • Aplicações de baixo tráfego geram tipicamente 100-500 MB de log/dia → 20-50 GB para 90 dias de retenção é folgado.
  • Use SSD. ES é I/O-bound. Em HDD trava.

CPU

  • 2 vCPU é o mínimo. 4 vCPU dão folga para ingest pipelines com grok pesado e queries de dashboard simultâneas.

4. Pré-requisitos

Kernel: vm.max_map_count

Elasticsearch usa mmap intensivamente. O default do Linux (65530) quebra na inicialização com erro max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144].

# Aplicar agora
sudo sysctl -w vm.max_map_count=262144

# Persistir
echo 'vm.max_map_count=262144' | sudo tee /etc/sysctl.d/99-elasticsearch.conf
sudo sysctl --system

# Verificar
sysctl vm.max_map_count

ulimits

O container já vem com nofile=65535 e memlock=unlimited configurados via ulimits no compose (mostrado adiante). Confirmar no host que /etc/security/limits.conf não limita o usuário do docker.

Swap

ES recomenda desabilitar swap ou usar bootstrap.memory_lock=true + memlock ulimit. Como rodamos em docker, basta ulimits.memlock=-1:-1 no compose.

# Opcional: desabilitar swap do host
sudo swapoff -a
# Comentar a linha de swap em /etc/fstab

Docker e Docker Compose

  • Docker Engine >= 24
  • Docker Compose v2 (docker compose, não docker-compose)
  • Network externa proxy já criada (compartilhada com nginx-proxy):
docker network ls | grep proxy || docker network create proxy

Diretório base

sudo mkdir -p /opt/docker/elastic/{config,data/es,data/kibana,backups,certs}
cd /opt/docker/elastic

5. Instalação docker compose (single-node)

Estrutura de arquivos:

/opt/docker/elastic/
├── docker-compose.yml
├── .env
├── config/
│   ├── filebeat.docker.yml
│   ├── ingest-nginx.json
│   └── ilm-logs-policy.json
├── data/
│   ├── es/        (volume Elasticsearch)
│   └── kibana/
├── backups/       (snapshots)
└── certs/         (TLS autogerado)

.env

STACK_VERSION=8.16.1
CLUSTER_NAME=logs-cluster
ES_PORT=9200
KIBANA_PORT=5601

# Preencher depois do bootstrap (passo 6)
ELASTIC_PASSWORD=
KIBANA_PASSWORD=
ENCRYPTION_KEY=                  # 32+ chars aleatórios

Gerar a ENCRYPTION_KEY antes:

openssl rand -hex 32

docker-compose.yml

name: elastic

networks:
  elastic_internal:
    driver: bridge
  proxy:
    external: true

volumes:
  es_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /opt/docker/elastic/data/es
  kibana_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /opt/docker/elastic/data/kibana
  es_backups:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /opt/docker/elastic/backups

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
    container_name: elasticsearch
    restart: unless-stopped
    environment:
      - node.name=es01
      - cluster.name=${CLUSTER_NAME}
      - discovery.type=single-node
      - bootstrap.memory_lock=true
      - ES_JAVA_OPTS=-Xms2g -Xmx2g
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.transport.ssl.enabled=true
      - xpack.license.self_generated.type=basic
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
      - path.repo=/usr/share/elasticsearch/backups
      - cluster.routing.allocation.disk.watermark.low=85%
      - cluster.routing.allocation.disk.watermark.high=90%
      - cluster.routing.allocation.disk.watermark.flood_stage=95%
    ulimits:
      memlock: { soft: -1, hard: -1 }
      nofile: { soft: 65536, hard: 65536 }
    volumes:
      - es_data:/usr/share/elasticsearch/data
      - es_backups:/usr/share/elasticsearch/backups
    ports:
      - "127.0.0.1:${ES_PORT}:9200"
    healthcheck:
      test:
        - CMD-SHELL
        - >
          curl -s -k -u elastic:${ELASTIC_PASSWORD}
          https://localhost:9200/_cluster/health
          | grep -E '"status":"(green|yellow)"' || exit 1
      interval: 20s
      timeout: 10s
      retries: 10
      start_period: 60s
    networks:
      - elastic_internal

  kibana:
    image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
    container_name: kibana
    restart: unless-stopped
    depends_on:
      elasticsearch:
        condition: service_healthy
    environment:
      - SERVER_NAME=kibana.example.com
      - SERVER_PUBLICBASEURL=https://kibana.example.com
      - ELASTICSEARCH_HOSTS=https://elasticsearch:9200
      - ELASTICSEARCH_USERNAME=kibana_system
      - ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}
      - ELASTICSEARCH_SSL_VERIFICATIONMODE=certificate
      - ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=/usr/share/kibana/config/certs/ca/ca.crt
      - XPACK_SECURITY_ENCRYPTIONKEY=${ENCRYPTION_KEY}
      - XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY=${ENCRYPTION_KEY}
      - XPACK_REPORTING_ENCRYPTIONKEY=${ENCRYPTION_KEY}
      - TELEMETRY_OPTIN=false
    volumes:
      - kibana_data:/usr/share/kibana/data
      - /opt/docker/elastic/certs:/usr/share/kibana/config/certs:ro
    ports:
      - "127.0.0.1:${KIBANA_PORT}:5601"
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://localhost:5601/api/status | grep -q '\"level\":\"available\"' || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 60s
    networks:
      - elastic_internal
      - proxy

  filebeat:
    image: docker.elastic.co/beats/filebeat:${STACK_VERSION}
    container_name: filebeat
    restart: unless-stopped
    user: root
    depends_on:
      elasticsearch:
        condition: service_healthy
    command: ["filebeat", "-e", "--strict.perms=false"]
    environment:
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
    volumes:
      - /opt/docker/elastic/config/filebeat.docker.yml:/usr/share/filebeat/filebeat.yml:ro
      - /opt/docker/elastic/certs/ca/ca.crt:/usr/share/filebeat/ca.crt:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - elastic_internal

Repare:

  • ES e Kibana bindam só em 127.0.0.1. Exposição externa é via nginx-proxy.
  • proxy está só no Kibana (nginx-proxy precisa alcançar Kibana, não ES).
  • mem_limit não é setado — ajuste se quiser teto duro de RSS.

6. Bootstrap de segurança

Na primeira subida o ES auto-configura CA + certificado de nó interno. Você só precisa extrair a CA e resetar as senhas dos usuários built-in.

6.1 Primeira subida com senha inicial

Defina um ELASTIC_PASSWORD provisório no .env (ele é o admin):

# .env
ELASTIC_PASSWORD=mude_isto_no_primeiro_boot

Suba só o Elasticsearch:

docker compose up -d elasticsearch
docker compose logs -f elasticsearch

Aguarde started e o healthcheck virar healthy:

docker compose ps elasticsearch

6.2 Extrair a CA autogerada

mkdir -p /opt/docker/elastic/certs/ca
docker cp elasticsearch:/usr/share/elasticsearch/config/certs/http_ca.crt \
  /opt/docker/elastic/certs/ca/ca.crt
chmod 644 /opt/docker/elastic/certs/ca/ca.crt

Esse ca.crt será montado no Kibana, Filebeat e qualquer outro cliente.

6.3 Resetar/gerar senha do kibana_system

O usuário kibana_system é built-in mas vem sem senha conhecida. Gere uma:

docker exec -it elasticsearch \
  bin/elasticsearch-reset-password -u kibana_system -b

A flag -b (batch) imprime a senha no stdout. Copie e cole no .env:

# .env
KIBANA_PASSWORD=<senha-gerada>

Você também pode resetar o usuário elastic (admin) para gerar uma senha forte:

docker exec -it elasticsearch \
  bin/elasticsearch-reset-password -u elastic -b
# atualizar ELASTIC_PASSWORD no .env

6.4 (Alternativa) Enrollment token

Se em vez de configurar ELASTICSEARCH_USERNAME/PASSWORD no Kibana você preferir o fluxo de enrollment automático:

docker exec -it elasticsearch \
  bin/elasticsearch-create-enrollment-token --scope kibana

Token válido por 30 minutos. Cole no setup wizard do Kibana — ele auto-popula kibana.yml com a senha do kibana_system e a CA. Para deploy “infra-as-code” o caminho do ELASTICSEARCH_USERNAME/PASSWORD é mais reproduzível.

6.5 Subir o Kibana

docker compose up -d kibana
docker compose logs -f kibana

Espere Kibana is now available. Teste local:

curl -sf http://127.0.0.1:5601/api/status | jq '.status.overall'

6.6 TLS

O ES já está com TLS no HTTP layer (porta 9200) e transport layer. A CA é autogerada e vale para o nome elasticsearch (DNS interno do compose). Clientes externos via 127.0.0.1:9200 precisam usar -k no curl ou montar a CA.

Para uma instalação single-node isso é suficiente. Se um dia quiser adicionar nós ou usar um cert público (Let’s Encrypt) para a API HTTP, regenere com bin/elasticsearch-certutil.


7. Reverse proxy nginx para Kibana

Adicione um vhost em /opt/docker/nginx/conf.d/kibana.conf (assumindo seu layout de nginx-proxy + Let’s Encrypt já existente):

server {
    listen 443 ssl http2;
    server_name kibana.example.com;

    ssl_certificate     /etc/letsencrypt/live/kibana.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/kibana.example.com/privkey.pem;
    include /etc/nginx/conf.d/ssl_common.conf;

    # Allowlist da rede confiável — restringir ao range da VPN
    allow 10.10.0.0/24;
    allow 100.64.0.0/10;       # Tailscale, se for o caso
    deny all;

    client_max_body_size 50m;
    proxy_read_timeout   300s;
    proxy_send_timeout   300s;
    proxy_connect_timeout 30s;

    location / {
        proxy_pass http://kibana:5601;

        proxy_http_version 1.1;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "upgrade";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_buffering off;
    }
}

server {
    listen 80;
    server_name kibana.example.com;
    return 301 https://$host$request_uri;
}

Reload:

docker exec nginx-proxy nginx -t && docker exec nginx-proxy nginx -s reload

Aponte o DNS interno (ou hosts file via VPN) kibana.example.com → 203.0.113.10, acesse via VPN e logue com elastic / <ELASTIC_PASSWORD>.


8. Index lifecycle (ILM)

A ideia: indexar logs em índices rotativos (rollover), manter os recentes em “hot”, mover os antigos para “warm” (force-merge + read-only), deletar passada a retenção.

8.1 Policy logs-policy

config/ilm-logs-policy.json:

{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_age": "7d",
            "max_primary_shard_size": "20gb"
          },
          "set_priority": { "priority": 100 }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "forcemerge": { "max_num_segments": 1 },
          "set_priority": { "priority": 50 },
          "readonly": {}
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": { "delete": {} }
      }
    }
  }
}

Aplicar:

curl -k -u elastic:$ELASTIC_PASSWORD -X PUT \
  https://127.0.0.1:9200/_ilm/policy/logs-policy \
  -H 'Content-Type: application/json' \
  -d @config/ilm-logs-policy.json

8.2 Index template para logs-*

Single-node não tem replicas (não tem outro nó pra replicar), então 0 replicas.

curl -k -u elastic:$ELASTIC_PASSWORD -X PUT \
  https://127.0.0.1:9200/_index_template/logs-template \
  -H 'Content-Type: application/json' \
  -d '{
    "index_patterns": ["logs-*"],
    "priority": 200,
    "data_stream": {},
    "template": {
      "settings": {
        "index.lifecycle.name": "logs-policy",
        "index.number_of_shards": 1,
        "index.number_of_replicas": 0,
        "index.refresh_interval": "5s",
        "index.codec": "best_compression"
      },
      "mappings": {
        "properties": {
          "@timestamp":    { "type": "date" },
          "service.name":  { "type": "keyword" },
          "service.env":   { "type": "keyword" },
          "log.level":     { "type": "keyword" },
          "message":       { "type": "text" },
          "trace.id":      { "type": "keyword" },
          "span.id":       { "type": "keyword" },
          "container.name":{ "type": "keyword" },
          "host.name":     { "type": "keyword" }
        }
      }
    }
  }'

Confira:

curl -k -u elastic:$ELASTIC_PASSWORD \
  https://127.0.0.1:9200/_index_template/logs-template?pretty

9. Ingerindo logs do Docker

Opção A — Filebeat container modular (recomendada para começar)

config/filebeat.docker.yml:

filebeat.autodiscover:
  providers:
    - type: docker
      hints.enabled: true
      hints.default_config:
        type: container
        paths:
          - /var/lib/docker/containers/${data.docker.container.id}/*.log
        processors:
          - add_docker_metadata: ~
          - add_host_metadata: ~

processors:
  - drop_event:
      when:
        equals:
          container.name: "filebeat"

output.elasticsearch:
  hosts: ["https://elasticsearch:9200"]
  username: "elastic"
  password: "${ELASTIC_PASSWORD}"
  ssl.certificate_authorities: ["/usr/share/filebeat/ca.crt"]
  # Escreve em data stream logs-<service>-<namespace>
  index: "logs-%{[container.labels.co_elastic_logs/service_name]:generic}-default"

setup.template.enabled: false
setup.ilm.enabled: false      # Estamos usando o template+ILM que criamos no passo 8

logging.level: info
logging.to_files: false

Suba:

docker compose up -d filebeat
docker compose logs -f filebeat

Hints nos containers das aplicações

No compose das suas aplicações, adicione labels para o Filebeat reconhecer:

services:
  api-backend:
    image: registry.example.com/api-backend:1.4.2
    labels:
      co.elastic.logs/enabled: "true"
      co.elastic.logs/json.keys_under_root: "true"
      co.elastic.logs/json.overwrite_keys: "true"
      co.elastic.logs/json.add_error_key: "true"
      co.elastic.logs/json.expand_keys: "true"
      co.elastic.logs/service_name: "api-backend"

  web-frontend:
    image: registry.example.com/web-frontend:2.1.0
    labels:
      co.elastic.logs/enabled: "true"
      co.elastic.logs/module: "nginx"
      co.elastic.logs/fileset.stdout: "access"
      co.elastic.logs/fileset.stderr: "error"
      co.elastic.logs/service_name: "web-frontend"

Opção B — docker logging driver

Alternativa sem coletor: cada container envia direto via driver.

services:
  app:
    logging:
      driver: gelf
      options:
        gelf-address: "udp://127.0.0.1:12201"
        tag: "app"

Requer Logstash com input gelf (porta 12201). Não recomendado: perde se Logstash reiniciar (UDP), e qualquer pull novo do container reseta config. Fica de plano B.

Opção C — Elastic Agent + Fleet (caminho moderno)

Adicione um Fleet Server e um Elastic Agent (mesmo container faz ambos):

  fleet-server:
    image: docker.elastic.co/elastic-agent/elastic-agent:${STACK_VERSION}
    container_name: fleet-server
    restart: unless-stopped
    depends_on:
      kibana:
        condition: service_healthy
    environment:
      - FLEET_SERVER_ENABLE=true
      - FLEET_SERVER_ELASTICSEARCH_HOST=https://elasticsearch:9200
      - FLEET_SERVER_ELASTICSEARCH_CA=/certs/ca.crt
      - FLEET_SERVER_SERVICE_TOKEN=${FLEET_SERVER_TOKEN}
      - FLEET_SERVER_POLICY_ID=fleet-server-policy
      - KIBANA_FLEET_SETUP=true
      - KIBANA_HOST=http://kibana:5601
    volumes:
      - /opt/docker/elastic/certs/ca/ca.crt:/certs/ca.crt:ro
    ports:
      - "127.0.0.1:8220:8220"
    networks:
      - elastic_internal

Gerar FLEET_SERVER_TOKEN:

docker exec -it elasticsearch bin/elasticsearch-service-tokens create elastic/fleet-server token1

Depois, em Kibana → Management → Fleet → Add Agent, gere o enrollment token e instale um Elastic Agent em cada host (curl -L https://artifacts.elastic.co/.../elastic-agent-linux.tar.gz). Toda integração (nginx, kafka, postgres, system) é então adicionada via UI sem mexer em arquivo YAML.

Recomendação: comece com Opção A (Filebeat), migre para Opção C quando tiver 3+ hosts produzindo log.


10. Ingerindo logs de uma aplicação Spring Boot

Objetivo: o Spring loga JSON estruturado em stdout; o docker captura; Filebeat parseia o JSON como objeto e indexa cada campo. Sem grok, sem regex.

10.1 Dependência Maven/Gradle

build.gradle.kts:

dependencies {
    implementation("net.logstash.logback:logstash-logback-encoder:7.4")
}

10.2 logback-spring.xml

src/main/resources/logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <springProperty name="appName" source="spring.application.name" defaultValue="api-backend"/>
    <springProperty name="appEnv"  source="spring.profiles.active"  defaultValue="default"/>

    <!-- Console plain em dev -->
    <springProfile name="default | dev | test">
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
            </encoder>
        </appender>
    </springProfile>

    <!-- JSON em prod -->
    <springProfile name="prod | docker">
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeContext>false</includeContext>
                <includeMdc>true</includeMdc>
                <customFields>{"service.name":"${appName}","service.env":"${appEnv}"}</customFields>
                <fieldNames>
                    <timestamp>@timestamp</timestamp>
                    <message>message</message>
                    <thread>thread.name</thread>
                    <logger>logger.name</logger>
                    <level>log.level</level>
                    <levelValue>[ignore]</levelValue>
                    <version>[ignore]</version>
                </fieldNames>
                <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
                    <maxDepthPerThrowable>30</maxDepthPerThrowable>
                    <maxLength>3000</maxLength>
                    <rootCauseFirst>true</rootCauseFirst>
                </throwableConverter>
            </encoder>
        </appender>
    </springProfile>

    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>

    <logger name="org.springframework.security" level="INFO"/>
    <logger name="org.hibernate.SQL" level="WARN"/>
</configuration>

10.3 MDC com trace ID

Spring Boot 3 + Micrometer Tracing já preenche traceId e spanId no MDC automaticamente. Para refletirem como trace.id / span.id (ECS), adicione no application.yml:

logging:
  pattern:
    correlation: "[${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

E configure o LogstashEncoder com:

<fieldNames>
    <mdc>[ignore]</mdc>     <!-- não dumpar todo MDC, mapear individual -->
</fieldNames>
<provider class="net.logstash.logback.composite.loggingevent.MdcJsonProvider">
    <fieldName>mdc</fieldName>
</provider>

Ou, mais simples, deixa o MDC inteiro virar campos top-level e renomeia via ingest pipeline (passo 11).

10.4 Sample de evento ingerido

Em Discover você verá:

{
  "@timestamp": "2026-05-11T15:32:11.421Z",
  "service.name": "api-backend",
  "service.env": "prod",
  "log.level": "ERROR",
  "logger.name": "com.example.api.LancamentoService",
  "thread.name": "http-nio-8080-exec-3",
  "message": "Falha ao buscar lançamentos do mês",
  "traceId": "8f3c2e...",
  "spanId": "a1b2c3...",
  "stack_trace": "java.lang.NullPointerException\n  at ...",
  "container.name": "api-backend",
  "container.image.name": "registry.example.com/api-backend:1.4.2"
}

11. Parsing com Ingest Pipeline

Use para logs não-JSON (ex: nginx access log padrão).

11.1 Pipeline para nginx access

config/ingest-nginx.json:

{
  "description": "Parse nginx access log",
  "processors": [
    {
      "grok": {
        "field": "message",
        "patterns": [
          "%{IPORHOST:source.address} - %{DATA:user.name} \\[%{HTTPDATE:nginx.access.time}\\] \"%{WORD:http.request.method} %{DATA:url.original} HTTP/%{NUMBER:http.version}\" %{NUMBER:http.response.status_code:int} %{NUMBER:http.response.body.bytes:long} \"%{DATA:http.request.referrer}\" \"%{DATA:user_agent.original}\""
        ],
        "ignore_missing": true
      }
    },
    {
      "date": {
        "field": "nginx.access.time",
        "formats": ["dd/MMM/yyyy:HH:mm:ss Z"],
        "target_field": "@timestamp"
      }
    },
    { "remove": { "field": "nginx.access.time", "ignore_missing": true } },
    { "geoip":     { "field": "source.address", "target_field": "source.geo", "ignore_missing": true } },
    { "user_agent":{ "field": "user_agent.original", "target_field": "user_agent",  "ignore_missing": true } },
    {
      "set": {
        "field": "event.kind",
        "value": "event"
      }
    }
  ],
  "on_failure": [
    {
      "set": {
        "field": "error.message",
        "value": "{{ _ingest.on_failure_message }}"
      }
    }
  ]
}

Criar:

curl -k -u elastic:$ELASTIC_PASSWORD -X PUT \
  https://127.0.0.1:9200/_ingest/pipeline/nginx-access \
  -H 'Content-Type: application/json' \
  -d @config/ingest-nginx.json

11.2 Aplicar via template

Edite o template logs-template para apontar index.default_pipeline quando o índice casar com logs-nginx-*, ou crie um template específico:

curl -k -u elastic:$ELASTIC_PASSWORD -X PUT \
  https://127.0.0.1:9200/_index_template/logs-nginx \
  -H 'Content-Type: application/json' \
  -d '{
    "index_patterns": ["logs-nginx-*"],
    "priority": 300,
    "data_stream": {},
    "template": {
      "settings": {
        "index.default_pipeline": "nginx-access",
        "index.lifecycle.name": "logs-policy",
        "index.number_of_shards": 1,
        "index.number_of_replicas": 0
      }
    }
  }'

11.3 Testar a pipeline

Use _simulate antes de produção:

curl -k -u elastic:$ELASTIC_PASSWORD -X POST \
  https://127.0.0.1:9200/_ingest/pipeline/nginx-access/_simulate \
  -H 'Content-Type: application/json' \
  -d '{
    "docs": [{
      "_source": {
        "message": "192.168.1.10 - - [11/May/2026:15:32:11 +0000] \"GET /api/v1/lancamentos HTTP/1.1\" 200 1532 \"-\" \"Mozilla/5.0\""
      }
    }]
  }'

12. Visualização no Kibana

12.1 Data view

Stack Management → Data Views → Create:

  • Name: logs
  • Index pattern: logs-*
  • Timestamp field: @timestamp

12.2 Discover

Discover → seleciona o data view logs. Filtros úteis:

  • log.level: ERROR
  • service.name: api-backend AND log.level: (ERROR OR WARN)
  • http.response.status_code >= 500

Salve searches frequentes (botão Save).

12.3 Dashboards básicos

Crie via Lens (drag & drop) — não precisa decorar Vega.

Dashboard “Plataforma — visão geral”:

  1. Taxa de erro por serviço — Bar vertical

    • Eixo X: service.name (Top values, 10)
    • Eixo Y: Count of records com filtro log.level: ERROR
    • Break down: nenhum
  2. Logs por nível ao longo do tempo — Area stacked

    • Eixo X: @timestamp (auto bucket)
    • Eixo Y: Count
    • Break down: log.level
  3. Top loggers em erro — Tabela

    • Linhas: logger.name (Top 20)
    • Métricas: Count (filtro log.level: ERROR)
  4. Status code distribution (nginx) — Donut

    • Slice by: http.response.status_code
    • Métrica: Count
  5. p95 de latência — se você logar http.response.time_ms

    • Linha: percentil 95 de http.response.time_ms
    • Eixo X: @timestamp
    • Break down: service.name

Salve o dashboard como “Plataforma — visão geral” e marque como home (Kibana → Settings).

12.4 Saved object JSON (exemplo)

Você pode exportar/importar dashboards via NDJSON:

{
  "attributes": {
    "title": "Logs por nível",
    "visualizationType": "lnsXY",
    "state": {
      "datasourceStates": { "formBased": { "layers": {} } },
      "visualization": { "preferredSeriesType": "area_stacked" }
    }
  },
  "type": "lens"
}

Stack Management → Saved Objects → Import.


13. Alertas

Use Rules (Stack Management → Alerts and Insights → Rules).

13.1 Conector Slack/Discord webhook

Stack Management → Connectors → Create:

  • Type: Webhook
  • Name: discord-ops
  • Method: POST
  • URL: https://discord.com/api/webhooks/<id>/<token>
  • Headers: Content-Type: application/json
  • Body template (Mustache):
{
  "content": "**[{{context.rule.name}}]** {{context.message}}\nHits: {{context.hits}} | Período: {{params.timeWindowSize}}{{params.timeWindowUnit}}"
}

13.2 Regra “5xx > 50 em 5 min”

Rules → Create → tipo Elasticsearch query:

  • Name: nginx-5xx-spike
  • Index: logs-nginx-*
  • Time field: @timestamp
  • Query (DSL):
{
  "query": {
    "bool": {
      "filter": [
        { "range": { "http.response.status_code": { "gte": 500 } } }
      ]
    }
  }
}
  • Threshold: is above 50
  • Time window: 5 minutes
  • Check every: 1 minute
  • Action: conector discord-ops, run when Query matched

13.3 Regra “erro fatal no backend”

  • Index: logs-api-backend-*
  • Query: log.level:FATAL OR log.level:ERROR
  • Threshold: is above 10 em 5 minutes
  • Action: webhook

13.4 Health do cluster

Stack Monitoring (Kibana) → Cluster alerts. Habilite as default rules: license expira, heap > 85%, disk watermark, version mismatch.


14. APM (opcional)

Stack Management → Integrations → instale “APM” para subir um APM Server (recente: ele é integrado ao Elastic Agent, não precisa container separado).

Spring Boot — agente Java

Baixe elastic-apm-agent-<ver>.jar e adicione ao Dockerfile:

ADD https://repo1.maven.org/maven2/co/elastic/apm/elastic-apm-agent/1.50.0/elastic-apm-agent-1.50.0.jar /opt/apm-agent.jar
ENV JAVA_TOOL_OPTIONS="-javaagent:/opt/apm-agent.jar"
ENV ELASTIC_APM_SERVICE_NAME=api-backend
ENV ELASTIC_APM_SERVER_URL=http://apm-server:8200
ENV ELASTIC_APM_ENVIRONMENT=prod
ENV ELASTIC_APM_APPLICATION_PACKAGES=com.example.api

Vue (frontend) — RUM

npm i @elastic/apm-rum
import { init as initApm } from "@elastic/apm-rum";

initApm({
  serviceName: "web-frontend",
  serverUrl: "https://apm.example.com",
  serviceVersion: import.meta.env.VITE_APP_VERSION,
  environment: "prod",
  distributedTracingOrigins: ["https://api.example.com"],
});

Em Kibana → Observability → APM você vê services, transactions, dependencies, e o trace.id correlaciona com os logs (clique “View related logs”).


15. Backup (snapshot)

15.1 Registrar repositório fs

Já está mapeado em path.repo=/usr/share/elasticsearch/backups no compose, montado para /opt/docker/elastic/backups.

curl -k -u elastic:$ELASTIC_PASSWORD -X PUT \
  https://127.0.0.1:9200/_snapshot/local-fs \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "fs",
    "settings": {
      "location": "/usr/share/elasticsearch/backups",
      "compress": true,
      "max_snapshot_bytes_per_sec": "50mb",
      "max_restore_bytes_per_sec": "50mb"
    }
  }'

# Verificar
curl -k -u elastic:$ELASTIC_PASSWORD \
  -X POST https://127.0.0.1:9200/_snapshot/local-fs/_verify

15.2 SLM policy diária

curl -k -u elastic:$ELASTIC_PASSWORD -X PUT \
  https://127.0.0.1:9200/_slm/policy/daily-snapshots \
  -H 'Content-Type: application/json' \
  -d '{
    "schedule": "0 30 3 * * ?",
    "name": "<snap-{now/d}>",
    "repository": "local-fs",
    "config": {
      "indices": ["logs-*", ".kibana*", ".security*"],
      "include_global_state": true
    },
    "retention": {
      "expire_after": "30d",
      "min_count": 7,
      "max_count": 30
    }
  }'

Rodar manualmente:

curl -k -u elastic:$ELASTIC_PASSWORD -X POST \
  https://127.0.0.1:9200/_slm/policy/daily-snapshots/_execute

15.3 Cópia offsite

Faça rsync do diretório /opt/docker/elastic/backups/ para outro host (NAS, borg, restic):

restic -r sftp:backup@nas:/backups/elastic backup /opt/docker/elastic/backups

15.4 Restore

curl -k -u elastic:$ELASTIC_PASSWORD -X POST \
  https://127.0.0.1:9200/_snapshot/local-fs/<snapshot-name>/_restore \
  -H 'Content-Type: application/json' \
  -d '{
    "indices": "logs-api-backend-*",
    "include_global_state": false,
    "rename_pattern": "logs-(.+)",
    "rename_replacement": "restored-logs-$1"
  }'

16. Upgrade

Elastic Stack tem regra: upgrade Elasticsearch antes do Kibana, e Kibana nunca pode ser de versão maior que ES.

16.1 Checklist pré-upgrade

# 1. Cluster verde?
curl -k -u elastic:$ELASTIC_PASSWORD \
  https://127.0.0.1:9200/_cluster/health?pretty

# 2. Deprecations
curl -k -u elastic:$ELASTIC_PASSWORD \
  https://127.0.0.1:9200/_migration/deprecations?pretty

# 3. Snapshot fresco
curl -k -u elastic:$ELASTIC_PASSWORD -X POST \
  https://127.0.0.1:9200/_slm/policy/daily-snapshots/_execute

Verifique também o Upgrade Assistant em Kibana → Stack Management → Upgrade Assistant.

16.2 Procedimento

  1. Bump STACK_VERSION no .env (ex: 8.16.18.17.0)
  2. docker compose pull elasticsearch
  3. docker compose up -d elasticsearch — espera healthy
  4. docker compose pull kibana && docker compose up -d kibana
  5. docker compose pull filebeat && docker compose up -d filebeat
  6. Confere _cluster/health e Discover

Upgrades entre majors (8 → 9) exigem reindex de índices criados em majors antigas. Upgrade Assistant marca quais.


17. Troubleshooting

max virtual memory areas vm.max_map_count [65530] is too low

ES não sobe. Resolução: passo 4.

sudo sysctl -w vm.max_map_count=262144

Persistir em /etc/sysctl.d/99-elasticsearch.conf.

circuit_breaking_exception ou OOM no heap

ES está estourando heap. Sinais: logs [parent] Data too large, restart loop, OOM Killer matando o container.

  • Aumente ES_JAVA_OPTS=-Xms4g -Xmx4g (50% da RAM do container, máx 31g).
  • Reduza tamanho de queries (from + size, agregações sem size).
  • Verifique mappings explodindo (mapping explosion) — _cat/indices?v&h=index,docs.count,store.size,pri,rep.

Cluster vira read_only_allow_delete — disco cheio

Os flood_stage watermark default (95%) trava escrita. Limpe disco, ou:

# Tira o read-only
curl -k -u elastic:$ELASTIC_PASSWORD -X PUT \
  https://127.0.0.1:9200/_all/_settings \
  -H 'Content-Type: application/json' \
  -d '{ "index.blocks.read_only_allow_delete": null }'

# Ajusta watermarks (cuidado em prod)
curl -k -u elastic:$ELASTIC_PASSWORD -X PUT \
  https://127.0.0.1:9200/_cluster/settings \
  -H 'Content-Type: application/json' \
  -d '{
    "transient": {
      "cluster.routing.allocation.disk.watermark.low":          "85%",
      "cluster.routing.allocation.disk.watermark.high":         "90%",
      "cluster.routing.allocation.disk.watermark.flood_stage":  "95%"
    }
  }'

Filebeat duplicando linhas

Causa comum: registry corrompido depois de mover container. O registry guarda offset por arquivo de log.

docker compose stop filebeat
docker exec filebeat rm -rf /usr/share/filebeat/data/registry
docker compose start filebeat

Você pode perder posição (re-indexa do começo). Para evitar duplicação no índice, mantenha campo _id determinístico via processor fingerprint na ingest pipeline:

{ "fingerprint": { "fields": ["@timestamp", "message", "container.id"], "target_field": "_id" } }

Kibana: “Kibana server is not ready yet”

Olhe docker compose logs kibana. Causas frequentes:

  • xpack.encryptedSavedObjects.encryptionKey faltando ou inconsistente entre restarts (alertas e connectors viram inutilizáveis). Use XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY do passo 5.
  • kibana_system com senha errada → resetar (passo 6.3).
  • ES ainda não está pronto — o depends_on: service_healthy cobre.
  • Versão Kibana ≠ versão ES.

MasterNotDiscoveredException em single-node

Faltou discovery.type=single-node. Confira no compose.

Filebeat não vê containers (autodiscover vazio)

  • Socket docker montado? /var/run/docker.sock precisa estar no compose do Filebeat com permissão de leitura.
  • user: root no Filebeat (default filebeat não tem grupo docker).
  • Containers das apps têm a label co.elastic.logs/enabled=true?

Logs JSON aparecendo como string em message

O Filebeat não decodificou. Cheque labels no container alvo:

labels:
  co.elastic.logs/json.keys_under_root: "true"
  co.elastic.logs/json.overwrite_keys: "true"
  co.elastic.logs/json.add_error_key: "true"

Ou no filebeat.docker.yml dentro de hints.default_config:

json.keys_under_root: true
json.overwrite_keys: true
json.add_error_key: true

Certificado autogerado expira

Os certs autogerados pelo ES têm validade default de 2 anos. Antes de expirar, regenere:

docker exec -it elasticsearch bin/elasticsearch-certutil http

Ou rode com xpack.security.http.ssl.enabled=false atrás de proxy nginx terminando TLS (não recomendado se Filebeat fala direto com ES de outros hosts).


18. Referências

Repos de referência:

Alternativas: