Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy

Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy

As Trilhas da Ilha saem do localhost: imagem Docker em dois estágios com usuário sem privilégios, o PID 1 e o SIGTERM que não chegava, o heap da JVM no contêiner, segredos em arquivo, nginx com HTTPS, gzip e limite de tentativas, o mesmo deploy sem Docker com systemd e um roteiro seguro.
Java

• • 35 min de leitura

Nesta aula as Trilhas da Ilha saem do localhost. Você vai montar imagens Docker enxutas que rodam como usuário sem privilégios e encerram com calma quando recebem o sinal de parada, guardar segredos fora do código, colocar o nginx na frente com HTTPS, compressão e limite de tentativas, e montar um roteiro de deploy que dá para repetir sem medo.

"Na minha máquina funciona" é a frase mais antiga da nossa profissão. O que ela esconde é tudo o que a sua máquina tem e o servidor não tem: a versão exata do JDK, um PostgreSQL já configurado, a variável de ambiente que você exportou há três meses e esqueceu, a pasta de fotos com as permissões certas. Um deploy confiável transforma cada uma dessas suposições em algo escrito, versionado e executado sempre do mesmo jeito. Hoje as ferramentas para isso são o Docker (para empacotar), o Docker Compose (para descrever o conjunto) e o nginx (para ficar entre a internet e a aplicação).

Contêineres em uma página

Um contêiner não é uma máquina virtual. É um processo comum do Linux, que o kernel isola com dois mecanismos:

  • Os namespaces dão ao processo uma visão própria do sistema: sua própria árvore de processos (onde ele é o PID 1), sua própria rede, seu próprio sistema de arquivos.
  • Os cgroups limitam quanto ele pode consumir de memória e de CPU.

Não há um segundo sistema operacional rodando; há um processo que acha que está sozinho.

Uma imagem é o sistema de arquivos inicial desse processo, montado em camadas empilhadas. Cada instrução do Dockerfile gera uma camada, e camadas iguais são reaproveitadas entre builds e entre imagens. Essa é a chave da eficiência: se só o seu código mudou, só a camada do código é reconstruída, enviada ao registro e baixada no servidor. As dependências, que são 95% do tamanho, ficam em cache.

O ensaio geral

Antes de empacotar o sistema inteiro, vale ensaiar os mecanismos com uma aplicação mínima, em que cada comportamento é fácil de observar. O servidor abaixo usa só o JDK: o HttpServer de "Extensão — Java no Servidor: Servlets, Jakarta EE, Spring e Quarkus", com virtual threads. Ele tem os mesmos pontos de contato com o mundo que a aplicação Spring: verificações de saúde, encerramento gracioso, um segredo e cabeçalhos vindos de um proxy.

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;

/** Um "ensaio geral" do deploy: o mínimo de servidor para exercitar sinais, saúde, segredos e proxy. */
public class Ensaio {
    static final AtomicBoolean pronto = new AtomicBoolean(false);

    static void log(String msg) {
        System.out.println(LocalTime.now().truncatedTo(ChronoUnit.MILLIS) + " " + msg);
    }

    public static void main(String[] args) throws Exception {
        int porta = Integer.parseInt(System.getenv().getOrDefault("PORTA", "8080"));
        var servidor = HttpServer.create(new InetSocketAddress(porta), 0);
        servidor.setExecutor(Executors.newVirtualThreadPerTaskExecutor());

        servidor.createContext("/saude/viva", ex -> responder(ex, 200, "UP"));
        servidor.createContext("/saude/pronta", ex -> {
            if (pronto.get()) responder(ex, 200, "UP"); else responder(ex, 503, "OUT_OF_SERVICE");
        });
        servidor.createContext("/eco", ex -> responder(ex, 200, """
                cliente visto pelo Java: %s
                Host: %s
                X-Forwarded-For: %s
                X-Forwarded-Proto: %s
                """.formatted(ex.getRemoteAddress().getAddress().getHostAddress(),
                ex.getRequestHeaders().getFirst("Host"),
                ex.getRequestHeaders().getFirst("X-Forwarded-For"),
                ex.getRequestHeaders().getFirst("X-Forwarded-Proto"))));
        servidor.createContext("/grande", ex -> responder(ex, 200, "Lagoinha do Leste, Sul, 4.5 km\n".repeat(2000)));
        servidor.createContext("/lento", ex -> {
            try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            responder(ex, 200, "terminei a requisição lenta");
        });
        servidor.createContext("/segredo", ex -> responder(ex, 200, descreverSegredo()));

        Runtime.getRuntime().addShutdownHook(Thread.ofPlatform().unstarted(() -> {
            log("sinal de término recebido: saindo do balanceador");
            pronto.set(false);                          // readiness passa a 503
            servidor.stop(5);                           // até 5 s para as requisições em andamento
            log("encerrado com calma");
        }));

        servidor.start();
        log("ouvindo na porta " + porta + " como usuário " + System.getProperty("user.name")
                + "; heap máximo " + Runtime.getRuntime().maxMemory() / (1024 * 1024) + " MB");
        Thread.sleep(1500);                             // "aquecimento": conexões, caches...
        pronto.set(true);
        log("pronto para receber tráfego");
    }

    /** O segredo vem de um ARQUIVO (preferido) ou de variável de ambiente; nunca é impresso. */
    static String descreverSegredo() throws IOException {
        Path arquivo = Path.of("/run/secrets/jwt_segredo");
        if (Files.isReadable(arquivo)) {
            return "segredo lido de " + arquivo + " (" + Files.readString(arquivo).strip().length() + " caracteres)";
        }
        String env = System.getenv("TRILHAS_JWT_SEGREDO");
        return env == null ? "SEM SEGREDO" : "segredo lido de variável de ambiente (" + env.length() + " caracteres)";
    }

