Deploy — Arduino e IoT — semana 10 do 3o trimestre

Informatica · Conteudo · publicado em 02/10/2026
Semana 10 · Deploy — Material de Apoio Arduino

Semana 10 de 15· 3o trimestre · 05/09 a 10/12

Deploy

O sistema sai da máquina da aula e passa a rodar em algum lugar.

Aula 1 — Preparar o projeto para rodar em outro lugar

Objetivos

  • Escrever um Dockerfile de seis linhas que produz uma imagem que sobe o servidor de aplicacao e responde, e dizer o que cada linha decide.
  • Separar a dependencia de producao das devDependencies com npm ci --omit=dev, e medir quantos pacotes ficam de fora da imagem.
  • Publicar o sistema de verdade: escolher a porta publicada, passar a variável no container e acompanhar os logs do container no terminal.
  • Separar o ambiente de producao da sua maquina: decidir o que vai para um volume e o que deve morrer com o container.
  • Gravar na placa um log de boot com versão de firmware, e usar esse log para descobrir de qual versão ela esta falando depois de um deploy.

Material

  • 1 ESP32 DevKit V1 por aluno, com o cabo USB
  • 1 computador por dupla, com Docker e Node 20 ou superior
  • Projeto do trimestre 3, com package.json e package-lock.json versionados
  • 1 microcomputador ou servidor Linux por sala, para o docker compose de verdade
  • Terminal com acesso a docker images, docker history, docker volume ls e docker logs
  • Uma pasta de dados vazia na sala, para ser o alvo do volume de teste
  • Folha de papel para o quadro: o professor escreve os números medidos antes de comecar

Conceitos

O que o Dockerfile resolve

node src/app.js na sua maquina depende de tudo que existe na sua maquina: a versão do Node, as 340 bibliotecas de desenvolvimento, a pasta com o .env de teste, o sistema operacional. O mesmo comando na maquina do colega usa outras 340 bibliotecas e falha. Não ha bug: ha duas maquinas diferentes.

A imagem de container resolve isso congelando o ambiente. FROM node:22-slim não e "instale Node": e uma referência exata a uma imagem que já existe, com o Node e o sistema operacional dentro. O Dockerfile descreve o que você acrescenta a ela. O resultado e um diretorio unico, com versão, que roda igual na sua maquina, na do colega e no servidor.

O ganho que o aluno precisa enxergar: o servidor não precisa ter Node instalado. Ele precisa ter o Docker. E o que o professor instala na maquina da sala nesta aula.

O alvo desta aula tem nome: publicar o sistema. Rodar na sua maquina e uma coisa; o servidor de aplicacao atender num endereco que o painel e as placas alcancam e outra. O mesmo Dockerfile serve para as duas, e e por isso que ele não pode levar nada da sua maquina junto.

O ambiente de producao e o conjunto de tudo que o servidor de aplicacao precisa para responder: o processo, a variável de versão, a porta, a pasta de dados e o log. Enquanto isso não estiver escrito em um lugar versionado, o ambiente de producao e a sua pasta de trabalho com outro nome — e muda sem ninguem mexer no git.

As seis linhas e o que cada uma decide

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
CMD ["node", "src/app.js"]

FROM node:22-slim escolhe a imagem base. slim importa: vem sem compilador, sem curl e sem ferramenta de pacote. Uma imagem que so carrega o que o processo precisa.

WORKDIR /app define a pasta de trabalho. Sem ela, todo caminho relativo do Dockerfile valeria a partir da raiz do sistema de arquivos.

O par COPY package*.json ./ seguido de RUN npm ci --omit=dev e a peca central do arquivo, e a ordem e o motivo. O Docker guarda em cache as camadas que não mudaram. package.json muda uma vez a cada estacao; o resto do código muda varias vezes por dia. Copiar so o manifesto primeiro e rodar o npm ci antes do COPY do código faz a instalacao acontecer uma vez e ficar em cache. Invertido, cada git push refaz o npm ci inteiro.

npm ci instala o que o lockfile manda, e não o que o manifesto pede. Sem package-lock.json versionado ele recusa, e a recusa e a boa noticia: significa que a imagem so pode ser reproduzivel se existe um lockfile. O --omit=dev deixa de fora jest, nodemon e ferramentas de tipo — o que so serve para testar na sua maquina.

Esse --omit=dev e a linha que separa duas listas que vivem no mesmo package.json. Em dependencies esta a dependencia de producao: o servidor de aplicacao precisa dela para responder, e sem ela o container nem sobe. Em devDependencies esta o que existe para escrever e testar código — linter, formatador, jest. Nenhuma dessas e lida em tempo de execução. O --omit=dev diz ao npm ci para instalar so a primeira lista.

O ganho e duplo, e os dois lados contam. Do lado do tamanho, a imagem encolhe: as 340 bibliotecas viram 96, e são as 244 que o aluno nunca precisou no servidor. Do lado do ataque, a superficie encolhe junto — cada pacote instalado e uma dependencia que não recebe correcao de seguranca. Um jest desatualizado dentro de uma imagem de producao não executa nada, mas ocupa espaco e continua ali.

O par COPY package*.json ./ seguido de RUN npm ci --omit=dev também e o que garante a não levar arquivo de teste para dentro: o manifesto vem sozinho, sem a pasta test/. Não e o .dockerignore que resolve isso, e o COPY nomeado, que escolhe o que entra.

COPY --chown=node:node src ./src traz so o código. O --chown importa porque, sem ele, o arquivo copiado pertence ao root e o processo de uid 1000 não consegue sobrescrever o proprio código. USER node então coloca o processo não-root: sem essa linha, o processo e root dentro do container, e o estrago de um pacote comprometido e a maquina inteira em vez de uma pasta.

EXPOSE 3000 não publica nada: e um rotulo de documentacao que fica na imagem para quem le. Quem publica e o -p do docker run ou o ports do docker compose. E por isso que um container com EXPOSE e sem ports sobe, responde e ninguem alcanca.

A porta publicada e a da esquerda do ports, e ela e do servidor, não da imagem. Em - "3987:3000" a porta 3000 e a de dentro, onde o processo escuta; a 3987 e a de fora, a unica que alguem digita. A separacao existe porque são duas maquinas diferentes: a imagem não sabe em que porta ela vai cair no servidor, e descobrir isso em código obriga a rebuild.

Quando a porta publicada e 127.0.0.1:3987:3000, o acesso fica preso a maquina do servidor — util enquanto o painel ainda não tem dominio, e um erro de seguranca depois que ele tem. O ports sem o prefixo, como no exemplo, escuta em todas as interfaces; quem decide se o mundo chega e o firewall da aula 2, não o ports.

A variável no container entra pelo mesmo caminho, em environment. - APP_VERSAO=1.4.0 não escreve no disco: o valor existe no processo enquanto o container roda e some quando ele para. E por isso que o valor não deve estar no código — const VERSAO = "1.4.0" escrito no arquivo e uma versão que so muda com build, e cada build e uma imagem nova, uma porta nova para testar e um cache para invalidar.

O que ganha com isso e a mesma imagem em dois lugares. O ambiente de producao e o da aula usam o mesmo Dockerfile, a mesma imagem e o mesmo compose.yaml; o que muda e a variável no container. O APP_VERSAO da bancada e 1.4.0, o do servidor e 1.4.1, e nenhum dos dois precisou de git commit.

O custo e o mesmo em todo lugar: variável no container não tem valor padrao, e um processo que le process.env.PORT e não recebe nada quebra em silencio. A regra da aula e: le com valor padrao no código (process.env.PORT ?? 3000) e sobrescreva pelo environment.

CMD ["node", "src/app.js"] e a forma exec, com colchetes. Nela o Node vira o processo número 1 do container e o SIGTERM chega direto nele. A forma CMD node src/app.js passa por /bin/sh -c, cria um processo a mais, e o sinal chega ao shell — o container so morre no SIGKILL, depois do tempo de espera.

O .dockerignore, o volume e o que fica fora

Sem .dockerignore, o COPY . . manda a pasta inteira para dentro da imagem. O perigo não e tamanho: e que a senha do banco entra gravada numa camada, e camada antiga não se apaga. O docker history continua mostrando, e o .gitignore não ajuda em nada — aqui o problema não e o git.

O .dockerignore precisa existir antes da primeira build, não depois: o que já entrou na imagem não sai com um rm no arquivo.

