Dia 10 — Docker e o banco em container
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:
| arquivo | modo | o que o uid 1000 faz |
|---|---|---|
| o código | 644 | lê, e não escreve |
o .env | 600 | Permission 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 comUSER noderesolve 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.EXPOSEeUSERnão ocupam camada;RUNeCOPYocupam, 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`.
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.code | significado | causa comum |
|---|---|---|
ECONNREFUSED | endereço resolveu, porta fechada | container subiu antes do banco, ou DB_HOST errado |
EAI_AGAIN / ENOTFOUND | o nome não resolveu | DB_HOST não é o nome de nenhum serviço |
ER_ACCESS_DENIED_ERROR | o banco recusou a senha | DB_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:
| campo | o que controla | no exemplo |
|---|---|---|
test | o comando que decide se está saudável | mysqladmin ping |
interval | de quanto em quanto tempo o comando roda | 5s |
timeout | quanto tempo o comando pode demorar antes de contar como falha | 5s |
retries | quantas falhas seguidas derrubam o serviço | 10 |
start_period | tempo inicial em que a falha não conta, para o banco poder acordar | 30s |
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().