    static void responder(HttpExchange ex, int status, String corpo) throws IOException {
        byte[] b = corpo.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
        ex.sendResponseHeaders(status, b.length);
        try (var out = ex.getResponseBody()) { out.write(b); }
    }
}

Os dois endereços de saúde têm papéis diferentes, os mesmos que o Spring Boot Actuator oferece.

  • Liveness ("estou vivo?") responde sempre que o processo está de pé. Se falhar, a solução é reiniciar o contêiner.
  • Readiness ("posso receber tráfego?") só responde 200 depois do aquecimento, e volta a 503 durante o encerramento. Um balanceador que respeite esse sinal para de mandar requisições novas antes de o processo sair.

Confundir os dois é um erro clássico. Uma liveness que depende do banco faz o orquestrador reiniciar todas as instâncias da aplicação quando o banco cai, e isso não resolve nada.

A imagem mínima não tem curl, e é melhor assim: cada ferramenta a menos é uma ferramenta a menos para um invasor usar. A verificação de saúde é um programinha Java de dez linhas:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

/** HEALTHCHECK sem curl na imagem: sai com 0 se a URL responder 200, com 1 em qualquer outro caso. */
public class Saude {
    public static void main(String[] args) {
        try {
            var cliente = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
            var pedido = HttpRequest.newBuilder(URI.create(args[0])).timeout(Duration.ofSeconds(2)).build();
            int status = cliente.send(pedido, HttpResponse.BodyHandlers.discarding()).statusCode();
            System.exit(status == 200 ? 0 : 1);
        } catch (Exception e) {
            System.exit(1);
        }
    }
}

O Dockerfile em dois estágios