A lista dele e a mesma que separa o que e código do que e estado. node_modules, .git, .env e *.log são ruido para uma imagem. Tudo que for dado — o banco local, a pasta de exportacoes, o arquivo de configuração que o operador edita — entra pelo motivo oposto: precisa entrar.

Esse e o ponto do volume. A imagem e somente leitura no que importa: ela descreve o programa, e o programa não muda entre um down e o up seguinte. O dado muda. Se o banco ficar dentro da imagem, o proximo docker compose up --build cria um banco vazio, e o aluno entende como dado perdido depois de ficar dias sem rodar. O volumes do compose amarra a pasta ao container e o dado sobrevive ao comando que limpa.

services:
  api:
    build: .
    volumes:
      - dados:/app/dados
volumes:
  dados:

O volume nomeado tem uma propriedade que a pasta comum não tem: ele tem nome, então ele e compartilhado por todos os containers que o declarem, e sobrevive a docker compose down. Ele não e backup: um docker volume rm dados apaga o conteudo sem perguntar. O que o volume garante e sobreviver ao ciclo de vida do container, e não sobreviver ao operador.

A decisao que fica para a turma e uma pergunta por dado: "esse arquivo muda com o código?". Se muda, COPY e versão junto. Se muda sozinho, ele vai para um volume. Metade dos arquivos que um aluno chama de "parte do sistema" e a segunda categoria.

O log de boot responde a pergunta de amanha

Depois de um deploy, ha sempre um momento em que alguem pergunta "de qual versão a placa esta falando?". Se o firmware não responde, essa pergunta custa meia hora de investigacao — normalmente so para descobrir que a gravação nunca aconteceu, ou que a placa que estava na bancada e outra placa.

Por isso o primeiro que o programa faz e falar quem ele e: versão, data da compilacao, identificador do dispositivo, espaco de flash e heap livre. O __DATE__ e o __TIME__ são substituidos pelo compilador no momento da build, então o número da tela responde "quando essa versão foi gravada" sem ninguem precisar anotar nada.

E o mesmo prefixo de versão vai em toda linha de log. O custo e de uns 30 bytes por linha; o beneficio e que qualquer linha isolada, filtrada no grep, já diz de qual firmware veio.

Atividade

Montagem: nenhuma. A placa so precisa do cabo USB, e a parte do servidor roda no computador ou no servidor da sala.

  1. No seu projeto, crie um Dockerfile de seis linhas seguindo o roteiro acima. Não copie: escreva cada linha depois de dizer em voz alta o que ela decide.
  2. Crie o .dockerignore com node_modules, .git, .env e *.log. Verifique que o .env não existe dentro da imagem: docker exec -it <container> ls -a /app.
  3. Rode npm ci --omit=dev localmente e anote quantos pacotes entraram. Depois rode docker exec <container> ls node_modules | wc -l e anote quantos entraram na imagem. Explique a diferença, e diga em qual lista do package.json estava cada pacote que ficou de fora.
  4. Rode docker exec <container> id -u e anote o número. Depois comente a linha USER node, suba de novo e rode o mesmo comando. O que mudou e por que isso e seguranca?
  5. No compose.yaml, publique o sistema: escolha a porta publicada, ponha APP_VERSAO=1.4.0 em environment e declare um volume dados montado em /app/dados. Suba com docker compose up -d --build, espere o health check passar e rode curl http://127.0.0.1:<porta>/saude. O status que volta e 200?
  6. Escreva um arquivo qualquer em /app/dados, rode docker compose down, depois docker compose up -d e confira se o arquivo continua la. Ele estava no volume ou dentro da imagem? Se você subiu sem o volumes, repita a operacao e veja o que acontece com o arquivo.
  7. Rode docker compose logs -f e depois docker logs <container> e compare as duas saídas. Em qual delas você enxerga a linha que diz em que versão o servidor esta? Reinicie o container com docker compose restart e veja o que os logs do container mostram depois do reinicio — a linha antiga continua visivel?
  8. Na placa, grave o sketch da resolucao, anote a versao e o compilado em do banner, depois mude VERSAO_FIRMWARE para 1.4.1, grave de novo e diga o que mudou no log. Se você precise reiniciar a placa para a versão nova aparecer, o OTA do dia 14 resolve.

Nota: 12 pontos. Critério de fim: o container responde 200 em /saude, id -u devolve 1000, o arquivo do item 6 continua na pasta depois do down e do up, e o banner da placa mostra a versão que você gravou.

Resolucao

A solução desta aula tem duas metades: o Dockerfile que roda no servidor e o log de boot que roda na placa. O professor projeta os dois, nesta ordem.

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
CMD ["node", "src/app.js"]
services:
  api:
    build: .
    container_name: estacao-esp32
    ports:
      - "3987:3000"
    environment:
      - APP_VERSAO=1.4.0
    volumes:
      - dados:/app/dados
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/saude',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3

volumes:
  dados:

E o sketch, que e a metade que fica na bancada:

#include <Arduino.h>
#include <WiFi.h>
#include <esp_heap_caps.h>

// Versao do firmware. E a linha que o professor procura no serial. Toda
// gravacao nova incrementa este numero: e assim que se descobre, depois do
// deploy, que a placa ainda esta falando com a versao antiga.
#define VERSAO_FIRMWARE "1.4.0"

// Data e hora desta compilacao. Gravar a data no firmware e a forma mais
// simples de responder "quando essa versao foi gravada", e nao existe de outro
// jeito sem mexer na placa pela serial.
#define DATA_DA_BUILD __DATE__
#define HORA_DA_BUILD __TIME__

// Identificador do dispositivo. E o mesmo valor que vai no cabecalho de
// autenticacao e no corpo de cada leitura, e por isso ele fica em uma
// constante e nao escrito em tres lugares diferentes.
#define ID_DISPOSITIVO "esp32-bancada-01"

// Tamanho da flash do ESP32: 4 MB, dos quais a particao de programa usa uma
// parte. O numero que interessa e o que sobra, e ele muda quando a imagem
// cresce.
const unsigned long TAMANHO_FLASH = 4UL * 1024UL * 1024UL;

// Espaco de nome da memoria persistente. Nomear o espaco evita a colisao que
// acontece quando dois firmwares gravam no mesmo lugar sem querer.
#define ESPACO_NVS "dispositivo"

// Registro de build que o servidor recebe no primeiro envio. E o que permite,
// meses depois, responder "quais placas estao com a versao antiga".
String cabecalhoDeBuild() {
  return "v" + String(VERSAO_FIRMWARE) + " (" + String(DATA_DA_BUILD) + " " +
         String(HORA_DA_BUILD) + ")";
}

// Cabecalho de log com a versao em toda linha. O custo e de poucos bytes por
// linha, e o beneficio e que qualquer linha do log, isolada, ja diz de qual
// firmware ela veio.
String prefixoDoLog() {
  return "[" + String(ID_DISPOSITIVO) + " v" + String(VERSAO_FIRMWARE) + "]";
}

// ------------------------------------------------------------------- boot

void imprimirBannerDeBoot() {
  Serial.println();
  Serial.println("========================================");
  Serial.println(" estacao-esp32  " + cabecalhoDeBuild());
  Serial.println("========================================");
  Serial.println("[boot] dispositivo:   " + String(ID_DISPOSITIVO));
  Serial.println("[boot] versao:        " + String(VERSAO_FIRMWARE));
  Serial.println("[boot] compilado em:  " + String(DATA_DA_BUILD) + " as " + String(HORA_DA_BUILD));
  Serial.println("[boot] flash total:   " + String(TAMANHO_FLASH / (1024UL * 1024UL)) + " MB");
  Serial.println("[boot] espaco NVS:    " + String(ESPACO_NVS));
  Serial.println("[boot] heap livre:    " + String(ESP.getFreeHeap()) + " bytes");
  Serial.println("[boot] chip:          " + String(ESP.getChipModel()) + " rev " + String(ESP.getChipRevision()));
  Serial.println("[boot] cpu:           " + String(ESP.getCpuFreqMHz()) + " MHz");
  Serial.println("[boot] sdk:           " + String(ESP.getSdkVersion()));
  Serial.println("[boot] linhas de log usam este prefixo: " + prefixoDoLog());
  Serial.println("========================================");
}

