Índice do curso
Lição 8 — Volumes e redes
Por Dorian Chávez · fundador e CEO da Hábil ·
É segunda-feira, 9h05 da manhã. Às 10h há uma reunião de revisão com a área de operações e pediram para levar o último relatório do revisor: quais serviços falharam durante o fim de semana. Na sexta à noite alguém atualizou o contêiner como sempre se faz: docker rm -f, docker run com a imagem nova, e para casa. O terminal é aberto, o relatório é procurado e não está lá. Não está no contêiner novo, que acaba de nascer, e o velho já não existe. Enquanto isso é assimilado, nota-se outra coisa: o contêiner novo diz FALLA para o serviço de teste, com uma mensagem que ainda não tinha aparecido: lookup curso-docker-prueba ... no such host. O serviço de teste está rodando, o docker ps o mostra, o curl da máquina chega até ele. Mas o revisor jura que ele não existe. São 9h12. Há dois problemas que parecem distintos e são a mesma pergunta: onde vive o que um contêiner precisa conservar, e por onde ele encontra os demais? Esta lição existe para que essa pergunta possa ser respondida antes das dez: o relatório é guardado em um lugar que sobrevive ao contêiner, com o dono correto, e os contêineres se encontram por nome em uma rede definida por quem opera, com a parte que não precisa de internet trancada em uma rede sem saída.
Tempo: 2 × 45 min
O que se constrói: o relatório que sobrevive e dois contêineres que conversam. A imagem curso-docker-revisor:1.5.0 traz uma pasta /datos que pertence ao usuário sem privilégios 65532 e guarda ali seu relatório por padrão. Um volume nomeado, curso-docker-reportes, é montado nessa pasta e conserva o relatório mesmo que o contêiner seja apagado. O serviço de teste vive apenas em uma rede interna, curso-docker-interna, sem saída para a internet; o revisor vive em uma rede definida por quem opera, curso-docker-red, por onde expõe sua porta, e se conecta também à interna para verificar o serviço de teste pelo nome.
O que se aprende: onde um contêiner escreve e por que isso se perde; os três tipos de montagem (volumes nomeados, bind mounts e tmpfs) e quando usar cada um; -v versus --mount; quem é o dono dos arquivos quando o processo não é root, e como corrigir isso a partir da imagem e não com root; o que acontece com o conteúdo de uma pasta quando se monta algo por cima (às vezes é copiado, às vezes é coberto); a rede padrão e por que ali não há nomes; as redes definidas pelo usuário e seu DNS interno; e as redes internas, sem saída.
De onde se parte. Da Lição 7 traz-se a imagem curso-docker-revisor:1.4.0, com seu HEALTHCHECK e seu STOPSIGNAL, e a forma de rodá-la reforçada: sistema de arquivos somente leitura, sem capabilities, sem escalada de privilégios, com --init e com limites. Traz-se também uma promessa: na Lição 7 o relatório era guardado em um tmpfs, que morre com o contêiner, e ficou pendente dar-lhe um lugar que dure. Da Lição 6 traz-se a montagem de um arquivo avulso (o token em /run/secrets/), usada sem explicação a fundo, e o erro de permissões com o usuário 65532: hoje esse mesmo erro volta, em uma pasta inteira. Da Lição 2 traz-se a imagem curso-docker-prueba:1.0.0 e o costume de publicar portas só em 127.0.0.1. E da Lição 3, uma frase que hoje ganha sentido: o revisor verificava a si mesmo «porque ainda não há rede entre contêineres».
O que esta lição não faz. Não usa o Compose: tudo é feito com docker volume, docker network e docker run, para que se veja cada peça; a Lição 9 escreve o mesmo em um compose.yaml. Não ensina drivers de volumes remotos (armazenamento em rede, na nuvem) nem redes que cruzam várias máquinas (overlay): este curso trabalha em uma única máquina, e o que se aprende aqui (o dono dos arquivos, o DNS por nome, a rede sem saída) é o que essas outras ferramentas também exigem. Não usa a rede host a não ser para dizer por que ela não se usa. E não faz backup nem restaura volumes com ferramentas especiais: o relatório é lido com um contêiner auxiliar, que é a forma de olhar dentro de um volume que funciona em qualquer máquina.
Sobre as execuções. Todas as saídas são reais e foram obtidas em 21 de outubro de 2026 em um Mac com Colima, que sobe uma máquina virtual com Ubuntu 24.04.4, a mesma base do Linux Mint 22, o sistema de referência do curso, com kernel 6.8 e processador arm64. Os scripts foram executados dentro dessa máquina virtual, com Docker 29.2.1 (cliente e daemon) e Buildx v0.31.1, e com a pasta de trabalho no disco próprio do Linux. Isso importa muito nesta lição, que trata de donos e permissões: as pastas compartilhadas entre o Mac e a máquina virtual mentem sobre eles, e nenhum exemplo de donos foi medido em uma dessas pastas. No Mint esse problema não existe. Duas diferenças que se verão com os próprios olhos: na máquina virtual o usuário que executou os scripts é o 501, com grupo 1000, de modo que onde a saída diz 501:1000 o Mint dirá quase certamente 1000:1000, o primeiro usuário criado ao instalar; e as datas dos ls -l estão na hora da máquina virtual, que é a do meridiano de Greenwich. Para não colidir com outras execuções que usavam a mesma máquina, os scripts foram lançados com outro prefixo de nomes e outra porta (as variáveis PREFIJO e PUERTO que eles trazem). Como nesta lição os nomes de redes, volumes e contêineres aparecem nas saídas (o DNS os usa), os scripts que mostram nomes trazem uma função nombres que, à vista, volta a escrever esse prefixo como curso-docker-; com o prefixo padrão não muda nada, de modo que o que se vê é o que se verá na máquina de quem estuda. Com seus valores padrão, os scripts usam nomes de apoio próprios que levam fig08 (a imagem curso-docker-revisor:fig08, e :fig08-l7, a 1.4.0 que o fig08_02.sh constrói; o contêiner curso-docker-revisor-fig08, o volume curso-docker-reportes-fig08, as redes curso-docker-red-fig08 e curso-docker-interna-fig08): assim nunca tocam na imagem curso-docker-revisor:1.5.0, no contêiner curso-docker-revisor, no volume curso-docker-reportes nem nas redes do projeto, e ao sair apagam só o que criaram. Pela mesma razão publicam sua porta, por padrão, em 127.0.0.1:8081, e não na 8080 do revisor: assim podem ser executados com o estado final no ar (se a 8081 estiver ocupada, passe-lhes outra, por exemplo PUERTO=8082 ./fig08_03.sh; a porta não aparece nas saídas). Usam, sem modificá-las, duas imagens que já estão na máquina: curso-docker-prueba:1.0.0, da Lição 2, e alpine:3.24.2, um contêiner auxiliar com shell para olhar volumes e redes, porque as duas imagens do curso não têm shell. O curso foi planejado com Docker Engine 29.8.2, e a mais recente publicada naquele dia era a 29.9.2, de 20 de outubro de 2026 (notas de versão); nada desta lição muda entre essas versões. O que muda de uma execução para outra, e é avisado onde aparece: os tempos da tabela do revisor (ocultados com <t>), o número do arquivo temporário do relatório (ocultado com <n>), os endereços IP dos contêineres e o do DNS da máquina (ocultados com <IP> e <DNS del anfitrión>), os identificadores de contêiner (<id>) e as datas. Tudo é ocultado no próprio script, à vista, nunca editando a saída. Os programas estão em programas/08-volumenes-y-redes/, cada um com sua saída ao lado, e estado/ guarda os arquivos do projeto como ficam ao terminar.
Ao terminar, será possível
- Explicar por que o que um contêiner escreve em seu próprio sistema de arquivos se perde com
docker rm, e escolher entre um volume nomeado, um bind mount e umtmpfspara cada dado dorevisor. - Provocar e diagnosticar o
permission deniedde um volume novo com um processo sem privilégios, e corrigi-lo na imagem comCOPY --chown, sem rodar comoroot. - Demonstrar que o relatório sobrevive ao
docker rmlendo-o a partir de um contêiner auxiliar. - Prever o que acontece com o conteúdo de uma pasta quando se monta por cima um volume novo, um volume com dados ou um bind mount, e explicar a diferença entre
-ve--mountquando a pasta da máquina não existe. - Rodar o
revisorcom um bind mount de uma pasta própria, acertando o dono com--user, e montar como somente leitura o que o contêiner não deve mudar. - Mostrar que na rede padrão um contêiner não encontra outro pelo nome, e que em uma rede definida pelo usuário encontra, lendo qual servidor DNS cada um usa.
- Trancar o serviço de teste em uma rede interna sem saída para a internet e conectar o
revisoràs duas redes, sem reiniciá-lo.
O porquê antes do como
Até agora cada contêiner do curso foi uma ilha descartável. Era criado, perguntava-se algo a ele, era apagado. Isso foi bom para aprender, porque um contêiner descartável não pode guardar surpresas. Mas um serviço de verdade tem duas necessidades que uma ilha não cobre.
A primeira é guardar algo que dure mais que ele. O revisor escreve um relatório; esse relatório é justamente o que alguém vai pedir na segunda-feira. E um contêiner foi feito para ser substituído: cada atualização, cada mudança de configuração, cada reinício da máquina com --rm acaba em um contêiner novo. A documentação do Docker diz isso sem rodeios: o que se escreve na camada do contêiner «não persiste quando o contêiner é destruído», e além disso «pode ser difícil tirar os dados do contêiner se outro processo precisar deles» (Docker, «Storage»). Se o relatório vive dentro do contêiner, vive o que o contêiner viver. E desde a Lição 7 o problema é ainda mais direto: o sistema de arquivos do revisor é somente leitura. Não é que o relatório vá se perder; é que nem sequer se pode escrevê-lo. Faz falta um lugar gravável, fora do contêiner, que o revisor possa usar sem deixar de ser somente leitura.
A segunda é falar com outros contêineres. O revisor existe para verificar serviços, e até hoje só verificou a si mesmo (propio http://127.0.0.1:8080/salud), porque dentro de um contêiner 127.0.0.1 é o próprio contêiner. Para verificar o serviço de teste ele precisa chegar a outro contêiner. Poderia usar o endereço IP, mas os endereços são distribuídos pelo Docker ao iniciar e mudam; ninguém quer escrever em REVISOR_SERVICIOS um endereço que deixa de valer no reinício seguinte. O que se quer é o que qualquer programa na internet faz: usar um nome. E assim que os contêineres se falam, aparece a pergunta de segurança da lição: se o serviço de teste só existe para que o revisor o consulte, por que ele deveria poder sair para a internet?
Então a lição tem duas metades, e as duas seguem o mesmo caminho: primeiro o erro, depois a correção.
- Armazenamento (8.1 a 8.7): onde um contêiner escreve, os três tipos de montagem, o erro de dono que se vai encontrar com um usuário sem privilégios e sua correção na imagem, o que acontece com o que já havia na pasta, e o
tmpfspara o que não deve durar. - Redes (8.8 a 8.11): a rede padrão, que não tem nomes; a rede definida por quem opera, que tem; e a rede interna, que não tem saída.
Com esse destino em mente, isto é o que se terá no final, na seção 8.10: o serviço de teste em uma rede sem saída, o revisor nas duas, encontrando-o pelo nome, e um contêiner que tenta sair para a internet a partir da rede interna e não consegue sequer resolver o nome do site:
== 3. Conectar el revisor también a la interna, sin reiniciarlo
SERVICIO ESTADO TIEMPO DETALLE
prueba OK <t> 200
curso-docker-interna-fig08 curso-docker-red-fig08
== 4. Desde la red interna no hay salida a internet
wget: bad address 'www.docker.com'
[código de salida: 1]
Os conceitos
8.1 Onde um contêiner escreve
8.1.1 A camada gravável, e por que ela não serve para guardar
Na Lição 4 viu-se que uma imagem é uma pilha de camadas somente leitura. Quando o Docker cria um contêiner, coloca por cima uma camada a mais, gravável e própria desse contêiner. Tudo o que o processo escreve (um arquivo novo, um arquivo modificado, um arquivo apagado) fica nessa camada; as da imagem não são tocadas, e por isso cem contêineres podem sair da mesma imagem sem interferir uns nos outros.
Essa camada tem três problemas para guardar um relatório:
- Morre com o contêiner. O
docker rma apaga. Odocker run --rma apaga ao sair. Uma atualização que substitui o contêiner a joga fora. - É difícil tirar algo dela. Está em algum lugar do armazenamento interno do Docker, com um caminho que não é para uso direto. Outro contêiner não consegue lê-la.
- No
revisorreforçado ela não existe para escrever. Com--read-only, a camada gravável não aceita escritas. É justo o que se queria na Lição 7: quem tomar o controle do processo não pode escrever no disco. Mas o própriorevisortambém não.
A saída é montar algo por cima de uma pasta do contêiner: um lugar gravável que não faz parte da camada do contêiner, que tem seu próprio ciclo de vida e que é montado só onde faz falta. O resto do sistema de arquivos continua somente leitura.
8.1.2 Os três tipos de montagem
O Docker oferece três tipos de montagem de interesse aqui (há outros dois que não serão usados: os named pipes, só do Windows, e as montagens de imagem, que colocam o conteúdo de outra imagem dentro do contêiner e exigem o armazenamento de imagens do containerd). A documentação os apresenta assim (Docker, «Storage»):
| Tipo | O que é | Onde vivem os dados | Quanto duram | Para quê |
|---|---|---|---|---|
| Volume (nomeado) | armazenamento «administrado pelo daemon do Docker» | em uma pasta do armazenamento do Docker, que não se toca | até que o volume seja apagado, mesmo que todos os contêineres sejam apagados | dados do serviço que devem durar: o relatório |
| Bind mount | «um vínculo direto» entre um caminho da máquina e um do contêiner | em uma pasta própria, onde se disser | o que durar essa pasta | arquivos que a pessoa e o contêiner devem ver: código em desenvolvimento, um arquivo de configuração, o token da Lição 6 |
tmpfs |
arquivos «diretamente na memória» da máquina | na memória | até que o contêiner pare | temporários que não devem durar nem tocar o disco |
A mesma página dá a recomendação curta: os volumes são para o armazenamento de longo prazo; os bind mounts, «quando é preciso acessar os arquivos tanto do contêiner quanto da máquina»; e os tmpfs, «apenas quando os dados não precisam persistir além da sessão do contêiner». No revisor isso se traduz em uma regra simples: o relatório vai em um volume. Os bind mounts já foram usados para o token e serão usados hoje para entender o problema dos donos; o tmpfs, para o que não deve sobreviver.
8.1.3 Duas formas de escrever o mesmo: -v e --mount
O Docker aceita duas sintaxes para montar, e elas aparecem por toda parte:
-v origem:destino[:opções](ou--volume). Curta. Se a origem é um nome (curso-docker-reportes), é um volume; se é um caminho absoluto (/home/usuario/reportes), é um bind mount. As opções vão no final, separadas por vírgulas:ropara somente leitura, por exemplo.--mount type=…,src=…,dst=…[,opções]. Longa, com nomes em cada campo:type=volume,type=bindoutype=tmpfs, e depoissrc(a origem),dst(o destino),readonly, e as opções próprias de cada tipo.
A documentação de volumes diz que, «em geral, --mount é preferível», porque «é mais explícito e admite todas as opções disponíveis» (Docker, «Volumes»). --mount admite todas as opções de cada tipo de montagem; -v só algumas. Para um volume, -v admite poucas: as de acesso (ro e rw), as de rotulagem do SELinux (z e Z, que no Mint não fazem falta) e a de não copiar o conteúdo da imagem, que será vista em 8.5. Essa segunda tem uma armadilha medida: a página de volumes a chama de volume-nocopy também com -v, mas o Docker 29.2.1 rejeita esse nome com invalid mode: volume-nocopy; com -v se escreve nocopy (-v vol:/datos:nocopy), e com --mount, volume-nocopy. Vê-se isso no fig08_09.sh, a solução do Exercício 4. E há uma diferença de comportamento que importa: quando a pasta da máquina não existe, -v a cria e --mount, por padrão, se recusa (8.5). Este curso usa -v para os volumes nomeados, porque é o que se lê na maioria dos exemplos e é igualmente claro (-v curso-docker-reportes:/datos não deixa dúvidas), e --mount para os bind mounts e os tmpfs, onde sua precisão evita erros.
8.2 Volumes nomeados
8.2.1 Criar, olhar, apagar
Um volume é criado com docker volume create <nome>, listado com docker volume ls, inspecionado com docker volume inspect e apagado com docker volume rm. Se em um docker run se monta um volume que não existe, o Docker o cria naquele momento; mesmo assim, criá-lo antes com seu nome deixa claro que é algo que se decidiu ter, e não um efeito colateral.
Os volumes do driver padrão, local, vivem em uma pasta da máquina dentro do armazenamento do Docker: a documentação mostra o caminho /var/lib/docker/volumes/<nome>/_data (Docker, «Volumes»). Essa pasta é do Docker e do root. Pode ser vista com sudo, mas não é para ser mexida à mão: para olhar dentro de um volume, o curso usa um contêiner auxiliar, um alpine descartável que monta o volume como somente leitura e roda ls ou cat. Funciona igual no Mint, em um Mac ou em um servidor, não precisa de sudo e não há como estragar os dados.
Um volume não é apagado com o contêiner. Essa é a sua razão de existir. Nem docker rm, nem docker rm -f, nem docker stop o tocam. A única exceção são os volumes anônimos (os criados sem nome, por exemplo com -v /datos sem origem): a documentação diz que esses são destruídos com o contêiner somente se ele foi criado com --rm (Docker, «Volumes»). Um volume nomeado vive até que seja apagado com docker volume rm. Por isso o curso sempre lhes dá nome, e com o prefixo curso-docker-: um volume anônimo é um identificador de 64 caracteres de que ninguém lembra de quem é, e que acaba ocupando disco para sempre.
8.3 O erro: um volume novo sobre uma pasta que a imagem não traz
8.3.1 Provocá-lo
Com o que foi visto, o plano parece óbvio: criar um volume, montá-lo em /datos, dizer ao revisor que guarde ali seu relatório com REVISOR_REPORTE=/datos/reporte.json. O script fig08_02.sh faz exatamente isso com a imagem da Lição 7, reforçada como ficou:
#!/usr/bin/env bash
# fig08_02.sh — El error: un volumen nuevo sobre una carpeta que la imagen NO trae.
# Construye el revisor de la Lección 7 (la 1.4.0, sin /datos), le monta un volumen
# con nombre recién creado en /datos y le pide guardar su reporte ahí.
set -euo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
# Nombres de apoyo con «fig08»: el guion nunca toca tu curso-docker-revisor:1.4.0,
# tu contenedor curso-docker-revisor ni tu volumen curso-docker-reportes, y al
# salir borra solo lo que creó.
PUERTO="${PUERTO:-8081}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
IMAGEN="${PREFIJO}revisor:fig08-l7"
NOMBRE="${PREFIJO}revisor-fig08"
VOLUMEN="${PREFIJO}reportes-fig08"
AYUDANTE=alpine:3.24.2
trap 'docker rm -f "$NOMBRE" "$NOMBRE-mira" >/dev/null 2>&1 || true
docker volume rm "$VOLUMEN" >/dev/null 2>&1 || true
docker rmi -f "$IMAGEN" >/dev/null 2>&1 || true
rm -rf "$TRABAJO"' EXIT
# El proyecto: el código que entrega el curso + el Dockerfile de la Lección 7.
cp -R "$AQUI/../revisor/." "$TRABAJO/"
cp "$AQUI/estado/.dockerignore" "$TRABAJO/"
cp "$AQUI/../07-salud-y-ciclo-de-vida/estado/Dockerfile" "$TRABAJO/Dockerfile"
cd "$TRABAJO"
docker build -q --build-arg VERSION=1.4.0 -t "$IMAGEN" . >/dev/null
ENDURECIDO="--read-only --cap-drop ALL --security-opt no-new-privileges --init --memory 64m --cpus 0.5 --pids-limit 50"
esperar_http() {
for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
echo "(no respondió $1)"; return 1
}
# Si corres el guion con otro PREFIJO, sus nombres se escriben como los del curso.
# Con el prefijo por omisión esto no cambia nada.
nombres() { sed "s/${PREFIJO}/curso-docker-/g"; }
# El número del archivo temporal cambia en cada corrida: se tapa a la vista.
tapar() { sed -E 's/reporte-[0-9]+\.tmp/reporte-<n>.tmp/'; }
echo "== 1. ¿La imagen de la Lección 7 trae una carpeta /datos?"
docker create --name "$NOMBRE-mira" "$IMAGEN" >/dev/null
docker export "$NOMBRE-mira" | tar -tv --numeric-owner | grep -c ' datos/' || true
docker rm "$NOMBRE-mira" >/dev/null
echo
echo "== 2. Un volumen con nombre, nuevo y vacío"
docker volume create "$VOLUMEN" | nombres
docker volume inspect --format 'Driver={{.Driver}} Mountpoint={{.Mountpoint}}' "$VOLUMEN" | nombres
echo
echo "== 3. El revisor de la Lección 7, endurecido, con el volumen en /datos"
docker run -d --name "$NOMBRE" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO \
-v "$VOLUMEN":/datos -e REVISOR_REPORTE=/datos/reporte.json \
-e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
curl -s -w ' [%{http_code}]\n' "http://127.0.0.1:$PUERTO/api/estados" | tapar
echo "-- /salud no escribe nada, así que sigue contestando:"
curl -s -w ' [%{http_code}]\n' "http://127.0.0.1:$PUERTO/salud"
echo
echo "== 4. ¿De quién es /datos? Lo mira un contenedor ayudante, de solo lectura"
docker run --rm -v "$VOLUMEN":/datos:ro "$AYUDANTE" ls -lnd /datos
echo "-- y el revisor corre como:"
docker inspect --format '{{.Config.User}}' "$NOMBRE"
$ ./fig08_02.sh
== 1. ¿La imagen de la Lección 7 trae una carpeta /datos?
0
== 2. Un volumen con nombre, nuevo y vacío
curso-docker-reportes-fig08
Driver=local Mountpoint=/var/lib/docker/volumes/curso-docker-reportes-fig08/_data
== 3. El revisor de la Lección 7, endurecido, con el volumen en /datos
{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: permission denied"}
[500]
-- /salud no escribe nada, así que sigue contestando:
{"estado":"vivo","version":"1.4.0"}
[200]
== 4. ¿De quién es /datos? Lo mira un contenedor ayudante, de solo lectura
drwxr-xr-x 2 0 0 4096 Oct 21 09:58 /datos
-- y el revisor corre como:
65532:65532
As linhas novas do script, antes de ler a saída. O docker create cria um contêiner sem iniciá-lo, e o docker export extrai seu sistema de arquivos completo como um arquivo tar; o tar -tv o lista sem desempacotar, e --numeric-owner mostra os donos como números. É a forma de olhar que pastas uma imagem traz quando ela não tem shell para rodar ls. O docker volume inspect mostra o que o Docker sabe de um volume. E -v "$VOLUMEN":/datos:ro no auxiliar monta o volume como somente leitura: o auxiliar apenas olha.
Agora a saída, bloco por bloco:
- A imagem da Lição 7 não traz
/datos. A contagem de linhas dotarque terminam emdatos/é0. Ninguém a criou: na Lição 7 o relatório ia para/tmp. - O volume é novo e está vazio. Seu driver é
locale ele vive em/var/lib/docker/volumes/curso-docker-reportes-fig08/_data, o caminho que a documentação indica. - O
revisorinicia, responde/salude falha ao guardar./api/estadosresponde500compermission deniedao abrir o arquivo temporário do relatório. (Lembre-se, da Lição 3, de que orevisorescreve primeiro um arquivo temporário na mesma pasta e depois o renomeia: por isso o erro menciona.reporte-<n>.tmpe nãoreporte.json.)/saludcontinua em200, porque não escreve nada; e como oHEALTHCHECKusa/salud, o Docker diria que este contêiner estáhealthy. Rodando, saudável para o Docker, e sem poder fazer seu trabalho: o segundo critério do curso, outra vez. - A pasta é do
root. O auxiliar mostra/datoscom dono0e grupo0, permissõesdrwxr-xr-x: só o dono escreve. E orevisorroda como65532:65532.
8.3.2 Por que acontece
Quando se monta um volume em um caminho que a imagem não traz, o Docker precisa criar esse ponto de montagem, e o volume novo nasce com uma pasta raiz que pertence ao root. Ninguém disse outra coisa. Enquanto o processo do contêiner foi root (como em tantos tutoriais), isso não se notava: o root escreve onde quer. Mas o revisor deixou de ser root na Lição 3, e um usuário sem privilégios não ignora as permissões de ninguém. Então o kernel faz o que faria com qualquer usuário da máquina que tenta escrever em uma pasta alheia: nega.
É o mesmo erro da Lição 6 com o arquivo do token, em outra forma. Lá o arquivo era da pessoa e o processo, 65532; aqui a pasta é do root e o processo, 65532. A pergunta que resolve é a mesma: quem é o dono, e com que usuário roda o processo?
Há três correções que se veem na internet, e só uma é boa:
- Rodar como
root(--user 0). Funciona e desfaz o trabalho de cinco lições. Não. - Mudar o dono do volume por fora, com um contêiner auxiliar que rode
chown 65532:65532 /datosantes de iniciar orevisor. Funciona, mas é um passo a mais que alguém tem de lembrar em cada máquina, e um contêiner que roda comorootsó para isso. - Fazer a imagem trazer a pasta, com o dono correto. É a correção desta lição, e funciona por uma regra dos volumes que se explica em 8.4.
8.4 A correção: a imagem traz /datos, do usuário correto
8.4.1 A regra que a torna possível
A documentação de volumes descreve o que acontece quando se monta um volume vazio em uma pasta do contêiner que já tem arquivos ou pastas: «esses arquivos ou pastas são propagados (copiados) para o volume» (Docker, «Volumes»). A documentação fala do conteúdo; o que ela não diz, e será medido em 8.4.3, é que a cópia leva também o dono e as permissões da pasta da imagem. Se a imagem traz /datos com dono 65532, um volume novo montado ali nasce com dono 65532. A correção, então, não está no docker run: está no Dockerfile.
8.4.2 O Dockerfile desta lição
Este é o Dockerfile com que a lição termina. É o da Lição 7 com três mudanças, marcadas abaixo:
# syntax=docker/dockerfile:1
# fig08_01.Dockerfile — Lección 8: la imagen trae la carpeta /datos, con el dueño
# correcto, para que un volumen nuevo herede ese dueño. Parte de la Lección 7.
FROM golang:1.27.3-trixie@sha256:604106102d33b1084e37ca08adf98ac0813624ae8595bd0d7f3965ecebded09c AS compilacion
WORKDIR /src
COPY go.mod ./
COPY cmd/ cmd/
COPY internal/ internal/
FROM compilacion AS pruebas
RUN --mount=type=cache,id=curso-docker-go-build,target=/root/.cache/go-build \
go test ./...
FROM pruebas AS binario
# ARG existe solo mientras se construye. Aquí se usa para grabar la versión
# dentro del binario; /salud la devuelve.
ARG VERSION=desarrollo
# Un secreto de CONSTRUCCIÓN (por ejemplo, credenciales para módulos privados)
# se monta solo durante este RUN y no queda en ninguna capa. Este curso no usa
# módulos privados: el montaje es opcional (required=false) y queda como patrón.
RUN --mount=type=cache,id=curso-docker-go-build,target=/root/.cache/go-build \
--mount=type=secret,id=netrc,target=/root/.netrc,required=false \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
-o /out/revisor ./cmd/revisor \
&& mkdir -p /out/datos
FROM gcr.io/distroless/static-debian13:nonroot@sha256:516757106de35205efb70b91ba03f6f1298424a0678c603255cf2852a7000087 AS final
ARG VERSION=desarrollo
LABEL org.opencontainers.image.title="revisor" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.source="https://www.habil.mx/es/cursos/docker/"
# ENV solo para valores por omisión que NO son secretos.
ENV REVISOR_PUERTO=8080 \
REVISOR_REPORTE=/datos/reporte.json
COPY --from=binario /out/revisor /revisor
# La carpeta de datos, del usuario sin privilegios. Un volumen con nombre que se
# monte aquí por primera vez copia este dueño.
COPY --from=binario --chown=65532:65532 /out/datos /datos
USER 65532:65532
EXPOSE 8080
# La salud la revisa el propio binario: la imagen no tiene shell ni curl.
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD ["/revisor", "healthcheck"]
# Señal de apagado explícita (es la de omisión; escribirla documenta la intención).
STOPSIGNAL SIGTERM
ENTRYPOINT ["/revisor"]
As três mudanças:
&& mkdir -p /out/datosno estágiobinario. Cria uma pasta vazia junto ao binário. É criada no estágio de compilação porque a imagem final, distroless, não tem shell nemmkdir: não há com que criar uma pasta ali. Acrescenta-se ao mesmoRUNque compila para não somar uma camada a mais a esse estágio; dá no mesmo na imagem final, que só recebe o que é copiado.COPY --from=binario --chown=65532:65532 /out/datos /datosno estágiofinal. Copia essa pasta vazia para/datose lhe dá dono e grupo65532. Segundo a referência do Dockerfile,--chown«define o dono dos arquivos copiados», e sem essa opção eles ficam com usuário e grupo0(referência do Dockerfile). Usam-se números e não nomes porque a imagem final não tem por que saber quem énonroot; o número é o que o kernel compara.REVISOR_REPORTE=/datos/reporte.jsonnoENV. O valor padrão de onde o relatório é guardado. É configuração, não segredo (Lição 6), e vale em qualquer ambiente, porque a pasta vem na imagem. Quem rodar a imagem sem dizer nada guarda em/datos, e se montar ali um volume, o relatório dura.
Essa terceira mudança tem uma consequência que convém ver antes que surpreenda. A imagem 1.5.0 espera que /datos seja gravável. Se for rodada com --read-only e sem volume, como na Lição 7, /api/estados volta a falhar, agora com read-only file system. Isso se verá no bloco 6 do fig08_03.sh, em 8.4.3.
Não é um defeito: é a imagem dizendo onde guarda. A partir desta lição, o revisor reforçado roda com algo gravável montado em /datos: quase sempre um volume nomeado, e um bind mount quando outra ferramenta da máquina precisa ler os relatórios (8.6). Isso se verá em todos os scripts seguintes, inclusive os de redes, e no compose.yaml da Lição 9.
8.4.3 Vê-lo funcionar, e vê-lo sobreviver
O script fig08_03.sh constrói a imagem 1.5.0, olha seu /datos, monta nela um volume novo e depois apaga o contêiner para ver o que sobra:
#!/usr/bin/env bash
# fig08_03.sh — El arreglo: la imagen 1.5.0 trae /datos del usuario 65532, y un
# volumen nuevo copia ese dueño. Después, el reporte sobrevive a docker rm, y un
# volumen en uso no se deja borrar.
set -euo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
# Nombres de apoyo con «fig08»: el guion nunca toca tu curso-docker-revisor:1.5.0,
# tu contenedor curso-docker-revisor ni tu volumen curso-docker-reportes, y al
# salir borra solo lo que creó.
PUERTO="${PUERTO:-8081}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
IMAGEN="${PREFIJO}revisor:fig08"
NOMBRE="${PREFIJO}revisor-fig08"
VOLUMEN="${PREFIJO}reportes-fig08"
AYUDANTE=alpine:3.24.2
trap 'docker rm -f "$NOMBRE" "$NOMBRE-mira" >/dev/null 2>&1 || true
docker volume rm "$VOLUMEN" >/dev/null 2>&1 || true
docker rmi -f "$IMAGEN" >/dev/null 2>&1 || true
rm -rf "$TRABAJO"' EXIT
cp -R "$AQUI/../revisor/." "$TRABAJO/"
cp "$AQUI/estado/.dockerignore" "$TRABAJO/"
cp "$AQUI/fig08_01.Dockerfile" "$TRABAJO/Dockerfile"
cd "$TRABAJO"
ENDURECIDO="--read-only --cap-drop ALL --security-opt no-new-privileges --init --memory 64m --cpus 0.5 --pids-limit 50"
esperar_http() {
for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
echo "(no respondió $1)"; return 1
}
# Si corres el guion con otro PREFIJO, sus nombres se escriben como los del curso.
nombres() { sed "s/${PREFIJO}/curso-docker-/g"; }
# Los identificadores de contenedor cambian en cada corrida: se tapan a la vista.
tapar_id() { sed -E 's/[0-9a-f]{64}/<id>/g'; }
revisor() {
docker run -d --name "$NOMBRE" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO \
-v "$VOLUMEN":/datos -e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
}
echo "== 1. Construir la 1.5.0 y mirar /datos dentro de la imagen"
docker build -q --build-arg VERSION=1.5.0 -t "$IMAGEN" . >/dev/null
docker image ls "$IMAGEN" --format 'tamaño: {{.Size}}'
docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$IMAGEN" | grep REVISOR
docker create --name "$NOMBRE-mira" "$IMAGEN" >/dev/null
docker export "$NOMBRE-mira" | tar -tv --numeric-owner | grep ' datos/' | awk '{print $1, $2, $NF}'
docker rm "$NOMBRE-mira" >/dev/null
echo
echo "== 2. Un volumen nuevo, montado en /datos: hereda el dueño de la imagen"
docker volume create "$VOLUMEN" >/dev/null
revisor
curl -s -o /dev/null -w '/api/estados: [%{http_code}]\n' "http://127.0.0.1:$PUERTO/api/estados"
docker run --rm -v "$VOLUMEN":/datos:ro "$AYUDANTE" ls -lnd /datos
docker run --rm -v "$VOLUMEN":/datos:ro "$AYUDANTE" ls -ln /datos
echo
echo "== 3. Un volumen en uso no se deja borrar"
salida=$(docker volume rm "$VOLUMEN" 2>&1) && rc=0 || rc=$?
echo "$salida" | nombres | tapar_id
echo "[código de salida: $rc]"
echo
echo "== 4. Borrar el contenedor: el volumen se queda, y el reporte con él"
docker rm -f "$NOMBRE" >/dev/null
echo "contenedores con ese nombre: $(docker ps -a --filter "name=^${NOMBRE}\$" --format '{{.Names}}' | wc -l)"
docker volume ls --filter "name=^${VOLUMEN}\$" --format 'volumen: {{.Name}} ({{.Driver}})' | nombres
docker run --rm -v "$VOLUMEN":/datos:ro "$AYUDANTE" cat /datos/reporte.json
echo
echo "== 5. Un contenedor nuevo con el mismo volumen sigue donde iba el otro"
revisor
docker run --rm -v "$VOLUMEN":/datos:ro "$AYUDANTE" ls -ln /datos | awk 'NR>1 {print $1, $3, $4, $NF}'
curl -s -o /dev/null -w '/api/estados: [%{http_code}]\n' "http://127.0.0.1:$PUERTO/api/estados"
docker inspect --format '{{range .Mounts}}{{.Type}} {{.Name}} -> {{.Destination}} (escribible: {{.RW}}){{end}}' "$NOMBRE" | nombres
docker rm -f "$NOMBRE" >/dev/null
echo
echo "== 6. La 1.5.0 endurecida SIN volumen en /datos: la raíz es de solo lectura"
docker run -d --name "$NOMBRE" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO \
-e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
curl -s -w ' [%{http_code}]\n' "http://127.0.0.1:$PUERTO/api/estados" | sed -E 's/reporte-[0-9]+\.tmp/reporte-<n>.tmp/'
$ ./fig08_03.sh
== 1. Construir la 1.5.0 y mirar /datos dentro de la imagen
tamaño: 16.2MB
REVISOR_PUERTO=8080
REVISOR_REPORTE=/datos/reporte.json
drwxr-xr-x 65532/65532 datos/
== 2. Un volumen nuevo, montado en /datos: hereda el dueño de la imagen
/api/estados: [200]
drwxr-xr-x 2 65532 65532 4096 Oct 21 13:10 /datos
total 4
-rw------- 1 65532 65532 92 Oct 21 13:10 reporte.json
== 3. Un volumen en uso no se deja borrar
Error response from daemon: remove curso-docker-reportes-fig08: volume is in use - [<id>]
[código de salida: 1]
== 4. Borrar el contenedor: el volumen se queda, y el reporte con él
contenedores con ese nombre: 0
volumen: curso-docker-reportes-fig08 (local)
[
{
"servicio": "propio",
"ok": true,
"codigo": 200,
"tiempo_ms": 1
}
]
== 5. Un contenedor nuevo con el mismo volumen sigue donde iba el otro
-rw------- 65532 65532 reporte.json
/api/estados: [200]
volume curso-docker-reportes-fig08 -> /datos (escribible: true)
== 6. La 1.5.0 endurecida SIN volumen en /datos: la raíz es de solo lectura
{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: read-only file system"}
[500]
Bloco por bloco:
- A imagem pesa 16.2 MB; a 1.4.0 da Lição 7 media 16.1 MB. Uma pasta vazia quase não pesa: a diferença é de arredondamento. Seu ambiente traz os dois valores padrão,
REVISOR_PUERTO=8080eREVISOR_REPORTE=/datos/reporte.json. E otarde seu sistema de arquivos já mostradatos/com dono65532/65532. - O volume novo herdou o dono.
/api/estadosresponde200. O auxiliar mostra/datoscom dono65532, não0: é a cópia de 8.4.1. Dentro estáreporte.json, de65532, com permissões-rw-------: só seu dono o lê e o escreve. (Esse600é decidido pelorevisor: ele cria o arquivo temporário com a funçãoos.CreateTempdo Go, que usa essas permissões, e depois o renomeia. O auxiliar consegue lê-lo porque roda comoroot.) O número de bytes depende de quantos serviços ele verifica. - Um volume em uso não se deixa apagar.
docker volume rmsai com código1e diz quem o está usando:volume is in use, com o identificador do contêiner entre colchetes. É uma proteção: não se pode tirar o chão de um contêiner que está escrevendo. - O contêiner vai embora; o volume e o relatório ficam. Depois do
docker rm -f, não há nenhum contêiner com esse nome, o volume continua na lista e o auxiliar lê o relatório completo: um serviço,propio, saudável, com código200(o tempo muda a cada execução). Isto é o que faltava na manhã de segunda-feira. - Um contêiner novo retoma o volume. O
revisornasce de novo, encontra o relatório anterior em seu lugar, o substitui com a verificação seguinte (200) e odocker inspectmostra a montagem:volume curso-docker-reportes-fig08 -> /datos (escribible: true). O sistema de arquivos do contêiner continua sendo somente leitura;/datosé a única exceção, e tem nome. - Sem o volume, a 1.5.0 reforçada não consegue guardar. É a consequência de 8.4.2:
read-only file systeme500. A imagem já diz onde guarda; quem a roda com--read-onlytem de dar a ela esse lugar.
8.4.4 Na pasta do projeto
O projeto vive em ~/curso-docker/revisor desde a Lição 3. A partir da raiz do repositório do curso, coloque ali o Dockerfile desta lição, construa a imagem que será conservada e crie o volume:
cp programas/08-volumenes-y-redes/estado/Dockerfile ~/curso-docker/revisor/Dockerfile
cd ~/curso-docker/revisor
docker build -q --build-arg VERSION=1.5.0 -t curso-docker-revisor:1.5.0 .
docker volume create curso-docker-reportes
O .dockerignore e a pasta secretos/ não mudam. Antes de seguir, repita à mão o erro e a correção com nomes próprios: rode curso-docker-revisor:1.4.0 com um volume recém-criado em /datos e -e REVISOR_REPORTE=/datos/reporte.json, e veja o 500; apague-o junto com esse volume; e rode curso-docker-revisor:1.5.0 com curso-docker-reportes. A única coisa que muda em relação aos scripts são os nomes. Os scripts desta lição não tocam em nada disso: usam nomes de apoio com fig08 e apagam só o que é deles.
8.5 Copiar versus cobrir: o que já havia na pasta
8.5.1 Quatro casos que convém não confundir
A regra de 8.4.1 tem condições, e quase todos os sustos com montagens vêm de esquecê-las. Quando se monta algo sobre uma pasta do contêiner que já tem conteúdo, acontece uma destas coisas:
| O que se monta | O que o contêiner vê | O que acontece com o conteúdo da imagem |
|---|---|---|
| um volume novo e vazio | o conteúdo da imagem | é copiado para o volume, com donos e permissões |
um volume novo com volume-nocopy |
uma pasta vazia | não é copiado; fica coberto |
| um volume que já tem dados | o que há no volume | fica coberto; nada é copiado |
| um bind mount | o que há na pasta da máquina | fica coberto; nada é copiado |
«Coberto» é a palavra da documentação: se se monta um volume com dados onde já havia arquivos, «os arquivos prévios ficam ocultos pela montagem» (Docker, «Volumes»); com um bind mount, «o conteúdo existente da pasta fica oculto» (Docker, «Bind mounts»). Não são apagados: continuam na camada da imagem, embaixo, e reaparecem se um contêiner for rodado sem a montagem. Mas enquanto a montagem existir, o contêiner não os vê.
Observe a terceira linha, porque ela tem uma consequência prática: a cópia ocorre somente na primeira vez, quando o volume está vazio. Se o volume curso-docker-reportes foi criado com a imagem 1.5.0 e amanhã uma imagem 1.6.0 traz algo diferente em /datos, o volume não é atualizado: ele já tem dados, então vale o volume. Para um relatório isso é exatamente o que se quer (os dados do serviço não devem mudar porque a imagem mudou). Para quem guarda em um volume arquivos que deveriam vir da imagem, é uma fonte clássica de «atualizei e continua igual».
8.5.2 Vê-lo com um contêiner auxiliar
O script fig08_04.sh mostra os quatro casos com o alpine auxiliar, sobre sua pasta /etc/apk, que traz cinco entradas. No final testa a diferença entre -v e --mount com uma pasta da máquina que não existe:
#!/usr/bin/env bash
# fig08_04.sh — Copiar contra tapar. Un volumen NUEVO montado sobre una carpeta
# con contenido recibe una copia de ese contenido; un bind mount, en cambio, la
# tapa. Y la diferencia entre -v y --mount cuando la carpeta del equipo no existe.
# No construye nada: usa el contenedor ayudante alpine:3.24.2.
set -uo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
# Nombres de apoyo con «fig08»: el guion nunca toca tus volúmenes del curso, y al
# salir borra solo lo que creó.
TRABAJO="$(mktemp -d)"
VOLUMEN="${PREFIJO}copia-fig08"
VOLUMEN_SIN_COPIA="${PREFIJO}sin-copia-fig08"
AYUDANTE=alpine:3.24.2
trap 'docker volume rm "$VOLUMEN" "$VOLUMEN_SIN_COPIA" >/dev/null 2>&1 || true
rm -rf "$TRABAJO"' EXIT
cd "$TRABAJO"
# La ruta de la carpeta temporal cambia en cada corrida: se escribe como «.».
tapar_ruta() { sed "s#$TRABAJO#.#g"; }
echo "== 1. La carpeta /etc/apk de la imagen, sin montar nada"
docker run --rm "$AYUDANTE" ls /etc/apk
echo
echo "== 2. Un volumen NUEVO montado ahí: el contenedor ve lo mismo..."
docker run --rm -v "$VOLUMEN":/etc/apk "$AYUDANTE" ls /etc/apk
echo "-- ...porque Docker copió el contenido al volumen. Míralo montado en otra ruta:"
docker run --rm -v "$VOLUMEN":/mirar:ro "$AYUDANTE" ls /mirar
echo
echo "== 3. Lo mismo con volume-nocopy: el volumen se queda vacío"
docker run --rm --mount type=volume,src="$VOLUMEN_SIN_COPIA",dst=/etc/apk,volume-nocopy \
"$AYUDANTE" ls /etc/apk | wc -l
echo
echo "== 4. Un bind mount de una carpeta vacía del equipo: tapa lo de la imagen"
mkdir vacia
docker run --rm --mount type=bind,src="$TRABAJO/vacia",dst=/etc/apk "$AYUDANTE" ls /etc/apk | wc -l
echo "-- y en el equipo, la carpeta sigue vacía: no se copió nada en ningún sentido"
ls vacia | wc -l
echo
echo "== 5. -v con una carpeta del equipo que NO existe: Docker la crea... como root"
docker run --rm -v "$TRABAJO/no-existia":/datos "$AYUDANTE" true
stat -c '%U:%G %n' no-existia
echo
echo "== 6. --mount con una carpeta que no existe: se niega"
docker run --rm --mount type=bind,src="$TRABAJO/tampoco-existe",dst=/datos "$AYUDANTE" true 2>&1 | tapar_ruta
echo "[código de salida: ${PIPESTATUS[0]}]"
$ ./fig08_04.sh
== 1. La carpeta /etc/apk de la imagen, sin montar nada
arch
keys
protected_paths.d
repositories
world
== 2. Un volumen NUEVO montado ahí: el contenedor ve lo mismo...
arch
keys
protected_paths.d
repositories
world
-- ...porque Docker copió el contenido al volumen. Míralo montado en otra ruta:
arch
keys
protected_paths.d
repositories
world
== 3. Lo mismo con volume-nocopy: el volumen se queda vacío
0
== 4. Un bind mount de una carpeta vacía del equipo: tapa lo de la imagen
0
-- y en el equipo, la carpeta sigue vacía: no se copió nada en ningún sentido
0
== 5. -v con una carpeta del equipo que NO existe: Docker la crea... como root
root:root no-existia
== 6. --mount con una carpeta que no existe: se niega
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: ./tampoco-existe
Run 'docker run --help' for more information
[código de salida: 125]
Bloco por bloco:
- Sem montagem,
/etc/apktem cinco entradas:arch,keys,protected_paths.d,repositorieseworld. - Com um volume novo, o contêiner vê as mesmas cinco. E não é que veja as da imagem «através» do volume: montado em outro caminho (
/mirar), em um contêiner distinto, o volume as tem. O Docker as copiou ao montá-lo pela primeira vez. - Com
volume-nocopy, zero entradas. A opção diz ao Docker que não copie mesmo que o volume esteja vazio (Docker, «Volumes»); com-vtambém existe, mas se escrevenocopy(8.1.3). Se algum dia ela aparecer em umdocker rundorevisor, já se sabe o que vai acontecer: o volume nasce com sua pasta deroot, como em 8.3, e volta opermission denied. Isso será comprovado no Exercício 4. - Com um bind mount de uma pasta vazia, zero entradas: o da imagem ficou coberto. E a pasta da máquina continua vazia: um bind mount não copia nada em nenhum sentido. O contêiner vê a pasta da máquina, tal como está, e nada mais.
-vcom um caminho da máquina que não existe: o contêiner inicia sem reclamar, e na máquina aparece a pastano-existia... com donoroot:root. Foi criada pelo daemon, que roda comoroot. A documentação diz assim: com-v, «o Docker cria a pasta no host para quem a pediu» (Docker, «Bind mounts»). O que ela não diz tão alto é o dono, e é o que morde: um erro de digitação no caminho deixa uma pasta derootno projeto, um contêiner escrevendo no lugar errado e, se o processo não éroot, outropermission denied.--mountcom um caminho que não existe: recusa.invalid mount config for type "bind": bind source path does not exist, com código125, o que odocker runusa quando o erro é do próprio Docker e não do programa. É o comportamento desejado: um erro de digitação aparece na hora. (O caminho aparece como./tampoco-existeporque o script oculta a pasta temporária, que muda a cada execução.) É o comportamento padrão:--mounttambém pode criar a pasta se isso for pedido com a opçãobind-create-src, que segundo a documentação «cria automaticamente a pasta de origem no host se ela não existir» (Docker, «Bind mounts»). Este curso não a usa, porque o que se busca é justamente que o erro de digitação seja visto. Por isso usa--mountpara os bind mounts.
8.6 Bind mounts e o dono dos arquivos
8.6.1 Quando um bind mount e não um volume
Um bind mount serve quando, a partir da própria máquina, é preciso ver ou colocar os arquivos sem passar pelo Docker: o código de um programa enquanto é desenvolvido, um arquivo de configuração editado à mão, o token da Lição 6, ou um relatório que outra ferramenta da máquina vai recolher. O preço é que o contêiner escreve diretamente no sistema de arquivos da máquina. A documentação avisa: com um bind mount «é possível mudar o sistema de arquivos do host a partir de processos que rodam em um contêiner», e os bind mounts têm, por padrão, «acesso de escrita aos arquivos do host» (Docker, «Bind mounts»). Por isso, quando o contêiner só precisa ler, a montagem vai com readonly, como o token.
8.6.2 O mesmo erro, pelo lado contrário
Com um volume, a pasta era de root e a correção foi fazer a imagem trazer a sua. Com um bind mount isso não serve: o contêiner vê a pasta da máquina, com o dono dela, e o que a imagem traga em /datos fica coberto (8.5). Então o choque volta, agora entre o usuário da máquina e o 65532. O script fig08_05.sh o provoca e o corrige sem root:
#!/usr/bin/env bash
# fig08_05.sh — Bind mount: la carpeta es tuya, el proceso es 65532. Primero el
# error de permisos; después el arreglo sin root (correr con TU usuario); y al
# final el mismo montaje de solo lectura.
# Córrelo en el disco propio de Linux: una carpeta compartida con otro sistema
# miente con los dueños y los permisos.
set -euo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
# Nombres de apoyo con «fig08»: el guion nunca toca tu curso-docker-revisor:1.5.0
# ni tu contenedor curso-docker-revisor, y al salir borra solo lo que creó.
PUERTO="${PUERTO:-8081}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
IMAGEN="${PREFIJO}revisor:fig08"
NOMBRE="${PREFIJO}revisor-fig08"
trap 'docker rm -f "$NOMBRE" >/dev/null 2>&1 || true
docker rmi -f "$IMAGEN" >/dev/null 2>&1 || true
rm -rf "$TRABAJO"' EXIT
cp -R "$AQUI/../revisor/." "$TRABAJO/"
cp "$AQUI/estado/.dockerignore" "$TRABAJO/"
cp "$AQUI/fig08_01.Dockerfile" "$TRABAJO/Dockerfile"
cd "$TRABAJO"
docker build -q --build-arg VERSION=1.5.0 -t "$IMAGEN" . >/dev/null
ENDURECIDO="--read-only --cap-drop ALL --security-opt no-new-privileges --init --memory 64m --cpus 0.5 --pids-limit 50"
SERVICIOS='propio http://127.0.0.1:8080/salud'
esperar_http() {
for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
echo "(no respondió $1)"; return 1
}
tapar() { sed -E 's/reporte-[0-9]+\.tmp/reporte-<n>.tmp/'; }
echo "== 1. Una carpeta tuya en el equipo"
mkdir reportes
stat -c '%u:%g %a %n' reportes
echo "tu usuario: $(id -u):$(id -g)"
echo
echo "== 2. Montada en /datos con un bind mount; el revisor corre como 65532"
docker run -d --name "$NOMBRE" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO \
--mount type=bind,src="$TRABAJO/reportes",dst=/datos \
-e REVISOR_SERVICIOS="$SERVICIOS" "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
curl -s -w ' [%{http_code}]\n' "http://127.0.0.1:$PUERTO/api/estados" | tapar
docker rm -f "$NOMBRE" >/dev/null
echo
echo "== 3. El arreglo sin root: correr el contenedor con TU usuario"
docker run -d --name "$NOMBRE" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO \
--user "$(id -u):$(id -g)" \
--mount type=bind,src="$TRABAJO/reportes",dst=/datos \
-e REVISOR_SERVICIOS="$SERVICIOS" "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
curl -s -o /dev/null -w '/api/estados: [%{http_code}]\n' "http://127.0.0.1:$PUERTO/api/estados"
echo "-- el reporte, visto desde el equipo, sin docker ni sudo:"
stat -c '%u:%g %a %n' reportes/reporte.json
head -c 60 reportes/reporte.json | tr -d '\n '; echo " …"
echo "-- lo que anotó Docker:"
docker inspect --format 'User={{.Config.User}}{{range .Mounts}} | {{.Type}} -> {{.Destination}} (escribible: {{.RW}}){{end}}' "$NOMBRE"
docker rm -f "$NOMBRE" >/dev/null
echo
echo "== 4. El mismo bind mount, de solo lectura"
docker run -d --name "$NOMBRE" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO \
--user "$(id -u):$(id -g)" \
--mount type=bind,src="$TRABAJO/reportes",dst=/datos,readonly \
-e REVISOR_SERVICIOS="$SERVICIOS" "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
curl -s -w ' [%{http_code}]\n' "http://127.0.0.1:$PUERTO/api/estados" | tapar
$ ./fig08_05.sh
== 1. Una carpeta tuya en el equipo
501:1000 775 reportes
tu usuario: 501:1000
== 2. Montada en /datos con un bind mount; el revisor corre como 65532
{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: permission denied"}
[500]
== 3. El arreglo sin root: correr el contenedor con TU usuario
/api/estados: [200]
-- el reporte, visto desde el equipo, sin docker ni sudo:
501:1000 600 reportes/reporte.json
[{"servicio":"propio","ok":true,"codigo" …
-- lo que anotó Docker:
User=501:1000 | bind -> /datos (escribible: true)
== 4. El mismo bind mount, de solo lectura
{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: read-only file system"}
[500]
Bloco por bloco:
- A pasta é do usuário da máquina: dono
501:1000, permissões775(o usuário e seu grupo escrevem; os demais, não). No Mint dirá1000:1000. O script imprime o usuário para que seja comparado. - Montada em
/datos,permission denied. Orevisorroda como65532, que não é o dono nem está no grupo1000, e para «os demais» a pasta não é gravável. A montagem não traduz nada: dentro do contêiner a pasta tem o mesmo dono numérico que fora. (Com Docker semrootou com remapeamento de usuários, o número de dentro é traduzido para outro fora; a instalação da Lição 1 não faz isso.) - A correção: rodar o contêiner com o usuário da máquina.
--user "$(id -u):$(id -g)"substitui o65532da imagem pelos números da pessoa, e agora o processo é o dono da pasta:200. E o relatório, visto da máquina, é dela (501:1000,600): lê-se semdockere semsudo. Repare no que não mudou: o processo continua sem serroot, continua sem capabilities e o sistema de arquivos continua somente leitura. Trocou-se um usuário sem privilégios por outro usuário sem privilégios, o da pessoa. Atenção: se o comando for executado comsudo,$(id -u)vale0e o contêiner estaria rodando comorootsem que se percebesse. Por isso na Lição 1 entrou-se no grupodocker: esses comandos vão semsudo. Odocker inspectconfirma:User=501:1000, e a montagembind -> /datos (escribible: true). - Como somente leitura, o kernel recusa. Com
readonly, orevisorvolta a falhar, agora comread-only file system: nem sendo o dono ele consegue escrever. Para o relatório não faz sentido, mas é exatamente o que se quer para um arquivo de configuração ou um segredo: o contêiner o lê e não consegue mudá-lo.
Por que não chmod 777 na pasta? Porque isso deixa qualquer usuário da máquina escrever ali, e qualquer contêiner com qualquer usuário enchê-la. É a versão em pasta do 644 da Lição 6, mas pior, porque aqui se escreve, não só se lê. --user com os números da pessoa resolve o problema onde ele nasce: o dono.
8.7 tmpfs: o que não deve durar
8.7.1 Para quê
Na Lição 7 usou-se --tmpfs /tmp para que o revisor pudesse escrever seu relatório com o sistema de arquivos somente leitura. Funcionou, e o relatório morria com o contêiner. Agora o relatório tem seu volume, mas o tmpfs continua tendo um lugar: tudo o que um programa escreve e não deve sobreviver. Arquivos temporários, caches de uma única execução, um arquivo com um segredo decifrado que não deve tocar o disco. A documentação resume: diferentemente de volumes e bind mounts, um tmpfs «é temporário e só persiste na memória do host», e «quando o contêiner para, a montagem tmpfs é removida» (Docker, «tmpfs mounts»). Também não pode ser compartilhado entre contêineres.
Dois valores padrão dessa página que convém saber: sem tamanho, o teto de um tmpfs é a metade da memória da máquina, e sua permissão é 1777, gravável por qualquer um (com o bit que impede apagar arquivos alheios, o mesmo do /tmp no Mint). E um aviso honesto: o kernel pode mandar para a memória de troca (swap) o que há em um tmpfs, então «em memória» não é garantia de que nunca toque o disco.
8.7.2 Volume e tmpfs, lado a lado
O script fig08_06.sh roda um contêiner somente leitura com as duas montagens, escreve em ambas, o reinicia e olha o que sobreviveu:
#!/usr/bin/env bash
# fig08_06.sh — tmpfs contra volumen. Un contenedor de solo lectura con dos
# lugares escribibles: un volumen en /datos y un tmpfs de 1 MB en /cache. Escribe
# en los dos, se reinicia, y se ve qué sobrevivió. También el tope del tmpfs.
# No construye nada: usa el contenedor ayudante alpine:3.24.2.
set -uo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
# Nombres de apoyo con «fig08»: el guion nunca toca tus contenedores ni tus
# volúmenes del curso, y al salir borra solo lo que creó.
NOMBRE="${PREFIJO}ayudante-fig08"
VOLUMEN="${PREFIJO}reportes-fig08"
AYUDANTE=alpine:3.24.2
trap 'docker rm -f "$NOMBRE" >/dev/null 2>&1 || true
docker volume rm "$VOLUMEN" >/dev/null 2>&1 || true' EXIT
# en COMANDO: corre COMANDO con sh dentro del contenedor y avisa si falló
en() { docker exec "$NOMBRE" sh -c "$1" 2>&1 || echo "[código de salida: $?]"; }
echo "== 1. Solo lectura, con un volumen en /datos y un tmpfs en /cache"
docker run -d --name "$NOMBRE" --read-only --cap-drop ALL --security-opt no-new-privileges --init \
--mount type=volume,src="$VOLUMEN",dst=/datos \
--mount type=tmpfs,dst=/cache,tmpfs-size=1m \
"$AYUDANTE" sleep 300 >/dev/null
echo "-- dónde está montado cada uno, de qué tipo y con qué opciones:"
en 'awk "\$2 == \"/datos\" || \$2 == \"/cache\" {print \$2, \$3, \$4}" /proc/mounts'
en 'stat -c "%a %n" /cache'
echo
echo "== 2. Escribir en los dos, y en la raíz"
en 'echo reporte > /datos/a.txt && echo temporal > /cache/b.txt && ls /datos /cache'
en 'echo hola > /c.txt'
echo
echo "== 3. El tmpfs tiene tope: 2 MB no caben en 1 MB"
en 'dd if=/dev/zero of=/cache/grande bs=1k count=2000 2>&1 | head -1'
en 'rm -f /cache/grande'
echo
echo "== 4. Reiniciar el contenedor"
docker restart -t 2 "$NOMBRE" >/dev/null
en 'ls /datos; echo "-- /cache tiene $(ls /cache | wc -l) archivos"'
$ ./fig08_06.sh
== 1. Solo lectura, con un volumen en /datos y un tmpfs en /cache
-- dónde está montado cada uno, de qué tipo y con qué opciones:
/cache tmpfs rw,nosuid,nodev,noexec,relatime,size=1024k,inode64
/datos ext4 rw,relatime,discard,errors=remount-ro,commit=30
1777 /cache
== 2. Escribir en los dos, y en la raíz
/cache:
b.txt
/datos:
a.txt
sh: can't create /c.txt: Read-only file system
[código de salida: 1]
== 3. El tmpfs tiene tope: 2 MB no caben en 1 MB
dd: error writing '/cache/grande': No space left on device
== 4. Reiniciar el contenedor
a.txt
-- /cache tiene 0 archivos
- As duas montagens e suas opções, lidas de
/proc/mounts, a lista que o kernel mantém./datoséext4: o volume vive no disco da máquina./cacheétmpfs, comsize=1024k(o1mpedido) e três opções que o Docker pôs por conta própria ao montá-lo com--mount:nosuid,nodevenoexec. Na Lição 7 elas foram escritas à mão com--tmpfs; com--mount type=tmpfsvêm sozinhas. A permissão é1777. - Escreve-se nas duas e não na raiz.
a.txtno volume,b.txtnotmpfs; e/c.txtfalha comRead-only file system, porque fora das montagens tudo é somente leitura. Três pastas, três comportamentos, e cada um foi pedido pelo nome. - O
tmpfstem teto. Escrever 2 MB em umtmpfsde 1 MB termina emNo space left on device. Semsize, esse teto seria a metade da memória da máquina: um programa com um erro que escreve sem parar poderia ocupar muito mais do que se imaginava. Com o teto, o erro aparece na hora e com uma mensagem clara. - Depois do
docker restart, o volume conservaa.txte otmpfsestá vazio. Um reinício basta para esvaziar otmpfs; não é preciso apagar o contêiner.
A documentação prefere --mount type=tmpfs e reconhece que --tmpfs «é menos verboso» e admite mais opções de montagem (Docker, «tmpfs mounts»). As duas servem. O importante é definir o tamanho.
8.7.3 Qual usar, no revisor
| Dado | Montagem | Por quê |
|---|---|---|
o relatório (/datos/reporte.json) |
volume nomeado em /datos |
precisa sobreviver ao contêiner; ninguém precisa editá-lo à mão |
o token (/run/secrets/revisor_token) |
bind mount de um arquivo, readonly |
é colocado de fora; o contêiner só o lê |
| um temporário que o programa não deva conservar | tmpfs com tamanho |
deve morrer com o contêiner e não ocupar disco |
| o código do programa | nenhuma: vai na imagem | é código, não dados (Lição 6) |
8.8 Redes: a rede padrão não tem nomes
8.8.1 O que é uma rede do Docker
Cada contêiner tem sua própria pilha de rede: suas interfaces, seu endereço IP, sua tabela de rotas, seu 127.0.0.1. É um dos isolamentos da Lição 0. Para que os contêineres se falem, o Docker os conecta a redes: por baixo, no Linux, uma ponte virtual (bridge) à qual se conecta uma interface de cada contêiner, como cabos em um mesmo switch. O driver padrão se chama justamente bridge.
O Docker cria três redes ao ser instalado, bridge, host e none (vistas com docker network ls), e se nada mais for dito, todo contêiner se conecta à bridge. Ali esteve cada contêiner do curso até hoje. Essa rede tem uma limitação que a documentação diz sem enfeites: na rede padrão, os contêineres «só podem ser alcançados por endereço IP». Mais ainda: «a rede bridge padrão é considerada um detalhe legado do Docker e não é recomendada para produção» (Docker, driver bridge).
8.8.2 Vê-lo: por nome não, por IP sim
O script fig08_07.sh coloca o revisor e o serviço de teste na rede padrão, e pede ao revisor que verifique http://curso-docker-prueba-fig08:9000/. Depois faz o mesmo em uma rede definida pelo usuário, e no final derruba o serviço de teste a partir de outro contêiner:
#!/usr/bin/env bash
# fig08_07.sh — Redes. En la red por omisión, el revisor no encuentra al servicio
# de prueba por su nombre (por IP sí). En una red definida por el usuario, sí lo
# encuentra, porque ahí hay un DNS. Y al romper el servicio, el reporte lo ve.
# Usa tu imagen curso-docker-prueba:1.0.0 (de la Lección 2) sin modificarla.
set -euo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
# Nombres de apoyo con «fig08»: el guion nunca toca tus contenedores, tus redes
# ni tus imágenes del curso, y al salir borra solo lo que creó.
PUERTO="${PUERTO:-8081}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
IMAGEN="${PREFIJO}revisor:fig08"
NOMBRE="${PREFIJO}revisor-fig08"
PRUEBA="${PREFIJO}prueba-fig08"
RED="${PREFIJO}red-fig08"
VOLUMEN="${PREFIJO}reportes-fig08"
IMAGEN_PRUEBA=curso-docker-prueba:1.0.0
AYUDANTE=alpine:3.24.2
trap 'docker rm -f "$NOMBRE" "$PRUEBA" >/dev/null 2>&1 || true
docker network rm "$RED" >/dev/null 2>&1 || true
docker volume rm "$VOLUMEN" >/dev/null 2>&1 || true
docker rmi -f "$IMAGEN" >/dev/null 2>&1 || true
rm -rf "$TRABAJO"' EXIT
docker image inspect "$IMAGEN_PRUEBA" >/dev/null 2>&1 \
|| { echo "falta $IMAGEN_PRUEBA: constrúyela como en la Lección 2"; exit 1; }
cp -R "$AQUI/../revisor/." "$TRABAJO/"
cp "$AQUI/estado/.dockerignore" "$TRABAJO/"
cp "$AQUI/fig08_01.Dockerfile" "$TRABAJO/Dockerfile"
cd "$TRABAJO"
docker build -q --build-arg VERSION=1.5.0 -t "$IMAGEN" . >/dev/null
ENDURECIDO="--read-only --cap-drop ALL --security-opt no-new-privileges --init --memory 64m --cpus 0.5 --pids-limit 50"
esperar_http() {
for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
echo "(no respondió $1)"; return 1
}
# Si corres el guion con otro PREFIJO, sus nombres se escriben como los del curso.
nombres() { sed "s/${PREFIJO}/curso-docker-/g"; }
# Lo que depende de cada equipo se tapa a la vista: los tiempos de la tabla, la
# dirección del DNS del anfitrión y las direcciones IP de los contenedores.
tabla() {
curl -s "http://127.0.0.1:$PUERTO/api/estados?formato=tabla" \
| sed -E 's/ [0-9.]+(ms|µs|s) / <t> /; s/on [0-9.]+:53/on <DNS del anfitrión>:53/' | nombres
}
tapar_ip() { sed -E 's/(^|[^0-9])(10|172|192)\.[0-9]+\.[0-9]+\.[0-9]+/\1<IP>/g'; }
revisor() { # revisor [opciones de red]
docker run -d --name "$NOMBRE" "$@" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO -v "$VOLUMEN":/datos \
-e REVISOR_SERVICIOS="prueba http://$PRUEBA:9000/ 2s" "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
}
echo "== 1. Los dos en la red por omisión (bridge): por nombre, no"
docker run -d --name "$PRUEBA" $ENDURECIDO "$IMAGEN_PRUEBA" >/dev/null
revisor
tabla
echo "-- la red de cada uno:"
docker inspect --format '{{.Name}}: {{range $red, $v := .NetworkSettings.Networks}}{{$red}}{{end}}' "$NOMBRE" "$PRUEBA" | nombres
echo "-- el DNS que reciben los contenedores de esa red es el del anfitrión:"
docker run --rm "$AYUDANTE" grep nameserver /etc/resolv.conf | sed -E 's/[0-9.]+$/<DNS del anfitrión>/' | head -1
echo "-- por IP, en cambio, sí llega:"
IP=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PRUEBA")
echo "IP del servicio de prueba: $IP" | tapar_ip
docker run --rm "$AYUDANTE" wget -qO- "http://$IP:9000/"
docker rm -f "$NOMBRE" "$PRUEBA" >/dev/null
echo
echo "== 2. Una red definida por el usuario"
docker network create "$RED" >/dev/null
docker network inspect --format '{{.Name}} driver={{.Driver}} interna={{.Internal}}' "$RED" | nombres
docker run -d --name "$PRUEBA" --network "$RED" $ENDURECIDO "$IMAGEN_PRUEBA" >/dev/null
revisor --network "$RED"
tabla
echo "-- en esta red, el DNS es el que Docker pone dentro de cada contenedor:"
docker run --rm --network "$RED" "$AYUDANTE" grep nameserver /etc/resolv.conf
docker run --rm --network "$RED" "$AYUDANTE" nslookup "$PRUEBA" 2>&1 \
| grep -E '^(Server|Name|Address)' | nombres | tapar_ip
echo "-- quién está conectado a la red:"
docker network inspect --format '{{range .Containers}}{{.Name}}{{println}}{{end}}' "$RED" | sed '/^$/d' | sort | nombres
echo
echo "== 3. Romper el servicio de prueba desde otro contenedor de la misma red"
docker run --rm --network "$RED" "$AYUDANTE" wget -qO- --post-data= "http://$PRUEBA:9000/modo/caido"
tabla
$ ./fig08_07.sh
== 1. Los dos en la red por omisión (bridge): por nombre, no
SERVICIO ESTADO TIEMPO DETALLE
prueba FALLA <t> no responde: Get "http://curso-docker-prueba-fig08:9000/": dial tcp: lookup curso-docker-prueba-fig08 on <DNS del anfitrión>:53: no such host
-- la red de cada uno:
/curso-docker-revisor-fig08: bridge
/curso-docker-prueba-fig08: bridge
-- el DNS que reciben los contenedores de esa red es el del anfitrión:
nameserver <DNS del anfitrión>
-- por IP, en cambio, sí llega:
IP del servicio de prueba: <IP>
sano
== 2. Una red definida por el usuario
curso-docker-red-fig08 driver=bridge interna=false
SERVICIO ESTADO TIEMPO DETALLE
prueba OK <t> 200
-- en esta red, el DNS es el que Docker pone dentro de cada contenedor:
nameserver 127.0.0.11
Server: 127.0.0.11
Address: 127.0.0.11:53
Name: curso-docker-prueba-fig08
Address: <IP>
-- quién está conectado a la red:
curso-docker-prueba-fig08
curso-docker-revisor-fig08
== 3. Romper el servicio de prueba desde otro contenedor de la misma red
modo: caido
SERVICIO ESTADO TIEMPO DETALLE
prueba FALLA <t> codigo 503
A primeira metade da saída, a rede padrão:
FALLA, comno such host. A mensagem completa se lê da direita para a esquerda:no such host(o nome não existe),lookup curso-docker-prueba-fig08 on <DNS del anfitrión>:53(perguntou ao servidor DNS da máquina, na porta 53, a do DNS),dial tcp(queria abrir uma conexão) eGet "http://…"(para fazer esta requisição). O servidor DNS da máquina não faz ideia de que contêineres rodam no Docker, então responde a única coisa que pode: esse nome não existe. O endereço real desse servidor depende de cada rede, e o script o oculta.- Os dois estão na mesma rede,
bridge. Não é que estejam separados: estão juntos e mesmo assim não se encontram pelo nome. - O DNS que recebem é o do host. A documentação explica que os contêineres da rede padrão recebem uma cópia do
/etc/resolv.confda máquina (Docker, «Networking»). O auxiliar o mostra:nameserver <DNS del anfitrión>. Um detalhe para o Mint: se no/etc/resolv.confda máquina aparecenameserver 127.0.0.53(o resolvedor local do systemd, o normal no Mint e no Ubuntu), esse endereço dentro do contêiner apontaria para o próprio contêiner, onde não há nenhum DNS, então o Docker não o copia como está; o contêiner recebe outros servidores. A documentação de «Networking» não detalha essa conversão; para saber o que o contêiner recebeu, compara-se com o auxiliar, que serve justamente para isso. - Por IP, chega. O script pergunta ao Docker o endereço do serviço de teste (ocultado como
<IP>) e o auxiliar faz uma requisição direta:sano. A rede funciona; o que falta são os nomes. E um IP não serve para configurar orevisor, porque é distribuído pelo Docker ao iniciar e pode mudar no seguinte.
Nos tutoriais antigos aparece a solução de antes para isso, --link. A seção 8.12 explica por que ela já não vale.
8.9 Redes definidas pelo usuário: nomes de graça
8.9.1 O que muda
Uma rede criada com docker network create (também com o driver bridge) se comporta de outro modo. A documentação lista as diferenças (Docker, driver bridge):
- DNS automático: «as redes bridge definidas pelo usuário oferecem resolução DNS automática entre contêineres». Cada contêiner é encontrado pelo nome.
- Melhor isolamento: só os contêineres conectados a essa rede se falam entre si. Na rede padrão, em compensação, qualquer contêiner que alguém inicie sem pensar fica ao lado dos demais.
- Conectar e desconectar com o contêiner em execução: um contêiner pode ser conectado a uma rede definida pelo usuário, ou desconectado, «na hora», sem ser recriado. Isso será feito em 8.10.
O DNS é fornecido por um servidor que o Docker coloca dentro de cada contêiner dessas redes, no endereço 127.0.0.11. A documentação o chama de servidor DNS integrado, e explica que ele responde os nomes dos contêineres da rede e reenvia todo o resto aos servidores DNS da máquina (Docker, «Networking»). Por isso um contêiner em uma rede própria resolve tanto curso-docker-prueba quanto www.docker.com.
8.9.2 Ler a segunda metade da execução
De volta à saída do fig08_07.sh, bloco 2:
- A rede:
curso-docker-red-fig08, driverbridge,interna=false(tem saída; em 8.10 será criada uma que não tem). OK,200. O mesmorevisor, com a mesma configuração, encontra o serviço de teste pelo nome. Não se mudou uma letra deREVISOR_SERVICIOS: mudou-se a rede.- O DNS agora é
127.0.0.11. O auxiliar, conectado à mesma rede, o mostra em seu/etc/resolv.conf, e onslookuppergunta pelo nome do serviço de teste: o servidor127.0.0.11responde com seu endereço (ocultado). - Quem está conectado: os dois contêineres, e mais ninguém. O auxiliar não aparece porque já terminou (
--rm).
E o bloco 3, o que justifica o curso inteiro: um contêiner da mesma rede faz POST /modo/caido no serviço de teste (modo: caido), e a verificação seguinte do revisor diz FALLA, codigo 503. O serviço de teste continua rodando; o revisor, que o verifica pelo nome através da rede, distingue o que o Docker acredita (o contêiner está no ar) do que acontece (responde 503). (A coluna TIEMPO da última linha sai com um espaço a mais porque o script oculta um tempo de largura distinta; é a tabela, não um erro.)
Uma nota sobre o nome: o revisor busca curso-docker-prueba-fig08 porque é assim que se chama o contêiner do script. Na pasta do projeto o contêiner se chama curso-docker-prueba, e REVISOR_SERVICIOS diz prueba http://curso-docker-prueba:9000/ 2s. O nome do contêiner é seu nome na rede; se for mudado, muda-se seu endereço.
8.10 Redes internas: sem saída
8.10.1 Por que trancar o serviço de teste
O serviço de teste só existe para que o revisor o consulte. Não precisa baixar nada, não precisa chamar ninguém, não precisa de internet. E tudo o que um contêiner pode alcançar é algo que um atacante que o controle também pode alcançar: baixar uma ferramenta, mandar dados para fora, usá-lo de trampolim. A resposta é uma rede interna: docker network create --internal. A referência do docker network create a descreve em uma frase, «restringe o acesso externo à rede», e a explica assim: os contêineres de uma rede interna «podem se comunicar entre si, mas não com nenhuma outra rede, porque não se configura uma rota padrão e se aplicam regras de firewall que descartam todo o tráfego de ou para outras redes» (referência do docker network create).
O desenho é o que a própria documentação usa de exemplo: uma rede interna «para se comunicar com contêineres que rodam serviços internos que não precisam de acesso à rede externa» (Docker, «Networking»). O serviço de teste, apenas na interna. O revisor, na rede normal (por onde expõe sua porta para o 127.0.0.1 da máquina) e na interna (por onde verifica o serviço de teste). Um contêiner pode estar em várias redes ao mesmo tempo: tem uma interface em cada uma.
8.10.2 Vê-lo
O script fig08_08.sh monta esse estado, que é o estado final da lição, em dois passos, para que se veja o que cada rede traz:
#!/usr/bin/env bash
# fig08_08.sh — Red interna. El servicio de prueba vive SOLO en una red interna,
# sin salida a internet. El revisor vive en la red normal (por donde expone
# su puerto) y se conecta además a la interna. Es el estado final de la lección.
# Usa tu imagen curso-docker-prueba:1.0.0 (de la Lección 2) sin modificarla.
set -uo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
# Nombres de apoyo con «fig08»: el guion nunca toca tus contenedores, tus redes,
# tus volúmenes ni tus imágenes del curso, y al salir borra solo lo que creó.
PUERTO="${PUERTO:-8081}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
IMAGEN="${PREFIJO}revisor:fig08"
NOMBRE="${PREFIJO}revisor-fig08"
PRUEBA="${PREFIJO}prueba-fig08"
RED="${PREFIJO}red-fig08"
INTERNA="${PREFIJO}interna-fig08"
VOLUMEN="${PREFIJO}reportes-fig08"
IMAGEN_PRUEBA=curso-docker-prueba:1.0.0
AYUDANTE=alpine:3.24.2
trap 'docker rm -f "$NOMBRE" "$PRUEBA" >/dev/null 2>&1 || true
docker network rm "$RED" "$INTERNA" >/dev/null 2>&1 || true
docker volume rm "$VOLUMEN" >/dev/null 2>&1 || true
docker rmi -f "$IMAGEN" >/dev/null 2>&1 || true
rm -rf "$TRABAJO"' EXIT
docker image inspect "$IMAGEN_PRUEBA" >/dev/null 2>&1 \
|| { echo "falta $IMAGEN_PRUEBA: constrúyela como en la Lección 2"; exit 1; }
cp -R "$AQUI/../revisor/." "$TRABAJO/"
cp "$AQUI/estado/.dockerignore" "$TRABAJO/"
cp "$AQUI/fig08_01.Dockerfile" "$TRABAJO/Dockerfile"
cd "$TRABAJO"
docker build -q --build-arg VERSION=1.5.0 -t "$IMAGEN" . >/dev/null || exit 1
ENDURECIDO="--read-only --cap-drop ALL --security-opt no-new-privileges --init --memory 64m --cpus 0.5 --pids-limit 50"
esperar_http() {
for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
echo "(no respondió $1)"; return 1
}
nombres() { sed "s/${PREFIJO}/curso-docker-/g"; }
tabla() {
curl -s "http://127.0.0.1:$PUERTO/api/estados?formato=tabla" \
| sed -E 's/ [0-9.]+(ms|µs|s) / <t> /' | nombres
}
echo "== 1. Dos redes: la normal y la interna"
docker network create "$RED" >/dev/null
docker network create --internal "$INTERNA" >/dev/null
docker network inspect --format '{{.Name}} driver={{.Driver}} interna={{.Internal}}' "$RED" "$INTERNA" | nombres
echo
echo "== 2. El servicio de prueba, solo en la interna; el revisor, solo en la normal"
docker run -d --name "$PRUEBA" --network "$INTERNA" $ENDURECIDO "$IMAGEN_PRUEBA" >/dev/null
docker run -d --name "$NOMBRE" --network "$RED" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO \
-v "$VOLUMEN":/datos -e REVISOR_SERVICIOS="prueba http://$PRUEBA:9000/ 2s" "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
tabla
echo
echo "== 3. Conectar el revisor también a la interna, sin reiniciarlo"
docker network connect "$INTERNA" "$NOMBRE"
tabla
docker inspect --format '{{range $red, $v := .NetworkSettings.Networks}}{{$red}} {{end}}' "$NOMBRE" | nombres
echo "-- el puerto publicado sigue en 127.0.0.1:"
docker port "$NOMBRE" | sed "s/:$PUERTO\$/:<puerto>/"
echo
echo "== 4. Desde la red interna no hay salida a internet"
docker run --rm --network "$INTERNA" "$AYUDANTE" wget -q -T 5 -O /dev/null https://www.docker.com/
echo "[código de salida: $?]"
echo "-- pero la puerta de enlace de la red interna sí contesta:"
PUERTA=$(docker network inspect --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}' "$INTERNA")
docker run --rm --network "$INTERNA" "$AYUDANTE" sh -c "ip route; ping -c 1 -W 2 $PUERTA | grep -E 'packets'" \
| sed -E 's/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?/<IP>\1/g'
echo "-- desde la red normal, sí:"
docker run --rm --network "$RED" "$AYUDANTE" wget -q -T 5 -O /dev/null https://www.docker.com/ \
&& echo "desde la red normal sí hay salida"
$ ./fig08_08.sh
== 1. Dos redes: la normal y la interna
curso-docker-red-fig08 driver=bridge interna=false
curso-docker-interna-fig08 driver=bridge interna=true
== 2. El servicio de prueba, solo en la interna; el revisor, solo en la normal
SERVICIO ESTADO TIEMPO DETALLE
prueba FALLA <t> no responde: Get "http://curso-docker-prueba-fig08:9000/": dial tcp: lookup curso-docker-prueba-fig08 on 127.0.0.11:53: no such host
== 3. Conectar el revisor también a la interna, sin reiniciarlo
SERVICIO ESTADO TIEMPO DETALLE
prueba OK <t> 200
curso-docker-interna-fig08 curso-docker-red-fig08
-- el puerto publicado sigue en 127.0.0.1:
8080/tcp -> 127.0.0.1:<puerto>
== 4. Desde la red interna no hay salida a internet
wget: bad address 'www.docker.com'
[código de salida: 1]
-- pero la puerta de enlace de la red interna sí contesta:
<IP>/16 dev eth0 scope link src <IP>
1 packets transmitted, 1 packets received, 0% packet loss
-- desde la red normal, sí:
desde la red normal sí hay salida
- Duas redes, as duas com driver
bridge; uma cominterna=true. - O
revisorapenas na rede normal não encontra o serviço de teste. Agora a mensagem dizlookup … on 127.0.0.11:53: no such host. Compare-a com a de 8.8: lá ele perguntou ao DNS da máquina (rede padrão); aqui perguntou ao DNS integrado do Docker (rede definida pelo usuário), que existe, mas só responde os nomes das redes do próprio contêiner. O serviço de teste está em outra rede, então para orevisorele não existe. É o isolamento de 8.9.1 funcionando: estar na mesma máquina não basta, é preciso compartilhar rede. Ler em que servidor DNS a pergunta foi feita diz de que tipo de rede se está falando. docker network connectsem reiniciar. Orevisoré conectado também à interna, com o contêiner em execução, e a verificação seguinte dizOK. Odocker inspectmostra as duas redes dorevisor(em ordem alfabética, com um espaço no final que o template coloca). E odocker portconfirma que a porta publicada continua em127.0.0.1(o número da máquina é ocultado, porque o script o recebe emPUERTO).- Da rede interna não há saída. Um auxiliar na interna tenta baixar
https://www.docker.com/e falha antes de tentar se conectar:wget: bad address 'www.docker.com', código1. Nem sequer consegue resolver o nome: a consulta sai do contêiner para o DNS integrado do Docker, e uma rede interna não tem saída para fora por onde essa consulta possa chegar a um servidor de nomes externo. (A documentação não detalha em que ponto exato a consulta é cortada, então a lição afirma apenas o que foi medido: o nome não é resolvido e o download nem começa.) Mas a rede interna não está isolada de tudo: o mesmo auxiliar mostra sua tabela de rotas, com uma única linha (a própria rede interna, sem rota padrão para fora), e mesmo assim seu gateway responde aoping(1 packets received). Da rede normal, o download funciona. A rede interna não é uma promessa: para fora não há rota, mas o gateway continua ali.
Um esclarecimento para não ficar com uma ideia a mais: «sem saída» não significa «inalcançável a partir da máquina». A referência diz que a máquina pode se comunicar diretamente com os endereços dos contêineres de uma rede interna. O que a rede interna corta é o caminho do contêiner para outras redes, a internet inclusive. E no outro sentido há uma fresta que convém conhecer, a que acaba de ser medida: a mesma referência esclarece que «a comunicação com o endereço do gateway (e, com ele, com os serviços do host configurados para isso) é possível». Um programa da máquina que escute em 0.0.0.0 (todas as interfaces), por exemplo um banco de dados de desenvolvimento, fica ao alcance dos contêineres da rede interna. É mais uma razão para o hábito da Lição 2: o que roda na máquina escuta em 127.0.0.1, não em todas as interfaces. Por isso o curso publica a porta do revisor pela rede normal, a que tem caminho para fora, e deixa a interna só para falar com o serviço de teste.
8.10.3 Na pasta do projeto: o estado final
Com a imagem curso-docker-revisor:1.5.0, o volume curso-docker-reportes e a imagem curso-docker-prueba:1.0.0 da Lição 2, estes são os comandos do estado final, com os nomes do curso:
docker network create curso-docker-red
docker network create --internal curso-docker-interna
docker volume create curso-docker-reportes
docker run -d --name curso-docker-prueba --network curso-docker-interna \
--read-only --cap-drop ALL --security-opt no-new-privileges --init \
--memory 64m --cpus 0.5 --pids-limit 50 curso-docker-prueba:1.0.0
docker run -d --name curso-docker-revisor --network curso-docker-red -p 127.0.0.1:8080:8080 \
--read-only --cap-drop ALL --security-opt no-new-privileges --init \
--memory 64m --cpus 0.5 --pids-limit 50 \
-v curso-docker-reportes:/datos \
-e REVISOR_SERVICIOS='prueba http://curso-docker-prueba:9000/ 2s' curso-docker-revisor:1.5.0
docker network connect curso-docker-interna curso-docker-revisor
(Se o volume já foi criado em 8.4.4, o docker volume create apenas devolve seu nome: não o esvazia.) Confira com curl -s 'http://127.0.0.1:8080/api/estados?formato=tabla' que o serviço de teste sai OK, e com docker ps --filter name=curso-docker- que os dois chegam a (healthy). O serviço de teste leva as mesmas opções reforçadas do revisor, sem porta e sem -e: é o Exercício 4 da Lição 7, e já se sabe que ele não precisa nem de um tmpfs porque não escreve nada. Estes dois contêineres, as duas redes e o volume são o que a Lição 9 escreve em um compose.yaml.
Ao terminar a lição, apague-os nesta ordem (primeiro os contêineres, porque uma rede ou um volume em uso não se apaga): docker rm -f curso-docker-revisor curso-docker-prueba e docker network rm curso-docker-red curso-docker-interna. O volume, se se quiser conservar o relatório, fica; se não, docker volume rm curso-docker-reportes.
8.11 O que não se faz: a rede do host
Outra das redes que o Docker cria ao ser instalado é a host. Um contêiner nela não tem rede própria: segundo a documentação, sua pilha de rede «não está isolada da máquina» e o contêiner «não recebe seu próprio endereço IP» (Docker, driver host). Escuta diretamente nas interfaces da máquina, vê todas as suas portas e todas as suas conexões locais, e -p deixa de ter efeito: o Docker o ignora com o aviso WARNING: Published ports are discarded when using host network mode. Isso último anula de uma só vez o hábito da Lição 2: já não se decide se a porta fica em 127.0.0.1 ou em todas as interfaces; fica onde o programa decidir escutar.
Às vezes se usa «porque assim funciona» quando um contêiner não encontra outro, ou para ganhar alguns microssegundos de rede. As duas razões têm melhor resposta: para encontrar outro contêiner, uma rede definida pelo usuário; para o desempenho, medir primeiro, porque quase nunca é a rede do contêiner que limita. O README do curso o põe como hábito desta lição: nunca a rede do host. O hábito vale para serviços de aplicação como o revisor; há usos legítimos em infraestrutura (por exemplo, um agente que mede a rede da própria máquina) que não são tema deste curso, e quem os necessita já sabe que está renunciando ao isolamento de propósito.
8.12 O que se encontra em tutoriais antigos
--link. A forma antiga de um contêiner encontrar outro pelo nome na rede padrão. A documentação já não a recomenda: os links na rede padrão estão «obsoletos e programados para remoção no Docker Engine 30.0», e pede-se usar «redes definidas pelo usuário em vez da flag legada--link» (Docker, links legados). Se aparecer, deve ser traduzida para uma rede.- «Contêineres só de dados» e
--volumes-from. Antes de existirem os volumes nomeados, criava-se um contêiner que nunca rodava só para «ter» um volume, e os demais o montavam com--volumes-from. Hoje o volume tem nome próprio e não precisa de um contêiner que o sustente. VOLUME /datosno Dockerfile «para que os dados persistam». A instrução existe e faz algo: marca um caminho como ponto de montagem, e se ao rodar nada for montado ali, o Docker cria um volume anônimo que é inicializado com o que a imagem tiver nesse caminho (referência do Dockerfile). O resultado é justo o contrário do que se busca: um volume sem nome por contêiner, que ninguém reconhece e que se acumula. E a instrução não pode escolher uma pasta da máquina. Este curso não a usa: orevisortraz a pasta/datoscom seu dono, e o volume nomeado é decidido por quem roda o contêiner.chmod -R 777sobre a pasta de dados para «consertar as permissões». Viu-se em 8.3 e 8.6 que o problema é o dono; um777o esconde e abre a pasta a qualquer um.
O erro que se vai ver
Esta lição tem dois erros principais, um por metade, e três secundários. Todos já apareceram.
O erro principal de armazenamento: o volume novo que o processo não consegue escrever. É o do fig08_02.sh:
{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: permission denied"}
[500]
Lê-se da direita para a esquerda, como o da Lição 6. permission denied é dito pelo kernel: o processo não tem permissão para criar um arquivo ali. open /datos/.reporte-<n>.tmp diz que arquivo, com o caminho dentro do contêiner: é o temporário que o revisor escreve antes de renomeá-lo, de modo que o problema é a pasta, não o reporte.json. E no pude guardar el reporte é acrescentado pelo revisor, que além disso responde 500 em vez de fingir que está tudo bem. A pergunta que resolve é a de sempre: «quem é o dono da pasta, e com que usuário roda o processo?». A primeira resposta é dada por um auxiliar (docker run --rm -v <volume>:/datos:ro alpine:3.24.2 ls -lnd /datos); a segunda, por docker inspect --format '{{.Config.User}}' <contêiner>. Se a pasta é de 0 e o processo é 65532, é um volume que nasceu sem uma pasta da imagem para copiar: ou a imagem não traz /datos (a 1.4.0), ou alguém usou volume-nocopy. A correção é fazer a imagem trazer a pasta com COPY --chown e o volume ser novo. O fato de o volume já existir não impede a cópia: enquanto estiver vazio, a primeira montagem sobre uma pasta da imagem a recebe, mesmo que tenha sido criado com docker volume create (é o que faz o bloco 2 do fig08_03.sh, e foi repetido à mão: um volume criado de antemão e montado antes com nocopy recebeu igualmente a pasta de 65532 ao ser montado depois com a imagem boa). O que a impede é o volume já ter algo dentro, por exemplo um arquivo que algum processo de root escreveu: então vale o volume, a pasta continua sendo de root e volta o permission denied. Nesse caso, se não há nada a conservar, docker volume rm e o docker run seguinte o cria de novo.
Com um bind mount o mesmo texto tem outra origem: a pasta é da máquina e o processo, 65532. Correção: --user "$(id -u):$(id -g)", ou fazer a pasta ser do 65532. O que não é uma correção, em nenhum dos dois casos: --user 0 ou chmod 777.
O erro principal de redes: o nome que não existe.
prueba FALLA <t> no responde: Get "http://curso-docker-prueba-fig08:9000/": dial tcp: lookup curso-docker-prueba-fig08 on <DNS del anfitrión>:53: no such host
no such host diz que o nome não pôde ser resolvido. O que diagnostica o caso é a quem se perguntou, logo antes do :53:
- O endereço do DNS da máquina: o contêiner está na rede padrão, que não tem DNS de contêineres. Correção: uma rede definida pelo usuário para os dois.
127.0.0.11: o contêiner está de fato em uma rede definida pelo usuário, com o DNS integrado do Docker, mas o outro contêiner não está em nenhuma de suas redes (ou se chama diferente, ou não está rodando). Correção:docker network connect, ou conferir o nome comdocker ps.
Esse detalhe transforma uma mensagem genérica em um diagnóstico de uma linha.
Os secundários:
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: ./tampoco-existe
Um --mount type=bind com um caminho da máquina que não existe (sai com 125; o caminho da mensagem é o que foi escrito). Quase sempre é um erro de digitação ou um caminho relativo ao lugar errado. Com -v esse erro não teria aparecido: o Docker teria criado a pasta, de root, e o contêiner teria iniciado com uma pasta vazia.
Error response from daemon: remove curso-docker-reportes-fig08: volume is in use - [<id>]
docker volume rm sobre um volume que algum contêiner, rodando ou parado, ainda tem montado. Apague primeiro o contêiner (o identificador entre colchetes diz qual). O mesmo vale para docker network rm e uma rede com contêineres conectados.
wget: bad address 'www.docker.com'
Em um contêiner de uma rede interna, qualquer nome de fora. Se aparecer em um contêiner que deveria ter saída, confira suas redes com docker inspect: provavelmente ele está apenas em uma interna.
O hábito de segurança da lição
Os dados são montados com nome, com o dono correto e só onde fazem falta; as redes são definidas por quem opera, a dos serviços internos não tem saída, e a do host não se usa. São três costumes que se escrevem juntos e se comprovam juntos. O primeiro: o sistema de arquivos continua somente leitura, e cada pasta gravável tem nome (um volume em /datos, um tmpfs com tamanho); nenhuma se conserta com root nem com 777, porque o problema é o dono e se resolve na imagem ou com --user. O segundo: cada contêiner está apenas nas redes de que precisa; o que não precisa de internet vive em uma rede --internal, e isso se comprova pedindo-lhe que saia (bad address). O terceiro: nunca --network host, que apaga de uma vez o isolamento de rede e o 127.0.0.1 da Lição 2. O acidente que isso evita é concreto: um serviço interno comprometido que não pode baixar nada nem mandar nada para fora, e que também não pode escrever em mais do que uma pasta escolhida por quem opera.
O que se faz errado
Guardar dados na camada do contêiner. Funciona até o primeiro docker rm, que em um serviço é a atualização seguinte. E com --read-only nem sequer funciona.
Rodar como root para que o volume se deixe escrever. Troca um problema de dono por um de segurança, e desfaz as Lições 3, 5 e 7. A imagem traz sua pasta com COPY --chown.
chmod 777 em uma pasta da máquina. Abre-a a todos os usuários e a todos os contêineres. A correção é o dono: --user com os números da pessoa.
-v com caminhos da máquina. Um erro de digitação cria uma pasta de root em silêncio. Para bind mounts, --mount type=bind, que por padrão recusa.
Montar com leitura e escrita o que o contêiner só lê. Configuração, segredos, certificados: readonly. Um bind mount gravável é uma porta do contêiner para o disco.
Volumes anônimos, por VOLUME no Dockerfile ou por -v /datos sem nome. Acumulam-se sem que ninguém saiba de quem são. Dê-lhes nome, com o prefixo do projeto.
Apagar volumes «por precaução». docker volume rm não tem lixeira. E docker volume prune -a apaga todos os volumes que nenhum contêiner use, com nome ou sem ele, inclusive os dados de projetos parados que não são de quem executa (sem -a, o prune só apaga os anônimos, segundo sua referência). Apague pelo nome exato.
Escrever endereços IP de contêineres na configuração. São distribuídos pelo Docker e mudam. Use nomes em uma rede definida pelo usuário.
Deixar tudo na rede padrão. Sem nomes, e com qualquer contêiner que alguém inicie sem pensar na mesma rede dos demais. A própria documentação a chama de detalhe legado.
Dar internet a quem não precisa. Um serviço interno com saída é um trampolim. --internal.
--network host «porque assim funciona». Se um contêiner não encontra outro, a resposta é uma rede definida por quem opera, não retirar o isolamento.
Exercícios
Faça-os em ordem. Antes de rodar cada um, escreva no diário de bordo o que se acredita que vai acontecer; compará-lo depois é metade do aprendizado. Ao terminar, apague o que foi criado: tudo leva o prefixo curso-docker-.
Exercício 1 — Volume, bind mount, tmpfs ou nada?
Para cada dado, escolha um dos quatro e diga por quê em uma frase:
- Um banco de dados de teste que um serviço usa e que deve sobreviver a uma atualização do contêiner.
- Um arquivo
config.yamlque é editado na máquina e que o contêiner só lê. - Os arquivos de sessão de um programa, que são regenerados a cada inicialização.
- O código-fonte de um programa enquanto é desenvolvido e testado em um contêiner.
- O binário do
revisor. - O relatório do
revisor, que outra ferramenta da máquina, fora do Docker, vai recolher a cada hora.
Exercício 2 — Leia o no such host
Sem rodar nada, diga que situação de rede produziu cada mensagem e com que comando seria corrigida. As duas são do revisor, buscando um contêiner que está rodando:
A) … lookup curso-docker-prueba on <DNS del anfitrión>:53: no such host
B) … lookup curso-docker-prueba on 127.0.0.11:53: no such host
Pergunta extra: no caso B, que outras duas causas, que não têm a ver com as redes, dariam exatamente a mesma mensagem?
Exercício 3 — O relatório que sobrevive (projeto)
Na pasta do projeto, com o estado final de 8.10.3 rodando, faça esta sequência e, antes de cada passo, preveja o resultado:
- Peça a tabela uma vez:
curl -s 'http://127.0.0.1:8080/api/estados?formato=tabla'. - Apague o contêiner do
revisorcomdocker rm -f curso-docker-revisor. - Leia o relatório com um auxiliar, sem nenhum
revisorrodando. - Crie o
revisorde novo com o comando de 8.10.3, sem odocker network connect, e peça a tabela. - Com esse
revisorrodando, tentedocker volume rm curso-docker-reportes. - Conecte-o à interna e peça a tabela outra vez.
Exercício 4 — volume-nocopy
Preveja o que acontece se a imagem curso-docker-revisor:1.5.0, reforçada, for rodada com um volume novo montado assim:
--mount type=volume,src=curso-docker-sin-copia,dst=/datos,volume-nocopy
O que responde /api/estados? De quem é /datos dentro do volume? Confira publicando a porta em 127.0.0.1:8081 (ou parando antes o curso-docker-revisor, que ocupa a 8080), e diga como isso se relaciona com o erro de 8.3. Pergunta extra: como se escreve o mesmo com -v? No fim, apague o contêiner e o volume.
Soluções
Solução 1
- Volume nomeado. Deve durar mais que o contêiner e ninguém precisa mexer nos arquivos à mão. É o caso do relatório.
- Bind mount somente leitura. É editado na máquina e o contêiner só o lê; com
readonly, o contêiner não pode mudá-lo mesmo que queira. tmpfscom tamanho. Não deve sobreviver ao contêiner, e assim também não ocupa disco nem deixa rastro.- Bind mount. A pessoa o edita e o contêiner precisa ver a mudança na hora. É o uso que a documentação dá como exemplo. (Em um contêiner que só o executa, somente leitura.)
- Nada: vai na imagem. É código (Lição 6). Montá-lo de fora faria a imagem testada não ser a que roda.
- Bind mount de uma pasta da máquina, com
--userdos números da pessoa, como em 8.6. Uma ferramenta da máquina fora do Docker precisa lê-lo em um caminho próprio, semsudo. Com um volume teria de passar por um contêiner auxiliar.
Solução 2
- A) Perguntou-se ao DNS da máquina: o
revisorestá na rede padrão, que não tem DNS de contêineres. Correção: criar uma rede (docker network create curso-docker-red) e rodar os dois contêineres com--network curso-docker-red, ou conectá-los a ela comdocker network connect. - B) Perguntou-se ao DNS integrado (
127.0.0.11): orevisorestá em uma rede definida pelo usuário, mas o serviço de teste não está em nenhuma de suas redes. É o que se viu no bloco 2 dofig08_08.sh. Correção:docker network connect <a rede do outro> curso-docker-revisor(ou conectar o outro à rede dorevisor).
Pergunta extra: a mesma mensagem sai se o nome está escrito errado em REVISOR_SERVICIOS (o DNS não adivinha), ou se o outro contêiner não está rodando: o DNS integrado só responde os nomes dos contêineres conectados naquele momento. docker ps --filter name=curso-docker- responde às duas dúvidas.
Solução 3
Os passos 2, 5 e a primeira tabela do 4 foram medidos com a mesma máquina e a mesma versão do Docker da lição, nos scripts fig08_03.sh e fig08_08.sh (com o sufixo -fig08 nos nomes); os demais se deduzem deles, e na pasta do projeto devem sair assim:
200, e a tabela dizprueba OK.- O contêiner desaparece; o volume não (bloco 4 do
fig08_03.sh). - O auxiliar (
docker run --rm -v curso-docker-reportes:/datos:ro alpine:3.24.2 cat /datos/reporte.json) deve imprimir o último relatório, em JSON. No bloco 4 dofig08_03.sho serviço se chamavapropio; no estado final deve se chamarprueba, com"ok": truee"codigo": 200. - A tabela diz
FALLAcomlookup curso-docker-prueba on 127.0.0.11:53: no such host, como no bloco 2 dofig08_08.sh: orevisorestá em uma rede própria, e o serviço de teste, em outra. docker volume rmrecusa:volume is in use, com o identificador do contêiner entre colchetes, e sai com código1(bloco 3 dofig08_03.sh).- Depois do
docker network connect,OK(bloco 3 dofig08_08.sh).
Solução 4
É o script fig08_09.sh, que testa a opção com --mount e com -v:
#!/usr/bin/env bash
# fig08_09.sh — Solución del Ejercicio 4: volume-nocopy. La imagen 1.5.0 trae
# /datos de 65532, pero si el volumen nuevo se monta con volume-nocopy, Docker no
# copia nada (ni el dueño) y vuelve el permission denied. Se prueba con --mount
# y con -v, que también admite la opción, pero con OTRO nombre: nocopy.
set -euo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
# Nombres de apoyo con «fig08»: el guion nunca toca tu curso-docker-revisor:1.5.0,
# tu contenedor curso-docker-revisor ni tus volúmenes, y al salir borra solo lo que creó.
PUERTO="${PUERTO:-8081}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
IMAGEN="${PREFIJO}revisor:fig08"
NOMBRE="${PREFIJO}revisor-fig08"
VOLUMEN="${PREFIJO}sin-copia-fig08"
AYUDANTE=alpine:3.24.2
trap 'docker rm -f "$NOMBRE" >/dev/null 2>&1 || true
docker volume rm "$VOLUMEN" >/dev/null 2>&1 || true
docker rmi -f "$IMAGEN" >/dev/null 2>&1 || true
rm -rf "$TRABAJO"' EXIT
cp -R "$AQUI/../revisor/." "$TRABAJO/"
cp "$AQUI/estado/.dockerignore" "$TRABAJO/"
cp "$AQUI/fig08_01.Dockerfile" "$TRABAJO/Dockerfile"
cd "$TRABAJO"
docker build -q --build-arg VERSION=1.5.0 -t "$IMAGEN" . >/dev/null
ENDURECIDO="--read-only --cap-drop ALL --security-opt no-new-privileges --init --memory 64m --cpus 0.5 --pids-limit 50"
esperar_http() {
for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
echo "(no respondió $1)"; return 1
}
probar() { # probar OPCIÓN-DE-MONTAJE…: corre el revisor con un volumen nuevo y mira /datos
docker run -d --name "$NOMBRE" -p "127.0.0.1:$PUERTO:8080" $ENDURECIDO "$@" \
-e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' "$IMAGEN" >/dev/null
esperar_http "http://127.0.0.1:$PUERTO/salud"
curl -s -w ' [%{http_code}]\n' "http://127.0.0.1:$PUERTO/api/estados" | sed -E 's/reporte-[0-9]+\.tmp/reporte-<n>.tmp/'
docker run --rm -v "$VOLUMEN":/datos:ro "$AYUDANTE" ls -lnd /datos
docker rm -f "$NOMBRE" >/dev/null
docker volume rm "$VOLUMEN" >/dev/null
}
echo "== 1. Con --mount …,volume-nocopy"
probar --mount type=volume,src="$VOLUMEN",dst=/datos,volume-nocopy
echo
echo "== 2. Con -v, el nombre de --mount no sirve"
salida=$(docker run --rm -v "$VOLUMEN":/datos:volume-nocopy "$IMAGEN" version 2>&1) && rc=0 || rc=$?
echo "$salida" | sed '/^$/d'
echo "[código de salida: $rc]"
docker volume rm "$VOLUMEN" >/dev/null 2>&1 || true
echo
echo "== 3. Con -v …:nocopy"
probar -v "$VOLUMEN":/datos:nocopy
$ ./fig08_09.sh
== 1. Con --mount …,volume-nocopy
{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: permission denied"}
[500]
drwxr-xr-x 2 0 0 4096 Oct 21 13:11 /datos
== 2. Con -v, el nombre de --mount no sirve
docker: Error response from daemon: invalid mode: volume-nocopy
Run 'docker run --help' for more information
[código de salida: 125]
== 3. Con -v …:nocopy
{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: permission denied"}
[500]
drwxr-xr-x 2 0 0 4096 Oct 21 13:11 /datos
É o erro de 8.3 com a imagem boa. A imagem 1.5.0 traz /datos de 65532, mas volume-nocopy diz ao Docker que não copie nada para o volume novo, e o que não se copia inclui o dono: o volume nasce com sua pasta de root, como quando a imagem não trazia a pasta (bloco 3 do fig08_04.sh). A lição: a correção de 8.4 depende da cópia na primeira montagem. Qualquer coisa que a impeça (volume-nocopy na primeira montagem, ou um volume que já tem conteúdo e por isso vale mais que a imagem) traz de volta o permission denied. Um volume que existia de antes mas está vazio não a impede: foi medido que ele recebe a pasta igualmente.
Pergunta extra: com -v, a opção se escreve nocopy (bloco 3). O nome de --mount não serve com -v: o Docker 29.2.1 o rejeita com invalid mode: volume-nocopy e código 125, ainda que a página de volumes o documente com esse nome (bloco 2). É um bom exemplo de por que o curso roda cada comando em vez de copiá-lo da documentação.
Como sei que consegui
Marque cada ponto só se for possível fazê-lo sem voltar a olhar o texto:
- Na pasta do projeto,
docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' curso-docker-revisor:1.5.0mostraREVISOR_REPORTE=/datos/reporte.json, edocker create+docker export+tar -tv --numeric-ownermostramdatos/com dono65532/65532. - Provoca-se o
permission deniedcom a imagem 1.4.0 e um volume novo, e explica-se usando as palavras dono,0e65532. - O relatório sobrevive ao
docker rm -f curso-docker-revisor: lê-se com um auxiliar somente leitura. - Explica-se com um exemplo quando o conteúdo de uma pasta é copiado ao montar e quando é coberto, e por que
--mounté mais seguro que-vpara um bind mount. - Roda-se o
revisorcom um bind mount de uma pasta da máquina e--user "$(id -u):$(id -g)", e ostatmostra que o relatório é de quem o rodou. - Um contêiner com
--read-onlyescreve em um volume e em umtmpfscomsize, e depois dodocker restarto arquivo do volume continua e o dotmpfs, não. - Mostra-se o
no such hostna rede padrão e oOKem uma rede definida pelo usuário, e diz-se qual servidor DNS cada uma usa (/etc/resolv.confde um auxiliar). - O estado final de 8.10.3 está rodando: o serviço de teste apenas em
curso-docker-interna, orevisornas duas, a tabela emOK, e um auxiliar na rede interna recebebad addressao tentar sair. - Rodam-se os scripts de
programas/08-volumenes-y-redes/e as saídas coincidem com as desta lição, exceto os tempos, as datas, os números de usuário e grupo da máquina e o que os scripts ocultam à vista. (Os scripts publicam na8081, então não colidem com orevisorna8080.) E ao terminar,docker ps -a --filter name=fig08,docker volume ls --filter name=fig08edocker network ls --filter name=fig08não mostram nada.
Resumo
Responda sem olhar a lição:
- O que é a camada gravável de um contêiner, e que três problemas ela tem para guardar o relatório do
revisor? - Que diferença há entre um volume nomeado, um bind mount e um
tmpfs, em onde vivem os dados e quanto duram? - Por que um volume novo montado em uma pasta que a imagem não traz dá
permission deniedcom um usuário sem privilégios? - O que faz
COPY --chown=65532:65532, e por que a correção depende de o volume ser novo e vazio? - Quando o conteúdo de uma pasta é copiado e quando é coberto ao se montar algo por cima?
- O que faz
-ve o que faz--mountquando a pasta da máquina não existe? - Como se corrige o
permission deniedde um bind mount semroote sem777? - Por que na rede padrão um contêiner não encontra outro pelo nome, e o que muda em uma rede definida pelo usuário?
- O que é
127.0.0.11, e o que diz ver esse endereço em umno such host? - O que uma rede
--internalcorta, o que não corta, e por que a porta dorevisoré publicada pela outra rede?
Fontes
Todas consultadas em 21 de outubro de 2026.
- Docker, «Storage»: que o escrito na camada do contêiner não persiste e é difícil de extrair; os tipos de montagem (incluídas as montagens de imagem) e quando usar cada um.
- Docker, «Volumes»: a cópia do conteúdo da imagem para um volume vazio e a opção
volume-nocopy; que um volume com dados cobre o que havia; que--mounté o preferido; o caminho/var/lib/docker/volumes/<nome>/_data; que os volumes sobrevivem ao contêiner e os anônimos são apagados com--rm. - Docker, «Bind mounts»: que cobrem o conteúdo existente; que
-vcria a pasta e--mountnão, por padrão, com o textobind source path does not exist, e a opçãobind-create-src; que dão acesso de escrita ao host por padrão; a opçãoreadonly. - Referência do
docker volume prune: que por padrão só apaga os volumes anônimos e que-aapaga todos os que não estão em uso. - Docker, «tmpfs mounts»: que um
tmpfsvive em memória e desaparece quando o contêiner para; que não se compartilha; seu tamanho máximo (50 % da memória) e sua permissão (1777) por padrão; que--mounté o preferido; o aviso sobre a memória de troca. - Docker, «Networking»: o servidor DNS integrado em
127.0.0.11e o reenvio de consultas externas; que os contêineres da rede padrão recebem uma cópia do/etc/resolv.confda máquina; as redes internas para serviços sem acesso externo. - Docker, driver
bridge: as diferenças entre a rede padrão e as definidas pelo usuário (DNS automático, isolamento, conectar com o contêiner em execução) e que a rede padrão é um detalhe legado não recomendado para produção. - Referência do
docker network create: a opção--internale o modo interno (sem rota padrão, tráfego para outras redes descartado, o gateway alcançável e a máquina que sim alcança os contêineres). - Docker, driver
host: que o contêiner não tem rede própria nem endereço IP e que as portas publicadas são descartadas com um aviso. - Docker, links legados: que
--linkna rede padrão está obsoleto e programado para remoção no Engine 30.0. - Referência do Dockerfile:
COPY --chowne o dono0por padrão; a instruçãoVOLUMEe seu volume inicializado com o conteúdo da imagem. - Referência do
docker run:-v,--mount,--tmpfs,--networke--user. - Docker Engine 29, notas de versão: as versões e datas de «Sobre as execuções».
- Colima: a ferramenta com que os programas foram executados no macOS.
Termos desta lição
| bind mount | montagem de uma pasta ou de um arquivo da máquina dentro do contêiner; o contêiner vê exatamente isso, com seu dono e suas permissões |
| camada gravável | a camada própria de cada contêiner, por cima das da imagem; é apagada com o contêiner |
| contêiner auxiliar | contêiner descartável (--rm) usado para olhar um volume ou uma rede; aqui, alpine:3.24.2 |
| DNS integrado | servidor de nomes que o Docker coloca em 127.0.0.11 dentro dos contêineres das redes definidas pelo usuário |
| rede definida pelo usuário | rede criada com docker network create; dá nomes por DNS e isola dos demais contêineres |
| rede interna | rede criada com --internal; seus contêineres se falam entre si, mas não saem para outras redes nem para a internet |
rede padrão (bridge) |
a rede a que o Docker conecta um contêiner se nenhuma outra for indicada; sem nomes, só endereços IP |
tmpfs |
montagem que vive em memória, com tamanho, e desaparece quando o contêiner para |
| volume nomeado | armazenamento administrado pelo Docker que sobrevive aos contêineres; é criado, listado e apagado com docker volume |
| volume anônimo | volume sem nome, criado por VOLUME ou por -v /caminho; difícil de reconhecer e fácil de esquecer |
Seguinte: Lição 9 — Compose: o sistema completo
Prefere e-mail? Escreva para hola@habil.mx