# ---------- Estágio 1: compilar e montar um runtime Java sob medida ----------
FROM local/jdk:25 AS construcao
WORKDIR /src
COPY src/ src/
RUN javac -d classes src/*.java \
 && jar --create --file app.jar --main-class Ensaio -C classes .
RUN jlink --add-modules java.base,java.net.http,jdk.httpserver \
          --strip-java-debug-attributes --no-man-pages --no-header-files --compress zip-6 \
          --output /jre

# ---------- Estágio 2: só o que roda em produção ----------
FROM local/ubuntu:24.04
RUN groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app
COPY --from=construcao /jre /opt/jre
COPY --from=construcao /src/app.jar /app/app.jar
ENV PATH=/opt/jre/bin:$PATH \
    LANG=C.UTF-8
USER app
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=3 \
  CMD ["java", "-Xshare:auto", "-XX:TieredStopAtLevel=1", "-cp", "/app/app.jar", "Saude", "http://127.0.0.1:8080/saude/pronta"]
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]

(As imagens local/... eram bases locais, montadas para o ensaio. No seu projeto, use eclipse-temurin:25-jdk e uma base como ubuntu:24.04 ou debian:stable-slim.)

Cada decisão tem um porquê.

  • Dois estágios. O primeiro tem o JDK completo, o compilador e o jlink, e é descartado. O segundo recebe só o runtime e o JAR. Nada de compilador, código-fonte ou ferramentas de build em produção.
  • Um runtime sob medida. O jlink de "Do JAR ao Executável — jlink, jpackage e GraalVM Native Image" monta uma JVM com apenas os três módulos de que o programa precisa: 42 MB, contra centenas do JDK. Um detalhe descoberto no ensaio: a opção --strip-debug falhou dentro do contêiner com Cannot run program "objcopy", porque no Linux ela também remove símbolos nativos com o objcopy do pacote binutils, ausente numa imagem enxuta. A saída foi --strip-java-debug-attributes, que só remove informações de depuração do bytecode.
  • Um usuário sem privilégios. Com USER app, se alguém explorar uma falha da aplicação, estará dentro do contêiner como um usuário comum, sem poder instalar nada nem mexer em arquivos do sistema. O log do ensaio confirma: ouvindo na porta 8080 como usuário app.
  • LANG=C.UTF-8. Sem ela, a primeira execução imprimiu usu?rio e m?ximo. A imagem mínima não define idioma, e o Java usa ASCII para a saída padrão. Uma linha resolve o problema, e também cada log com acento.
  • ENTRYPOINT na forma de lista. É o assunto da próxima seção.

PID 1 e o sinal que não chegava

Quando você roda docker stop, o Docker envia SIGTERM ao processo de PID 1 do contêiner, espera um prazo (10 segundos por padrão) e, se ele não saiu, envia SIGKILL, que não pode ser tratado. O gancho de encerramento do Java (addShutdownHook) roda com SIGTERM, mas nunca com SIGKILL.

O ENTRYPOINT tem duas formas, e a diferença entre elas é o PID 1:

ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]   # forma "exec": PID 1 é o java
ENTRYPOINT java -XX:MaxRAMPercentage=75 -jar /app/app.jar                # forma "shell": PID 1 é /bin/sh -c "..."

Gerei as duas imagens e parei cada contêiner com docker stop:

== exec: PID 1 = java -XX:MaxRAMPercentage=75 -jar /app/app.jar
docker stop levou 0.2 s; exit code 143
14:10:47.323 ouvindo na porta 8080 como usuário app; heap máximo 6024 MB
14:10:48.828 pronto para receber tráfego
14:10:50.260 sinal de término recebido: saindo do balanceador
14:10:50.262 encerrado com calma

== shell: PID 1 = /bin/sh -c java -XX:MaxRAMPercentage=75 -jar /app/app.jar
docker stop levou 10.2 s; exit code 137
14:10:50.934 ouvindo na porta 8080 como usuário app; heap máximo 6024 MB
14:10:52.438 pronto para receber tráfego

Na forma exec, o Java recebeu o SIGTERM, rodou o gancho e saiu em 0,2 s com o código 143 (128 + 15, o número do SIGTERM). Na forma shell, o PID 1 é o sh. O kernel trata o PID 1 de um namespace de forma especial: sinais sem um tratador instalado são ignorados. O sh não trata SIGTERM e também não o repassa ao filho. O Docker esperou os 10 segundos inteiros e matou tudo com SIGKILL (código 137 = 128 + 9). O gancho nunca rodou. Em produção, isso significa requisições cortadas no meio, transações abortadas e cada deploy levando dez segundos a mais.

A mesma armadilha aparece com scripts de entrada (entrypoint.sh) que fazem alguma preparação antes de subir a aplicação. A regra é que a última linha precisa ser exec java .... O exec substitui o shell pelo Java, que herda o PID 1.

E as requisições em andamento? Disparei um pedido de dois segundos e, meio segundo depois, dei docker stop:

terminei a requisição lenta [curl terminou]

14:11:27.525 sinal de término recebido: saindo do balanceador
14:11:29.012 encerrado com calma

O servidor.stop(5) esperou a requisição terminar (1,5 s) antes de sair, e o cliente recebeu a resposta completa. Esse é o encerramento gracioso, e ele só funciona se o sinal chegar.

A JVM dentro de limites

A JVM detecta os limites do cgroup e dimensiona o heap de acordo com eles, e não com a memória da máquina. Por padrão, porém, o heap máximo é um quarto da memória disponível: pensado para uma máquina compartilhada, e não para um contêiner que existe só para aquela JVM. Com o contêiner limitado a 512 MB:

MaxHeapSize=134217728 UseSerialGC=true   (padrão)
MaxHeapSize=402653184 UseSerialGC=true   (-XX:MaxRAMPercentage=75)

Sem ajuste, a aplicação teria 128 MB de heap num contêiner de 512 MB, e três quartos da memória paga ficariam ociosos. Com -XX:MaxRAMPercentage=75, são 384 MB (o log do ensaio mostrou 371 MB, porque o maxMemory() desconta uma área do coletor). Os 25% restantes ficam para o que não é heap: metaspace, pilhas de threads, buffers de rede e o código compilado pelo JIT. Não vá a 100%: o contêiner seria morto por falta de memória (OOMKilled) sem nenhum OutOfMemoryError no log.

A segunda linha revela outra surpresa: UseSerialGC=true. No JDK 25, uma máquina com menos de 2 CPUs ou menos de 1792 MB de memória não é considerada "classe servidor", e a JVM escolhe o coletor Serial, que pausa tudo para coletar. Para um servidor web isso pode ser aceitável com heaps pequenos, mas a escolha deve ser sua, e não um acidente. Use -XX:+UseG1GC se quiser o G1 de "Quem Recolhe o Lixo — os Coletores G1 e ZGC" (o JDK 27 passou a escolher o G1 também nesses ambientes). O -XshowSettings:system mostra o que a JVM enxergou: com --cpus 1.5, ela reportou Effective CPU Count: 2, arredondando a cota para cima.

Segredos: nem no código, nem no ambiente

A senha do banco e a chave do JWT não podem estar no repositório. A primeira ideia costuma ser a variável de ambiente, e é melhor que nada. Mas no ensaio, com o segredo passado por -e TRILHAS_JWT_SEGREDO=...:

$ docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' e-env | grep JWT
TRILHAS_JWT_SEGREDO=AeKC3BUtQjbL6WXIItMLFLvXsoBoocyLE=...

O valor aparece inteiro para qualquer pessoa com acesso ao Docker do servidor. Ele também é herdado por todo processo filho, costuma sair em despejos de diagnóstico e, se alguém um dia imprimir o ambiente num log de erro, lá se foi a chave.

O caminho melhor é o segredo em arquivo. O Compose monta cada segredo como um arquivo em /run/secrets/, e a aplicação o lê de lá. A primeira tentativa no ensaio, porém, respondeu SEM SEGREDO:

$ docker compose exec app ls -ln /run/secrets/
-rw------- 1 0 0 44 Sep 30 14:11 jwt_segredo

Em um Compose sem Swarm, o segredo é uma montagem do arquivo do host, com o dono e as permissões do host. O arquivo pertencia ao root, com modo 600, e a aplicação roda como o usuário 10001, que não podia lê-lo. A correção é dar o arquivo, no host, ao mesmo UID do contêiner:

sudo chown 10001:10001 segredos/jwt_segredo && sudo chmod 400 segredos/jwt_segredo
-r-------- 1 10001 10001 44 Sep 30 14:11 jwt_segredo
segredo lido de /run/secrets/jwt_segredo (44 caracteres)

E, com o segredo em arquivo, o docker inspect não o mostra em lugar nenhum.

No Spring Boot, a ponte entre esses arquivos e as propriedades é o configtree:

spring.config.import=optional:configtree:/run/secrets/

Cada arquivo da pasta vira uma propriedade com o nome do arquivo. Um arquivo /run/secrets/trilhas.jwt.segredo preenche trilhas.jwt.segredo, e um /run/secrets/spring.datasource.password preenche a senha do banco, sem uma linha de código. Com isso, a linha trilhas.jwt.segredo=${TRILHAS_JWT_SEGREDO} de "Extensão — Quem É Você? Spring Security e JWT" sai do application.properties principal e fica só num perfil de desenvolvimento.

nginx na frente

Uma aplicação Java pode falar HTTPS e escutar a porta 443 sozinha. Mesmo assim, quase todo deploy põe um proxy reverso na frente, porque ele concentra, num lugar só e com código muito testado, uma lista de responsabilidades:

  • Terminar o TLS, com os certificados e a renovação num lugar só.
  • Redirecionar HTTP para HTTPS.
  • Comprimir as respostas de texto.
  • Limitar tamanho de corpo e taxa de requisições.
  • Acrescentar cabeçalhos de segurança.
  • Servir de ponto estável enquanto a aplicação reinicia atrás dele.

A configuração, testada no ensaio:

# Limite de tentativas no login: 5 por minuto por IP, com rajada pequena
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

upstream aplicacao {
    server app:8080;
    keepalive 16;
}

# Porta 80: só existe para mandar todo mundo para o HTTPS
server {
    listen 80 default_server;
    server_name _;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl default_server;
    http2 on;                      # nginx 1.25.1+; no 1.24 use "listen 443 ssl http2"
    server_name _;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    server_tokens off;             # não anuncie a versão do nginx

    # Cabeçalhos de segurança para todas as respostas
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header X-Frame-Options "DENY" always;

    # Uploads: um pouco acima do limite do Spring (6 MB), para o erro amigável vir de lá
    client_max_body_size 7m;

    # Compressão de texto (fotos JPEG já vêm comprimidas)
    gzip on;
    gzip_types text/plain text/css application/json application/javascript application/problem+json;
    gzip_min_length 1024;

    location = /entrar {
        limit_req zone=login burst=3 nodelay;
        limit_req_status 429;
        proxy_pass http://aplicacao;
        include /etc/nginx/proxy_comum.conf;
    }

    location = /api/v1/auth/token {
        limit_req zone=login burst=3 nodelay;
        limit_req_status 429;
        proxy_pass http://aplicacao;
        include /etc/nginx/proxy_comum.conf;
    }

    location / {
        proxy_pass http://aplicacao;
        include /etc/nginx/proxy_comum.conf;
    }
}

E o arquivo incluído em cada location:

proxy_http_version 1.1;
proxy_set_header Connection "";
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_set_header X-Forwarded-Host  $host;
proxy_set_header X-Forwarded-Port  $server_port;
proxy_read_timeout 30s;

Com o conjunto no ar, cada linha foi conferida com curl:

--- http → https
301 https://localhost/trilhas?q=lago
--- eco via https
cliente visto pelo Java: 172.18.0.3
Host: localhost
X-Forwarded-For: 172.18.0.1
X-Forwarded-Proto: https
--- cabeçalhos
HTTP/2 200
server: nginx
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
x-frame-options: DENY
--- gzip
62000 bytes sem gzip
472 bytes com gzip
--- upload de 8 MB
413
--- 8 tentativas seguidas de login
404 404 404 404 429 429 429 429

Cada linha ensina algo.

  • O redirecionamento preserva caminho e parâmetros ($request_uri). Um link antigo com http:// continua funcionando.
  • A aplicação vê o nginx, não o cliente. "Cliente visto pelo Java: 172.18.0.3" é o endereço do nginx na rede interna do Compose. O endereço real está no X-Forwarded-For, e o esquema (https) no X-Forwarded-Proto. Se a aplicação não souber disso, três coisas quebram em silêncio: os redirecionamentos do Post/Redirect/Get e do login apontam para http:// (e o navegador passa pelo redirecionamento do nginx a cada formulário), o cookie Secure nunca é emitido porque a requisição "parece" HTTP, e os logs registram o mesmo IP para todo mundo. No Spring Boot, a correção é server.forward-headers-strategy=native. O Tomcat passa a usar os cabeçalhos X-Forwarded-*, mas só quando vêm de endereços de proxies internos (redes privadas como a 172.16.0.0/12 do Docker), para que um cliente não possa forjá-los.
  • server_tokens off. Na primeira versão, a resposta dizia server: nginx/1.24.0 (Ubuntu). Anunciar a versão exata facilita a vida de quem procura servidores com uma falha conhecida.
  • Compressão. O texto repetitivo caiu de 62 KB para 472 bytes. Texto costuma comprimir muito bem, e a economia vale mais em conexões móveis.
  • Limite de corpo. O upload de 8 MB recebeu 413 do nginx, sem chegar à aplicação. O limite fica um pouco acima do limite do Spring (6 MB, visto em "Extensão — O Site Completo: htmx, Login, Área Administrativa e Upload"): assim, arquivos entre 6 e 7 MB recebem a mensagem amigável da aplicação, e só os absurdos são cortados na porta.
  • Limite de taxa. A taxa é de 5 por minuto, com rajada de 3. As quatro primeiras tentativas passaram (a aplicação de ensaio não tem /entrar, daí o 404), e as seguintes receberam 429. É a defesa contra força bruta que ficou pendente na aula de segurança.

Duas armadilhas de versão apareceram no caminho. A diretiva http2 on; só existe a partir do nginx 1.25.1: o 1.24 do Ubuntu 24.04 recusou a configuração, e o contêiner ficou reiniciando em laço. E o default_server importa. As imagens de nginx trazem um site padrão escutando a porta 80, e sem default_server o nosso bloco de redirecionamento perde para ele. Na imagem oficial, a forma mais simples é montar a sua configuração por cima de /etc/nginx/conf.d/default.conf.

A aplicação de verdade no contêiner

Com os mecanismos ensaiados, a imagem das Trilhas da Ilha segue o mesmo desenho, acrescentando o que o Spring Boot oferece. O Dockerfile usa as camadas extraídas que a documentação do Spring Boot recomenda:

# ---------- 1. Construir ----------
FROM eclipse-temurin:25-jdk AS construcao
WORKDIR /fonte
COPY mvnw pom.xml ./
COPY .mvn/ .mvn/
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q dependency:go-offline
COPY src/ src/
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q package -DskipTests \
 && cp target/*.jar application.jar \
 && java -Djarmode=tools -jar application.jar extract --layers --destination extraido
COPY implantacao/Saude.java /tmp/Saude.java
RUN javac -d /saude /tmp/Saude.java

# ---------- 2. Rodar ----------
FROM eclipse-temurin:25-jre
RUN groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app \
 && mkdir -p /var/lib/trilhas/fotos && chown app:app /var/lib/trilhas/fotos
WORKDIR /aplicacao
# Da camada que menos muda para a que mais muda: um deploy de código só reenvia a última
COPY --from=construcao /fonte/extraido/dependencies/ ./
COPY --from=construcao /fonte/extraido/spring-boot-loader/ ./
COPY --from=construcao /fonte/extraido/snapshot-dependencies/ ./
COPY --from=construcao /fonte/extraido/application/ ./
COPY --from=construcao /saude /saude
ENV LANG=C.UTF-8
USER app
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=60s --retries=3 \
  CMD ["java", "-XX:TieredStopAtLevel=1", "-cp", "/saude", "Saude", "http://127.0.0.1:8080/actuator/health/readiness"]
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-XX:+UseG1GC", "-jar", "application.jar"]

Os testes não rodam aqui (-DskipTests) porque rodam antes, na integração contínua, onde há Docker para o Testcontainers. O --mount=type=cache guarda o repositório do Maven entre builds, para que um build com código novo não baixe a internet de novo. A ordem das cópias segue a frequência de mudança: dependências mudam raramente, e o seu código muda a cada deploy.

O perfil de produção reúne o que as aulas anteriores deixaram anotado, em application-prod.properties:

# Atrás do nginx: confiar nos X-Forwarded-* vindos da rede interna
server.forward-headers-strategy=native

# Cookies de sessão só em HTTPS, sem acesso por JavaScript, e SameSite
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax

# Segredos em arquivos: /run/secrets/<nome.da.propriedade>
spring.config.import=optional:configtree:/run/secrets/

# Encerramento gracioso (padrão no Boot): 20 s para esvaziar requisições
spring.lifecycle.timeout-per-shutdown-phase=20s

# Saúde: liveness e readiness separados; nada além de health exposto
management.endpoint.health.probes.enabled=true
management.endpoints.web.exposure.include=health

# Documentação interativa desligada em produção
springdoc.swagger-ui.enabled=false

trilhas.fotos.pasta=/var/lib/trilhas/fotos

O Actuator entra com a dependência spring-boot-starter-actuator, e a cadeia do site ganha .requestMatchers("/actuator/health/**").permitAll(): o HEALTHCHECK roda dentro do contêiner e não tem sessão. De fora, o nginx deve esconder esses endereços com location /actuator { return 404; }. Não há motivo para a internet saber se o seu banco está de pé.

Uma otimização opcional vem do "Do JAR ao Executável — jlink, jpackage e GraalVM Native Image": o cache AOT do Java 25. Uma execução de treino registra as classes carregadas e ligadas, e as próximas partidas pulam esse trabalho:

RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-XX:MaxRAMPercentage=75", "-XX:+UseG1GC", "-jar", "application.jar"]

O spring.context.exit=onRefresh sobe o contexto e sai. Mas "subir o contexto" das Trilhas inclui conectar ao PostgreSQL e rodar o Flyway, e durante o docker build não há banco. Para usar o cache AOT neste projeto, o treino precisa acontecer com um banco disponível (por exemplo, na integração contínua, com um contêiner ao lado) ou com essas partes desligadas no perfil de treino. Por isso ele fica fora do Dockerfile principal.

O conjunto: compose.yaml

services:
  app:
    image: ghcr.io/sua-conta/trilhas:${VERSAO:?defina VERSAO}
    restart: unless-stopped
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/trilhas
      SPRING_DATASOURCE_USERNAME: trilhas
      SPRING_MONGODB_URI: mongodb://mongo:27017/trilhas_da_ilha
    secrets:
      - source: jwt
        target: trilhas.jwt.segredo            # vira a propriedade trilhas.jwt.segredo
      - source: senha_postgres
        target: spring.datasource.password
    volumes:
      - fotos:/var/lib/trilhas/fotos
    mem_limit: 1g
    read_only: true                            # o sistema de arquivos da imagem não muda
    tmpfs:
      - /tmp
    stop_grace_period: 40s                     # > os 20 s do Spring + folga
    depends_on:
      postgres: { condition: service_healthy }
      mongo:    { condition: service_healthy }
    logging:
      options: { max-size: "20m", max-file: "5" }

  postgres:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_DB: trilhas
      POSTGRES_USER: trilhas
      POSTGRES_PASSWORD_FILE: /run/secrets/senha_postgres
      POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=pt-BR"
    secrets:
      - senha_postgres
    volumes:
      - dados_postgres:/var/lib/postgresql   # no 18, NÃO em .../data (veja abaixo)
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "trilhas", "-d", "trilhas"]
      interval: 5s
      retries: 10

  mongo:
    image: mongo:8
    restart: unless-stopped
    volumes:
      - dados_mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 5s
      retries: 10

  proxy:
    image: nginx:1.30
    restart: unless-stopped
    depends_on:
      app: { condition: service_healthy }
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/trilhas.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/proxy_comum.conf:/etc/nginx/proxy_comum.conf:ro
      - /etc/letsencrypt/live/trilhas.example.com:/etc/nginx/certs:ro

volumes:
  dados_postgres:
  dados_mongo:
  fotos:

secrets:
  jwt:
    file: ./segredos/jwt
  senha_postgres:
    file: ./segredos/senha_postgres

Os pontos que merecem atenção:

  • Só o nginx publica portas. PostgreSQL, MongoDB e a aplicação existem apenas na rede interna do Compose. Um banco exposto à internet é encontrado por robôs em minutos.
  • depends_on com service_healthy. "Contêiner iniciado" não é "banco pronto". A aplicação só sobe quando o PostgreSQL e o MongoDB passam nas verificações de saúde, e o nginx só quando a aplicação está pronta. É a estratégia de espera do Testcontainers, agora em produção.
  • O stop_grace_period precisa cobrir o encerramento gracioso. O Spring espera até 20 s pelas requisições em andamento. Com o padrão do Docker, 10 s, o SIGKILL chegaria no meio disso. 40 s dá a folga.
  • O volume de fotos herda o dono da imagem. Na primeira montagem, um volume nomeado vazio é preenchido com o conteúdo e as permissões da pasta da imagem. Por isso o chown app:app no Dockerfile: sem ele, a aplicação não conseguiria gravar fotos. Um bind mount de uma pasta do host não tem esse comportamento, e aí o dono precisa ser ajustado no host.
  • read_only: true faz a imagem virar somente leitura em tempo de execução. Um invasor não consegue deixar um arquivo malicioso no sistema. As únicas áreas graváveis são o /tmp em memória e o volume de fotos.
  • Rotação de logs. Sem max-size, o log de um contêiner cresce até encher o disco, e esse é um dos incidentes mais comuns em servidores pequenos.
  • Imagens com versão. postgres:18, mongo:8 e nginx:1.30 nunca são latest. A troca de versão maior é uma decisão, e às vezes muda o próprio Compose: a partir do 18, a imagem do PostgreSQL guarda os dados em /var/lib/postgresql/18/docker, e o volume passa a ser montado em /var/lib/postgresql. Se ela encontrar um volume no antigo /var/lib/postgresql/data, recusa-se a subir com uma mensagem de erro, em vez de gravar no lugar errado. A imagem da aplicação usa a variável VERSAO, e o :? faz o Compose recusar-se a subir se ela não estiver definida.

HTTPS de verdade

O ensaio usou um certificado autoassinado, que o navegador recusaria. Em produção, o Let's Encrypt emite certificados gratuitos e automáticos. Com o certbot no servidor e o domínio apontando para ele:

sudo certbot certonly --webroot -w /var/www/acme -d trilhas.example.com

O nginx precisa responder o desafio em /.well-known/acme-challenge/, servindo essa pasta no bloco da porta 80 antes do redirecionamento. Os certificados valem 90 dias, e o certbot instala uma tarefa agendada que os renova. Depois de renovar, basta docker compose exec proxy nginx -s reload para o nginx carregá-los sem derrubar conexões.

O mesmo deploy sem Docker

Nada do que foi feito até aqui depende do Docker em si. Sinais, verificações de saúde, segredos em arquivo e o nginx na frente valem do mesmo jeito num servidor comum, com os programas instalados direto no sistema. O ensaio foi repetido assim, com o Java e o nginx 1.30 instalados na máquina, e as respostas foram as mesmas: o 301 com caminho e parâmetros, os cabeçalhos de segurança, 62000 bytes que viram 472 com gzip, o 413 no upload de 8 MB e a sequência 404 404 404 404 429 429 429 429. A única diferença foi no /eco: o cliente visto pelo Java passou a ser 127.0.0.1, o nginx na mesma máquina. O encerramento também se comportou igual: com uma requisição lenta em andamento, o SIGTERM esperou a resposta sair, e o Java terminou com o código 143.

Com o Java 25, o nginx, o PostgreSQL e o MongoDB instalados (no Ubuntu, sudo apt install nginx certbot; os bancos como em "Extensão — PostgreSQL: o Banco Servidor, JSONB e Arrays" e "Extensão — MongoDB: Documentos, Agregações e Quando Escolher Cada Modelo"), o papel do Compose passa para o systemd. Primeiro, um usuário sem privilégios, as pastas e os segredos, com os nomes das propriedades:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin trilhas
sudo mkdir -p /opt/trilhas /var/lib/trilhas/fotos /etc/trilhas/segredos
sudo chown trilhas:trilhas /var/lib/trilhas/fotos
sudo cp segredos/jwt /etc/trilhas/segredos/trilhas.jwt.segredo
sudo cp segredos/senha_postgres /etc/trilhas/segredos/spring.datasource.password
sudo chown trilhas:trilhas /etc/trilhas/segredos/* && sudo chmod 400 /etc/trilhas/segredos/*
sudo cp target/*.jar /opt/trilhas/application.jar

Depois, o serviço, em /etc/systemd/system/trilhas.service:

[Unit]
Description=Trilhas da Ilha
After=network-online.target postgresql.service mongod.service
Wants=network-online.target

[Service]
User=trilhas
Group=trilhas
WorkingDirectory=/opt/trilhas
Environment=SPRING_PROFILES_ACTIVE=prod
Environment=SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/trilhas
Environment=SPRING_DATASOURCE_USERNAME=trilhas
Environment=SPRING_MONGODB_URI=mongodb://localhost:27017/trilhas_da_ilha
# Os segredos aparecem em /run/secrets só para este serviço: o mesmo configtree do Compose
BindReadOnlyPaths=/etc/trilhas/segredos:/run/secrets
# A aplicação escuta só na própria máquina: quem fala com a internet é o nginx
Environment=SERVER_ADDRESS=127.0.0.1
ExecStart=/usr/bin/java -Xmx768m -XX:+UseG1GC -jar /opt/trilhas/application.jar
# O Java sai com 143 (128 + 15) quando recebe SIGTERM: para o systemd, é saída normal
SuccessExitStatus=143
TimeoutStopSec=40
Restart=on-failure
# Mesmo espírito do read_only do Compose: só a pasta de fotos é gravável
ProtectSystem=strict
ReadWritePaths=/var/lib/trilhas/fotos
PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

Cada linha tem um equivalente no Compose.

  • BindReadOnlyPaths faz a pasta de segredos aparecer em /run/secrets, só para este serviço. O application-prod.properties continua o mesmo, com o mesmo configtree.
  • SERVER_ADDRESS=127.0.0.1 faz o papel da rede interna do Compose. Sem ela, a porta 8080 ficaria aberta para a internet, contornando o nginx. O PostgreSQL e o MongoDB dos pacotes já escutam só em localhost por padrão.
  • -Xmx768m em vez de MaxRAMPercentage. Fora de um contêiner, a "memória disponível" é a da máquina inteira, dividida com os dois bancos, e o heap precisa de um número escolhido por você.
  • SuccessExitStatus=143 diz ao systemd que sair com 143 depois do SIGTERM é o encerramento normal, não uma falha.
  • TimeoutStopSec=40 é o stop_grace_period: o systemd espera esse tempo antes do SIGKILL. O padrão dele é 90 s.
  • ProtectSystem=strict com ReadWritePaths é o read_only: true: o sistema inteiro fica somente leitura para o serviço, menos a pasta de fotos.
sudo systemctl daemon-reload
sudo systemctl enable --now trilhas
journalctl -u trilhas -f                       # os logs, no lugar do docker compose logs

O nginx usa a mesma configuração (no 1.24 do Ubuntu 24.04, com a forma antiga do http2, como diz o comentário), com server 127.0.0.1:8080; no upstream, em /etc/nginx/conf.d/trilhas.conf, e o proxy_comum.conf em /etc/nginx/. O pacote do Ubuntu traz um site padrão que também declara listen 80 default_server, e o nginx -t recusa a combinação com "a duplicate default server for 0.0.0.0:80". Ele é só um link e pode sair:

sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --webroot -w /var/www/acme -d trilhas.example.com \
     --deploy-hook "systemctl reload nginx"

O --deploy-hook recarrega o nginx a cada renovação. O deploy de uma versão nova é copiar o JAR e reiniciar o serviço (sudo systemctl restart trilhas), guardando o JAR anterior. Reverter é copiá-lo de volta e reiniciar. Os cuidados da próxima seção valem igual.

O deploy como procedimento

Com tudo descrito em arquivos, o deploy vira uma sequência curta e repetível:

  1. Na integração contínua (GitHub Actions, por exemplo), a cada push na branch principal:

    • ./mvnw verify, com os testes de unidade e os de integração com Testcontainers;
    • docker build e docker push ghcr.io/sua-conta/trilhas:<hash-do-commit>.
  2. No servidor: VERSAO=<hash> docker compose pull app && VERSAO=<hash> docker compose up -d app.
  3. O Compose troca só o contêiner da aplicação. A nova versão sobe, o Flyway aplica as migrações pendentes e a verificação de saúde passa. A antiga recebe o SIGTERM e sai com calma.

Três cuidados tornam esse procedimento seguro.

  • Migrações compatíveis com a versão anterior. Durante alguns segundos, e numa reversão por mais tempo, o código antigo roda com o esquema novo. É por isso que "Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway" insistiu no expand/contract: primeiro acrescentar (coluna nova, opcional), depois migrar o código, só então remover.
  • Reverter é voltar a tag, não o banco. Se a versão nova falhar, VERSAO=<hash-anterior> docker compose up -d app restaura o código em segundos. As migrações não são desfeitas, e é exatamente por isso que precisam ser compatíveis.
  • Uma instância significa alguns segundos de indisponibilidade. Entre a saída da versão antiga e a prontidão da nova, o nginx responde 502. Para eliminar essa janela, rode duas instâncias da aplicação atrás do upstream do nginx e troque uma de cada vez. É o primeiro passo de uma estrada que leva aos orquestradores como o Kubernetes, assunto para outro curso.

E, antes de qualquer outra coisa, backup:

  • um pg_dump diário;
  • um mongodump;
  • uma cópia do volume de fotos, levada para fora do servidor.

E um teste de restauração, de verdade, de tempos em tempos. Backup que nunca foi restaurado é uma hipótese, não uma garantia.

Fechando a Trilha A

A Trilha A começou em "Extensão — Java no Servidor: Servlets, Jakarta EE, Spring e Quarkus" com uma pergunta: como o Java que você aprendeu vira um sistema que as pessoas usam? Treze aulas depois, a resposta está no ar.

Primeiro veio a persistência. Com SQL e JDBC sobre SQLite vieram a disciplina do PreparedStatement e o medo saudável do SQL injection. O Spring Boot perdeu a magia da injeção de dependência quando construímos um contêiner em miniatura para ver por dentro, e o JPA mostrou o contexto de persistência, o dirty checking e o problema N+1. Spring Data e Flyway transformaram consultas em interfaces e o esquema em código versionado. Depois vieram três bancos: o PostgreSQL com JSONB, arrays e busca textual em português, o MySQL com suas diferenças de dialeto e de collation, e o MongoDB com documentos, agregações e critérios honestos para escolher entre os modelos.

Em seguida a aplicação abriu a porta para fora, com uma API REST de contratos em records, validação declarativa e erros no formato da RFC 9457. Vieram as senhas guardadas com hash lento, os JWTs verificados (e atacados) e as regras de acesso testadas linha a linha, e o Testcontainers garantiu que os testes falam com bancos de verdade. O site trouxe Thymeleaf com escape por construção, formulários com Post/Redirect/Get, htmx, login com sessão e um upload que não confia em nada. Por fim, nesta aula, a aplicação ganhou imagem, sinais, segredos, proxy e procedimento.

Repare quantas vezes o capstone reapareceu. O Farol ensinou HTTP lendo bytes de um socket, e cada anotação do Spring MVC foi lida como "a peça do Farol que ela substitui". A defesa contra path traversal voltou nas fotos, e o encerramento gracioso voltou no SIGTERM do deploy. Os frameworks não são mágica: são o que você já sabe fazer, feito por muita gente, com muito cuidado, por muitos anos. Saber o que há por baixo é o que permite usá-los sem medo e depurá-los quando falham.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um colega escreveu o entrypoint.sh abaixo e percebeu que cada deploy demora dez segundos a mais. Explique por que e corrija-o.

#!/bin/sh
echo "Iniciando Trilhas da Ilha versão $VERSAO"
java -XX:MaxRAMPercentage=75 -jar application.jar
Ver resposta

✓ Resposta: O script é o PID 1 do contêiner, e o Java é um processo filho do sh. O docker stop envia SIGTERM ao PID 1, e o sh, sem um tratador para esse sinal, o ignora (regra do kernel para o PID 1 de um namespace) e não o repassa ao Java. Depois de 10 segundos vem o SIGKILL, que mata tudo sem encerramento gracioso. É exatamente o comportamento medido na aula (10,2 s, código 137). A correção é trocar a última linha por exec java -XX:MaxRAMPercentage=75 -jar application.jar. O exec substitui o shell pelo Java, que passa a ser o PID 1 e recebe o sinal.

Exercício 2

A aplicação roda num contêiner com mem_limit: 1g e sem nenhuma opção de memória. Qual será o heap máximo, e que coletor a JVM do Java 25 vai escolher se o contêiner também tiver cpus: 1? O que você mudaria?

Ver resposta

✓ Resposta: O heap máximo será 256 MB, um quarto de 1 GB, e três quartos da memória ficariam sem uso pelo heap. Com 1 CPU (menos de 2) e 1 GB (menos de 1792 MB), a máquina não é "classe servidor", e o JDK 25 escolhe o SerialGC. Eu acrescentaria -XX:MaxRAMPercentage=75 (cerca de 768 MB de heap, sobrando 256 MB para metaspace, threads e buffers) e escolheria o coletor explicitamente, por exemplo -XX:+UseG1GC, confirmando o resultado com java -XX:+PrintFlagsFinal -version dentro do próprio contêiner.

Exercício 3

Depois de pôr o nginx na frente, o login passou a redirecionar para http://trilhas.example.com/admin/trilhas, e o navegador faz uma volta extra pelo redirecionamento do nginx a cada login. Qual é a causa, e qual é a correção no Spring Boot?

Ver resposta

✓ Resposta: A aplicação recebe a requisição do nginx em HTTP puro, na porta 8080, e sem instrução acredita que o cliente usou HTTP. Por isso monta as URLs de redirecionamento com http://. O nginx envia X-Forwarded-Proto: https, mas a aplicação o ignora. A correção é server.forward-headers-strategy=native: o Tomcat passa a usar os cabeçalhos X-Forwarded-* (só quando vêm de proxies internos) para definir esquema, host, porta e IP do cliente. Com isso os redirecionamentos saem com https://, o cookie Secure é emitido corretamente e os logs passam a registrar o IP real.

Exercício 4

Por que o client_max_body_size do nginx (7 MB) é maior que o limite do Spring (6 MB), e não igual ou menor?

Ver resposta

✓ Resposta: Porque quem dá a melhor resposta ao usuário é a aplicação. Com 7 MB no nginx, um arquivo de 6,5 MB chega ao Spring, que lança MaxUploadSizeExceededException, e o advice LimiteDeUpload redireciona de volta ao formulário com "O arquivo passa do limite de 5 MB". Se o nginx cortasse em 6 MB ou menos, o usuário veria uma página crua de "413 Request Entity Too Large" do próprio nginx. O limite do nginx continua útil: protege a aplicação de uploads absurdos (1 GB), que seriam recusados antes de ocupar conexões e memória do lado Java.

Exercício 5

Na primeira tentativa do ensaio, o segredo montado pelo Compose não pôde ser lido pela aplicação. Descreva a causa e duas formas de resolver, apontando uma vantagem de cada.

Ver resposta

✓ Resposta: Em um Compose sem Swarm, segredos de arquivo são montagens do arquivo do host com o mesmo dono e permissões. O arquivo era root:root com modo 600, e a aplicação roda como o UID 10001. A primeira solução é, no host, chown 10001:10001 e chmod 400 no arquivo. A vantagem é que só aquele usuário lê o segredo, com permissão mínima. A segunda é deixar o arquivo legível por um grupo (chgrp para um GID compartilhado e chmod 440) e acrescentar esse grupo ao usuário do contêiner (group_add no Compose). A vantagem é permitir que mais de um serviço (por exemplo, a aplicação e um contêiner de backup) leia o mesmo segredo sem que todos rodem com o mesmo UID. Em ambos os casos, o segredo continua fora do docker inspect, ao contrário de uma variável de ambiente.

Comentários

Mais em Java

Do Código ao Bytecode — JDK, javac, JVM e o Primeiro Programa
Do Código ao Bytecode — JDK, javac, JVM e o Primeiro Programa

Instalação do JDK 25, a diferença entre JDK, JRE e JVM, o primeiro programa…

Extensão — Páginas Dinâmicas: Thymeleaf e Formulários
Extensão — Páginas Dinâmicas: Thymeleaf e Formulários

Nesta aula as Trilhas da Ilha ganham páginas HTML geradas no servidor com…

Extensão — PostgreSQL: o Banco Servidor, JSONB e Arrays
Extensão — PostgreSQL: o Banco Servidor, JSONB e Arrays

O projeto das Trilhas da Ilha passa para o PostgreSQL, instalado na máquina ou…