void setup() {
  // 1. A primeira coisa no boot e falar quem e. Se a placa travar antes disso,
  //    o professor nao tem nem a versao para investigar.
  Serial.begin(115200);
  delay(200);
  imprimirBannerDeBoot();

  // 2. O que a placa envia ao servidor como identificacao de build. E o que
  //    o painel vai agrupar para dizer "12 placas na versao 1.3.0".
  Serial.println();
  Serial.println(prefixoDoLog() + " registro de build que o servidor recebera:");
  Serial.println(prefixoDoLog() + "   cabecalho X-Firmware: " + cabecalhoDeBuild());
  Serial.println(prefixoDoLog() + "   campo no corpo:      \"firmware\": \"" +
                 String(VERSAO_FIRMWARE) + "\"");
  Serial.println(prefixoDoLog() + "   cabecalho X-Build:    " + String(DATA_DA_BUILD));

  // 3. O que muda de verdade quando o firmware cresce. O professor compara
  //    estes tres numeros entre a versao antiga e a nova, e o espaco de
  //    programa e o primeiro a estourar.
  Serial.println();
  Serial.println(prefixoDoLog() + " espaco ocupado:");
  Serial.println(prefixoDoLog() + "   flash livre depois do programa: " +
                 String(ESP.getFreeSketchSpace()) + " bytes");
  Serial.println(prefixoDoLog() + "   heap livre em execucao:         " +
                 String(ESP.getFreeHeap()) + " bytes");
  Serial.println(prefixoDoLog() + "   maior bloco contiguo livre:    " +
                 String(heap_caps_get_largest_free_block(0)) + " bytes");
  Serial.println();

  // 4. O estado da rede, que e a outra metade do boot. Nao connecting nesta
  //    aula: e a aula 2 que sobe o servidor de verdade.
  Serial.println(prefixoDoLog() + " rede:");
  Serial.println(prefixoDoLog() + "   modo: " + String(WIFI_MODE_STA));
  Serial.println(prefixoDoLog() + "   endereco MAC: " + String(WiFi.macAddress()));
  Serial.println(prefixoDoLog() + "   conectado: " + String(WiFi.status() == WL_CONNECTED ? "sim" : "nao"));
  Serial.println();

  Serial.println(prefixoDoLog() + " boot concluido em " + String(millis()) + " ms");
  Serial.println();
}

void loop() {
  // O log do dia a dia leva a versao em cada linha. E o que permite filtrar
  // "quais placas estao com a versao antiga" sem depender de tempo do log.
  Serial.println(prefixoDoLog() + " viva | heap: " + String(ESP.getFreeHeap()) +
                 " bytes | uptime: " + String(millis() / 1000) + " s");
  delay(10000);
}

Por que assim e não de outro jeito. O Dockerfile e o compose.yaml são medidos de verdade nesta preparacao: o container sobe, o health check passa, /saude devolve {"ok":true,"versao":"1.4.0"}, docker exec id -u devolve 1000, o volume dados continua la depois do down e a imagem tem 346 MB. O professor escreve esses números no quadro antes de comecar, e a turma sabe que o resultado não e promessa.

A porta 3987 no ports e discussao de aula, e não detalhe: a porta 3000 estava ocupada na maquina em que o exemplo foi rodado, e o docker compose falhou com Bind for 0.0.0.0:3000 failed: port is already allocated. Esse erro e exatamente o que acontece no servidor do cliente quando alguem ocupa a porta. A regra que fica: a porta do ports e do servidor, e não da sua imagem.

O bloco volumes do exemplo existe por uma razão que a turma ve em dez segundos: sem ele, o item 6 da atividade perde o arquivo. O dado do /app/dados não faz parte do código e por isso não entra no COPY nomeado; ele vem montado de fora, e o docker compose down derruba o container sem tocar no volume. Esse e o contrato inteiro do ambiente de producao em seis linhas de YAML: o que e código vai na imagem, o que e dado vem de fora, e o que muda de um servidor para outro vem por variável.

O environment com APP_VERSAO=1.4.0 faz a mesma imagem servir para a bancada e para o servidor. A imagem e a mesma; muda o valor que o processo le no start. Por isso o /saude da resposta mostra 1.4.0 sem que ninguem tenha editado o app.js.

Os logs do container entram no roteiro porque são o unico lugar onde a falha aparece antes do usuario relatar. docker compose logs -f acompanha o processo enquanto ele roda; docker logs <container> mostra a mesma saída depois que o container morreu. Depois de um restart, o log do container comeca de novo e a linha antiga não esta mais la — a linha que o professor precisa as sete da manha esta no journalctl do host, e e isso que a aula 2 resolve sem container.

O healthcheck do compose e o health check do dia 2 do deploy, so que aqui dentro do compose em vez do systemd. O comando faz uma requisicao a /saude e sai com código diferente de zero se o status não for 200. A partir de retries: 3, o Docker marca o container como unhealthy — e o painel do aluno passa a mostrar a placa sem dado antes do professor precisar olhar.

restart: unless-stopped e o Restart=always do systemd em forma de container: se o processo morre, o Docker levanta de novo. A aula 2 mostra a versão sem container.

O sketch não conecta em rede nenhuma, e essa e a escolha: o log de boot e a unica parte que precisa estar certa para o deploy ser diagnosticavel. Conectar no WiFi aqui custaria 15 segundos de espera em cada placa da sala, e se o access point do laboratorio estiver ocupado, a aula para.

heap_caps_get_largest_free_block(0) substitui uma pergunta enganosa. O "heap livre" e um total, e um total alto convive com um bloco contiguo minúsculo — que e o que impede uma alocacao grande. A placa tem memória, mas não tem os 40 KB seguidos que a tela OLED do dia 12 vai pedir. Esse número e o que explica um reboot que ninguem entende.

Criterios de correcao

CritérioPontos
Dockerfile com as seis linhas na ordem correta, COPY do manifesto antes do npm ci2 pontos
.env ausente dentro da imagem, verificado com docker exec2 pontos
id -u devolvendo 1000 com o USER node acima do CMD2 pontos
compose.yaml com ports, environment e volumes declarados2 pontos
Arquivo em /app/dados ainda presente depois de down e de up2 pontos
docker logs do container lido e comparado com o do host após o restart1 ponto
Banner de boot da placa com versão, data da build e identificador1 ponto

Erros comuns

ErroComo apareceCorrecao
npm ci falha com "can only install with an existing package-lock.json"Build para na linha 4"Falta o lockfile. Rode npm install uma vez, versione o package-lock.json, e o npm ci passa a funcionar. E o erro que garante reprodutibilidade."
COPY . . antes do npm ciBuild demora 40 s a cada git push"O cache da camada de dependencia foi invalido. Volte COPY package*.json ./ para antes do RUN."
CMD node src/app.js sem colchetesContainer so morre no SIGKILL, depois de 10 s"Forma exec, com colchetes. Sem ela o SIGTERM chega ao /bin/sh e não ao Node."
EXPOSE 3000 e achado que publica a portaAluno sobe e tenta acessar do outro computador"EXPOSE e um rotulo. Quem publica e ports do compose ou -p do docker run."
id -u devolvendo 0Aluno fala que USER node "não funciona""Funciona: se deu 0, a linha não chegou a ser executada. USER tem que estar antes do CMD."
.env dentro da imagemdocker exec ls -a /app mostra o arquivo"Falta a linha no .dockerignore, e ela tem que estar la antes da build. O que já entrou não sai com rm."
Imagem de 1,2 GBAluno se orgulha: " quanto maior, melhor""Maior e mais lenta de baixar, mais lenta de iniciar e com mais superficie de ataque. O número bom e o menor que ainda roda."
Porta 3000 ocupadaBind for 0.0.0.0:3000 failed: port is already allocated"A porta e do servidor, não da sua imagem. Troque a porta publicada e mantenha a interna."
Escrever o dado direto em /app/dados sem declarar o volumesArquivo some depois do docker compose down"Dentro da imagem o dado não sobrevive a um down. Declare o volume e monte: dados:/app/dados."
Montar a pasta errada no volumeContainer sobe e /app/dados continua vazio"A ordem importa: o que vem antes dos dois pontos e o nome do volume, e o que vem depois e o caminho de dentro. Confira com docker inspect."
docker volume rm dados rodado sem quererDado do ambiente de producao sumiu"O volume sobrevive ao container, não ao operador. Antes de remover, o backup e um docker run montando o volume em uma pasta."
docker logs depois do restart sem a linha antigaAluno diz que "o log apagou sozinho""O log do container recomeca a cada start. Quem guarda a historia e o journalctl do host, que e a aula 2."
process.env.PORT sem valor padrao no códigoServidor quebra em silencio no servidor e funcionava na maquina"Variável no container não tem valor padrao. Leia com process.env.PORT ?? 3000 e sobrescreva pelo environment."
Versão escrita no app.js em vez do environmentPrecisa de build nova so para trocar o número"A versão e valor de ambiente, não código. Uma imagem, dois ambientes."

