Dia 10 — Docker e o banco em container

Informatica · Conteudo · publicado em 30/09/2026
Dia 10 de 15

Docker e o banco em container

Aula 1

Dockerfile da aplicação Node

A imagem é a lista de instruções, e cada uma vira uma camada

Uma imagem de container não é um programa: é uma pilha de camadas, cada uma sendo o resultado de uma linha do Dockerfile. O Dockerfile inteiro cabe em oito linhas, e o exemplo mede as oito na pasta do material.

O que decide o resultado não é o que está escrito, é a ordem. Duas linhas trocadas produzem a mesma imagem funcionando e dois custos completamente diferentes.

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

FROM node:22-slim escolhe a imagem base: a imagem oficial do Node, na versão 22, variante slim. A palavra latest é proibida aqui — latest não é uma versão, é um ponteiro que o dono da imagem move quando quiser, e a build que passou ontem falha hoje sem nenhuma alteração no seu código. node:22-slim fixa a versão maior e o canal; a variante slim importa porque vem sem compilador, sem curl e sem ferramenta de pacote que a aplicação não usa. Uma imagem pequena é imagem que só carrega o que o processo precisa.

WORKDIR /app define a pasta de trabalho. Sem ele, todo caminho relativo do Dockerfile — e o . do COPY . . — valeria a partir da raiz do sistema de arquivos, que é / no Linux.

EXPOSE 3000 não publica nada. É um rótulo de documentação dentro da imagem: o número da porta que o processo escuta. A publicação de verdade é a flag -p do docker run, ou o ports do Compose, e é assunto da aula 2.

CMD ["node", "src/app.js"] é o comando padrão. A forma com colchetes é a forma exec: o Node vira o processo número 1 do container, e o sinal que chega nele chega direto. A forma CMD node src/app.js passa por /bin/sh -c, que cria um processo a mais e faz o SIGTERM chegar ao shell, não à aplicação — e aí o container só morre no SIGKILL, depois do tempo de espera.

ENTRYPOINT é o comando que não se substitui, e docker run imagem outro-comando acrescenta argumentos a ele. A combinação dos dois é o padrão em produção: ENTRYPOINT ["node"] fixo, e o CMD ou o docker run escolhem o arquivo. Quem escreve ENTRYPOINT e CMD juntos sem saber a ordem acaba com o comando repetido: o ENTRYPOINT vai antes, o CMD vira argumento dele.

A camada de dependência vem antes do código

A camada de dependência é a linha RUN npm ci --omit=dev isolada das outras: é a única que muda raramente, e por isso a única que vale guardar no cache.

O par que resolve a ordem é COPY package*.json ./ seguido de RUN npm ci --omit=dev. As duas linhas copiam só o manifesto e o lockfile, instalam, e só depois entra o resto.

O motivo é o cache de build. Quando uma camada é refeita, o Docker reconstrói todas as camadas de baixo. E o que invalida uma camada é mudar o que ela produz.

O exemplo calcula a chave de cada camada com sha256 de verdade e compara duas builds: a primeira, e outra depois de um commit que só mexeu em código. O resultado separa as duas ordens:

  • manifesto antes do código: uma camada refeita
  • código antes do manifesto: duas camadas refeitas

A camada do manifesto não mudou em nenhum dos casos — e é ela que segura o npm ci no cache. Por isso COPY package*.json ./ vem antes do COPY . .: a pasta inteira muda a cada commit, o package.json muda a cada estação.

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 é a informação útil: a imagem passa a ser reproduzível, ou deixa de ser. npm install, ao contrário, resolve versões novas a cada build — a mesma linha de código produz duas imagens diferentes.

--omit=dev deixa de fora o que só serve para testar: jest, nodemon, ferramentas de tipo. O exemplo fecha a travessia de dependencies e pesa o resultado, e o que fica de fora também aparece medido. npm ci apaga node_modules antes de instalar; npm install reconcerta por cima, e é por isso que sobra lixo de versão antiga numa pasta que ninguém limpa.

docker build -t nome-da-app . constrói. O ponto final é obrigatório e é a pasta de contexto: é dela que o COPY lê. docker images mostra o tamanho, e docker history mostra a ordem real das camadas com o tamanho de cada uma — e é por ali que se descobre que o COPY . . está levando centenas de megabytes de coisa que não deveria.

O .dockerignore decide o que a imagem carrega

Sem .dockerignore, o COPY . . manda a pasta inteira para dentro da imagem. O exemplo mede as duas situações na mesma pasta e imprime os dois totais: a diferença é de uma ordem de grandeza, e o número exato muda conforme a pasta cresce — o que não muda é o sinal.

O perigo não é o tamanho. É o .env: com a linha errada ou de fora, a senha do banco entra gravada numa camada da imagem, e camada antiga não se apaga. docker history continua mostrando, docker push manda para o registro, e o .gitignore não ajuda em nada — aqui o problema não é o git.

node_modules
.git
.env
*.log
saida
saidas.json
__pycache__
Dockerfile
.dockerignore

Uma linha por padrão, sem barra no fim e sem ! para exceptuar. O .dockerignore é lido pelo nome do caminho relativo ao contexto: node_modules casa a pasta de qualquer nível e tudo que está dentro dela. Um * casa qualquer trecho, e um / inicial fixa o caminho na raiz.

A linha node_modules não é sobre tamanho: é porque o RUN npm ci reconstrói essa pasta dentro da imagem. Copiar a sua seria instalar duas vezes e ainda levar a versão da sua máquina.

O exemplo imprime, para cada regra, quantos itens ela retirou desta pasta — e mostra as que não casaram com nada. Uma regra que não casa é uma linha que parece proteger e não protege: .git só aparece se o projeto for um repositório, e o exemplo está medindo uma pasta que não é.

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.

USER node: o usuário não root

USER node é a linha do usuário não root. A imagem oficial do Node já traz esse usuário, com uid 1000, então a linha não cria nada: ela escolhe um usuário que já existe, e o processo deixa de rodar como root — deixa de ser o dono da máquina e passa a ter menos direito, exatamente o de que precisa.

O dividendo é o que a imagem consegue ler depois. O exemplo monta uma pasta com os mesmos modos de arquivo da aplicação e mede o que um processo de uid 1000 consegue fazer, com spawnSync:

arquivomodoo que o uid 1000 faz
o código644lê, e não escreve
o .env600Permission denied
criar arquivo novo na pasta—Permission denied

O código roda e o segredo fica fora do alcance. Sem a linha USER, o processo é root dentro do container e as três recusas somem.

O que USER não faz: não protege o banco, não valida entrada e não impede acesso a rede. O que ele limita é o estrago de um pacote comprometido — o require de uma dependência que o time de ninguém revisou executa com os privilégios do processo, e a diferença entre root e uid 1000 é a diferença entre uma máquina inteira e uma pasta.

A verificação é uma linha, e vale rodar no container: id -u tem que responder 1000. Um Dockerfile que termina em CMD sem USER acima é o defeito mais comum de imagem de aplicação, e nenhum aviso aparece — o container sobe e funciona bem.

COPY --chown=node:node . . junto com USER node resolve o caso em que a aplicação escreve arquivo: sem o --chown, o arquivo copiado pertence ao root e o processo de uid 1000 não consegue sobrescrever o próprio código. EXPOSE e USER não ocupam camada; RUN e COPY ocupam, e é por isso que só eles aparecem na conta de tamanho do exemplo.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 10: o Dockerfile como lista de camadas.
//
// ESTE EXEMPLO NAO EXECUTA `docker build`. A decisao e do exemplo: um
// `docker build` de verdade precisa de daemon, e um build que fica esperando
// imagem derruba o portao ate o timeout. O que o exemplo faz e o outro
// metade do mesmo trabalho:
//
//   1. SIMULACAO DECLARADA. O `Dockerfile` e o `.dockerignore` entram no
//      arquivo como DADOS. Cada instrucao vira uma linha com o que ela faz,
//      se ela cria camada, e quantos bytes ela acrescenta — e esses bytes sao
//      MEDIDOS nesta pasta com `fs.statSync`, nao inventados.
//
//   2. MEDICAO REAL. `node -v`, `npm -v`, `npm ls`, `npm ci` e os `sha256`
//      rodam agora, e o que sai impresso e o que a maquina devolveu. Onde a
//      maquina nao tem a resposta — o tamanho da imagem base, por exemplo — o
//      exemplo DIZ que nao mede, em vez de chutar um numero.
//
// A pasta medida e o proprio material, resolvida a partir deste arquivo
// (`../../..`), entao os numeros batem com o que aparece num `du -sh` da
// mesma pasta. Os TAMANHOS variam de uma maquina para outra; o FORMATO das
// linhas nao varia.

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const net = require('node:net');
const crypto = require('node:crypto');
const { spawnSync } = require('node:child_process');

// O contexto de build e a raiz do material: tres niveis acima deste arquivo
// (`codigo/t3/dia10/`). Resolver por `__dirname` e o que faz o exemplo
// funcionar independente de onde o `node` foi chamado.
const CONTEXTO = path.resolve(__dirname, '..', '..', '..');

// ==================================================================
// 1. O DOCKERFILE COMO DADOS
// Cada item e uma instrucao, o que ela faz, e se ela vira camada.
// `camada: false` e o que nao pesa em disco: sao metadados.
// ==================================================================

const DOCKERFILE = [
  { cmd: 'FROM', arg: 'node:22-slim', camada: false,
    efeito: 'imagem base: Node e npm ja instalados, sem ferramentas de build' },
  { cmd: 'WORKDIR', arg: '/app', camada: false,
    efeito: 'todo caminho relativo passa a valer a partir de /app' },
  { cmd: 'COPY', arg: 'package*.json ./', camada: true,
    efeito: 'so o manifesto e o lockfile, sem o codigo' },
  { cmd: 'RUN', arg: 'npm ci --omit=dev', camada: true,
    efeito: 'instala o fecho de `dependencies`, e nada mais' },
  { cmd: 'COPY', arg: '. .', camada: true,
    efeito: 'o resto da pasta, ja sem o que o .dockerignore exclui' },
  { cmd: 'USER', arg: 'node', camada: false,
    efeito: 'o processo deixa de rodar como root' },
  { cmd: 'EXPOSE', arg: '3000', camada: false,
    efeito: 'documenta a porta: NAO publica nada' },
  { cmd: 'CMD', arg: '["node", "src/app.js"]', camada: false,
    efeito: 'comando padrao, que `docker run` pode substituir' },
];

