Deploy — Arduino e IoT — semana 10 do 3o trimestre
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
Dockerfilede 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
devDependenciescomnpm 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.jsonepackage-lock.jsonversionados - 1 microcomputador ou servidor Linux por sala, para o
docker composede verdade - Terminal com acesso a
docker images,docker history,docker volume lsedocker 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.
- No seu projeto, crie um
Dockerfilede seis linhas seguindo o roteiro acima. Não copie: escreva cada linha depois de dizer em voz alta o que ela decide. - Crie o
.dockerignorecomnode_modules,.git,.enve*.log. Verifique que o.envnão existe dentro da imagem:docker exec -it <container> ls -a /app. - Rode
npm ci --omit=devlocalmente e anote quantos pacotes entraram. Depois rodedocker exec <container> ls node_modules | wc -le anote quantos entraram na imagem. Explique a diferença, e diga em qual lista dopackage.jsonestava cada pacote que ficou de fora. - Rode
docker exec <container> id -ue anote o número. Depois comente a linhaUSER node, suba de novo e rode o mesmo comando. O que mudou e por que isso e seguranca? - No
compose.yaml, publique o sistema: escolha a porta publicada, ponhaAPP_VERSAO=1.4.0emenvironmente declare um volumedadosmontado em/app/dados. Suba comdocker compose up -d --build, espere o health check passar e rodecurl http://127.0.0.1:<porta>/saude. Ostatusque volta e200? - Escreva um arquivo qualquer em
/app/dados, rodedocker compose down, depoisdocker compose up -de confira se o arquivo continua la. Ele estava no volume ou dentro da imagem? Se você subiu sem ovolumes, repita a operacao e veja o que acontece com o arquivo. - Rode
docker compose logs -fe depoisdocker 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 comdocker compose restarte veja o que os logs do container mostram depois do reinicio — a linha antiga continua visivel? - Na placa, grave o sketch da resolucao, anote a
versaoe ocompilado emdo banner, depois mudeVERSAO_FIRMWAREpara1.4.1, grave de novo e diga o que mudou no log. Se você precise reiniciar a placa para a versão nova aparecer, oOTAdo 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ério | Pontos |
|---|---|
Dockerfile com as seis linhas na ordem correta, COPY do manifesto antes do npm ci | 2 pontos |
.env ausente dentro da imagem, verificado com docker exec | 2 pontos |
id -u devolvendo 1000 com o USER node acima do CMD | 2 pontos |
compose.yaml com ports, environment e volumes declarados | 2 pontos |
Arquivo em /app/dados ainda presente depois de down e de up | 2 pontos |
docker logs do container lido e comparado com o do host após o restart | 1 ponto |
| Banner de boot da placa com versão, data da build e identificador | 1 ponto |
Erros comuns
| Erro | Como aparece | Correcao |
|---|---|---|
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 ci | Build 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 colchetes | Container 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 porta | Aluno 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 0 | Aluno 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 imagem | docker 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 GB | Aluno 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 ocupada | Bind 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 volumes | Arquivo 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 volume | Container 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 querer | Dado 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 antiga | Aluno 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ódigo | Servidor 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 environment | Precisa 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
200sem consultar o banco, e dizer por que ela não pode depender dele. - Ler
docker logsejournalctl, 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
nginxde 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
404de 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,
systemdenginxinstalados - O container
estacao-esp32da aula 1, ainda parado na imagem - 1 terminal com acesso a
systemctl,journalctl,docker logs,docker inspect,curless -ltnp - 1 nome de domínio de teste apontando para o IP público da máquina da sala, mais um cliente
certbotpara 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 logs | journalctl -u estacao | |
|---|---|---|
| quem escreve | o próprio processo | o supervisor do sistema |
| sobrevive a reinício do processo | não | sim |
| sobrevive a reboot da máquina | não | sim, se persistent estiver ligado |
| onde o professor olha | desenvolvimento | produçã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.
- Escreva a unidade do
systemddo seu projeto, comRestart=always,RestartSec=5eEnvironmentFileapontando para o.env. Não coloque valor de segredo no arquivo da unidade: ponha o caminho. Depois rodesystemctl daemon-reload,systemctl enable --nowesystemctl is-enabled, e anote a palavra que saiu. - Explique, em duas linhas, a diferença entre o que o
Restart=alwaysresolve e o que oenableresolve. Cite o caso concreto de um servidor que reinicia de madrugada. - No
compose.yamlda aula 1, acrescente o blocohealthchecke suba comdocker compose up -d. Espere odocker psmostrarhealthye anote quanto tempo levou. - Com o container no ar, rode
curl -i http://127.0.0.1:3987/saudee anote o status. Depois rodecurl -i http://127.0.0.1:3987/rota-que-nao-existee anote o status. Os dois são diferentes? Por que isso importa? - Derrube o container de propósito com
docker kill estacao-esp32e, sem rodar nenhum outro comando, veja o que acontece. Anote quantos segundos ele levou para voltar e o que odocker logsmostrou. - Depois do
docker kill, rodedocker logs estacao-esp32e procure a linha do start anterior. Ela ainda está lá? Compare comjournalctl -u estacaoe diga qual dos dois sobrou. - Escreva uma rotina de uma linha que grava o
Health.Statusem um arquivo a cada minuto e imprime o alerta de queda na primeira vez que o valor deixar de serhealthy. Media o tempo entre odocker kille o alerta e anote os dois números. - Crie a unidade do
systemdpara onginx, com umserverque escuta443, apontaproxy_passpara127.0.0.1:3987e traz o certificado do servidor. Diga qual nome de domínio ele atende. - Amarre o serviço em
127.0.0.1em vez de0.0.0.0, rodess -ltnpe escreva a regra de firewall que você aplicaria: quais portas abrir e por quê. - 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-existee veja o log da placa. O que os dois pedidos produziram de diferente no log? - 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ério | Pontos |
|---|---|
Unidade do systemd com Restart=always, RestartSec e o caminho do .env, sem valor de segredo no arquivo | 3 pontos |
systemctl is-enabled com a resposta anotada e a diferença entre reiniciar o processo e reiniciar a máquina | 3 pontos |
healthcheck no compose e docker inspect respondendo healthy | 3 pontos |
Container voltou sozinho depois do docker kill, com o tempo anotado | 2 pontos |
200 em /saude e 404 em rota inexistente, ambos anotados | 1 ponto |
Item 6 respondido: docker logs perde a saída do start anterior e o journalctl não | 1 ponto |
| Alerta de queda rodando, com os dois tempos medidos: intervalo da falha e atraso do alerta | 2 pontos |
nginx com proxy_pass para 127.0.0.1:3987, server_name e o certificado do servidor apontados | 2 pontos |
ss -ltnp executado e a regra de firewall escrita, com as portas justificadas uma a uma | 1 ponto |
Erros comuns
| Erro | Como aparece | Correcao |
|---|---|---|
| Serviço que não sobe após reiniciar | systemctl 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 publicada | reverse 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_pass | Todas 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 banco | SELECT 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 cliente | Relató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ção | Aluno 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 ausente | Processo 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 porta | Aluno 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 lugar | ufw 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 infinito | Nada 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 firmware | docker 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.