Desafio extra

Adicione ao Dockerfile uma etapa RUN npm audit --omit=dev antes do CMD, e veja o que acontece quando existe uma dependencia vulneravel. Explique por que essa etapa falha o build, e discuta: se npm audit reprova o build, o que acontece com uma vulnerabilidade que so foi publicada depois da sua imagem estar no ar?

>

A resolucao, compilada

// Aula 1 do dia 10: preparar o projeto para rodar em outro lugar.
//
// Este e o log de BOOT do firmware, e e a unica coisa que a placa faz hoje.
// A razao: depois de um deploy, a pergunta que o professor faz e "de qual
// versao a placa esta falando", e a unica resposta confiavel e o proprio
// firmware.
//
// A placa imprime nome, versao, build, id do dispositivo, tamanho da flash e
// heap livre no boot. Toda versao nova de firmware passa a declarar um
// VERSAO_FIRMWARE diferente, e o numero muda no log.

#include <Arduino.h>
#include <WiFi.h>
#include <esp_heap_caps.h>

// Versao do firmware. E a linha que o professor procura no serial. Toda
// gravacao nova incrementa este numero: e assim que se descobre, depois do
// deploy, que a placa ainda esta falando com a versao antiga.
#define VERSAO_FIRMWARE "1.4.0"

// Data e hora desta compilacao. Gravar a data no firmware e a forma mais
// simples de responder "quando essa versao foi gravada", e nao existe de outro
// jeito sem mexer na placa pela serial.
#define DATA_DA_BUILD __DATE__
#define HORA_DA_BUILD __TIME__

// Identificador do dispositivo. E o mesmo valor que vai no cabecalho de
// autenticacao e no corpo de cada leitura, e por isso ele fica em uma
// constante e nao escrito em tres lugares diferentes.
#define ID_DISPOSITIVO "esp32-bancada-01"

// Tamanho da flash do ESP32: 4 MB, dos quais a particao de programa usa uma
// parte. O numero que interessa e o que sobra, e ele muda quando a imagem
// cresce.
const unsigned long TAMANHO_FLASH = 4UL * 1024UL * 1024UL;

// Espaco de nome da memoria persistente. Nomear o espaco evita a colisao que
// acontece quando dois firmwares gravam no mesmo lugar sem querer.
#define ESPACO_NVS "dispositivo"

// Registro de build que o servidor recebe no primeiro envio. E o que permite,
// meses depois, responder "quais placas estao com a versao antiga".
String cabecalhoDeBuild() {
  return "v" + String(VERSAO_FIRMWARE) + " (" + String(DATA_DA_BUILD) + " " +
         String(HORA_DA_BUILD) + ")";
}

// Cabecalho de log com a versao em toda linha. O custo e de poucos bytes por
// linha, e o beneficio e que qualquer linha do log, isolada, ja diz de qual
// firmware ela veio.
String prefixoDoLog() {
  return "[" + String(ID_DISPOSITIVO) + " v" + String(VERSAO_FIRMWARE) + "]";
}

// ------------------------------------------------------------------- boot

void imprimirBannerDeBoot() {
  Serial.println();
  Serial.println("========================================");
  Serial.println(" estacao-esp32  " + cabecalhoDeBuild());
  Serial.println("========================================");
  Serial.println("[boot] dispositivo:   " + String(ID_DISPOSITIVO));
  Serial.println("[boot] versao:        " + String(VERSAO_FIRMWARE));
  Serial.println("[boot] compilado em:  " + String(DATA_DA_BUILD) + " as " + String(HORA_DA_BUILD));
  Serial.println("[boot] flash total:   " + String(TAMANHO_FLASH / (1024UL * 1024UL)) + " MB");
  Serial.println("[boot] espaco NVS:    " + String(ESPACO_NVS));
  Serial.println("[boot] heap livre:    " + String(ESP.getFreeHeap()) + " bytes");
  Serial.println("[boot] chip:          " + String(ESP.getChipModel()) + " rev " + String(ESP.getChipRevision()));
  Serial.println("[boot] cpu:           " + String(ESP.getCpuFreqMHz()) + " MHz");
  Serial.println("[boot] sdk:           " + String(ESP.getSdkVersion()));
  Serial.println("[boot] linhas de log usam este prefixo: " + prefixoDoLog());
  Serial.println("========================================");
}

void setup() {
  // 1. A primeira coisa no boot e falar quem e. Se a placa travar antes disso,
  //    o professor nao tem nem a versao para investigate.
  Serial.begin(115200);
  delay(200);
  imprimirBannerDeBoot();

  // 2. O que a placa envia ao servidor como identificacao de build. E o que
  //    o painel vai agrupar para dizer "12 placas na versao 1.3.0".
  Serial.println();
  Serial.println(prefixoDoLog() + " registro de build que o servidor recebera:");
  Serial.println(prefixoDoLog() + "   cabecalho X-Firmware: " + cabecalhoDeBuild());
  Serial.println(prefixoDoLog() + "   campo no corpo:      \"firmware\": \"" +
                 String(VERSAO_FIRMWARE) + "\"");
  Serial.println(prefixoDoLog() + "   cabecalho X-Build:    " + String(DATA_DA_BUILD));

  // 3. O que muda de verdade quando o firmware cresce. O professor compara
  //    estes tres numeros entre a versao antiga e a nova, e o espaco de
  //    programa e o primeiro a estourar.
  Serial.println();
  Serial.println(prefixoDoLog() + " espaco ocupado:");
  Serial.println(prefixoDoLog() + "   flash livre depois do programa: " +
                 String(ESP.getFreeSketchSpace()) + " bytes");
  Serial.println(prefixoDoLog() + "   heap livre em execucao:         " +
                 String(ESP.getFreeHeap()) + " bytes");
  Serial.println(prefixoDoLog() + "   maior bloco contiguo livre:    " +
                 String(heap_caps_get_largest_free_block(0)) + " bytes");
  Serial.println();

  // 4. O estado da rede, que e a outra metade do boot. Nao connecting nesta
  //    aula: e a aula 2 que sobe o servidor de verdade.
  Serial.println(prefixoDoLog() + " rede:");
  Serial.println(prefixoDoLog() + "   modo: " + String(WIFI_MODE_STA));
  Serial.println(prefixoDoLog() + "   endereco MAC: " + String(WiFi.macAddress()));
  Serial.println(prefixoDoLog() + "   conectado: " + String(WiFi.status() == WL_CONNECTED ? "sim" : "nao"));
  Serial.println();

  Serial.println(prefixoDoLog() + " boot concluido em " + String(millis()) + " ms");
  Serial.println();
}

void loop() {
  // O log do dia a dia leva a versao em cada linha. E o que permite filtrar
  // "quais placas estao com a versao antiga" sem depender de tempo do log.
  Serial.println(prefixoDoLog() + " viva | heap: " + String(ESP.getFreeHeap()) +
                 " bytes | uptime: " + String(millis() / 1000) + " s");
  delay(10000);
}

Sem saída de compilação gravada. Rode python3 validar.py -t 3 dia10 aula1.

Aula 2 — Colocar no ar e olhar o log de verdade

Objetivos

  • Subir o serviço com systemd, ligar o restart automático e explicar por que ele resolve o processo que morreu e não resolve o servidor que reiniciou.
  • Escrever a verificação de saúde que responde 200 sem consultar o banco, e dizer por que ela não pode depender dele.
  • Ler docker logs e journalctl, e montar um alerta de queda com o tempo entre a falha e o aviso medido no relógio.
  • Publicar o serviço atrás de um nginx de reverse proxy, com domínio, certificado no servidor e firewall fechando o resto.
  • Fazer a placa responder a verificação de saúde e o 404 de rota inexistente, e explicar por que um dispositivo sem log é um dispositivo cego.