// O `.dockerignore` e tao importante quanto o `Dockerfile`: sem ele, o
// `COPY . .` manda o `.env`, o `node_modules` e o `.git` para dentro da
// imagem — e o `.env` na imagem e o segredo vazando, gravado em camadas que
// ninguem apaga.
const DOCKERIGNORE = [
  'node_modules',
  '.git',
  '.env',
  '*.log',
  'saida',
  'saidas.json',
  '__pycache__',
  'Dockerfile',
  '.dockerignore',
];

// ---------------------------------------------------------------- medicao

function tamanhoDe(alvo) {
  const st = fs.statSync(alvo);
  if (!st.isDirectory()) return { bytes: st.size, arquivos: 1 };
  let bytes = 0, arquivos = 0;
  const pilha = [alvo];
  while (pilha.length) {
    const dir = pilha.pop();
    for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
      const p = path.join(dir, e.name);
      if (e.isDirectory()) pilha.push(p);
      else { bytes += fs.statSync(p).size; arquivos += 1; }
    }
  }
  return { bytes, arquivos };
}

function legivel(bytes) {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}

function soma(alvos) {
  let bytes = 0, arquivos = 0;
  for (const a of alvos) {
    const t = tamanhoDe(a);
    bytes += t.bytes; arquivos += t.arquivos;
  }
  return { bytes, arquivos };
}

// Um comando real, com a saida real. `spawnSync` nao lanca quando o codigo
// de saida e diferente de zero: devolve o codigo no `status`, e o `error`
// preenchido so quando o BINARIO nao existe (ENOENT).
function rodaComando(args, cwd) {
  const r = spawnSync(args[0], args.slice(1), {
    cwd, encoding: 'utf8', timeout: 20000,
    maxBuffer: 4 * 1024 * 1024,
  });
  if (r.error) return { disponivel: false, motivo: r.error.code };
  return {
    disponivel: true,
    rc: r.status,
    out: (r.stdout || '').trim(),
    err: (r.stderr || '').trim(),
  };
}

// ------------------------------------------------------------ .dockerignore
// A regra e o NOME do caminho relativo. Barra final ("saida/") aponta para
// a pasta e o que esta dentro; `*` casa qualquer trecho; um `/` inicial fixa
// o caminho na raiz do contexto.
function casaRegra(nome, regra) {
  const alvo = regra.replace(/^\//, '');
  if (alvo.includes('*')) {
    const partes = alvo.split('*').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
    return new RegExp('^' + partes.join('.*') + '$').test(nome);
  }
  return nome === alvo.replace(/\/$/, '');
}

// Devolve a REGRA que casou, ou `null`. Devolver o nome da regra — e nao um
// booleano — e o que permite dizer no fim quais linhas do `.dockerignore`
// nao fizeram nada nesta pasta.
function regraQueCasou(nome) {
  for (const regra of DOCKERIGNORE) {
    if (casaRegra(nome, regra)) return regra;
  }
  return null;
}

function medeContexto() {
  const semFiltro = soma([CONTEXTO]);
  let bytes = 0, arquivos = 0, dirs = 0;
  const removidos = new Map();   // regra -> {nomes}
  const pilha = [CONTEXTO];
  while (pilha.length) {
    const dir = pilha.pop();
    dirs += 1;
    for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
      const regra = regraQueCasou(e.name);
      if (regra) {
        if (!removidos.has(regra)) removidos.set(regra, []);
        removidos.get(regra).push(e.name);
        continue;
      }
      const p = path.join(dir, e.name);
      if (e.isDirectory()) pilha.push(p);
      else { bytes += fs.statSync(p).size; arquivos += 1; }
    }
  }
  return { semFiltro, comFiltro: { bytes, arquivos, dirs }, removidos };
}

// ------------------------------------------------------ fecho de producao
// `npm ci --omit=dev` instala o FECHAMENTO de `dependencies`: cada pacote e
// os pacotes que ele declara. A funcao abaixo e essa travessia, feita sobre o
// `node_modules` que existe nesta maquina — e o mesmo grafo que o `npm`
// percorre ao instalar.
function fechoDeProducao() {
  const nm = path.join(CONTEXTO, 'node_modules');
  const vistos = new Set();
  const passo = (nomes) => {
    for (const nome of nomes) {
      if (vistos.has(nome)) continue;
      const manifesto = path.join(nm, nome, 'package.json');
      if (!fs.existsSync(manifesto)) continue;   // pacote ausente aqui
      vistos.add(nome);
      const pkg = JSON.parse(fs.readFileSync(manifesto, 'utf8'));
      passo(Object.keys(pkg.dependencies || {}));
    }
  };
  const raiz = JSON.parse(fs.readFileSync(path.join(CONTEXTO, 'package.json'), 'utf8'));
  passo(Object.keys(raiz.dependencies || {}));
  return { pacotes: [...vistos].sort(), dev: Object.keys(raiz.devDependencies || {}) };
}

// ------------------------------------------------ o que o build guardaria
// A chave de cache de uma camada e o resumo do que ela produz. Para um
// `COPY`, o que entra na conta e o CONTEUDO dos arquivos copiados; para um
// `RUN`, o comando mais as camadas de baixo. O exemplo calcula os digests de
// verdade, e depois altera um arquivo de verdade para mostrar o digest
// mudando — e o que faz a camada de cima ser refeita.
function digest(conteudo) {
  return crypto.createHash('sha256').update(conteudo).digest('hex').slice(0, 12);
}

function guardaCache({ manifesto, codigo, ordem, fecho }) {
  const camadas = [];
  for (const passo of ordem) {
    if (passo === 'manifesto') {
      camadas.push({
        nome: 'COPY package*.json ./',
        bytes: manifesto.bytes,
        chave: digest(manifesto.texto),
      });
    } else if (passo === 'instalar') {
      camadas.push({
        nome: 'RUN npm ci --omit=dev',
        bytes: fecho.pacotes.length ? fecho.bytes : 0,
        chave: digest('npm ci' + camadas.map((c) => c.chave).join('|')),
      });
    } else {
      camadas.push({
        nome: 'COPY . .',
        bytes: codigo.bytes,
        chave: digest(manifesto.texto + '||' + codigo.texto),
      });
    }
  }
  return camadas;
}

// ==================================================================