Material

  • 1 ESP32 DevKit V1 por aluno, com o cabo USB
  • 1 computador ou servidor Linux por sala, com Docker, systemd e nginx instalados
  • O container estacao-esp32 da aula 1, ainda parado na imagem
  • 1 terminal com acesso a systemctl, journalctl, docker logs, docker inspect, curl e ss -ltnp
  • 1 nome de domínio de teste apontando para o IP público da máquina da sala, mais um cliente certbot para o certificado no servidor
  • 1 celular ou um segundo computador por dupla, para acessar a placa pelo WiFi

Conceitos

systemd: subir o serviço e o que ele faz quando o processo morreu

O padrão do sistema Linux hoje é um arquivo de unidade que descreve como rodar um processo e o que fazer quando ele para.

[Unit]
Description=Estacao ESP32
After=network.target

[Service]
Type=simple
User=node
WorkingDirectory=/opt/estacao
ExecStart=/usr/bin/node src/app.js
Environment=APP_VERSAO=1.4.0
EnvironmentFile=/opt/estacao/.env
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Restart=always é a linha que importa. Ela diz: se o processo sair, levanta de novo — é o restart automático do serviço inteiro. Com RestartSec=5, o supervisor espera cinco segundos antes, o que evita que um processo com erro de configuração entre em laço e consuma a máquina. Restart=on-failure é o mais conservador: levanta só em saída com erro, e não quando você para o serviço de propósito.

Agora a linha que ninguém escreve e todo mundo precisa. Sem o par do [Install] e do enable, o serviço não sobe após reiniciar a máquina: ele funciona hoje, o servidor reinicia à noite, e na manhã seguinte o serviço está parado e ninguém sabe por quê. Restart=always cuida do processo que morre; o enable cuida da máquina que reinicia. São dois problemas diferentes e o aluno quase sempre confunde os dois.

sudo systemctl daemon-reload        # relê a unidade depois de editar o arquivo
sudo systemctl enable --now estacao  # habilita no boot e sobe agora
systemctl is-enabled estacao         # enabled
systemctl status estacao --no-pager

systemctl is-enabled é a prova de uma linha: responde a pergunta do aluno com uma palavra, em vez de uma explicação. Se a resposta for disabled, o serviço não sobe mais no próximo boot, e o systemctl start que o aluno deu ontem não muda nada disso.

EnvironmentFile=/opt/estacao/.env é o lugar do segredo. O arquivo .env fica no servidor, com dono root e permissão 600, e nunca no repositório. O systemd lê o arquivo e passa cada linha como variável de ambiente para o processo. O .gitignore do projeto não protege nada aqui: o problema não é o git, é o arquivo estar no disco com a permissão errada.

StandardOutput=journal faz a saída do processo ir para o journal, e é por isso que journalctl -u estacao -f mostra o log ao vivo. Sem essa linha, a saída do processo simplesmente desaparece.

Health check: a verificação de saúde que não consulta o banco

Uma verificação de saúde é uma rota que responde 200 quando o processo está saudável. Ela é a única forma de um monitor externo saber a diferença entre "o processo existe" e "o processo funciona". O termo em inglês para a mesma coisa é health check, e é assim que aparece no compose, no docker inspect e em qualquer painel que o aluno vá encontrar no mercado de trabalho.

A regra que o professor grita nesta aula: a verificação de saúde não consulta o banco, em nenhuma hipótese. Se /saude fizer SELECT 1 e o banco estiver lento, a verificação de saúde demora, o supervisor marca o serviço como quebrado e reinicia um processo que estava perfeito e só tinha o banco lento. A verificação de saúde verifica o processo; a dependência se verifica em outra rota.

Com docker compose, o health check fica no próprio arquivo:

healthcheck:
  test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/saude',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
  interval: 10s
  timeout: 3s
  retries: 3

O comando faz um GET e sai com código 0 quando o status é 200. A partir de retries: 3 falhas seguidas, o Docker marca o container como unhealthy — e o painel do aluno passa a mostrar o dispositivo sem dado antes de alguém ligar.

Verificar na mão:

docker inspect --format '{{.State.Health.Status}}' estacao-esp32

O resultado real desta aula, medido no container da aula 1, é healthy, com as quatro últimas checagens em exit=0.

Uma URL que ninguém cadastrou tem de responder 404 Not Found, e não 200 com uma página de erro dentro. A diferença não é estética: o 200 mente para o monitor e para o curl, e um cliente que faz fetch e trata tudo como sucesso vai tentar ler o corpo de um erro como se fosse leitura. O WebServer da placa tem onNotFound para isso, e o professor aciona a rota inexistente de propósito, na frente da turma. O log mostra -> 404, o contador de acessos sobe, e o aluno vê que a placa sabe dizer que não sabe.

journalctl e o alerta de queda: monitorar é mais do que olhar log

docker logs mostra a saída do processo desde o último start. Quando o container reinicia, a saída antiga sai da janela. Em uma máquina que reiniciou três vezes em uma semana, o log que o engenheiro precisa às sete da manhã pode ter sido apagado pelo próprio supervisor.

O log de verdade fica no journalctl, e o que você configura é quem escreve nele:

docker logsjournalctl -u estacao
quem escreveo próprio processoo supervisor do sistema
sobrevive a reinício do processonãosim
sobrevive a reboot da máquinanãosim, se persistent estiver ligado
onde o professor olhadesenvolvimentoprodução

No compose, restart: unless-stopped é a mesma ideia do Restart=always: o Docker levanta o container se o processo morre. São camadas diferentes fazendo a mesma coisa — o systemd cuida do serviço, o Docker cuida do container, e cada um conhece o seu.

Só que monitorar não é olhar. Um docker inspect a cada minuto é uma pessoa olhando, e pessoa cansada. Monitorar de verdade é deixar o estado do health check em um arquivo e ter o aviso que sai sozinho quando o estado deixa de ser healthy: uma mensagem no grupo da equipe, um e-mail, um webhook. O alerta de queda é o que transforma "eu vi o log depois" em "o cliente viu o alerta antes de mim".

E aqui aparece a métrica que ninguém ensina e todo mundo precisa: o tempo entre a falha real e o alerta. Esse intervalo tem duas partes, o retries do health check (três checagens de dez em dez segundos, trinta segundos de espera) e o intervalo da rotina que monitora. Trinta e cinco segundos contra o timeout: 3s da checagem é a diferença entre um alerta que acerta o cliente e um alerta que liga depois que ele já resolveu o problema. O que o usuário lembra não é o instante em que o alerta chegou, é o tempo que ele ficou sem saber.

docker inspect --format '{{.State.Health.Status}}' estacao-esp32
journalctl -u estacao -f --since '10 min ago' | tail -40

nginx na frente: reverse proxy, domínio, certificado e firewall

Reverse proxy é o serviço da frente que recebe a chamada da internet e encaminha para o seu serviço, que fica escutando só na rede local. É assim que o seu serviço passa a rodar atrás de proxy, e o aluno acha que isso é performance. Não é: é controle de quem chega perto.

O serviço da aula 1 está publicado em 3987 no host. O que muda aqui é para onde ele escuta: se o serviço continuar em 0.0.0.0 com a porta publicada, qualquer pessoa que descubra o IP do servidor acessa o processo direto, sem TLS e sem nenhum log do nginx. Amarrado em 127.0.0.1, ele só existe para quem está na mesma máquina — que é exatamente quem o nginx é.

server {
  listen 443 ssl;
  server_name estacao.exemplo.com;

  ssl_certificate     /etc/letsencrypt/live/estacao.exemplo.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/estacao.exemplo.com/privkey.pem;

  location / {
    proxy_pass http://127.0.0.1:3987;
    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;
  }
}

São quatro peças e cada uma tem nome. O domínio é o server_name: o cliente pede por nome, o nginx escolhe a configuração certa e o certificado do servidor casa com esse nome. O certificado do servidor é o par ssl_certificate e ssl_certificate_key, que faz o 443 responder em HTTPS; o certbot emite e renova sozinho, e a renovação é o que ninguém lembra de configurar até o cliente dizer que o navegador está vermelho. O proxy_pass é o caminho do reverse proxy: é a linha que diz com quem o nginx fala. E o X-Forwarded-For é a obrigação que vem junto: sem ele, todas as linhas de log do serviço mostram o IP do nginx, e ninguém que tenta acessar de fora descobre de onde veio a chamada.

O nginx também é o que responde 502 Bad Gateway quando o serviço de trás está parado, e 404 quando o caminho não existe. Duas perguntas, duas respostas, e é a mesma distinção que a placa faz com onNotFound: 502 diz "chegou aqui e o vizinho não respondeu", 404 diz "cheguei aqui e não sei o que você pediu".

Nenhuma regra de acesso de fora funciona sem o firewall: ss -ltnp responde qual porta está escutando e por qual processo, ufw decide quem entra, e o material fecha com a regra única — abrir porta é uma decisão, não um efeito colateral. 80 e 443 para o nginx, 22 para o administrador, e a 3987 fechada para o mundo.

ss -ltnp | grep 3987          # a porta que responde hoje
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status numbered

Atividade

Montagem: nenhuma. A placa precisa apenas do cabo USB e, se a rede do laboratório funcionar, do IP que ela mesma imprime no serial.

  1. Escreva a unidade do systemd do seu projeto, com Restart=always, RestartSec=5 e EnvironmentFile apontando para o .env. Não coloque valor de segredo no arquivo da unidade: ponha o caminho. Depois rode systemctl daemon-reload, systemctl enable --now e systemctl is-enabled, e anote a palavra que saiu.
  2. Explique, em duas linhas, a diferença entre o que o Restart=always resolve e o que o enable resolve. Cite o caso concreto de um servidor que reinicia de madrugada.
  3. No compose.yaml da aula 1, acrescente o bloco healthcheck e suba com docker compose up -d. Espere o docker ps mostrar healthy e anote quanto tempo levou.
  4. Com o container no ar, rode curl -i http://127.0.0.1:3987/saude e anote o status. Depois rode curl -i http://127.0.0.1:3987/rota-que-nao-existe e anote o status. Os dois são diferentes? Por que isso importa?
  5. Derrube o container de propósito com docker kill estacao-esp32 e, sem rodar nenhum outro comando, veja o que acontece. Anote quantos segundos ele levou para voltar e o que o docker logs mostrou.
  6. Depois do docker kill, rode docker logs estacao-esp32 e procure a linha do start anterior. Ela ainda está lá? Compare com journalctl -u estacao e diga qual dos dois sobrou.
  7. Escreva uma rotina de uma linha que grava o Health.Status em um arquivo a cada minuto e imprime o alerta de queda na primeira vez que o valor deixar de ser healthy. Media o tempo entre o docker kill e o alerta e anote os dois números.
  8. Crie a unidade do systemd para o nginx, com um server que escuta 443, aponta proxy_pass para 127.0.0.1:3987 e traz o certificado do servidor. Diga qual nome de domínio ele atende.
  9. Amarre o serviço em 127.0.0.1 em vez de 0.0.0.0, rode ss -ltnp e escreva a regra de firewall que você aplicaria: quais portas abrir e por quê.
  10. Grave o sketch da resolução, conecte a placa na rede do laboratório e acesse http://<ip-que-apareceu-no-serial>/saude. Depois acesse /rota-que-nao-existe e veja o log da placa. O que os dois pedidos produziram de diferente no log?
  11. Escreva, em três linhas, o que a sua verificação de saúde não deve checar, e o que ela deve checar no lugar.

Nota: 18 pontos. Critério de fim: o container voltou sozinho depois do docker kill, o aluno tem o status do /saude e do 404 anotados, e tem os dois números do item 7 — o intervalo da falha e o do alerta.

Resolucao

A unidade do systemd, que o professor escreve no projetor:

[Unit]
Description=Estacao ESP32
After=network.target

[Service]
Type=simple
User=node
WorkingDirectory=/opt/estacao
ExecStart=/usr/bin/node src/app.js
Environment=APP_VERSAO=1.4.0
EnvironmentFile=/opt/estacao/.env
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

E a placa, que é a metade que fica na bancada:

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>

// Rede do laboratorio. Os dois valores sao FICTICIOS de proposito: nenhum
// segredo real entra em sketch de exemplo, nem em material, nem no git.
#define WIFI_SSID "lab-esp32-aula"
#define WIFI_PASSWORD "troque-esta-pela-sua"

// A placa vira o servidor. O IP vem do roteador, e muda a cada boot: e por
// isso que o professor imprime o IP no serial e nao espera que o aluno
// adivinhe.
const int PORTA_LOCAL = 80;

// Versao do firmware. Mesma regra da aula 1: o numero do log e a resposta
// para "de qual versao a placa esta falando".
#define VERSAO_FIRMWARE "1.4.0"

// Caminho do health check. Esse e o endereco que o monitor externo procura
// para saber se o processo responde. Sem ele, nao ha como distinguir
// "subiu" de "subiu e esta funcionando".
#define CAMINHO_SAUDE "/saude"

// Caminho de leitura. Serve para mostrar que o health check responde em um
// endereco e o dado responde em outro.
#define CAMINHO_LEITURA "/leitura"

WebServer servidor(PORTA_LOCAL);

// Contadores do log. Um log que so diz "ok" nao responde pergunta nenhuma;
// estes tres respondem as tres perguntas do dia a dia.
unsigned int totalRequisicoes = 0;
unsigned int requisicoesSaude = 0;
unsigned int requisicoesLeitura = 0;
unsigned long ultimoAtendimento = 0;
uint32_t inicio = 0;

// ------------------------------------------------------------------- rotas

// O health check. Responde 200 com um corpo pequeno e NAO faz consulta em
// banco: se o health check depende do banco, ele falha quando o banco cai, e
// ai o professor reinicia um processo que estava perfeito.
void tratarSaude() {
  requisicoesSaude++;
  String corpo = "{\"ok\":true";
  corpo += ",\"versao\":\"" + String(VERSAO_FIRMWARE) + "\"";
  corpo += ",\"uptime_s\":" + String(millis() / 1000);
  corpo += ",\"requisicoes\":" + String(totalRequisicoes);
  corpo += "}";
  servidor.send(200, "application/json", corpo);
  Serial.println("[acesso] GET " + String(CAMINHO_SAUDE) + " -> 200 (health check)");
}

void tratarLeitura() {
  requisicoesLeitura++;
  String corpo = "{\"device_id\":\"esp32-bancada-01\"";
  corpo += ",\"leitura\":\"23.5\"";
  corpo += ",\"firmware\":\"" + String(VERSAO_FIRMWARE) + "\"}";
  servidor.send(200, "application/json", corpo);
  Serial.println("[acesso] GET " + String(CAMINHO_LEITURA) + " -> 200 (leitura)");
}

// Rota que o aluno nao pediu e que o professor usa para mostrar o 404: uma
// URL que ninguem cadastrou tem de responder 404, e nao 200 com pagina de
// erro, e nao 500.
void tratarNaoEncontrado() {
  Serial.println("[acesso] GET " + String(servidor.uri()) + " -> 404 (rota inexistente)");
  servidor.send(404, "application/json", "{\"erro\":\"rota nao encontrada\"}");
}

// ------------------------------------------------------------------- rede

bool conectarNaRede(unsigned long tempoLimiteMs) {
  Serial.println("[rede] conectando em " + String(WIFI_SSID) + " ...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long comeco = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - comeco) < tempoLimiteMs) {
    delay(250);
  }

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[rede] FALHOU apos " + String(tempoLimiteMs / 1000) + " s");
    Serial.println("[rede] codigo de status: " + String(WiFi.status()));
    Serial.println("[rede] sem rede a placa sobe o servidor em 192.168.4.1 (modo AP).");
    return false;
  }

  Serial.println("[rede] conectado em " + String(millis() - comeco) + " ms");
  Serial.println("[rede] IP: " + String(WiFi.localIP()));
  Serial.println("[rede] RSSI: " + String(WiFi.RSSI()) + " dBm");
  Serial.println("[rede] sinal fraco e abaixo de -67 dBm: " +
                 String(WiFi.RSSI() < -67 ? "sim" : "nao"));
  return true;
}