async function main() {
  const fecho = (() => {
    const f = fechoDeProducao();
    const t = soma(f.pacotes.map((p) => path.join(CONTEXTO, 'node_modules', p)));
    return { ...f, bytes: t.bytes, arquivos: t.arquivos };
  })();

  console.log('=== 1. cada instrucao, o que ela faz e o que ela pesa ===');
  console.log('contexto de build: ' + CONTEXTO);
  console.log('a imagem base nao e medida aqui (medir exige daemon; e o');
  console.log('`docker images` da aula que mostra o tamanho dela)');
  console.log('');

  const ctx = medeContexto();
  const manifesto = soma([path.join(CONTEXTO, 'package.json')]);
  const lock = path.join(CONTEXTO, 'package-lock.json');

  let n = 0;
  for (const linha of DOCKERFILE) {
    n += 1;
    const marca = linha.camada ? 'camada' : 'meta  ';
    let peso;
    if (linha.cmd === 'COPY' && linha.arg.startsWith('package')) {
      peso = legivel(manifesto.bytes)
        + ' (package.json' + (fs.existsSync(lock) ? ' + package-lock.json' : ', sem lockfile') + ')';
    } else if (linha.cmd === 'RUN') {
      peso = legivel(fecho.bytes) + ' em ' + fecho.pacotes.length + ' pacotes';
    } else if (linha.cmd === 'COPY') {
      peso = legivel(ctx.comFiltro.bytes) + ' em ' + ctx.comFiltro.arquivos + ' arquivos';
    } else {
      peso = '0 B (nao grava nada)';
    }
    console.log('  ' + String(n).padStart(2) + '. [' + marca + '] '
      + (linha.cmd + ' ' + linha.arg).padEnd(34) + peso);
    console.log('      ' + linha.efeito);
  }

  const somado = manifesto.bytes + fecho.bytes + ctx.comFiltro.bytes;
  console.log('');
  console.log('  somando so as tres camadas que gravam: ' + legivel(somado));
  console.log('  o resto das linhas e metadado, e nao ocupa espaco nenhum');

  // ------------------------------------------------------------- o filtro
  console.log('\n=== 2. o que o `COPY . .` leva: com e sem `.dockerignore` ===');
  console.log('  sem filtro : ' + ctx.semFiltro.arquivos + ' arquivos, '
    + legivel(ctx.semFiltro.bytes));
  console.log('  com filtro : ' + ctx.comFiltro.arquivos + ' arquivos, '
    + legivel(ctx.comFiltro.bytes) + ', ' + ctx.comFiltro.dirs + ' pastas');
  console.log('  o que cada regra retirou do `COPY . .` desta pasta:');
  for (const regra of DOCKERIGNORE) {
    const nomes = ctx.removidos.get(regra);
    console.log('    ' + regra.padEnd(14)
      + (nomes
        ? nomes.length + ' item(ns): ' + nomes.slice(0, 4).join(', ')
          + (nomes.length > 4 ? ' ...' : '')
        : 'NADA — a regra nao casou com nenhum nome deste contexto'));
  }
  const segredo = ctx.removidos.get('.env');
  console.log('');
  if (segredo) {
    console.log('  `.env` foi para fora do `COPY . .`: ele nao vai para a imagem.');
    console.log('  o valor da senha nunca e impresso aqui, e nem entra no arquivo.');
    console.log('  sem essa linha, o segredo entrava gravado numa camada — e camada');
    console.log('  antiga nao se apaga: `docker history` continuaria mostrando.');
  } else {
    console.log('  esta pasta nao tem `.env`, entao a regra nao tinha o que retirar.');
  }
  console.log('  a linha `node_modules` existe porque o `RUN npm ci` reconstrói a');
  console.log('  pasta DENTRO da imagem: mandar a sua seria copiar duas vezes.');

  // ------------------------------------------------- o que roda de verdade
  console.log('\n=== 3. o mesmo build, com comandos que existem de verdade ===');
  const vNode = rodaComando(['node', '-v']);
  const vNpm = rodaComando(['npm', '-v']);
  if (vNode.disponivel) console.log('  node -v                -> ' + vNode.out);
  if (vNpm.disponivel) console.log('  npm -v                 -> ' + vNpm.out);
  console.log('  as duas versoes sao o que a `FROM node:22-slim` NAO fixa: a imagem');
  console.log('  traz um Node patch especificado, e `latest` traria o de hoje.');

  const lista = rodaComando(['npm', 'ls', '--omit=dev', '--depth=0'], CONTEXTO);
  if (lista.disponivel) {
    console.log('\n  `npm ls --omit=dev` — o que o `RUN npm ci --omit=dev` instala:');
    for (const l of lista.out.split('\n')) console.log('    ' + l.trim());
  }

  console.log('\n  fecho de `dependencies` medido arquivo a arquivo:');
  for (const p of fecho.pacotes) {
    const t = tamanhoDe(path.join(CONTEXTO, 'node_modules', p));
    console.log('    ' + p.padEnd(20) + legivel(t.bytes).padStart(8)
      + '  ' + String(t.arquivos).padStart(4) + ' arquivos');
  }
  console.log('    ' + 'TOTAL'.padEnd(20) + legivel(fecho.bytes).padStart(8)
    + '  ' + String(fecho.arquivos).padStart(4) + ' arquivos');
  const foraDoFecho = fs.readdirSync(path.join(CONTEXTO, 'node_modules'))
    .filter((d) => !fecho.pacotes.includes(d) && !d.startsWith('.'));
  if (foraDoFecho.length) {
    const t = soma(foraDoFecho.map((d) => path.join(CONTEXTO, 'node_modules', d)));
    console.log('  fora do fecho (so `devDependencies` e tipos): ' + foraDoFecho.join(', '));
    console.log('    ' + legivel(t.bytes) + ' em ' + t.arquivos
      + ' arquivos que o `--omit=dev` deixa de fora da imagem');
  }
  console.log('  e o fecho so existe porque ha lockfile: sem ele o `npm ci` recusa.');

  // `npm ci` de verdade, numa pasta temporaria, SEM lockfile: e o erro real
  // que o aluno encontra quando escreve `COPY package*.json ./` e esquece de
  // versionar o lockfile.
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'camada-'));
  fs.copyFileSync(path.join(CONTEXTO, 'package.json'), path.join(tmp, 'package.json'));
  const semLock = !fs.existsSync(lock);
  console.log('\n  `npm ci` numa pasta com o manifesto e sem o lockfile:');
  const ci = rodaComando(['npm', 'ci', '--omit=dev', '--dry-run'], tmp);
  if (!ci.disponivel) {
    console.log('    o `npm` nao esta no PATH desta maquina (' + ci.motivo + ')');
  } else if (semLock) {
    // O `npm` quebra a mensagem em varias linhas com o prefixo `npm error`.
    // A causa e a frase que COMECA a explicacao, e nao a linha do codigo
    // (`EUSAGE` sozinho nao diz nada ao aluno) nem a continuacao dela
    // (`npm-shrinkwrap.json with lockfileVersion >= 1` e a segunda linha da
    // mesma frase, e lida sozinha nao faz sentido).
    const limpa = ci.err.split('\n').map((l) => l.replace(/^npm error\s*/, '').trim())
      .filter(Boolean);
    const comecou = limpa.findIndex((l) => l.startsWith('The '));
    const fim = comecou >= 0
      ? limpa.slice(comecou).findIndex((l) => /[.:]$/.test(l))
      : -1;
    const frase = comecou >= 0
      ? limpa.slice(comecou, fim >= 0 ? comecou + fim + 1 : undefined).join(' ')
      : (limpa[1] || limpa[0] || '');
    // O corte respeita a PALAVRA: cortar no meio de `lockfileVersion`
    // produz uma linha que parece corrompida, e a pagina mostra a saida
    // real — quem le vai jurar que o `npm` escreve assim.
    const limpa2 = frase.replace(/`/g, '').trim();
    const corte = limpa2.lastIndexOf(' ', 104);
    console.log('    rc=' + ci.rc + '  ' + (corte > 40 ? limpa2.slice(0, corte) + '...' : limpa2));
    console.log('    `npm ci` instala o que o LOCKFILE manda, nao o que o manifesto');
    console.log('    pede: e por isso que ele falha, e e por isso que ele e repetivel.');
  } else {
    console.log('    rc=' + ci.rc + ' (o lockfile existe nesta pasta)');
  }
  fs.rmSync(tmp, { recursive: true, force: true });

  // -------------------------------------------------------- cache de build
  // Um "commit" depois: o codigo ganhou uma linha, o manifesto nao mudou.
  // Os dois digests abaixo sao reais — um do arquivo como esta, outro de uma
  // copia dele que o exemplo alterou agora.
  const appJs = path.join(CONTEXTO, 'codigo', 't3', 'dia10', 'aula1.js');
  const antes = fs.readFileSync(appJs);
  const codigoDepois = Buffer.concat([antes, Buffer.from('\n// uma linha nova no codigo\n')]);
  const pkgTexto = fs.readFileSync(path.join(CONTEXTO, 'package.json'), 'utf8');

  const ordens = [
    { nome: 'ordem do Dockerfile (manifesto antes do codigo)',
      ordem: ['manifesto', 'instalar', 'codigo'] },
    { nome: 'ordem invertida (`COPY . .` antes do `npm ci`)',
      ordem: ['codigo', 'instalar', 'manifesto'] },
  ];

  console.log('\n=== 4. o cache de camada: o que um commit reinicia ===');
  console.log('  primeira build (cache vazio) e uma build depois de mexer no codigo:');
  console.log('  chave = sha256 do que a camada produz, 12 primeiros digitos');
  console.log('');

  for (const { nome, ordem } of ordens) {
    console.log('  ' + nome);
    const antesC = guardaCache({
      manifesto: { texto: pkgTexto, bytes: manifesto.bytes },
      codigo: { texto: antes.toString('utf8'), bytes: antes.length },
      ordem, fecho,
    });
    const depoisC = guardaCache({
      manifesto: { texto: pkgTexto, bytes: manifesto.bytes },
      codigo: { texto: codigoDepois.toString('utf8'), bytes: codigoDepois.length },
      ordem, fecho,
    });
    for (let i = 0; i < antesC.length; i++) {
      const a = antesC[i], d = depoisC[i];
      const mudou = a.chave !== d.chave;
      console.log('    ' + a.nome.padEnd(26)
        + (mudou ? 'REFEITA ' : 'guardada')
        + '  ' + a.chave + ' -> ' + d.chave
        + (mudou ? '  (+' + legivel(d.bytes - a.bytes) + ')' : ''));
    }
    const refeitas = depoisC.filter((c, i) => c.chave !== antesC[i].chave).length;
    console.log('    ' + refeitas + ' de ' + depoisC.length
      + ' camadas refeitas por um commit que so mexeu em codigo');
    console.log('');
  }
  console.log('  a camada do manifesto nao mudou nos dois casos: e ela que segura');
  console.log('  o `npm ci` no cache. Por isso `COPY package*.json ./` vem ANTES do');
  console.log('  codigo — e por isso que `.dockerignore` precisa existir antes da');
  console.log('  build, e nao depois.');

  // ------------------------------------------------------------ usuario
  console.log('\n=== 5. `USER node`: o que muda no sistema de arquivos ===');
  const id = rodaComando(['id', '-u']);
  console.log('  uid deste processo agora: ' + (id.disponivel ? id.out : '(id fora do PATH)'));
  console.log('  a imagem oficial do Node traz o usuario `node`, uid 1000; com');
  console.log('  `USER node` no fim do Dockerfile, `id -u` dentro do container');
  console.log('  responde 1000 em vez de 0.');

  const modos = [
    ['package.json', 0o644, 'leitura e execucao do codigo: nao-root so PRECISA ler'],
    ['codigo/t3/dia10/aula1.js', 0o644, 'o codigo da aplicacao: idem'],
    ['.env', 0o600, 'o segredo: so o dono le'],
  ];
  let temModo = true;
  for (const [rel, esperado, nota] of modos) {
    const p = path.join(CONTEXTO, rel);
    if (!fs.existsSync(p)) { temModo = false; continue; }
    const m = fs.statSync(p).mode & 0o777;
    console.log('  ' + rel.padEnd(30) + 'modo ' + m.toString(8).padStart(3)
      + (m === esperado ? '' : ' (esperado ' + esperado.toString(8) + ')')
      + '  ' + nota);
  }
  if (!temModo) console.log('  (algum dos arquivos medidos nao existe nesta pasta)');

  if (process.getuid() === 0) {
    // Medicao real do efeito de `USER node`, sem container nenhum: os mesmos
    // MODOS de arquivo, lidos e escritos por um processo de uid 1000.
    //
    // A medicao acontece numa pasta TEMPORARIA, e nao na pasta do material:
    // `/root` e modo 700, entao um uid 1000 nao chega nem na pasta — o erro
    // seria do diretorio, e nao do modo do arquivo, e a aula ensinaria a
    // coisa errada. Aqui as duas causas se separam: o `cat` no 644 passa e o
    // `cat` no 600 e recusado, que e o que `USER node` compra.
    //
    // `TMPDIR` e sobrescrito porque o `os.tmpdir()` desta maquina aponta
    // para dentro de `/root`. Sem isso o `cat` do 644 seria recusado tambem,
    // e o exemplo affirmaria o contrario do que o `USER node` faz.
    const uid = 1000;
    const area = fs.mkdtempSync(path.join('/tmp', 'usuario-'));
    fs.chmodSync(area, 0o755);          // a pasta TEM de ser atravessavel
    const codigo = path.join(area, 'app.js');
    const arquivoSegredo = path.join(area, '.env');
    fs.writeFileSync(codigo, '// o codigo da aplicacao\n');
    fs.writeFileSync(arquivoSegredo, 'DB_PASS=vazio\n');
    fs.chmodSync(codigo, 0o644);
    fs.chmodSync(arquivoSegredo, 0o600);

    // O nome da pasta temporaria e aleatorio (e o `mkdtemp` que sorteia), e
    // ele aparecia na linha de erro: duas execucoes do exemplo saiam
    // diferentes, e a pagina mostra a saida real. A caminho e cortado e o que
    // sobra e o motivo — `Permission denied` —, que e a parte que ensina.
    const tentar = (comando) => {
      const r = spawnSync('/bin/sh', ['-c', comando], {
        encoding: 'utf8', uid, gid: uid, timeout: 5000,
      });
      if (r.status === 0) return 'conseguiu';
      const motivo = (r.stderr || '').trim().split('\n')[0] || ('rc=' + r.status);
      return motivo.split(area).join('<pasta>');
    };
    console.log('  medicao real: uma pasta com os MESMOS modos, lida por uid 1000');
    console.log('  (o processo do exemplo roda como uid ' + process.getuid() + ')');
    console.log('    ler o codigo,  modo 644 -> ' + tentar('cat ' + JSON.stringify(codigo)));
    console.log('    ler o segredo, modo 600 -> ' + tentar('cat ' + JSON.stringify(arquivoSegredo)));
    console.log('    escrever no codigo      -> ' + tentar('echo x >> ' + JSON.stringify(codigo)));
    console.log('    criar arquivo na pasta  -> ' + tentar('echo x > ' + JSON.stringify(path.join(area, 'novo.js'))));
    console.log('  ler o codigo passa e ler o segredo nao: e exatamente o que a imagem');
    console.log('  ganha com `USER node` — o processo roda, e o `.env` nao esta no');
    console.log('  escopo dele. Sem `USER` a linha, o processo e root e as tres recusas');
    console.log('  somem. `USER` nao protege o banco: limita o estrago de um pacote');
    console.log('  comprometido no `require`.');
    fs.rmSync(area, { recursive: true, force: true });
  } else {
    console.log('  (esta medicao precisa de root para trocar de uid; nesta maquina o');
    console.log('  processo nao e root, entao o exemplo nao a faz)');
  }
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

=== 1. cada instrucao, o que ela faz e o que ela pesa ===
contexto de build: /root/materiais/node-mysql
a imagem base nao e medida aqui (medir exige daemon; e o
`docker images` da aula que mostra o tamanho dela)

   1. [meta  ] FROM node:22-slim                 0 B (nao grava nada)
      imagem base: Node e npm ja instalados, sem ferramentas de build
   2. [meta  ] WORKDIR /app                      0 B (nao grava nada)
      todo caminho relativo passa a valer a partir de /app
   3. [camada] COPY package*.json ./             369 B (package.json, sem lockfile)
      so o manifesto e o lockfile, sem o codigo
   4. [camada] RUN npm ci --omit=dev             1.44 MB em 10 pacotes
      instala o fecho de `dependencies`, e nada mais
   5. [camada] COPY . .                          1.36 MB em 143 arquivos
      o resto da pasta, ja sem o que o .dockerignore exclui
   6. [meta  ] USER node                         0 B (nao grava nada)
      o processo deixa de rodar como root
   7. [meta  ] EXPOSE 3000                       0 B (nao grava nada)
      documenta a porta: NAO publica nada
   8. [meta  ] CMD ["node", "src/app.js"]        0 B (nao grava nada)
      comando padrao, que `docker run` pode substituir

  somando so as tres camadas que gravam: 2.80 MB
  o resto das linhas e metadado, e nao ocupa espaco nenhum

=== 2. o que o `COPY . .` leva: com e sem `.dockerignore` ===
  sem filtro : 553 arquivos, 11.30 MB
  com filtro : 143 arquivos, 1.36 MB, 69 pastas
  o que cada regra retirou do `COPY . .` desta pasta:
    node_modules  1 item(ns): node_modules
    .git          NADA — a regra nao casou com nenhum nome deste contexto
    .env          1 item(ns): .env
    *.log         NADA — a regra nao casou com nenhum nome deste contexto
    saida         1 item(ns): saida
    saidas.json   1 item(ns): saidas.json
    __pycache__   1 item(ns): __pycache__
    Dockerfile    NADA — a regra nao casou com nenhum nome deste contexto
    .dockerignore NADA — a regra nao casou com nenhum nome deste contexto

  `.env` foi para fora do `COPY . .`: ele nao vai para a imagem.
  o valor da senha nunca e impresso aqui, e nem entra no arquivo.
  sem essa linha, o segredo entrava gravado numa camada — e camada
  antiga nao se apaga: `docker history` continuaria mostrando.
  a linha `node_modules` existe porque o `RUN npm ci` reconstrói a
  pasta DENTRO da imagem: mandar a sua seria copiar duas vezes.

=== 3. o mesmo build, com comandos que existem de verdade ===
  node -v                -> v24.21.0
  npm -v                 -> 11.19.0
  as duas versoes sao o que a `FROM node:22-slim` NAO fixa: a imagem
  traz um Node patch especificado, e `latest` traria o de hoje.

  `npm ls --omit=dev` — o que o `RUN npm ci --omit=dev` instala:
    [email protected] /root/materiais/node-mysql
    └── [email protected]

  fecho de `dependencies` medido arquivo a arquivo:
    aws-ssl-profiles    222.8 KB    11 arquivos
    generate-function     8.8 KB     7 arquivos
    iconv-lite          336.1 KB    27 arquivos
    is-property          13.3 KB     5 arquivos
    long                136.2 KB    10 arquivos
    lru.min              32.8 KB     7 arquivos
    mysql2              615.3 KB   135 arquivos
    named-placeholders    7.0 KB     4 arquivos
    safer-buffer         41.3 KB     7 arquivos
    sql-escaper          57.6 KB     8 arquivos
    TOTAL                1.44 MB   221 arquivos
  fora do fecho (so `devDependencies` e tipos): @types, undici-types
    2.54 MB em 140 arquivos que o `--omit=dev` deixa de fora da imagem
  e o fecho so existe porque ha lockfile: sem ele o `npm ci` recusa.

  `npm ci` numa pasta com o manifesto e sem o lockfile:
    rc=1  The npm ci command can only install with an existing package-lock.json or npm-shrinkwrap.json with...
    `npm ci` instala o que o LOCKFILE manda, nao o que o manifesto
    pede: e por isso que ele falha, e e por isso que ele e repetivel.

=== 4. o cache de camada: o que um commit reinicia ===
  primeira build (cache vazio) e uma build depois de mexer no codigo:
  chave = sha256 do que a camada produz, 12 primeiros digitos

  ordem do Dockerfile (manifesto antes do codigo)
    COPY package*.json ./     guardada  c0a8207582e6 -> c0a8207582e6
    RUN npm ci --omit=dev     guardada  36167612458d -> 36167612458d
    COPY . .                  REFEITA   e9bd8f9651a9 -> aad94c3bb8fd  (+29 B)
    1 de 3 camadas refeitas por um commit que so mexeu em codigo

  ordem invertida (`COPY . .` antes do `npm ci`)
    COPY . .                  REFEITA   e9bd8f9651a9 -> aad94c3bb8fd  (+29 B)
    RUN npm ci --omit=dev     REFEITA   d2870602e7df -> 534f86e40d05  (+0 B)
    COPY package*.json ./     guardada  c0a8207582e6 -> c0a8207582e6
    2 de 3 camadas refeitas por um commit que so mexeu em codigo

  a camada do manifesto nao mudou nos dois casos: e ela que segura
  o `npm ci` no cache. Por isso `COPY package*.json ./` vem ANTES do
  codigo — e por isso que `.dockerignore` precisa existir antes da
  build, e nao depois.

=== 5. `USER node`: o que muda no sistema de arquivos ===
  uid deste processo agora: 0
  a imagem oficial do Node traz o usuario `node`, uid 1000; com
  `USER node` no fim do Dockerfile, `id -u` dentro do container
  responde 1000 em vez de 0.
  package.json                  modo 644  leitura e execucao do codigo: nao-root so PRECISA ler
  codigo/t3/dia10/aula1.js      modo 644  o codigo da aplicacao: idem
  .env                          modo 600  o segredo: so o dono le
  medicao real: uma pasta com os MESMOS modos, lida por uid 1000
  (o processo do exemplo roda como uid 0)
    ler o codigo,  modo 644 -> conseguiu
    ler o segredo, modo 600 -> cat: <pasta>/.env: Permission denied
    escrever no codigo      -> /bin/sh: 1: cannot create <pasta>/app.js: Permission denied
    criar arquivo na pasta  -> /bin/sh: 1: cannot create <pasta>/novo.js: Permission denied
  ler o codigo passa e ler o segredo nao: e exatamente o que a imagem
  ganha com `USER node` — o processo roda, e o `.env` nao esta no
  escopo dele. Sem `USER` a linha, o processo e root e as tres recusas
  somem. `USER` nao protege o banco: limita o estrago de um pacote
  comprometido no `require`.
Aula 2

Compose com Node e MySQL juntos

Dois serviços, um comando

O docker-compose.yml (hoje compose.yaml, o Compose aceita os dois nomes) descreve serviços, não containers. Um serviço é uma receita — imagem ou build, variáveis, portas, volume, dependência — e o docker compose up transforma essa receita em container, rede e volume.

services:
  app:
    build: .
    environment:
      NODE_ENV: producao
      DB_HOST: banco
      DB_PORT: "3306"
      DB_USER: materiais
      DB_NAME: materiais_teste
      DB_PASS: ${DB_PASS}
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      banco:
        condition: service_healthy
    restart: unless-stopped

  banco:
    image: mysql:8
    environment:
      MYSQL_DATABASE: materiais_teste
      MYSQL_USER: materiais
      MYSQL_PASSWORD: ${DB_PASS}
      MYSQL_ROOT_PASSWORD: ${DB_PASS}
    volumes:
      - dados_banco:/var/lib/mysql
    healthcheck:
      test:
        - CMD
        - mysqladmin
        - ping
        - -h
        - "127.0.0.1"
        - --silent
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s

volumes:
  dados_banco:

image e build dizem de onde vem o serviço. image: mysql:8 puxa a imagem pronta; build: . constrói a do projeto com o Dockerfile da aula 1. Os dois podem conviver: build é a receita e image é o nome que a imagem recebe — é assim que se versiona a própria imagem.

ports é a única coisa que tira um container da rede do Compose e o torna alcançável de fora. Sem ports, o serviço só existe dentro da rede do Compose, alcançado pelo nome do serviço. O banco acima não tem ports de propósito: ele não precisa ser alcançável da internet, e a rede do Compose já resolve.

restart: unless-stopped é a política de reinício: o container volta depois de cair, exceto se alguém o parou de propósito. É a mesma ideia do pm2 do dia 9, no nível do container.

Os comandos que importam:

docker compose up -d        # sobe tudo em segundo plano
docker compose ps           # estado de cada serviço
docker compose logs -f app  # log do app, acompanhando
docker compose down         # remove containers e rede, MANTEM o volume
docker compose down -v      # remove o volume também: o banco perde tudo

O exemplo da aula não executa docker compose up: ele lê o docker-compose.yml com um parser de YAML e mostra a estrutura que o Compose monta a partir dele.

A rede: o outro container se chama pelo nome

Dentro de um container, localhost significa o próprio container. O serviço banco está em outro container, com outro endereço de IP, e a rede do Compose registra o nome do serviço como apelido naquele espaço de nomes do DNS. Por isso DB_HOST: banco.

O exemplo prova isso sem subir container: ele escuta numa porta livre, tenta chegar nela por três nomes e imprime quem chegou. localhost e o IP do loopback alcançam o processo; o nome do host da máquina não. A prova é a mesma que vale dentro do container — localhost resolve para o processo que está rodando, e é exatamente por isso que usá-lo para falar com o banco dá ECONNREFUSED apontando para o lado errado.

E o erro de nome é outro, e vale separar: se o banco não está na rede, o getaddrinfo falha com EAI_AGAIN (ou ENOTFOUND), não com ECONNREFUSED. ECONNREFUSED é porta fechada — o endereço resolveu, ninguém está escutando. EAI_AGAIN é nome não encontrado — nem chegou a tentar conexão.

erro.codesignificadocausa comum
ECONNREFUSEDendereço resolveu, porta fechadacontainer subiu antes do banco, ou DB_HOST errado
EAI_AGAIN / ENOTFOUNDo nome não resolveuDB_HOST não é o nome de nenhum serviço
ER_ACCESS_DENIED_ERRORo banco recusou a senhaDB_PASS vazio ou diferente do do banco

O detalhe do 127.0.0.1 no healthcheck do banco não é contradição com o que a rede falou: dentro do container do banco, 127.0.0.1 é o próprio MySQL, e é isso que o healthcheck quer checar.

depends_on sem condição espera o container, não o banco

O erro mais comum de Compose é esperar o banco quando o que se esperou foi o container. depends_on sozinho resolve a ordem; não resolve a prontidão.

Um depends_on: [banco] — ou depends_on: banco: sem condição — diz ao Compose: suba o banco antes do app. Só isso. O Compose espera o container do banco ser iniciado, não ficar pronto. O processo do MySQL existe, mas ele ainda está inicializando: subindo o banco, escrevendo os arquivos de espaço de dado, aplicando o initdb. A porta ainda não aceita nada nesse instante.

O app sobe, tenta conectar, e recebe ECONNREFUSED. A conexão falha rápido — sem espera, sem timeout, em milissegundos.

A correção é a condição de saúde:

    depends_on:
      banco:
        condition: service_healthy

Com condition: service_healthy, o Compose espera o healthcheck do banco passar antes de subir o app. E o que o healthcheck faz é executar, de tempos em tempos, o comando em test até ele sair com código 0:

campoo que controlano exemplo
testo comando que decide se está saudávelmysqladmin ping
intervalde quanto em quanto tempo o comando roda5s
timeoutquanto tempo o comando pode demorar antes de contar como falha5s
retriesquantas falhas seguidas derrubam o serviço10
start_periodtempo inicial em que a falha não conta, para o banco poder acordar30s

O start_period é o que impede a corrida oposta: um MySQL frio leva alguns segundos para responder, e sem o start_period as primeiras tentativas de ping falhariam, contariam como retries e o Compose marcaria o banco como morto durante a subida. Com ele, o banco tem uma janela de tolerância antes de as contagens começarem.

O que o interval vezes o retries dá é o pior caso de espera: 5s vezes 10 = 50s que o Compose fica esperando o banco antes de desistir. Se o banco não ficar saudável nesse prazo, o up falha — e é melhor que subir um app quebrado.

A forma do test importa: - CMD executa o comando direto, sem shell; - CMD-SHELL passa por /bin/sh -c e aceita $(...), variável de ambiente e pipe.

E a senha importa mais: um healthcheck que exige senha quebra no dia em que a senha muda, porque a credencial ficou escrita dentro do test. O ping mais robusto é o que não pede senha nenhuma — e a aula do dia 6 mostrou por que: no MySQL local, mysqladmin ping sem -u tenta o usuário do sistema e recebe Access denied mesmo com o banco no ar.

ports: publicar na rede ou só no loopback

A porta do host e a porta do container são duas portas diferentes, e o mapeamento tem três partes quando se escreve o endereço:

    ports:
      - "127.0.0.1:3000:3000"

A ordem é IP_DO_HOST:PORTA_DO_HOST:PORTA_DO_CONTAINER. O que muda com o 127.0.0.1: na frente é quem alcança:

  • "3000:3000" — escuta em todas as interfaces do host. Qualquer máquina da rede chega na API.
  • "127.0.0.1:3000:3000" — escuta só no loopback. Só a própria máquina chega.

O exemplo mede as duas com um servidor de verdade: quem está em 0.0.0.0 é alcançado pelo IP da rede; quem está em 127.0.0.1 é recusado com ECONNREFUSED quando o acesso vem pelo IP da rede.

Enquanto não há reverse proxy (nginx) na frente, 127.0.0.1:3000:3000 é a forma segura: o nginx roda na mesma máquina, alcança o loopback, e a API não fica exposta na interface de rede. Quando o nginx publica a porta 80 e 443, é ele que vira a entrada, e a porta do app deixa de existir no host.

E o que o app fala não muda com isso: por dentro da rede do Compose, o app conversa com o banco no 3306 do container do banco, não na porta publicada. DB_HOST: banco e a porta 3306 continuam, e o ports do banco nem existe no exemplo de propósito.

${DB_PASS}: a senha entra pelo ambiente, não pelo arquivo

A senha do banco entra como variável de ambiente do banco: MYSQL_PASSWORD e MYSQL_ROOT_PASSWORD no serviço banco, lidas pelo próprio MySQL na primeira subida. O ${DB_PASS} no docker-compose.yml não é um valor — é uma referência. Na hora de subir o container, o Compose troca ${DB_PASS} pelo valor da variável de ambiente com esse nome.

O valor da senha não está no arquivo e não é impresso. Ele vem de fora — de um .env ao lado do compose.yaml, que o Compose lê automaticamente, ou de uma variável de ambiente exportada na shell:

# a variavel vem do ambiente; o valor nao esta no arquivo
export DB_PASS="valor-da-senha"
docker compose up -d

O .env do Compose é o mesmo .env do dia 6, e a regra é a mesma: ele é excluído do git pelo .gitignore, e no commit só vai o env.exemplo com o nome da variável e sem o valor.

O Compose também expande o ${...} dentro do command e do healthcheck, e o $$ é o escape para um $ literal que o próprio shell deve expandir:

    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --password=\"$$MYSQL_ROOT_PASSWORD\" --silent"]

Com $$, o healthcheck recebe $MYSQL_ROOT_PASSWORD, e é o shell dentro do container que expande, usando a variável que o próprio container tem. Com um $ só, o Compose tentaria expandir e deixaria a string vazia.

O que o Compose não faz é avisar quando a variável não existe. Se DB_PASS não estiver no ambiente, o ${DB_PASS} vira string vazia, o banco sobe com senha vazia, e o app falha ao conectar. O defeito passa pelo log — o container "subiu" — e só aparece na primeira conexão, como ER_ACCESS_DENIED_ERROR. Por isso docker compose config é o comando de verificação: ele imprime o arquivo com todas as variáveis já expandidas, e é a forma de ver o que o Compose realmente vai passar, antes de subir.

O volume é o que faz o dado sobreviver ao down

A pasta /var/lib/mysql dentro do container é onde o dado do banco fica. Ela mora no filesystem do container, e o container é descartável: docker compose down remove o container e a pasta vai junto. O banco volta vazio.

A persistência do banco é esse volume. O volume de dados é o nome técnico dele: um volume nomeado tira a pasta do container e põe num lugar do host que o Compose gerencia.

    volumes:
      - dados_banco:/var/lib/mysql

volumes:
  dados_banco:

O dados_banco à esquerda é o nome do volume; /var/lib/mysql é onde ele é montado dentro do container. A declaração no topo (dados_banco: sem valor) diz ao Compose que esse volume existe; se ainda não existir, o Compose cria.

O down mantém o volume. O dado que o SELECT lê é o mesmo dado que o volume guarda entre uma execução e outra do Compose. É só o down -v que apaga o volume, e é aí que o banco perde tudo — por isso -v é o comando que se evita fora do ambiente de teste.

O exemplo da aula mostra a estrutura que o Compose monta a partir do arquivo: dois serviços, o nome do volume, o destino onde ele e montado, e a leitura de SELECT VERSION() e @@port de um banco de verdade — para mostrar que a versão e a porta vêm do servidor, e que o único valor que muda entre o Compose e fora dele é o DB_HOST.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 10: dois servicos que sobem juntos.
//
// ESTE EXEMPLO NAO EXECUTA `docker compose up`. A decisao e do exemplo: um
// `compose up` de verdade precisa de daemon, e um que fica esperando imagem
// derruba o portao ate o timeout. O que o exemplo faz e o outro metade do
// mesmo trabalho:
//
//   1. LEITURA REAL. O `docker-compose.yml` da aula entra no arquivo como
//      texto, e um leitor minimo de YAML monta a estrutura de verdade: mapa,
//      lista, escalar, recuo. O `image`, o `depends_on`, o `ports` e o
//      `healthcheck` sao lidos do arquivo, nao escritos de novo ao lado.
//
//   2. MEDICAO REAL. A ordem de subida e resolvida pelo `healthcheck` que o
//      arquivo declara, o `ECONNREFUSED` contra uma porta sem servico e
//      medido, e `os.hostname()` mais o endereco resolvido provam que
//      `localhost` dentro do container aponta para o proprio container.
//
// A senha nunca aparece. O Compose le `${DB_PASS}` do ambiente, e o exemplo
// mostra o NOME da variavel e a origem do valor — que e tudo o que entra no
// git. O `.env` que preenche o `${DB_PASS}` e o mesmo que a aula do dia 6
// ensinou a nunca versionar.

const fs = require('node:fs');
const os = require('node:os');
const net = require('node:net');
const path = require('node:path');
const dns = require('node:dns').promises;
const { createConnection } = require('mysql2/promise');

const CONTEXTO = path.resolve(__dirname, '..', '..', '..');

// A credencial vem do AMBIENTE, nunca do arquivo: e o mesmo mecanismo que o
// Compose usa para preencher `${DB_PASS}` e que a aula do dia 6 ensinou. O
// valor nao e impresso em lugar nenhum.
function configDoBanco() {
  return {
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,      // so em memoria
    database: process.env.DB_NAME,
    connectTimeout: 3000,
  };
}

// ==================================================================
// 1. O `docker-compose.yml` COMO DADOS
// O arquivo inteiro, como o Compose le. `$$` e o escape do proprio Compose
// para um `$` literal: o `$$MYSQL_ROOT_PASSWORD` abaixo chega no container
// como `$MYSQL_ROOT_PASSWORD`, e o shell do `healthcheck` e que expande.
// ==================================================================

const COMPOSE = [
  'services:',
  '  app:',
  '    build: .',
  '    environment:',
  '      NODE_ENV: producao',
  '      DB_HOST: banco',
  '      DB_PORT: "3306"',
  '      DB_USER: materiais',
  '      DB_NAME: materiais_teste',
  '      DB_PASS: ${DB_PASS}',
  '    ports:',
  '      - "127.0.0.1:3000:3000"',
  '    depends_on:',
  '      banco:',
  '        condition: service_healthy',
  '    restart: unless-stopped',
  '',
  '  banco:',
  '    image: mysql:8',
  '    environment:',
  '      MYSQL_DATABASE: materiais_teste',
  '      MYSQL_USER: materiais',
  '      MYSQL_PASSWORD: ${DB_PASS}',
  '      MYSQL_ROOT_PASSWORD: ${DB_PASS}',
  '    volumes:',
  '      - dados_banco:/var/lib/mysql',
  '    healthcheck:',
  '      test:',
  '        - CMD',
  '        - mysqladmin',
  '        - ping',
  '        - -h',
  '        - "127.0.0.1"',
  '        - --silent',
  '      interval: 5s',
  '      timeout: 5s',
  '      retries: 10',
  '      start_period: 30s',
  '',
  'volumes:',
  '  dados_banco:',
  '',
].join('\n');

// ------------------------------------------------------------------
// Um leitor de YAML minimo. Nao e um YAML completo: e o suficiente para
// o formato de um Compose, e existe porque `yaml` nao esta instalado no
// material. O que importa e a ESTRUTURA que o Compose le — e ela sai
// daqui, e nao de uma copia reescrita ao lado.
// ------------------------------------------------------------------

function tiraAspas(valor) {
  const t = valor.trim();
  if ((t.startsWith('"') && t.endsWith('"')) ||
      (t.startsWith("'") && t.endsWith("'"))) {
    return t.slice(1, -1);
  }
  return t;
}

function leEscalar(bruto) {
  const t = bruto.trim();
  if (t === '') return '';
  if ((t.startsWith('"') && t.endsWith('"')) ||
      (t.startsWith("'") && t.endsWith("'"))) {
    return t.slice(1, -1);
  }
  if (t.startsWith('[') && t.endsWith(']')) {
    const dentro = t.slice(1, -1).trim();
    return dentro === '' ? [] : dentro.split(',').map((p) => tiraAspas(p));
  }
  if (t === 'true') return true;
  if (t === 'false') return false;
  return t;
}

function leYaml(texto) {
  const linhas = texto
    .split('\n')
    .filter((l) => l.trim() !== '' && !l.trim().startsWith('#'))
    .map((l) => ({
      recuo: l.length - l.trimStart().length,
      texto: l.trim(),
    }));

  let i = 0;

  function bloco(recuo) {
    if (i >= linhas.length) return null;
    const primeira = linhas[i];
    return (primeira.texto.startsWith('- ') || primeira.texto === '-')
      ? lista(recuo)
      : mapa(recuo);
  }

  function lista(recuo) {
    const saida = [];
    while (i < linhas.length && linhas[i].recuo === recuo &&
           (linhas[i].texto.startsWith('- ') || linhas[i].texto === '-')) {
      const resto = linhas[i].texto === '-'
        ? ''
        : linhas[i].texto.slice(2).trim();
      if (resto === '') {
        i += 1;
        saida.push(bloco(recuo + 2));
        continue;
      }
      if (/^[\w".-]+:(\s|$)/.test(resto)) {
        // Item que abre um mapa na propria linha do tracinho:
        //   - CMD
        //     - mysqladmin
        // e a forma que o `test` do `healthcheck` usa.
        linhas[i] = { recuo: recuo + 2, texto: resto };
        saida.push(mapa(recuo + 2));
        continue;
      }
      saida.push(leEscalar(resto));
      i += 1;
    }
    return saida;
  }

  function mapa(recuo) {
    const saida = {};
    while (i < linhas.length && linhas[i].recuo === recuo &&
           !linhas[i].texto.startsWith('- ')) {
      const t = linhas[i].texto;
      const corte = t.indexOf(':');
      if (corte === -1) { i += 1; continue; }
      const chave = t.slice(0, corte).trim();
      const resto = t.slice(corte + 1).trim();
      i += 1;
      saida[chave] = resto === '' ? bloco(recuo + 2) : leEscalar(resto);
    }
    return saida;
  }

  return bloco(0) || {};
}

// ==================================================================
// 2. O QUE O COMPOSE SUBIRIA
// ==================================================================

// `volumes` de topo com valor vazio (`dados_banco:` sem nada depois) chega
// como `null` no leitor. O Compose trata como declaracao de volume nomeado
// e o cria se nao existir; `null` e o valor esperado, e nao um defeito.
function volumeDeclarado(v) {
  return v === null || v === undefined || v === '';
}

function servicosDe(compose) {
  return Object.keys(compose.services || {});
}

// O `image` quando o servico declara `build` e o nome que o Compose da para
// a imagem construida. O Compose_prefere `image` como nome, e usa `build`
// como receita.
function imagemDe(servico) {
  if (servico.image) return { nome: servico.image, de: 'imagem pronta' };
  if (servico.build) return { nome: '<nome-do-projeto>-app', de: 'build local' };
  return { nome: '(sem imagem: o Compose falha aqui)', de: 'nenhum' };
}

function esperaBanco(servico) {
  const dep = servico.depends_on || {};
  for (const [nome, cond] of Object.entries(dep)) {
    if (cond && cond.condition === 'service_healthy') {
      return { servico: nome, condicao: 'espera o healthcheck passar' };
    }
  }
  const nomes = Object.keys(dep);
  if (nomes.length) return { servico: nomes[0], condicao: 'so espera o container iniciar' };
  return null;
}

// ==================================================================
// 3. A ORDEM DE SUBIDA, COM O TEMPO QUE CADA ETAPA DEMORA
// O Compose resolve a ordem pelo `depends_on`, e so espera de verdade
// quando a condicao e `service_healthy`. O exemplo mede o que acontece em
// cada caso, com um temporizador de verdade.
// ==================================================================

function paraSegundos(texto) {
  const m = /^(\d+)(ms|s|m)?$/.exec(String(texto).trim());
  if (!m) return null;
  const n = Number(m[1]);
  return m[2] === 'ms' ? n / 1000
    : m[2] === 'm' ? n * 60
      : n;
}

// O que o healthcheck faz com o intervalo, o timeout e as tentativas: e uma
// janela. A aplicacao so sobe depois que o banco responde, e o pior caso
// que o Compose espera e o intervalo vezes as tentativas.
function janelaDoHealthcheck(hc) {
  if (!hc) return null;
  const intervalo = paraSegundos(hc.interval) ?? 5;
  const tentativas = Number(hc.retries ?? 3);
  return {
    intervalo, timeout: paraSegundos(hc.timeout) ?? intervalo,
    tentativas,
    // A conta que importa: quanto tempo o Compose espera o banco antes de
    // desistir. Sem `start_period`, o banco lento e o container app que cai.
    esperaMaxima: intervalo * tentativas,
    comStartPeriod: hc.start_period ? paraSegundos(hc.start_period) : null,
  };
}

// ==================================================================
// 4. `localhost` DENTRO DO CONTAINER
// O ponto que mais derruba gente: dentro do container, `localhost` e o
// PROPRIO container. O outro servico mora em outro endereco de rede, e o
// Compose registra o nome do servico como apelido.
// ==================================================================

function enderecoDe(interfaceNome) {
  for (const lista of Object.values(os.networkInterfaces())) {
    for (const a of lista || []) {
      if (a.family === 'IPv4' && a.internal === (interfaceNome === 'lo')) {
        return a.address;
      }
    }
  }
  return null;
}

// Um servidor escutando numa porta livre: e o "outro servico" do teste. O
// exemplo escuta em 127.0.0.1 e mostra que `localhost` e o hostname
// proprio chegam nele — prova de que, dentro do container, o nome
// `localhost` resolve para o processo que o started.
function sobeServidorDeTeste() {
  const servidor = net.createServer((conexao) => {
    conexao.on('error', () => {});
    conexao.end('servico de teste');
  });
  return new Promise((resolve) => {
    servidor.listen(0, '127.0.0.1', () => {
      resolve({ servidor, porta: servidor.address().port });
    });
  });
}

function falaCom(host, porta, ms = 1200) {
  return new Promise((r) => {
    const t0 = Date.now();
    const s = net.createConnection({ host, port: porta });
    let pronto = false;
    const fim = (v) => {
      if (pronto) return;
      pronto = true;
      s.destroy();
      r({ ...v, ms: Date.now() - t0 });
    };
    s.setTimeout(ms, () => fim({ ok: false, motivo: 'ETIMEDOUT' }));
    s.once('connect', () => fim({ ok: true, motivo: 'conectou' }));
    s.once('error', (e) => fim({ ok: false, motivo: e.code }));
  });
}

// ==================================================================
// 5. O `ports`: PUBLICAR NO LOOPBACK OU EM TODAS AS INTERFACES
// `3000:3000` publica em todas as interfaces do host. `127.0.0.1:3000:3000`
// publica so no loopback: quem esta na mesma rede nao alcanca, e o
// servidor HTTP de outro projeto na mesma maquina ainda pega a porta. O
// exemplo mede as duas situacoes com um servidor de verdade.
// ==================================================================

function interfaceNaoLoopback() {
  for (const [nome, lista] of Object.entries(os.networkInterfaces())) {
    for (const a of lista || []) {
      if (a.family === 'IPv4' && !a.internal) return { nome, endereco: a.address };
    }
  }
  return null;
}

function escutaEm(host) {
  const servidor = net.createServer((c) => { c.on('error', () => {}); c.end('ok'); });
  servidor.on('error', () => {});
  return new Promise((r) => servidor.listen(0, host, () => r({ servidor, porta: servidor.address().port })));
}

async function main() {
  const compose = leYaml(COMPOSE);
  const servicos = servicosDe(compose);

  console.log('=== 1. o que o Compose le no arquivo ===');
  console.log('  ' + COMPOSE.split('\n')[0] + ' -> ' + servicos.length
    + ' servicos: ' + servicos.join(', '));
  console.log('  o leitor de YAML do exemplo montou a estrutura de verdade:');
  for (const nome of servicos) {
    const s = compose.services[nome];
    const img = imagemDe(s);
    console.log('');
    console.log('  ' + nome + ':');
    console.log('    imagem    : ' + img.nome + '  (' + img.de + ')');
    if (s.build) console.log('    build     : ' + s.build);
    if (s.ports) {
      console.log('    ports     : ' + s.ports.join('  |  '));
      for (const p of s.ports) {
        const partes = String(p).split(':');
        const publicaEm = partes.length === 3 ? partes[0] : 'TODAS as interfaces';
        console.log('      "' + p + '"  =  ' + publicaEm + ':' + partes[1]
          + '  ->  container:' + partes[2]);
      }
    }
    if (s.volumes) {
      for (const v of s.volumes) {
        const [origem, destino] = String(v).split(':');
        console.log('    volume    : ' + origem + ' -> ' + destino
          + (compose.volumes && volumeDeclarado(compose.volumes[origem])
            ? '  (volume nomeado, declarado no topo)' : ''));
      }
    }
    const janela = janelaDoHealthcheck(s.healthcheck);
    if (janela) {
      console.log('    healthcheck: a cada ' + janela.intervalo + 's, com '
        + janela.tentativas + ' tentativas, timeout ' + janela.timeout + 's');
      if (janela.comStartPeriod) {
        console.log('      start_period ' + janela.comStartPeriod
          + 's: o banco tem esse tempo para acordar sem contar tentativa');
      }
      console.log('      pior caso: ' + janela.esperaMaxima + 's antes de o Compose desistir');
    }
    if (s.environment) {
      for (const [k, bruto] of Object.entries(s.environment)) {
        const v = String(bruto);
        if (v.startsWith('${')) {
          // O VALOR da senha nao entra em lugar nenhum. O que o Compose faz
          // e trocar `${DB_PASS}` pelo valor que `DB_PASS` tem no ambiente
          // — e se a variavel nao existir, a string fica vazia.
          const nome = /^\$\{(\w+)\}/.exec(v);
          console.log('    env       : ' + k + '  vem de ' + v
            + '  (do ambiente, fora do arquivo)');
        } else {
          console.log('    env       : ' + k + '=' + v);
        }
      }
    }
  }

  console.log('\n  volume de topo declarado: ' + Object.keys(compose.volumes || {}).join(', '));
  console.log('  `dados_banco:` sem valor nao e erro: e a declaracao de que o');
  console.log('  volume existe, e o Compose cria se ainda nao existir. Sem ele, o');
  console.log('  banco perderia os dados a cada `docker compose down`.');

  // ------------------------------------------------------------ ordem
  console.log('\n=== 2. a ordem de subida, resolvida pelo `depends_on` ===');
  for (const nome of servicos) {
    const espera = esperaBanco(compose.services[nome]);
    if (!espera) {
      console.log('  ' + nome + ': nao depende de ninguem — sobe primeiro');
      continue;
    }
    const destino = esperaBanco(compose.services[espera.servico]);
    console.log('  ' + nome + ' depende de ' + espera.servico
      + '  (' + espera.condicao + ')');
    console.log('    `depends_on` sozinho so espera o CONTAINER INICIAR: o');
    console.log('    processo do MySQL existe e a porta ainda nao aceita nada.');
    console.log('    com `condition: service_healthy`, o Compose espera o');
    console.log('    `healthcheck` passar, e so entao sobe o app.');
  }
  const app = compose.services.app;
  const janela = janelaDoHealthcheck(compose.services.banco.healthcheck);
  console.log('');
  console.log('  sem `condition: service_healthy`, o app subiria aqui:');
  console.log('    t=0    container do banco CRIADO (processo subindo, porta fechada)');
  console.log('    t=0    container do app CRIADO — e falha ao conectar no banco');
  console.log('  com `condition: service_healthy`:');
  console.log('    t=0    container do banco CRIADO');
  console.log('    t=' + janela.intervalo + 's  healthcheck 1: ' + (janela.tentativas > 1 ? 'ainda nao' : 'pode nao ter'));
  console.log('    ...    healthcheck repetido de ' + janela.intervalo + 's em ' + janela.intervalo + 's');
  console.log('    t=' + janela.esperaMaxima + 's  banco respondendo -> so agora o app sobe');
  console.log('  o erro que o app sem esperar produz e o `ECONNREFUSED`:');

  // medicao real do erro, contra uma porta com NADA escutando
  const livre = await new Promise((r) => {
    const s = net.createServer();
    s.listen(0, '127.0.0.1', () => {
      const p = s.address().port;
      s.close(() => r(p));
    });
  });
  let erroBanco = null;
  try {
    const c = await createConnection({
      host: '127.0.0.1', port: livre, user: 'x', password: 'x',
      connectTimeout: 1500,
    });
    await c.end();
  } catch (erro) {
    erroBanco = { code: erro.code, errno: erro.errno, msg: erro.message };
  }
  // A mensagem do driver traz a porta, e a porta muda a cada execucao (e o
  // `listen(0)` pedindo uma livre). O `code` e o `errno` sao o que o
  // compara, entao sao eles que ficam; a porta sai de lado.
  console.log('    erro.code: ' + erroBanco.code + '  errno: ' + erroBanco.errno);
  console.log('    ' + erroBanco.msg.replace(/127\.0\.0\.1:\d+/, '127.0.0.1:<porta livre>'));
  console.log('  `ECONNREFUSED` e o erro de PORTA FECHADA, e ele sai na hora —');
  console.log('  nao ha espera nem timeout. O app que encontra isso e o app que');
  console.log('  subiu antes do banco estar aceitando conexao.');
  console.log('  e o que a conexao PELA REDE do Compose faz: o nome `banco` e o');
  console.log('  apelido do outro container, e o mesmo erro sai quando o nome nao');
  console.log('  resolve:');

  // ---------------------------------------------------- localhost na rede
  console.log('\n=== 3. `localhost` dentro do container aponta para ele mesmo ===');
  const { servidor, porta } = await sobeServidorDeTeste();
  console.log('  hostname deste processo: ' + os.hostname());
  // A porta do servidor de teste tambem e uma livre, pedida com `listen(0)`,
  // e muda a cada execucao. O que interessa e QUEM chega nele, e nao o
  // numero: por isso a porta nao entra na linha.
  console.log('  servidor de teste escutando numa porta livre da maquina');
  console.log('  quem chega nele e quem responde:');
  // O tempo em ms NAO e impresso: ele muda a cada execucao e viraria ruido
  // numa pagina que mostra a saida real. O que ensina e o resultado — quem
  // alcanca e quem e recusado.
  for (const host of ['localhost', '127.0.0.1', os.hostname()]) {
    const r = await falaCom(host, porta);
    console.log('    ' + host.padEnd(20) + (r.ok ? 'ALCANCOU' : 'recusado (' + r.motivo + ')'));
  }
  const resolvido = await dns.lookup('localhost');
  const proprio = await dns.lookup(os.hostname());
  console.log('  `localhost` resolve para: ' + resolvido.address
    + '  (o proprio container)');
  console.log('  o hostname do container resolve para: ' + proprio.address
    + '  — o mesmo endereco, quando a rede do Compose traz o apelido');
  console.log('  POR ISSO o app fala com o banco pelo NOME do servico:');
  console.log('  `DB_HOST: banco` no Compose, e `DB_HOST=banco` no processo.');
  console.log('  Com `localhost`, o app procuraria a si mesmo e o erro seria o');
  console.log('  mesmo `ECONNREFUSED` — so que apontando para o lado errado.');

  const alvo = interfaceNaoLoopback();
  console.log('\n  e o que o nome do servico e, na pratica:');
  try {
    await dns.lookup('banco');
    console.log('    `banco` resolve nesta maquina: existe aqui um container com esse nome');
  } catch (erro) {
    console.log('    `banco` NAO resolve nesta maquina: ' + erro.code
      + ' (' + erro.syscall + ' ' + erro.hostname + ')');
    console.log('    e o esperado: o apelido so existe DENTRO da rede do Compose.');
    console.log('    a maquina que roda o exemplo nao esta nessa rede — e e por isso');
    console.log('    que o exemplo mede o `ECONNREFUSED` e o `EAI_AGAIN` separados.');
  }

  await new Promise((r) => servidor.close(r));

  // -------------------------------------------------------------- ports
  console.log('\n=== 4. `ports`: publicar so no loopback ===');
  if (alvo) {
    console.log('  interface nao-loopback desta maquina: ' + alvo.nome + ' = ' + alvo.endereco);
    for (const host of ['0.0.0.0', '127.0.0.1']) {
      const { servidor: s, porta: p } = await escutaEm(host);
      const noLoopback = await falaCom('127.0.0.1', p);
      const naRede = await falaCom(alvo.endereco, p);
      const rotulo = host === '0.0.0.0' ? '"3000:3000"      ' : '"127.0.0.1:3000:3000"';
      console.log('  ' + rotulo + ' -> de 127.0.0.1: ' + (noLoopback.ok ? 'alcanca' : 'nao')
        + '   de ' + alvo.endereco + ': ' + (naRede.ok ? 'ALCANCA (exposto na rede)' : 'nao alcanca'));
      await new Promise((r) => s.close(r));
    }
    console.log('  a primeira linha e o que o `ports: - "3000:3000"` faz: qualquer');
    console.log('  maquina da rede chega na API. A segunda e o `127.0.0.1:` na frente:');
    console.log('  so a propria maquina chega, e e o que se quer enquanto o reverse');
    console.log('  proxy (nginx) nao esta na frente. Sem porta publicada, o app so e');
    console.log('  alcancavel por outro container da MESMA rede, pelo nome do servico.');
  } else {
    console.log('  (esta maquina nao tem interface nao-loopback; a medicao do');
    console.log('  0.0.0.0 contra a rede nao pode ser feita aqui)');
  }

  // ------------------------------------------------ a variavel de ambiente
  console.log('\n=== 5. `${DB_PASS}`: de onde vem, e por que o valor nao esta no arquivo ===');
  const variaveis = Object.keys(compose.services.banco.environment);
  const doBanco = variaveis.filter((k) => k.startsWith('MYSQL_'));
  const doApp = Object.keys(app.environment);
  console.log('  variaveis que o Compose preenche a partir do ambiente:');
  for (const k of doApp.concat(doBanco)) {
    const valor = app.environment[k] ?? compose.services.banco.environment[k];
    const casa = /^\$\{(\w+)\}/.exec(String(valor));
    if (casa) {
      console.log('    ' + k.padEnd(22) + valor + '   <- ' + casa[1] + ' do ambiente');
    }
  }
  console.log('  o valor da senha nao esta no arquivo e nao e impresso aqui: o');
  console.log('  Compose troca o `${DB_PASS}` pelo valor da variavel na hora de');
  console.log('  subir o container, e o valor vai para o ambiente do processo.');
  const tem = 'DB_PASS' in process.env;
  console.log('  `DB_PASS` esta no ambiente deste processo agora? ' + (tem ? 'sim' : 'nao')
    + (tem ? '' : ' (o harness le o `.env`; aqui o exemplo nao imprime o valor)'));
  console.log('  se a variavel faltasse, o Compose NAO falha: ele deixa a string');
  console.log('  vazia, e o banco sobe com senha vazia. Esse e o defeito que passa');
  console.log('  pelo log e so aparece na hora de conectar.');

  // -------------------------------------------- o volume e a persistencia
  console.log('\n=== 6. `volumes`: o que o `down` apaga e o que ele nao apaga ===');
  const declarados = Object.keys(compose.volumes || {});
  console.log('  volume nomeado declarado: ' + declarados.join(', '));
  console.log('  `docker compose down`    : remove containers e rede, MANTEM o volume');
  console.log('  `docker compose down -v`  : remove o volume tambem, e o banco perde tudo');
  console.log('  a pasta `/var/lib/mysql` do container e onde o dado fica; sem o');
  console.log('  volume, ela morre com o container, e todo `down` recomecia do zero.');
  console.log('  o mesmo dado que o `SELECT` le, e o que o volume guarda entre');
  console.log('  uma execucao e outra do Compose.');

  // ------------------------------------------------------ o erro do banco
  console.log('\n=== 7. a conexao real, do jeito que o app faria ===');
  console.log('  o exemplo roda FORA do Compose, entao a variavel de ambiente que ele');
  console.log('  leu e a do `.env` da aula do dia 6, e nao o `${DB_PASS}` do arquivo.');
  console.log('  no Compose o mesmo codigo leria DB_HOST=banco, o nome do servico;');
  console.log('  aqui le o que o `.env` traz, e a unica linha que muda.');
  console.log('');

  // A conexao e aberta aqui e fechada no `finally`: o exemplo nao depende da
  // conexao que o harness deixa pronta, e assim o `end()` e garantido mesmo
  // se a consulta falhar.
  const c = await createConnection(configDoBanco());
  try {
    const [versao] = await c.query('SELECT VERSION() AS versao, DATABASE() AS banco');
    console.log('  banco em uso: ' + versao[0].banco);
    console.log('  versao capturada com SELECT VERSION(): ' + versao[0].versao);
    console.log('  a versao e capturada, nunca escrita a mao: a do banco de quem');
    console.log('  le a pagina pode ser outra, e o exemplo imprime a que devolveu.');

    // A porta que o Compose publica no container e a que o MySQL escuta
    // dentro dele. As duas sao 3306 no exemplo, e a unica mudanca no Compose
    // e o valor de `DB_HOST`.
    const [porta] = await c.query('SELECT @@port AS porta, @@datadir AS datadir');
    console.log('  porta que o MySQL escuta DENTRO do container: ' + porta[0].porta);
    console.log('  `ports` do Compose mapeia essa porta para a do host — e o app');
    console.log('  continua falando em ' + porta[0].porta + ', nunca na porta do host.');
  } catch (erro) {
    console.log('  a consulta falhou: ' + (erro.code || erro.name) + ' - ' + erro.message);
  } finally {
    await c.end();
    console.log('  conexao fechada com end().');
  }
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

=== 1. o que o Compose le no arquivo ===
  services: -> 2 servicos: app, banco
  o leitor de YAML do exemplo montou a estrutura de verdade:

  app:
    imagem    : <nome-do-projeto>-app  (build local)
    build     : .
    ports     : 127.0.0.1:3000:3000
      "127.0.0.1:3000:3000"  =  127.0.0.1:3000  ->  container:3000
    env       : NODE_ENV=producao
    env       : DB_HOST=banco
    env       : DB_PORT=3306
    env       : DB_USER=materiais
    env       : DB_NAME=materiais_teste
    env       : DB_PASS  vem de ${DB_PASS}  (do ambiente, fora do arquivo)

  banco:
    imagem    : mysql:8  (imagem pronta)
    volume    : dados_banco -> /var/lib/mysql  (volume nomeado, declarado no topo)
    healthcheck: a cada 5s, com 10 tentativas, timeout 5s
      start_period 30s: o banco tem esse tempo para acordar sem contar tentativa
      pior caso: 50s antes de o Compose desistir
    env       : MYSQL_DATABASE=materiais_teste
    env       : MYSQL_USER=materiais
    env       : MYSQL_PASSWORD  vem de ${DB_PASS}  (do ambiente, fora do arquivo)
    env       : MYSQL_ROOT_PASSWORD  vem de ${DB_PASS}  (do ambiente, fora do arquivo)

  volume de topo declarado: dados_banco
  `dados_banco:` sem valor nao e erro: e a declaracao de que o
  volume existe, e o Compose cria se ainda nao existir. Sem ele, o
  banco perderia os dados a cada `docker compose down`.

=== 2. a ordem de subida, resolvida pelo `depends_on` ===
  app depende de banco  (espera o healthcheck passar)
    `depends_on` sozinho so espera o CONTAINER INICIAR: o
    processo do MySQL existe e a porta ainda nao aceita nada.
    com `condition: service_healthy`, o Compose espera o
    `healthcheck` passar, e so entao sobe o app.
  banco: nao depende de ninguem — sobe primeiro

  sem `condition: service_healthy`, o app subiria aqui:
    t=0    container do banco CRIADO (processo subindo, porta fechada)
    t=0    container do app CRIADO — e falha ao conectar no banco
  com `condition: service_healthy`:
    t=0    container do banco CRIADO
    t=5s  healthcheck 1: ainda nao
    ...    healthcheck repetido de 5s em 5s
    t=50s  banco respondendo -> so agora o app sobe
  o erro que o app sem esperar produz e o `ECONNREFUSED`:
    erro.code: ECONNREFUSED  errno: -111
    connect ECONNREFUSED 127.0.0.1:<porta livre>
  `ECONNREFUSED` e o erro de PORTA FECHADA, e ele sai na hora —
  nao ha espera nem timeout. O app que encontra isso e o app que
  subiu antes do banco estar aceitando conexao.
  e o que a conexao PELA REDE do Compose faz: o nome `banco` e o
  apelido do outro container, e o mesmo erro sai quando o nome nao
  resolve:

=== 3. `localhost` dentro do container aponta para ele mesmo ===
  hostname deste processo: vmi3339533
  servidor de teste escutando numa porta livre da maquina
  quem chega nele e quem responde:
    localhost           ALCANCOU
    127.0.0.1           ALCANCOU
    vmi3339533          recusado (ECONNREFUSED)
  `localhost` resolve para: ::1  (o proprio container)
  o hostname do container resolve para: 127.0.1.1  — o mesmo endereco, quando a rede do Compose traz o apelido
  POR ISSO o app fala com o banco pelo NOME do servico:
  `DB_HOST: banco` no Compose, e `DB_HOST=banco` no processo.
  Com `localhost`, o app procuraria a si mesmo e o erro seria o
  mesmo `ECONNREFUSED` — so que apontando para o lado errado.

  e o que o nome do servico e, na pratica:
    `banco` NAO resolve nesta maquina: EAI_AGAIN (getaddrinfo banco)
    e o esperado: o apelido so existe DENTRO da rede do Compose.
    a maquina que roda o exemplo nao esta nessa rede — e e por isso
    que o exemplo mede o `ECONNREFUSED` e o `EAI_AGAIN` separados.

=== 4. `ports`: publicar so no loopback ===
  interface nao-loopback desta maquina: eth0 = 161.97.108.223
  "3000:3000"       -> de 127.0.0.1: alcanca   de 161.97.108.223: ALCANCA (exposto na rede)
  "127.0.0.1:3000:3000" -> de 127.0.0.1: alcanca   de 161.97.108.223: nao alcanca
  a primeira linha e o que o `ports: - "3000:3000"` faz: qualquer
  maquina da rede chega na API. A segunda e o `127.0.0.1:` na frente:
  so a propria maquina chega, e e o que se quer enquanto o reverse
  proxy (nginx) nao esta na frente. Sem porta publicada, o app so e
  alcancavel por outro container da MESMA rede, pelo nome do servico.

=== 5. `${DB_PASS}`: de onde vem, e por que o valor nao esta no arquivo ===
  variaveis que o Compose preenche a partir do ambiente:
    DB_PASS               ${DB_PASS}   <- DB_PASS do ambiente
    MYSQL_PASSWORD        ${DB_PASS}   <- DB_PASS do ambiente
    MYSQL_ROOT_PASSWORD   ${DB_PASS}   <- DB_PASS do ambiente
  o valor da senha nao esta no arquivo e nao e impresso aqui: o
  Compose troca o `${DB_PASS}` pelo valor da variavel na hora de
  subir o container, e o valor vai para o ambiente do processo.
  `DB_PASS` esta no ambiente deste processo agora? sim
  se a variavel faltasse, o Compose NAO falha: ele deixa a string
  vazia, e o banco sobe com senha vazia. Esse e o defeito que passa
  pelo log e so aparece na hora de conectar.

=== 6. `volumes`: o que o `down` apaga e o que ele nao apaga ===
  volume nomeado declarado: dados_banco
  `docker compose down`    : remove containers e rede, MANTEM o volume
  `docker compose down -v`  : remove o volume tambem, e o banco perde tudo
  a pasta `/var/lib/mysql` do container e onde o dado fica; sem o
  volume, ela morre com o container, e todo `down` recomecia do zero.
  o mesmo dado que o `SELECT` le, e o que o volume guarda entre
  uma execucao e outra do Compose.

=== 7. a conexao real, do jeito que o app faria ===
  o exemplo roda FORA do Compose, entao a variavel de ambiente que ele
  leu e a do `.env` da aula do dia 6, e nao o `${DB_PASS}` do arquivo.
  no Compose o mesmo codigo leria DB_HOST=banco, o nome do servico;
  aqui le o que o `.env` traz, e a unica linha que muda.

  banco em uso: materiais_teste
  versao capturada com SELECT VERSION(): 10.11.14-MariaDB-0ubuntu0.24.04.1
  a versao e capturada, nunca escrita a mao: a do banco de quem
  le a pagina pode ser outra, e o exemplo imprime a que devolveu.
  porta que o MySQL escuta DENTRO do container: 3306
  `ports` do Compose mapeia essa porta para a do host — e o app
  continua falando em 3306, nunca na porta do host.
  conexao fechada com end().