void setup() {
  Serial.begin(115200);
  delay(200);
  inicio = millis();
  Serial.println();
  Serial.println("========================================");
  Serial.println(" estacao-esp32 v" + String(VERSAO_FIRMWARE) + " — no ar");
  Serial.println("========================================");

  bool naRede = conectarNaRede(20000);

  if (!naRede) {
    // Fallback de Access Point. A placa que nao tem rede ainda precisa
    // responder alguma coisa, senao o dispositivo fica invisivel para quem
    // tentar configurar.
    Serial.println("[rede] ligando Access Point de rescue");
    WiFi.mode(WIFI_AP);
    WiFi.softAP("esp32-rescue", "troque-esta-senha");
    Serial.println("[rede] AP ativo. endereco: 192.168.4.1");
  }

  servidor.on(String(CAMINHO_SAUDE), HTTP_GET, tratarSaude);
  servidor.on(String(CAMINHO_LEITURA), HTTP_GET, tratarLeitura);
  servidor.onNotFound(tratarNaoEncontrado);
  servidor.begin();
  Serial.println("[http] servidor ouvindo na porta " + String(PORTA_LOCAL));
  Serial.println("[http] health check em " + String(CAMINHO_SAUDE));
  Serial.println();

  // O que o professor faz na frente da turma: derruba o servidor e mostra o
  // log. E o que a aula 2 do dia 7 (systemd) faz no servidor de verdade.
  Serial.println("[demo] simulando uma queda: o processo vai chamar exit(1).");
  Serial.println("[demo] quem manda o processo voltar e o supervisor: systemd,");
  Serial.println("[demo] restart: unless-stopped do compose, ou o EspRestart do dia 14.");
  Serial.println("[demo] nada disso esta rodando agora: a placa nao tem supervisor.");
  Serial.println();

  Serial.println("[log] formato de uma linha de acesso:");
  Serial.println("[log]   " + String(millis() / 1000) + "s | GET /leitura | 200 | 12ms | esp32-bancada-01");
  Serial.println("[log] o tempo entre dois requests e o que separa 'lento' de 'travado'.");
}

void loop() {
  servidor.handleClient();

  // A placa tambem responde sozinha, sem ninguem perguntar. E o que permite
  // dizer que o processo esta vivo mesmo com ninguem acessando.
  if (ultimoAtendimento == 0) {
    ultimoAtendimento = millis();
  }
  if (millis() - ultimoAtendimento > 30000) {
    ultimoAtendimento = millis();
    Serial.println("[log] " + String(millis() / 1000) + "s | viva | requisicoes: " +
                   String(totalRequisicoes) + " (saude " + String(requisicoesSaude) +
                   ", leitura " + String(requisicoesLeitura) + ") | heap: " +
                   String(ESP.getFreeHeap()) + " bytes | v" + String(VERSAO_FIRMWARE));
  }
}

Por que assim e não de outro jeito. O sketch não conecta no servidor da aula 1. A placa é o servidor, ao mesmo tempo, e isso é deliberado: é a única forma de a aula funcionar sem depender de IP, de firewall e de rede do laboratório. O aluno vê a mecânica do log de acesso e do 404 num dispositivo de verdade, e a mesma mecânica vale no Node.

O conectarNaRede tem tempo limite. Um while (WiFi.status() != WL_CONNECTED) sem limite trava a placa para sempre, e o aluno fica sem log nenhum para investigar — o pior estado possível. Com limite, o pior caso é uma placa que iniciou, disse que não conseguiu, e continuou viva. O mesmo vale no dia 11, e com mais razão ainda.

O fallback em modo Access Point resolve um problema real: uma placa que não tem rede fica invisível. Sem AP, ela não aparece em lugar nenhum e o dono não tem como descobrir. Com AP, ele se conecta nela e descobre o endereço. O professor conta esse caso de produto, e ele aconteceu.

totalRequisicoes e requisicoesSaude não são decoração. Um log que só diz ok não responde pergunta nenhuma; com os contadores, o log responde "quantas máquinas health checks?", "quantas leituras?" e "está tudo parado?". A resposta para "está tudo parado" é justamente a diferença entre 0 e 1 requisição, e sem contador ninguém vê.

A linha de log tem a forma tempo | rota | status | duração | dispositivo. Nenhum campo é decoração: cada um responde uma pergunta diferente da investigação de amanhã.

Por que o sketch não muda quando entra o proxy. O sketch é a bancada, e a bancada não muda porque a produção ganhou um proxy. Isso é didático, não preguiça: o aluno precisa ver que a verificação de saúde é uma convenção entre dois programas, e que ela funciona igual em WebServer da placa, em Express no Node e no painel que ele vai encontrar no trabalho. O que muda do outro lado da bancada é o endereço: o proxy_pass do nginx aponta para 127.0.0.1:3987, e a rota /saude da placa é http://<ip-no-serial>/saude. O nome é o mesmo dos dois lados, e essa é a única coisa que um monitor precisa combinar com o processo que ele vigia.

Criterios de correcao

CritérioPontos
Unidade do systemd com Restart=always, RestartSec e o caminho do .env, sem valor de segredo no arquivo3 pontos
systemctl is-enabled com a resposta anotada e a diferença entre reiniciar o processo e reiniciar a máquina3 pontos
healthcheck no compose e docker inspect respondendo healthy3 pontos
Container voltou sozinho depois do docker kill, com o tempo anotado2 pontos
200 em /saude e 404 em rota inexistente, ambos anotados1 ponto
Item 6 respondido: docker logs perde a saída do start anterior e o journalctl não1 ponto
Alerta de queda rodando, com os dois tempos medidos: intervalo da falha e atraso do alerta2 pontos
nginx com proxy_pass para 127.0.0.1:3987, server_name e o certificado do servidor apontados2 pontos
ss -ltnp executado e a regra de firewall escrita, com as portas justificadas uma a uma1 ponto

Erros comuns

ErroComo apareceCorrecao
Serviço que não sobe após reiniciarsystemctl is-enabled devolve disabled e o aluno acha que o enable é enfeite"O Restart=always só levanta processo que morre. Quem levanta serviço depois do reboot é o WantedBy com o systemctl enable. São dois problemas e o aluno só pensou em um."
Serviço escutando em 0.0.0.0 com a porta publicadareverse proxy instalado e o serviço ainda acessível direto pelo IP"O nginx é a porta da frente, e só funciona se a porta de trás estiver amarrada em 127.0.0.1. Proxy na frente com serviço aberto atrás é decoração."
X-Forwarded-For faltando no proxy_passTodas as linhas de log mostram o IP do nginx"Sem o cabeçalho, o serviço não sabe de onde veio a chamada e o log perde a informação mais cara que ele tem. Copie os quatro cabeçalhos."
health check consultando o bancoSELECT 1 dentro de /saude"Banco lento derruba a verificação de saúde e o supervisor reinicia um processo bom. /saude verifica o processo; a dependência se verifica em /pronto."
Alerta que chega depois do clienteRelatório com "não houve incidente" e três linhas de alerta às 4h"Some o retries e o intervalo da rotina. O alerta compete com a perception do cliente, e quem chega tarde já resolveu."
docker logs usado como log de produçãoAluno diz "ontem o log mostrava isso" e não mostra"Depois de um restart, a janela antiga sai. Em produção o log está no journalctl, ou no arquivo que a sua aplicação escreve."
Restart=always com RestartSec ausenteProcesso entra em laço e a máquina fica lenta"Sem RestartSec, o supervisor levanta na hora, a falha se repete e o consumo de CPU vai a 100%. Cinco segundos é o mínimo sensato."
EXPOSE achado que publica a portaAluno tenta acessar a porta de fora"Publicar é ports do compose ou -p do docker run. EXPOSE é só rótulo."
curl sem -i e o aluno lê 200 do 404"Deu certo, gravou!""Sem -i o curl imprime só o corpo, e o corpo do erro parece uma resposta. Use -i ou --fail."
Porta aberta para qualquer lugarufw status com Anywhere em todas as linhas"systemd não protege porta e o nginx também não protege a porta do serviço. Regra única: 80, 443 e 22; o resto fica fechado."
Placa sem rede travada em laço infinitoNada aparece no serial, nem o banner"Todo while de espera precisa de tempo limite. Sem ele, a placa para de falar e você perde a única pista que tinha."
Log sem versão do firmwaredocker logs cheio de linhas iguais"Cada linha de log do dispositivo precisa do id e da versão. Sem isso, duas placas diferentes produzem logs indistinguíveis."

Desafio extra

Escreva um script que lê docker inspect de todos os containers a cada minuto, guarda o Health.Status de cada um em um arquivo e imprime um alerta de queda quando algum deixar de ser healthy, com o tempo que ele ficou fora do ar. Meça quanto tempo passou entre a falha real e o alerta, e escreva por que esse tempo — e não o tempo de detectar a falha — é o que o usuário lembra. Depois faça a mesma coisa pelo outro lado: uma verificação de saúde do domínio, que responde 200 só quando o nginx responde em 443 e o certificado do servidor não está expirado. Diga o que muda quando a verificação passa a depender de dois componentes, e se isso não é exatamente o erro do health check que consulta o banco.

>

A resolucao, compilada

// Aula 2 do dia 10: colocar no ar e olhar o log de verdade.
//
// O sketch de hoje e o mesmo firmware da aula 1, com uma parte a mais: ele
// sobe um servidor de exemplo DENTRO da placa, responde o health check e
// registra a cada requisicao. E assim que o professor mostra a diferenca
// entre o log do seu terminal e o log de um servico que ninguem esta olhando.
//
// Nenhuma rede precisa estar de pe: o servidor de exemplo roda dentro da
// propria placa, e o no do laboratorio precisa estar na mesma rede WiFi.

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>

// Rede do laboratorio. Os dois valores sao FICTICIOS de proposito: nenhum
// segredo real entra em sketch de exemplo, nem em material, nem no git.
#define WIFI_SSID "lab-esp32-aula"
#define WIFI_PASSWORD "troque-esta-pela-sua"

// A placa vira o servidor. O IP vem do roteador, e muda a cada boot: e por
// isso que o professor imprime o IP no serial e nao espera que o aluno
// adivinhe.
const int PORTA_LOCAL = 80;

// Versao do firmware. Mesma regra da aula 1: o numero do log e a resposta
// para "de qual versao a placa esta falando".
#define VERSAO_FIRMWARE "1.4.0"

// Caminho do health check. Esse e o endereco que o monitor externo procura
// para saber se o processo responde. Sem ele, nao ha como distinguir
// "subiu" de "subiu e esta funcionando".
#define CAMINHO_SAUDE "/saude"

// Caminho de leitura. Serve para mostrar que o health check responde em um
// endereco e o dado responde em outro.
#define CAMINHO_LEITURA "/leitura"

WebServer servidor(PORTA_LOCAL);

// Contadores do log. Um log que so diz "ok" nao responde pergunta nenhuma;
// estes tres respondem as tres perguntas do dia a dia.
unsigned int totalRequisicoes = 0;
unsigned int requisicoesSaude = 0;
unsigned int requisicoesLeitura = 0;
unsigned long ultimoAtendimento = 0;
uint32_t inicio = 0;

// ------------------------------------------------------------------- rotas

// O health check. Responde 200 com um corpo pequeno e NAO faz consulta em
// banco: se o health check depende do banco, ele falha quando o banco cai, e
// ai o professor reinicia um processo que estava perfeito.
void tratarSaude() {
  requisicoesSaude++;
  String corpo = "{\"ok\":true";
  corpo += ",\"versao\":\"" + String(VERSAO_FIRMWARE) + "\"";
  corpo += ",\"uptime_s\":" + String(millis() / 1000);
  corpo += ",\"requisicoes\":" + String(totalRequisicoes);
  corpo += "}";
  servidor.send(200, "application/json", corpo);
  Serial.println("[acesso] GET " + String(CAMINHO_SAUDE) + " -> 200 (health check)");
}

void tratarLeitura() {
  requisicoesLeitura++;
  String corpo = "{\"device_id\":\"esp32-bancada-01\"";
  corpo += ",\"leitura\":\"23.5\"";
  corpo += ",\"firmware\":\"" + String(VERSAO_FIRMWARE) + "\"}";
  servidor.send(200, "application/json", corpo);
  Serial.println("[acesso] GET " + String(CAMINHO_LEITURA) + " -> 200 (leitura)");
}

// Rota que o aluno nao pediu e que o professor usa para mostrar o 404: uma
// URL que ninguem cadastrou tem de responder 404, e nao 200 com pagina de
// erro, e nao 500.
void tratarNaoEncontrado() {
  Serial.println("[acesso] GET " + String(servidor.uri()) + " -> 404 (rota inexistente)");
  servidor.send(404, "application/json", "{\"erro\":\"rota nao encontrada\"}");
}

// ------------------------------------------------------------------- rede

bool conectarNaRede(unsigned long tempoLimiteMs) {
  Serial.println("[rede] conectando em " + String(WIFI_SSID) + " ...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long comeco = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - comeco) < tempoLimiteMs) {
    delay(250);
  }

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[rede] FALHOU apos " + String(tempoLimiteMs / 1000) + " s");
    Serial.println("[rede] codigo de status: " + String(WiFi.status()));
    Serial.println("[rede] sem rede a placa sobe o servidor em 192.168.4.1 (modo AP).");
    return false;
  }

  Serial.println("[rede] conectado em " + String(millis() - comeco) + " ms");
  Serial.println("[rede] IP: " + String(WiFi.localIP()));
  Serial.println("[rede] RSSI: " + String(WiFi.RSSI()) + " dBm");
  Serial.println("[rede] sinal fraco e abaixo de -67 dBm: " +
                 String(WiFi.RSSI() < -67 ? "sim" : "nao"));
  return true;
}

void setup() {
  Serial.begin(115200);
  delay(200);
  inicio = millis();
  Serial.println();
  Serial.println("========================================");
  Serial.println(" estacao-esp32 v" + String(VERSAO_FIRMWARE) + " — no ar");
  Serial.println("========================================");

  bool naRede = conectarNaRede(20000);

  if (!naRede) {
    // Fallback de Access Point. A placa que nao tem rede ainda precisa
    // responder alguma coisa, senao o dispositivo fica invisivel para quem
    // tentar configurar.
    Serial.println("[rede] ligando Access Point de rescue");
    WiFi.mode(WIFI_AP);
    WiFi.softAP("esp32-rescue", "troque-esta-senha");
    Serial.println("[rede] AP ativo. endereco: 192.168.4.1");
  }

  servidor.on(String(CAMINHO_SAUDE), HTTP_GET, tratarSaude);
  servidor.on(String(CAMINHO_LEITURA), HTTP_GET, tratarLeitura);
  servidor.onNotFound(tratarNaoEncontrado);
  servidor.begin();
  Serial.println("[http] servidor ouvindo na porta " + String(PORTA_LOCAL));
  Serial.println("[http] health check em " + String(CAMINHO_SAUDE));
  Serial.println();

  // O que o professor faz na frente da turma: derruba o servidor e mostra o
  // log. E o que a aula 2 do dia 7 (systemd) faz no servidor de verdade.
  Serial.println("[demo] simulando uma queda: o processo vai chamar exit(1).");
  Serial.println("[demo] quem manda o processo voltar e o supervisor: systemd,");
  Serial.println("[demo] restart: unless-stopped do compose, ou o EspRestart do dia 14.");
  Serial.println("[demo] nada disso esta rodando agora: a placa nao tem supervisor.");
  Serial.println();

  Serial.println("[log] formato de uma linha de acesso:");
  Serial.println("[log]   " + String(millis() / 1000) + "s | GET /leitura | 200 | 12ms | esp32-bancada-01");
  Serial.println("[log] o tempo entre dois requests e o que separa 'lento' de 'travado'.");
}

void loop() {
  servidor.handleClient();

  // A placa tambem responde sozinha, sem ninguem perguntar. E o que permite
  // dizer que o processo esta vivo mesmo com ninguem acessando.
  if (ultimoAtendimento == 0) {
    ultimoAtendimento = millis();
  }
  if (millis() - ultimoAtendimento > 30000) {
    ultimoAtendimento = millis();
    Serial.println("[log] " + String(millis() / 1000) + "s | viva | requisicoes: " +
                   String(totalRequisicoes) + " (saude " + String(requisicoesSaude) +
                   ", leitura " + String(requisicoesLeitura) + ") | heap: " +
                   String(ESP.getFreeHeap()) + " bytes | v" + String(VERSAO_FIRMWARE));
  }
}

Sem saída de compilação gravada. Rode python3 validar.py -t 3 dia10 aula2.