Hardware e sistemas operacionais · parte 25 de 27
Docker e Kubernetes: do contêiner ao uso profissional
Como um contêiner funciona no Linux, como o Docker constrói e roda imagens e como o Kubernetes orquestra pods, do zero ao uso profissional.
Publicada em · 98 min de leitura
Um de navio é uma caixa de metal de tamanho padrão. O guindaste não precisa saber se dentro dela há banana ou eletrônico, e o navio também não. Basta que a caixa caiba nos encaixes e possa ser içada do mesmo jeito em qualquer porto. O Docker faz algo parecido com software: empacota a aplicação junto com tudo o que ela precisa e entrega uma caixa que roda igual em qualquer máquina que tenha o motor certo. O Kubernetes é o porto que decide em qual navio cada caixa vai, troca a caixa que quebrou e põe mais guindastes quando o movimento aumenta. A analogia tem limite, porque um contêiner de software não tem o peso de um contêiner de navio, e o isolamento dele é feito por mecanismos do kernel, não por uma tampa de aço.
Esta aula fecha a fase 8 da Trilha de Hardware, que trata de virtualização e contêineres. A trilha dividia o assunto em máquinas virtuais, namespaces e cgroups e Docker no Mac. Aqui esses três temas aparecem juntos, e o Kubernetes entra no fim, porque ele resolve um problema que o Docker sozinho não resolve: manter muitos contêineres vivos em muitas máquinas. Os pré-requisitos são processos e threads, memória virtual, namespaces e cgroups, e a aula Carga da CPU no Mac. Namespaces e cgroups têm notas na pasta Docker do vault (Namespaces e CGroups), e esta aula as cita. Processos e threads e memória virtual ainda não têm nota própria na trilha, então o texto explica o necessário no lugar em que aparecem.
Os exemplos foram rodados numa máquina Apple Silicon (arm64), com macOS 27.0.0, Docker Desktop com servidor 28.3.2 e um cluster minikube com Kubernetes 1.34.0, em 3 de novembro de 2026. Onde a saída aparece no texto, ela é a saída real do comando, com trechos cortados quando indicado. Os números de carga da máquina variaram de 12 a 48 durante os testes, e a seção 9 explica por que isso importa.
Mapa da aula
| Parte | Seções | Pergunta que responde |
|---|---|---|
| Base | 1 e 2 | O que é um contêiner no Linux e em que ele difere de uma máquina virtual? |
| Empacotar e rodar | 3 e 4 | Como uma é montada em camadas e como o Docker a executa? |
| Orquestrar | 5 a 7 | Qual problema o Kubernetes resolve, como ele é organizado e quais objetos usamos? |
| Operar | 8 e 9 | Como probes, limites e rollouts mantêm a aplicação saudável, e como a CPU entra nisso? |
| Armadilhas e prática | 10 a 13 | Quais erros são comuns, como praticar e o que ainda não foi confirmado? |
| Uso profissional | 15 | Como passar de uma máquina para registries, clusters e operação no dia a dia? |
1. O que é um contêiner
Um contêiner é um processo comum do Linux que roda com uma visão e um limite de uso definidos por quem o criou. Não há um computador escondido dentro do computador. Há um processo, e o kernel decide o que esse processo enxerga e quanto ele pode consumir. Dois mecanismos do kernel fazem isso. Os namespaces controlam o que o processo enxerga, como outros processos, arquivos e interfaces de rede. Os cgroups controlam quanto ele pode gastar de CPU, memória e disco.
Antes de contêineres, rodar duas aplicações na mesma máquina dava conflito. Uma queria a porta 8080, a outra precisava de uma versão diferente de uma biblioteca, e uma terceira comia toda a memória e derrubava as demais. Uma máquina virtual resolve esses conflitos, mas cada VM carrega um sistema operacional inteiro, com seu kernel, seu boot e seus processos de fundo. O contêiner resolve o conflito sem carregar o sistema inteiro, porque o que se separa é a visão e o consumo, e o kernel continua sendo um só.
Namespaces: o que o processo enxerga
Um é um recorte da visão do sistema. A página do man-pages do Linux (versão 6.19, datada de 8 de fevereiro de 2026, consultada em 3 de novembro de 2026) descreve oito tipos: , IPC, mount, network, PID, time, user e UTS. Cada um isola um tipo de recurso. O PID isola os números de processo, o mount isola a árvore de pontos de montagem, o network isola interfaces, tabelas de roteamento e portas, o IPC isola filas e memória compartilhada entre processos, e o UTS isola o nome da máquina.
Para ver isso sem teoria, rodei um contêiner e pedi para ele listar os próprios namespaces. O resultado mostra um número de identificação para cada um:
$ docker run --rm alpine ls -l /proc/self/ns
total 0
lrwxrwxrwx 1 root root 0 Nov 3 14:36 cgroup -> cgroup:[4026533649]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 ipc -> ipc:[4026533647]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 mnt -> mnt:[4026533645]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 net -> net:[4026533650]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 pid -> pid:[4026533648]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 pid_for_children -> pid:[4026533648]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 time -> time:[4026531834]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 time_for_children -> time:[4026531834]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Nov 3 14:36 uts -> uts:[4026533646]
[exit 0]
Os números de cgroup, ipc, mnt, net, pid e uts são novos, entre 4026533645 e 4026533650. Os de time e user são 4026531834 e 4026531837. Não comparei esses dois com o namespace do host, então digo apenas que eles são diferentes dos novos. Para o PID, a comparação foi feita. O namespace de PID do contêiner tem o número 4026533648. Com --pid=host, o mesmo comando passa a ver o namespace inicial da VM, que tem outro número:
$ docker run --rm alpine readlink /proc/self/ns/pid
pid:[4026533648]
$ docker run --rm --pid=host alpine readlink /proc/self/ns/pid
pid:[4026531836]
Um detalhe que vale a pena: o ps dentro do contêiner mostra o próprio comando como PID 1, porque é o primeiro processo daquele namespace de PID. No host, esse mesmo processo tem outro número. A aplicação enxerga um PID 1, e o kernel conhece o número real.
O nome do host também é isolado pelo namespace UTS. Com --hostname, o contêiner muda o próprio nome sem tocar na máquina:
$ docker run --rm --hostname app-demo alpine hostname
app-demo
Uma ressalva sobre a nota Container. Ela afirma que o namespace de usuário mapeia usuários para garantir segurança no host. Nesta instalação, o docker info lista apenas duas opções de segurança, seccomp e cgroupns, e nenhuma indica remapeamento de usuários. Ou seja, o processo que roda como root dentro do contêiner, nesta configuração, não é isolado por um mapa de usuários. A seção 10 volta a esse ponto, e a nota Container fica como pendência de revisão.
cgroups: quanto o processo pode usar
Um cgroup é um grupo de processos ao qual o kernel aplica limites e contabiliza consumo. Na versão 2 da hierarquia, que é a que esta máquina usa (o docker info mostra Cgroup v2), cada limite aparece como um arquivo dentro de /sys/fs/cgroup. Quando dou --memory=64m e --cpus=0.5 ao contêiner, o Docker escreve esses valores nos arquivos do grupo dele, e o contêiner consegue ler os próprios limites:
$ docker run --rm --memory=64m --cpus=0.5 alpine sh -c 'cat /sys/fs/cgroup/memory.max; cat /sys/fs/cgroup/cpu.max'
67108864
50000 100000
O memory.max está em bytes: 67108864 é 64 MiB. O cpu.max tem dois números, a quota e o período, ambos em microssegundos. A quota de 50000 em cada período de 100000 µs (100 ms) significa meio núcleo: o grupo pode usar 50 ms de CPU a cada 100 ms. A conta é sempre essa: --cpus=0.5 vira quota igual a 0,5 vez o período.
A parte que muda o comportamento da aplicação é o que acontece quando o limite é atingido. Para CPU, o kernel não mata nada. Ele segura o grupo até o próximo período. Para memória, o kernel primeiro tenta liberar páginas e, se não conseguir, invoca o OOM killer dentro do grupo. Essas duas reações produzem sintomas muito diferentes, e a seção 8 mostra as duas.
A figura abaixo mostra dois contêineres sobre o mesmo kernel. Cada um tem a sua visão e o seu limite. A cor rosa marca os limites, porque é ali que um valor baixo produz lentidão ou morte.
Repare que a figura liga os dois contêineres ao kernel pelas caixas de cgroups, e não pelas de namespaces. Isso foi uma escolha de desenho para não cruzar caixas. Os namespaces também são chamadas ao kernel, então a leitura correta é que ambos os recursos vivem no kernel, e a figura mostra apenas um dos caminhos.
O kernel é um só
O ponto central dos contêineres é que todos compartilham o kernel do sistema hospedeiro. Um programa de contêiner faz chamadas de sistema ao mesmo kernel que atende o resto da máquina. Isso se vê no uname, que mostra o nome do kernel em uso. No Mac:
$ uname -srm
Darwin 27.0.0 arm64
Dentro de um contêiner:
$ docker run --rm alpine uname -a
Linux c0280842c963 6.10.14-linuxkit #1 SMP Sat May 17 08:28:57 UTC 2025 aarch64 Linux
O primeiro é o kernel do macOS, com o nome XNU, que o uname apresenta como Darwin. O segundo é um kernel Linux, versão 6.10.14, com o sufixo linuxkit. Esse kernel roda na VM que o Docker Desktop cria para rodar contêineres.
De onde veio o Docker e quem fala com o kernel
O Docker surgiu em 2013, numa empresa de hospedagem chamada dotCloud, que o abriu como software livre naquele ano. Antes dele, a mesma ideia de isolar processos com recursos do kernel já existia em ferramentas como o LXC, que era o backend original do Docker. O que o Docker acrescentou foi a experiência de uso: uma imagem empacotada, um formato de construção (o Dockerfile) e um comando simples para rodar tudo. Com o tempo, a parte de baixo foi separada em componentes próprios, para que outras ferramentas pudessem reutilizá-la. Essa separação é a cadeia que vale entender.
A cadeia tem quatro peças principais, e cada uma cuida de uma parte do trabalho:
- O cliente (
docker) é o programa que você digita. Ele não cria contêiner nenhum. Só envia pedidos, pela API REST, ao daemon, por um socket Unix (em geral/var/run/docker.sock). - O daemon (
dockerd) recebe esses pedidos. Ele cuida das imagens, das redes, dos volumes e da API. Para criar um contêiner, ele não faz o trabalho pesado sozinho: delega ao containerd. - O containerd gerencia o ciclo de vida dos contêineres (criar, iniciar, parar), guarda o estado e cuida das camadas da imagem. Ele roda como um processo separado e conversa com o daemon por gRPC.
- O runc é o programa que, de fato, cria o contêiner. Ele segue a especificação de runtime da OCI, uma especificação aberta que descreve como um processo isolado deve ser montado. O containerd chama o runc uma vez por contêiner, e o runc termina depois de entregar o processo ao kernel.
Quando você roda docker run, o caminho é este: o cliente envia o pedido ao daemon, o daemon pede ao containerd que crie o contêiner, o containerd chama o runc, e o runc faz as chamadas ao kernel que montam o isolamento. Depois que o processo do contêiner começa, o runc sai de cena, e um pequeno processo intermediário (o shim) mantém o contêiner rodando e repassa a saída e o código de saída.
A comunicação com o kernel acontece por chamadas de sistema, que são os pontos de entrada que o kernel oferece a qualquer programa. O runc usa algumas delas. A chamada clone ou unshare cria os novos namespaces, e setns entra em um namespace já existente. mount e pivot_root montam o sistema de arquivos da imagem como a raiz do contêiner. Para os limites, o runc escreve valores nos arquivos do cgroup, que são uma interface de arquivos (o mesmo mecanismo visto na seção de cgroups). Por fim, execve substitui o processo do runc pelo processo do contêiner, que passa a ser o PID 1 dentro do próprio namespace.
Nada disso é mágica do Docker. Qualquer programa que faça essas chamadas consegue criar um contêiner. Por isso o runc pode ser usado sem o Docker, e existem outras ferramentas que usam a mesma base.
Duas observações sobre a instalação desta aula. O docker info mostra o runtime padrão como runc, o containerd e o runc com as versões que aparecem na seção de ambiente, e o driver de cgroup como cgroupfs. Os nomes exatos das chamadas de sistema que o runc usa em cada versão e a ordem exata delas não foram lidos no código-fonte nesta aula, então a lista acima descreve o papel de cada uma, e não uma sequência verificada.
Por que um contêiner no Mac depende de uma VM Linux
O kernel que um contêiner usa precisa ser Linux, porque os namespaces e cgroups são recursos desse kernel. O macOS tem outro kernel, então o Docker Desktop não consegue simplesmente criar um namespace no Mac. Ele cria uma máquina virtual com um kernel Linux, e os contêineres rodam dentro dela. A documentação oficial do Docker, em sua página sobre gerenciadores de VM, diz que o Docker Desktop usa esses gerenciadores para alimentar a “Linux VM that runs containers”, ou seja, a VM Linux que roda os contêineres. A página não diz qual gerenciador é o padrão no macOS, então isso não está afirmado aqui.
Do lado do Mac, a VM aparece como um único processo. Esse é o motivo de a aula de Carga da CPU no Mac ter encontrado a VM como vilã: o ps mostra uma linha só com a soma de tudo que roda dentro dela. A seção 9 mostra a medição.
2. Contêiner e máquina virtual
A diferença mais visível entre contêiner e máquina virtual está em quantas camadas uma chamada atravessa. Numa VM, a aplicação chama o sistema convidado, o sistema convidado conversa com um hypervisor, e o hypervisor divide o hardware. Num contêiner no Linux, a aplicação chama o kernel direto, e o kernel é o mesmo que atende todos os outros contêineres.
A figura abaixo coloca as duas arquiteturas lado a lado, cada uma com as camadas que a chamada percorre. Os pacotes que descem representam chamadas, e a cor laranja marca o código que o usuário escreve. Ela foi desenhada para um host Linux, porque no Mac a de kernel compartilhado fica dentro da VM do Docker Desktop.
Na VM, cada máquina tem o próprio kernel e pode ter outro sistema operacional, outro conjunto de bibliotecas do sistema e outro boot. Isso dá um isolamento forte, porque cada convidado tem um hypervisor entre ele e o hardware. O preço é o peso: cada VM carrega um sistema inteiro, demora para subir e reserva memória para o kernel dela antes de receber a primeira requisição.
No contêiner, a separação é mais leve. Subir um contêiner é criar um processo com namespaces e cgroups, e isso leva o tempo de iniciar um programa. O preço é que todos os contêineres dependem do mesmo kernel. Uma falha de kernel afeta todos ao mesmo tempo, e um contêiner que consegue explorar uma brecha no kernel tem um caminho mais curto até o resto da máquina. Por isso, para tenant não confiável, muita gente combina contêineres com uma VM por baixo. Algumas equipes combinam as duas coisas para cargas de confiança baixa, mas a análise de risco dessa prática não foi feita aqui, e a seção 13 a lista como não confirmada.
3. Imagens e camadas
Uma imagem Docker é uma receita congelada, feita de camadas. Cada camada é um conjunto de arquivos que se soma às anteriores. Quando um contêiner começa, o Docker empilha essas camadas em somente leitura e adiciona por cima uma camada gravável, que guarda tudo o que o processo cria ou altera. Essa pilha é montada por um sistema de arquivos em camadas, chamado . Nesta máquina, o docker info mostra Storage: overlayfs, que é o driver que faz essa união com o mecanismo OverlayFS do kernel.
Uma imagem é uma pilha de camadas
O comando docker history mostra as camadas de uma imagem na ordem em que foram criadas. Aqui está a imagem de exemplo que eu montei, que você verá em seguida:
$ docker history exemplo/app:1.0.0
IMAGE CREATED CREATED BY SIZE COMMENT
d2ba0d391ca8 1 second ago ENTRYPOINT ["/usr/local/bin/app"] 0B buildkit.dockerfile.v0
<missing> 1 second ago EXPOSE map[8080/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 1 second ago COPY /out/app /usr/local/bin/app # buildkit 8.02MB buildkit.dockerfile.v0
<missing> 1 second ago USER app 0B buildkit.dockerfile.v0
<missing> 10 seconds ago RUN /bin/sh -c adduser -D -u 10001 app # bui… 41kB buildkit.dockerfile.v0
<missing> 6 months ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0
<missing> 6 months ago ADD alpine-minirootfs-3.20.10-aarch64.tar.gz… 9.49MB buildkit.dockerfile.v0
[exit 0]
Algumas linhas não ocupam espaço. ENTRYPOINT, EXPOSE e USER são só metadados gravados na configuração da imagem, por isso aparecem com 0B. O espaço de fato está nas linhas ADD, RUN e COPY, que criam arquivos. A camada mais antiga, ADD do minirootfs do Alpine, pesa 9,49 MB. A linha RUN adduser pesa 41 kB, porque cria um usuário com alguns arquivos de configuração. A linha COPY pesa 8,02 MB, que é o binário da aplicação.
A figura abaixo mostra essa pilha, com as camadas de baixo para cima e as duas camadas graváveis de dois contêineres que usam a mesma imagem.
A camada gravável de cada contêiner é o que explica dois comportamentos que surpreendem quem começa. O primeiro é que dados criados dentro do contêiner somem quando o contêiner é removido, porque moram nessa camada. O segundo é que dois contêineres da mesma imagem não se afetam, porque cada um tem a camada gravável dele, e as camadas de baixo são somente leitura. A seção 4 mostra os dois casos com saídas reais.
Dockerfile: a receita
O arquivo Dockerfile descreve como montar a imagem, uma instrução por linha, e cada instrução vira uma camada. Para não ficar só na teoria, montei uma aplicação de exemplo, em Go, que responde em duas rotas: /healthz e /. O programa usa só a biblioteca padrão e lê a porta e uma versão de variáveis de ambiente.
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
porta := os.Getenv("PORTA")
if porta == "" {
porta = "8080"
}
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ola do conteiner, versao %s\n", os.Getenv("VERSAO"))
})
log.Printf("escutando na porta %s", porta)
log.Fatal(http.ListenAndServe(":"+porta, nil))
}
O go.mod declara só o nome do módulo e a versão da linguagem (module exemplo.local/app e go 1.27). O Dockerfile usa dois estágios:
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /out/app .
FROM alpine:3.20
RUN adduser -D -u 10001 app
USER app
COPY --from=build /out/app /usr/local/bin/app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/app"]
A primeira instrução, FROM, escolhe a imagem de partida. O estágio chamado build usa a imagem de Go, que tem o compilador, e compila o programa com CGO_ENABLED=0, que gera um binário sem depender de bibliotecas C do sistema. O segundo estágio parte de alpine:3.20, que é uma imagem pequena, cria um usuário sem privilégios com adduser -D -u 10001 app, troca para esse usuário com USER app e copia só o binário do primeiro estágio com COPY --from=build. O EXPOSE documenta a porta, mas não publica nada, e o ENTRYPOINT diz o que roda quando o contêiner inicia.
Ao lado do Dockerfile fica um arquivo .dockerignore, com .git e *.md. Ele diz ao Docker quais arquivos do diretório não devem entrar no contexto de build. Sem ele, o Docker envia a pasta inteira ao daemon, incluindo o histórico do git, e cada arquivo a mais é mais coisa para copiar e mais chance de a camada COPY mudar sem motivo.
Para montar a imagem:
$ docker build -t exemplo/app:1.0.0 .
...
#11 [build 4/4] RUN CGO_ENABLED=0 go build -o /out/app .
#11 DONE 8.3s
...
#13 naming to docker.io/exemplo/app:1.0.0 done
[exit 0]
O log completo tem mais linhas de progresso, que foram removidas aqui. O passo que demorou foi o go build, com 8,3 segundos.
Cache de camadas
Quando o mesmo comando roda de novo sem nenhuma mudança, o Docker não refaz o trabalho. Ele reaproveita cada camada que já existe, e o log mostra CACHED no lugar do tempo:
$ docker build -t exemplo/app:1.0.0 .
...
#8 [stage-1 2/3] RUN adduser -D -u 10001 app
#8 CACHED
#9 [build 2/4] WORKDIR /src
#9 CACHED
#10 [build 3/4] COPY go.mod main.go ./
#10 CACHED
#11 [build 4/4] RUN CGO_ENABLED=0 go build -o /out/app .
#11 CACHED
#12 [stage-1 3/3] COPY --from=build /out/app /usr/local/bin/app
#12 CACHED
[exit 0]
Esse comportamento tem uma regra que a documentação do Docker descreve sem rodeios: quando uma camada muda, ela precisa ser refeita, e todas as camadas depois dela também são afetadas. A documentação dá o caso de um COPY de main.c seguido de RUN make build. Se o arquivo mudar, o COPY roda de novo e o RUN também, mesmo que o resultado final fosse o mesmo.
A consequência prática é a ordem das instruções. No Dockerfile de exemplo, o go.mod é copiado antes do código, e o go build vem depois do COPY do código. Se eu mudar uma linha de main.go, o cache de COPY go.mod main.go é invalidado, e o go build roda de novo, mas o FROM e o adduser continuam em cache. Já um Dockerfile que copia a pasta inteira logo no início e depois instala dependências invalida a instalação a cada mudança de qualquer arquivo. O padrão é colocar primeiro o que muda menos, como as dependências, e deixar o código para o fim.
A figura abaixo mostra a decisão que o Docker toma para cada instrução, com o caminho de falha.
Na figura, o losango é a decisão para cada instrução. O caminho de cima reaproveita e o de baixo executa. Quando a execução falha, o build para, e a imagem não é gravada. As instruções seguintes não aparecem na figura porque o texto acima já explica o laço: cada instrução passa pela mesma decisão, e a primeira que muda invalida as seguintes.
Build em múltiplos estágios
O estágio de build tem o compilador de Go, os arquivos fonte e o cache do módulo, e nada disso precisa ir para a imagem que roda em produção. O Dockerfile de dois estágios resolve isso. Medi os tamanhos com docker image ls:
exemplo/app:1.0.0 26.1MB
golang:1.27-alpine 373MB
alpine:3.20 13.6MB
A imagem final tem 26,1 MB, dos quais 13,6 MB são a base Alpine e o resto é o binário e a camada do usuário. A imagem de Go, de 373 MB, é o estágio que fica de fora. A documentação do Docker sobre boas práticas diz que builds multi-estágio reduzem o tamanho da imagem final e também podem rodar etapas em paralelo. Só o primeiro ganho foi medido aqui.
Tags e
Uma tag é um nome legível, como alpine:3.20. Um digest é um hash do conteúdo, com a forma sha256: seguida de 64 caracteres hexadecimais. A diferença importa porque a tag é mutável: quem publica a imagem pode apontar a mesma tag para outro conteúdo amanhã. O digest não muda, porque é derivado do conteúdo. A documentação do Docker sobre boas práticas diz que tags são mutáveis e que, para uma cadeia de suprimentos íntegra, a imagem pode ser fixada por digest.
Para ver o digest que a minha máquina tem da imagem base:
$ docker image inspect alpine:3.20 --format '{{index .RepoDigests 0}}'
alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
[exit 0]
O digest mostra qual conteúdo está na minha máquina. Ele não diz se a tag alpine:3.20 hoje aponta para o mesmo conteúdo. Para isso, seria preciso consultar o registro. Esse é o motivo de a seção 10 tratar a tag latest como um erro.
4. Docker na prática
Com a imagem pronta, o ciclo de uso se resume a poucos comandos: docker run cria e inicia um contêiner a partir de uma imagem, docker exec abre um comando dentro de um contêiner que já roda, docker logs mostra a saída do processo principal, e docker rm remove o contêiner. Esta seção mostra cada um deles com saídas reais, e depois passa pelos volumes, pelas redes, pelas variáveis de ambiente e pelos limites.
Rodar, inspecionar e parar
O comando a seguir sobe a aplicação de exemplo, publica a porta 8080 da máquina na porta 8080 do contêiner e define duas variáveis de ambiente:
$ docker run -d --name app1 -p 8080:8080 -e PORTA=8080 -e VERSAO=1.0.0 exemplo/app:1.0.0
165c7039a83011904fff32c90afe435df46c441338bbe187f9c56a41b6212c9c
$ curl -s http://localhost:8080/
ola do conteiner, versao 1.0.0
$ curl -s http://localhost:8080/healthz
ok
$ docker logs app1
2026/11/03 14:37:53 escutando na porta 8080
$ docker exec app1 id
uid=10001(app) gid=10001(app) groups=10001(app)
$ docker exec app1 sh -c 'echo VERSAO=$VERSAO'
VERSAO=1.0.0
A flag -d tira o contêiner do terminal. O -p 8080:8080 é a publicação de porta, que liga uma porta do host à porta do contêiner. Sem ela, o processo escuta dentro do contêiner, e nada de fora chega até ele. O -e define variáveis de ambiente, que a aplicação lê com os.Getenv. O docker exec mostra que o processo roda como o usuário app, com UID 10001, criado no Dockerfile. Se o processo rodasse como root, o id mostraria uid=0.
Para parar e remover, o comando é docker rm -f, que mata o processo e apaga o contêiner. A camada gravável vai junto, e é isso que aparece no caso dos volumes, a seguir.
Volumes: dados que sobrevivem ao contêiner
é um lugar do disco do host, ou da VM no caso do Mac, que o Docker monta dentro do contêiner e que não faz parte da camada gravável. Para mostrar a diferença, escrevi um arquivo dentro de um contêiner sem volume, removi o contêiner e tentei ler o arquivo de novo:
$ docker run --name sem-volume alpine sh -c 'mkdir -p /var/lib/banco && echo registro-1 > /var/lib/banco/dados.txt && cat /var/lib/banco/dados.txt'
registro-1
[exit 0]
$ docker rm sem-volume
sem-volume
$ docker run --rm alpine cat /var/lib/banco/dados.txt
cat: can't open '/var/lib/banco/dados.txt': No such file or directory
[exit 1]
Depois, o mesmo teste com um volume nomeado:
$ docker volume create dados-teste
dados-teste
$ docker run --rm -v dados-teste:/var/lib/banco alpine sh -c 'echo registro-1 > /var/lib/banco/dados.txt'
$ docker run --rm -v dados-teste:/var/lib/banco alpine cat /var/lib/banco/dados.txt
registro-1
O primeiro contêiner escreveu o arquivo em /var/lib/banco, que é o volume, e o segundo, que é outro contêiner, leu o mesmo arquivo. O volume existe independentemente de qualquer contêiner, por isso sobreviveu à remoção. Uma base de dados dentro de contêiner precisa desse volume, e a seção 10 trata o caso.
Redes e portas
Contêineres numa rede definida pelo usuário conseguem se encontrar pelo nome. Criei uma rede, subi um servidor nginx com um nome e chamei esse nome de dentro de outro contêiner:
$ docker network create rede-app
$ docker run -d --name web --network rede-app -p 8081:80 nginx:1.27-alpine
$ docker run --rm --network rede-app alpine wget -qO- http://web | head -4
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
[exit 0]
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:8081/
HTTP 200
O cliente de dentro da rede chegou ao servidor pelo nome web, e não por um IP. Isso é a resolução de nomes da rede do usuário, que o Docker faz automaticamente. A porta 8081 publicada também atende a máquina, e o curl do Mac recebeu HTTP 200. Esse teste usou uma rede definida pelo usuário. Não testei o comportamento da rede bridge padrão, então não afirmo nada sobre ela.
Variáveis de ambiente
Uma variável de ambiente entra no contêiner pelo -e no docker run, ou pela seção environment no . Ela fica visível para qualquer um que consiga listar o ambiente do processo, e também aparece na imagem quando é definida com ENV no Dockerfile. A seção 10 mostra esse problema com um teste.
Limites de recursos
Os limites entram no docker run com --cpus e --memory. O primeiro limita a fração de CPU, e o segundo limita o uso de memória. Para ver o que acontece quando o limite é ultrapassado, rodei um contêiner com 32 MB de limite que aloca cerca de 100 MB de uma vez:
$ docker run --name oom1 --memory=32m alpine sh -c 'x=$(head -c 100000000 /dev/zero | tr "\0" a); echo "tamanho ${#x}"'
[exit 137]
$ docker inspect --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' oom1
OOMKilled=true ExitCode=137
O processo não chegou a imprimir o tamanho. O kernel o matou por falta de memória dentro do grupo. O código de saída 137 é 128 mais 9, o sinal SIGKILL, e o docker inspect confirma que foi o OOM killer, com OOMKilled=true.
Para CPU, o efeito é outro. Rodei um laço de CPU por 5 segundos num contêiner com --cpus=0.5 e, depois, o mesmo laço num contêiner sem limite. Lendo o arquivo cpu.stat do próprio contêiner antes e depois, a diferença fica clara:
$ docker run --name cpu3 --cpus=0.5 alpine sh -c 'timeout 5 sh -c "while :; do :; done"; cat /sys/fs/cgroup/cpu.stat'
usage_usec 2436640
user_usec 2398708
system_usec 37931
nr_periods 52
nr_throttled 46
throttled_usec 2414591
nr_bursts 0
burst_usec 0
$ docker run --name cpu4 alpine sh -c 'timeout 5 sh -c "while :; do :; done"; cat /sys/fs/cgroup/cpu.stat'
usage_usec 5100790
user_usec 5060539
system_usec 40251
nr_periods 0
nr_throttled 0
throttled_usec 0
Com o limite, o grupo passou por 52 períodos e foi estrangulado em 46 deles, somando 2,41 segundos de espera (throttled_usec em microssegundos). Sem limite, nenhum período foi estrangulado. A quantidade de CPU consumida ficou em torno de meio núcleo, o que bate com a quota de 50000 em 100000. A seção 9 explica o que isso significa para a latência.
Os limites aparecem também no docker stats, que mostra o uso e o limite de cada contêiner em tempo real. Para o contêiner de 64 MB com meio núcleo, a linha ficou assim, em repouso:
NAME CPU % MEM USAGE / LIMIT MEM %
app2 0.00% 4.902MiB / 64MiB 7.66%
Compose: um conjunto de serviços em um arquivo
Uma aplicação real costuma ter mais de um contêiner, como a aplicação e o banco. Subir cada um com um docker run longo não escala, e o Docker Compose resolve isso com um arquivo YAML que descreve os serviços. O arquivo abaixo tem a aplicação de exemplo e um Postgres 17, com um volume para os dados do banco e um teste de saúde:
services:
app:
build: .
ports:
- "8080:8080"
environment:
PORTA: "8080"
VERSAO: "compose-1"
cpus: 0.5
mem_limit: 128m
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?defina POSTGRES_PASSWORD no ambiente}
volumes:
- dados-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
volumes:
dados-db:
A linha POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?...} lê a senha de uma variável de ambiente e recusa a execução se ela não existir. Não coloquei senha no arquivo, e isso importa porque o arquivo vai para o git. Sem a variável, o Compose para antes de subir qualquer coisa:
$ env -u POSTGRES_PASSWORD docker compose config --quiet
error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable POSTGRES_PASSWORD is missing a value: defina POSTGRES_PASSWORD no ambiente
[exit 1]
Com a variável definida, para o teste, com um valor descartável só para esta máquina, o Compose sobe os dois serviços, na ordem que o depends_on pede. A saída abaixo é um trecho, sem as linhas de progresso do build:
$ env POSTGRES_PASSWORD=descartavel-local docker compose up -d --build
app-app Built
Network app_default Created
Volume "app_dados-db" Created
Container app-db-1 Healthy
Container app-app-1 Started
$ env POSTGRES_PASSWORD=descartavel-local docker compose exec db pg_isready -U postgres
/var/run/postgresql:5432 - accepting connections
$ curl -s http://localhost:8080/
ola do conteiner, versao compose-1
O depends_on com condition: service_healthy faz a aplicação esperar até o banco responder ao pg_isready. Repare que o app de exemplo não conecta ao banco. A dependência só mostra a ordem de subida, e nenhum código de conexão existe no programa. O limite de cpus: 0.5 e mem_limit: 128m aparece no docker inspect como NanoCpus=500000000 e Memory=134217728, que são meio núcleo e 128 MiB.
Para derrubar o conjunto sem perder o banco (a saída abaixo é um trecho do log):
$ env POSTGRES_PASSWORD=descartavel-local docker compose down
Container app-app-1 Removed
Container app-db-1 Removed
Network app_default Removed
$ docker volume ls --filter name=dados-db
DRIVER VOLUME NAME
local app_dados-db
O down remove contêineres e a rede, mas mantém o volume. Para apagar o volume também, seria preciso docker compose down -v, e esse comando precisa ser usado com cuidado, porque o banco some junto.
5. O problema que o Kubernetes resolve
O Docker resolve bem o problema de empacotar e rodar uma aplicação numa máquina. Quando a aplicação passa a ter várias cópias, espalhadas por várias máquinas, aparecem perguntas que o docker run não responde. Quem decide em qual máquina cada cópia vai rodar? O que acontece quando a máquina cai? Como atualizar a versão sem derrubar o serviço? Como uma cópia encontra as outras, e como o tráfego chega a cada uma delas?
Um docker run com --restart always reinicia o contêiner quando ele cai, mas só na mesma máquina. Se a máquina inteira falha, nada no Docker move a aplicação para outra. Um operador precisa perceber, escolher outra máquina, copiar a imagem e subir o contêiner de novo. Em produção, essa sequência precisa acontecer às três da manhã, sem ninguém acordado. Esse é o primeiro problema: escala e autocorreção exigem uma coisa que decide o estado desejado e age para mantê-lo.
O segundo problema é a atualização. Trocar a versão de uma aplicação com três cópias, sem parar o serviço, significa subir uma cópia nova, esperar que ela esteja pronta, tirar uma cópia velha, e repetir. Fazer isso à mão é possível, mas é lento e sujeito a erro. O Kubernetes automatiza esse procedimento e permite voltar à versão anterior com um comando.
O terceiro problema é a descoberta. Quando a aplicação tem várias cópias e elas mudam de endereço toda vez que sobem, o cliente precisa de um nome estável e de um balanceamento entre elas. O Kubernetes resolve isso com o objeto , que tem um nome estável e envia o tráfego apenas para as cópias prontas.
O Kubernetes é, portanto, um sistema que recebe um estado desejado (por exemplo, três cópias da versão 1.0.0 atendendo na porta 8080) e fica comparando esse estado com o real, agindo quando há diferença. Esse modelo, de declarar o que se quer e deixar um laço de controle corrigir, é o centro da ferramenta. Ele funciona como um laço que roda enquanto o cluster existir.
6. Arquitetura do Kubernetes
Um cluster Kubernetes tem duas partes. O toma decisões e guarda o estado do cluster. Os nós (nodes) são as máquinas que de fato rodam os contêineres. A figura abaixo mostra as duas partes, com um engenheiro enviando um manifesto e um rodando num nó.
Control plane
O kube-apiserver é o servidor da API do Kubernetes. Todo pedido passa por ele: o kubectl apply, a consulta de um pod, a atualização de estado de um nó. Ele valida os objetos, grava o estado e avisa os demais componentes quando algo muda. A página de componentes da documentação oficial o descreve como o componente servidor central que expõe a API HTTP do Kubernetes.
O é um armazenamento chave-valor consistente e altamente disponível, e guarda todos os dados do API server. A página de componentes da documentação descreve o etcd como o armazenamento de todos os dados do API server. Por isso, perder o etcd sem backup significa perder o estado do cluster, e a seção 13 lista como não confirmado o detalhe de backup, que não foi testado aqui.
O kube-scheduler procura os pods que ainda não têm nó e escolhe um nó adequado para cada um. A escolha considera os requests de recursos, que a seção 8 explica. Ele não executa nada. Só decide e registra a decisão no API server.
O kube-controller-manager roda os controllers, que são laços de controle. Um controller de , por exemplo, compara quantos pods existem com quantos foram pedidos e cria ou remove pods para igualar. Um controller de faz a mesma coisa para a troca de versões. O cloud-controller-manager integra o cluster com a nuvem, e não aparece no minikube local.
Nós
O roda em cada nó. Ele recebe do API server a lista de pods que devem rodar naquele nó e garante que os contêineres desses pods estejam de pé. A documentação diz que o kubelet garante que os pods estejam em execução, incluindo seus contêineres.
O container runtime é o software que de fato cria os contêineres. Em instalações comuns, o kubelet não fala diretamente com o Docker Engine. Ele fala com um runtime por meio da Container Runtime Interface (), uma API padronizada. A documentação oficial sobre runtimes diz que o dockershim foi removido do projeto Kubernetes a partir da versão 1.24, e que o Kubernetes 1.37 exige um runtime compatível com a CRI. Mesmo assim, o nó deste cluster reporta docker://28.4.0 na coluna de runtime. Isso indica que o minikube, com o Docker como driver, entregou o Docker Engine ao kubelet por um adaptador que não verifiquei nesta aula.
O kube-proxy mantém as regras de rede do nó para implementar os Services. Quando um cliente chama o nome de um Service, são essas regras que encaminham a conexão a um dos pods prontos. A documentação diz que é um componente opcional, e o minikube o instala por padrão.
O ciclo de controle
O padrão que une essas peças é o de estado desejado. Você escreve o manifesto, o API server grava o desejo no etcd, e um controller percebe a diferença entre o desejo e o real. O controller cria ou remove objetos pelo API server, o scheduler escolhe nós, e o kubelet faz a parte concreta. Nenhum componente manda diretamente no outro. Todos observam o API server e agem sobre a parte que lhes cabe. Esse é o motivo de um cluster conseguir se recuperar sozinho: o laço continua rodando e não depende de uma ação humana para perceber a falha.
A figura mostra esse desenho em forma de caixas. O caminho do manifesto vai do engenheiro ao API server, e o caminho do pod vai do API server ao kubelet, ao runtime e ao contêiner. A linha entre o API server e o etcd é de mão dupla, porque o API server lê e grava nele.
O cluster desta aula
Para os experimentos, criei um cluster local com minikube, usando o Docker como driver. A versão do Kubernetes foi a padrão do minikube nessa data, que é a 1.34.0. A versão estável mais recente listada na documentação em 3 de novembro de 2026 é a 1.37.1, e a página de releases indica que a 1.34 chegou ao fim de suporte em 27 de outubro de 2026. Ou seja, o cluster usado aqui já estava fora de suporte quando os testes rodaram. Isso não muda o que as saídas mostram, mas é um detalhe para quem for reproduzir a aula com uma versão nova.
$ kubectl version
Client Version: v1.34.1
Kustomize Version: v5.7.1
Server Version: v1.34.0
$ kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
minikube Ready control-plane 4m45s v1.34.0 192.168.49.2 <none> Ubuntu 22.04.5 LTS 6.10.14-linuxkit docker://28.4.0
A coluna KERNEL-VERSION mostra 6.10.14-linuxkit, o mesmo kernel que o contêiner de Docker mostrou na seção 1. O nó do minikube é um contêiner (imagem kicbase) rodando dentro da VM do Docker Desktop, e por isso compartilha o kernel dessa VM. Os componentes do control plane aparecem como pods no namespace kube-system:
$ kubectl get pods -n kube-system --no-headers | awk '{print $1, $3}'
coredns-66bc5c9577-rcvww Running
etcd-minikube Running
kube-apiserver-minikube Running
kube-controller-manager-minikube Running
kube-proxy-g47lw Running
kube-scheduler-minikube Running
storage-provisioner Running
Esse é o control plane rodando como contêineres no próprio cluster. Em instalações de produção, ele costuma rodar em máquinas separadas, mas a lógica é a mesma.
7. Objetos principais
O Kubernetes organiza tudo em objetos, descritos em YAML e enviados ao API server. Esta seção percorre os mais usados. Cada um tem uma função específica, e a combinação deles forma a aplicação.
Pod
Pod é a menor unidade que o Kubernetes agenda. Um pod tem um ou mais contêineres que dividem o mesmo endereço de rede e os mesmos volumes. Na maioria dos casos, um pod tem um só contêiner, e a ideia de vários é reservada para processos que precisam ser inseparáveis, como um contêiner principal e um auxiliar de log. Um pod nasce, roda e morre. Quando ele morre, o Kubernetes não o ressuscita. Ele cria outro, com outro nome e outro endereço IP. Por isso ninguém deve depender do IP de um pod.
Um pod sozinho é frágil, porque nada garante que ele será recriado se cair. Por isso, quase nunca se cria um pod diretamente em produção. Cria-se um objeto que mantém pods.
ReplicaSet
O ReplicaSet mantém um número fixo de pods iguais. Ele tem um seletor de rótulos, que diz quais pods ele conta como seus, e um número de réplicas desejado. Se um pod sai, o controller de ReplicaSet cria outro. Se há pods a mais, remove. O ReplicaSet não sabe nada sobre versões nem sobre atualizações. Ele só mantém a quantidade.
Deployment
O Deployment cria e gerencia ReplicaSets. Ele é o objeto que a maioria das pessoas usa para aplicações sem estado. Quando você muda a imagem ou uma variável de ambiente do pod template, o Deployment cria um ReplicaSet novo, sobe os pods dele aos poucos e reduz o antigo. A documentação diz que o Deployment fornece atualizações declarativas para pods e ReplicaSets, e que não se deve mexer diretamente nos ReplicaSets que pertencem a um Deployment. As mudanças corretas passam pelo Deployment.
Na prática, a cadeia é Deployment, depois ReplicaSet, depois Pod. Cada nível cuida de uma pergunta: o Deployment pergunta qual versão deve estar no ar, o ReplicaSet pergunta quantas cópias dessa versão devem existir, e o Pod roda uma cópia.
Service
O Service dá um nome estável e um IP virtual para um conjunto de pods. Ele escolhe os pods por um seletor de rótulos e, por padrão, do tipo ClusterIP, só é acessível de dentro do cluster. O Service acompanha os pods que mudam, e só envia tráfego para os que estão prontos, graças à readiness , que a seção 8 explica. Um teste de dentro do cluster mostrou o Service respondendo:
$ kubectl run cliente --rm -i --restart=Never --image=alpine:3.20 --command -- wget -qO- http://app
ola do conteiner, versao 1.0.0
pod "cliente" deleted from default namespace
O nome app foi resolvido pelo DNS do cluster, e a requisição chegou a um dos três pods. A lista de endpoints mostra os três endereços que o Service usa no momento:
$ kubectl get endpoints app
NAME ENDPOINTS AGE
app 10.244.0.3:8080,10.244.0.4:8080,10.244.0.5:8080 4s
O comando emitiu um aviso de que o objeto Endpoints está depreciado desde a versão 1.33, e que o recomendado é o EndpointSlice. Mantive o Endpoints por ser o que o comando mostra de forma compacta, e a diferença não muda a leitura.
O Ingress é um objeto para expor serviços HTTP para fora do cluster, com roteamento por nome de host e caminho. Ele, sozinho, não faz nada. A documentação diz que é preciso ter um controlador de Ingress instalado, e que criar só o recurso não produz efeito. Um dos controladores mais conhecidos era o ingress-nginx.
Esse controlador foi aposentado. O anúncio oficial, publicado pelo Kubernetes SIG Network e pelo Security Response Committee em 11 de novembro de 2025, indicou que a manutenção de melhor esforço do ingress-nginx terminaria em março de 2026. Depois disso, o projeto não receberia novas versões, correções de bugs nem patches de segurança. O anúncio recomenda migrar para a Gateway API ou para outro controlador. A página de Ingress da documentação diz que o projeto recomenda usar Gateway no lugar de Ingress, e que a API Ingress está congelada. Ela continua disponível e não será removida, mas não recebe novas funções. Esta aula não testou a Gateway API, então a migração não foi executada aqui.
e
O ConfigMap guarda configuração que não é segredo: pares de chave e valor, arquivos ou trechos de configuração. O pod recebe esses valores como variáveis de ambiente ou como arquivos montados. Assim, a mesma imagem serve para ambientes diferentes, e mudar uma configuração não exige reconstruir a imagem.
O Secret guarda dados sensíveis, como senhas e chaves. A documentação oficial é clara sobre um ponto que surpreende: por padrão, os Secrets são armazenados sem criptografia no etcd, e quem tem acesso à API ou ao etcd pode lê-los. A codificação em base64 que o Kubernetes usa para o valor não é criptografia. Para provar isso, criei um Secret com um valor descartável e decodifiquei:
$ kubectl create secret generic app-segredo --from-literal=chave=valor-descartavel-teste
secret/app-segredo created
$ kubectl get secret app-segredo -o jsonpath='{.data.chave}'
dmFsb3ItZGVzY2FydGF2ZWwtdGVzdGU=
$ kubectl get secret app-segredo -o jsonpath='{.data.chave}' | base64 -d
valor-descartavel-teste
Qualquer pessoa com leitura no Secret recupera o valor com um comando de uma linha. A proteção real depende de controle de acesso (RBAC com privilégio mínimo) e de criptografia em repouso no etcd, que a própria documentação lista como passos necessários. Nenhum dos dois foi configurado nesta máquina de teste, e isso aparece na seção 13.
Namespaces
O namespace do Kubernetes é um agrupamento lógico de objetos dentro de um cluster. Ele não tem nada a ver com o namespace do Linux, apesar do nome igual. Serve para separar equipes, ambientes e cotas. Os objetos de sistema ficam em kube-system, e os testes desta aula ficaram no namespace default, que é o padrão quando nenhum outro é indicado.
8. probes, requests e limits, , e
Esta seção reúne os mecanismos que mantêm a aplicação saudável no dia a dia. Ela começa com as probes, que dizem ao kubelet se um contêiner está vivo e se pode receber tráfego. Depois vêm os requests e limits de CPU e de memória, que são a ponte entre o Kubernetes e o cgroup. Por fim, HPA, rolling update e rollback.
Probes
Uma probe é uma checagem periódica feita pelo kubelet. A documentação define três tipos. A liveness probe decide se o contêiner precisa ser reiniciado. A readiness probe decide se o pod pode receber tráfego do Service, e quando ela falha, o endereço do pod sai da lista de endpoints, mas o contêiner não é reiniciado. A startup probe cuida de aplicações que demoram para subir: enquanto ela não passa, a documentação diz que as demais probes ficam sem efeito, e se ela falha, o kubelet mata o contêiner.
O manifesto desta aula define as duas probes que importam no dia a dia. A readiness verifica /healthz a cada 5 segundos, e a liveness verifica o mesmo caminho a cada 10 segundos, depois de um atraso inicial de 10 segundos, e reinicia o contêiner após três falhas seguidas. O describe do pod mostra os valores efetivos, incluindo os padrões:
Liveness: http-get http://:8080/healthz delay=10s timeout=1s period=10s #success=1 #failure=3
Readiness: http-get http://:8080/healthz delay=0s timeout=1s period=5s #success=1 #failure=3
Os valores que não definimos vieram do padrão da API: timeoutSeconds de 1 segundo, successThreshold de 1 e failureThreshold de 3. A documentação confirma esses padrões. O periodSeconds padrão é de 10 segundos.
A liveness probe deve ser usada com cuidado. A documentação alerta que uma liveness mal configurada causa reinícios em cascata, principalmente sob carga alta, quando a aplicação demora a responder e é reiniciada justamente quando mais precisa de capacidade. Para aplicações que dependem de um banco ou de outro serviço, o caminho recomendado é deixar a checagem da dependência na readiness, e manter a liveness só para dizer se o processo está vivo.
A figura abaixo mostra os estados de um pod com as três probes. A cor rosa marca o estado de reinício, que é o caminho de falha.
Um teste mostrou o efeito da readiness. A primeira tentativa foi mudar o caminho da probe para /nao-existe, e o pod continuou pronto. O motivo está no código da aplicação: o handler de / responde a qualquer caminho que não seja /healthz, com status 200. A probe não falhou porque a aplicação respondeu, mesmo com a rota errada. Esse é um erro de desenho, e a lição é que uma probe só vale quando falha pela razão certa. Para forçar uma falha de verdade, mudei a porta da readiness para 9999, onde não há nenhum processo escutando:
$ kubectl get pods -l app=app
NAME READY STATUS RESTARTS AGE
app-75cc88c944-65rn8 0/1 Running 0 41s
app-7f757dfcd8-44xfl 1/1 Running 0 103s
app-7f757dfcd8-5v6jd 1/1 Running 0 104s
app-7f757dfcd8-dp8xn 1/1 Running 0 102s
$ kubectl get endpoints app
NAME ENDPOINTS AGE
app 10.244.0.16:8080,10.244.0.17:8080,10.244.0.18:8080 2m22s
$ kubectl rollout status deployment/app --timeout=40s
Waiting for deployment "app" rollout to finish: 1 out of 3 new replicas have been updated...
error: timed out waiting for the condition
O pod novo ficou em 0/1, ou seja, rodando mas não pronto. Os três endpoints continuaram sendo os pods antigos, que estavam prontos e recebiam tráfego. O rollout travou e expirou, porque o novo pod nunca ficou pronto. Esse comportamento é o que protege o serviço: com a configuração de maxUnavailable: 0, a versão ruim não tira os pods bons do ar. O rollback desfez a mudança e o Deployment voltou a três pods prontos.
Requests e limits
Requests e limits são as duas faces do mesmo recurso. O é a quantidade que o pod pede e que o scheduler usa para escolher o nó. O é o teto que o kubelet e o kernel aplicam. A documentação diz que o scheduler decide o nó a partir dos requests, e que os limits não entram nessa conta. Um nó só aceita um pod se a soma dos requests dos pods já instalados, mais o novo, couber na capacidade dele.
No manifesto, o contêiner pede 100 millicores de CPU e 32 MiB de memória, e tem teto de 250 millicores e 64 MiB. A unidade de CPU é o núcleo, e 1000m vale um núcleo inteiro. Assim, 250m é um quarto de núcleo, e 100m é um décimo. A memória é medida em bytes, com sufixos como Mi, que é mebibyte.
O describe do pod mostra os valores aplicados:
Limits:
cpu: 250m
memory: 64Mi
Requests:
cpu: 100m
memory: 32Mi
O request de CPU de 100m é o que o scheduler reserva, e o kubelet garante que o contêiner tenha pelo menos essa parte. Se o nó tiver folga, o pod pode usar mais, até o limit. O limit de CPU é traduzido para a quota do cgroup, como a seção 1 mostrou: 250m vira 25000 100000, o que dá um quarto de núcleo a cada período de 100 ms. Isso aparece com clareza no teste de da seção 8.
Quando um contêiner passa do limit de memória, o kernel o mata. O Kubernetes marca o estado do contêiner como OOMKilled, com código de saída 137. Para provar, criei um pod com 32 MiB de limite, rodando o mesmo código que aloca cerca de 100 MB:
apiVersion: v1
kind: Pod
metadata:
name: oom-teste
spec:
restartPolicy: Never
containers:
- name: consome
image: alpine:3.20
command: ["sh", "-c", "x=$(head -c 100000000 /dev/zero | tr '\\0' a); echo tamanho ${#x}"]
resources:
limits:
memory: 32Mi
$ kubectl apply -f oom-pod.yaml
pod/oom-teste created
$ kubectl get pod oom-teste
NAME READY STATUS RESTARTS AGE
oom-teste 0/1 OOMKilled 0 20s
$ kubectl get pod oom-teste -o jsonpath='{.status.containerStatuses[0].state.terminated.reason} exitCode={.status.containerStatuses[0].state.terminated.exitCode}'
OOMKilled exitCode=137
$ kubectl logs oom-teste
O log veio vazio porque o processo morreu antes de imprimir o tamanho. A memória estourou, o kernel matou o processo, e o Kubernetes registrou a causa. O que o usuário vê é um pod com OOMKilled, e a ação correta é aumentar o limit ou reduzir o consumo, não reiniciar esperando que a coisa melhore sozinha. O RESTARTS ficou em zero porque a restartPolicy do pod de teste é Never. Em um Deployment, a mesma morte causaria reinícios com espera crescente.
Throttling de CPU
O caso da CPU é mais traiçoeiro. O processo não morre. Ele fica mais lento, e nenhum erro aparece nos logs. Para mostrar, criei um pod com limit de 250m que roda um laço de CPU por 5 segundos e depois imprime uma mensagem:
apiVersion: v1
kind: Pod
metadata:
name: cpu-teste
spec:
restartPolicy: Never
containers:
- name: laco
image: alpine:3.20
command: ["sh", "-c", "timeout 5 sh -c 'while :; do :; done'; echo fim-do-laco; sleep 600"]
resources:
limits:
cpu: 250m
$ kubectl logs cpu-teste
Terminated
fim-do-laco
$ kubectl exec cpu-teste -- cat /sys/fs/cgroup/cpu.max
25000 100000
$ kubectl exec cpu-teste -- cat /sys/fs/cgroup/cpu.stat
usage_usec 1286985
user_usec 1264250
system_usec 22734
nr_periods 54
nr_throttled 50
throttled_usec 1541990
nr_bursts 0
burst_usec 0
O cpu.max mostra a quota de 25000 em cada período de 100000 µs, ou seja, 250m. Dos 54 períodos que o grupo passou, 50 foram estrangulados. O tempo total de espera foi de throttled_usec 1541990 µs, cerca de 1,54 segundos, num laço que o usuário, se estivesse com o terminal aberto, veria como um processo rodando normalmente. O log não tem erro. O processo terminou e imprimiu a mensagem. Só o contador revela o problema.
O efeito sobre a latência é o que a seção 9 explora. Um serviço com limit de 250m, que precisa de 100 ms de CPU por requisição, leva cerca de 400 ms por requisição no caso ideal, quando recebe carga contínua, porque só consegue 25 ms por período de 100 ms. Ninguém escreveu um bug. A requisição só demorou mais, e a métrica de latência é o único sinal disso.
HorizontalPodAutoscaler
O HorizontalPodAutoscaler, ou HPA, ajusta o número de réplicas de um Deployment com base em uma métrica, como a utilização média de CPU. A documentação dá a fórmula do algoritmo, com o trecho em que se lê desiredReplicas = ceil(currentReplicas × currentMetricValue / desiredMetricValue). Ou seja, se há 4 réplicas e a utilização média é o dobro do alvo, o HPA pede 8.
Alguns detalhes da documentação importam para quem usa. A utilização é calculada em relação ao request de CPU de cada contêiner, e sem request o HPA não age sobre aquela métrica. O controle roda periodicamente, com intervalo padrão de 15 segundos. Existe uma tolerância de 10% por padrão, e a documentação diz que, se a relação entre a métrica atual e a alvo estiver dentro dessa faixa de 1,0, nenhuma escala acontece. Para reduzir réplicas, a janela de estabilização padrão é de 300 segundos, e para aumentar, a janela é zero. A documentação também diz que o HPA depende de uma API de métricas, que em geral é fornecida pelo metrics-server.
Esse recurso não foi executado nesta aula, porque exigia instalar e configurar o metrics-server no minikube, e o ambiente já estava com carga alta. Os valores acima vêm da documentação, e a seção 13 registra isso.
Rolling update
O rolling update troca a versão de um Deployment sem parar o serviço. A estratégia padrão é gradual, com dois parâmetros: maxSurge, que diz quantos pods a mais que o desejado podem existir durante a troca, e maxUnavailable, que diz quantos podem estar indisponíveis. Os dois aceitam número absoluto ou percentual. A documentação diz que o padrão é 25% para ambos, que o maxSurge arredonda para cima e o maxUnavailable arredonda para baixo.
O manifesto desta aula usa maxSurge: 1 e maxUnavailable: 0. Com três réplicas, isso significa que o total pode ir a quatro pods durante a troca, e que sempre haverá três prontos. A figura mostra a sequência como uma linha do tempo, com o Deployment conduzindo as trocas de escala e o Service recebendo os pods prontos.
Na prática, o Deployment cria um ReplicaSet novo com zero pods, sobe um pod novo, espera que ele fique pronto, e só então reduz um pod antigo. A linha de tempo mostra essa ordem. Para trocar a variável de ambiente de versão, rodei o comando de set env, que cria uma nova revisão:
$ kubectl set env deployment/app VERSAO=2.0.0
deployment.apps/app env updated
$ kubectl rollout status deployment/app --timeout=180s
Waiting for deployment "app" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "app" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "app" rollout to finish: 1 old replicas are pending termination...
deployment "app" successfully rolled out
$ kubectl get rs -l app=app
NAME DESIRED CURRENT READY AGE
app-5c6bb8cf5 3 3 3 3s
app-7f757dfcd8 0 0 0 37s
app-864cb957f9 0 0 0 32s
Os ReplicaSets antigos ficaram com zero réplicas, mas não foram apagados. Eles são o histórico que permite voltar atrás. A documentação diz que o padrão de revisionHistoryLimit é 10, então até dez ReplicaSets antigos ficam guardados. Para voltar, basta o undo:
$ kubectl rollout history deployment/app
REVISION CHANGE-CAUSE
2 <none>
3 <none>
4 <none>
$ kubectl rollout undo deployment/app
deployment.apps/app rolled back
$ kubectl rollout status deployment/app --timeout=180s
deployment "app" successfully rolled out
$ kubectl get rs -l app=app
NAME DESIRED CURRENT READY AGE
app-5c6bb8cf5 0 0 0 13s
app-7f757dfcd8 3 3 3 47s
app-864cb957f9 0 0 0 42s
A revisão 3 foi o ReplicaSet da versão 1.0.0, que voltou a ser o ativo. O rollback não copia a imagem de novo. Ele troca qual ReplicaSet recebe as réplicas, o que torna a volta rápida, desde que as imagens antigas ainda estejam no nó. O rollout status mostra a progressão, e com um timeout ele sai com código de erro se a troca não termina. A documentação define progressDeadlineSeconds com padrão de 600 segundos e diz que, ao estourar esse prazo, o Deployment recebe a condição ProgressDeadlineExceeded, sem que o Kubernetes tome outra ação por conta própria. No teste da readiness com porta errada, o rollout status saiu com erro de timeout, porque o prazo que eu defini (40 segundos) acabou antes do prazo do Deployment. O ProgressDeadlineExceeded só aparece depois de 600 segundos, e não foi observado nesta aula.
9. Conexão com CPU e memória
Os números da seção 8 dizem o que o kernel faz. Esta seção responde a pergunta que a aula de carga da CPU já sugeria: por que um limite baixo deixa a máquina ou o serviço lento, sem nenhum erro aparente?
A resposta está no período. O cgroup não distribui a CPU por tempo contínuo. Ele divide o tempo em períodos de 100 ms e dá a cada grupo uma quota dentro de cada período. Quando a quota acaba, o grupo espera o próximo período inteiro, mesmo que a CPU esteja ociosa. Um processo com limite de meio núcleo pode rodar 50 ms e ficar parado 50 ms, e isso se repete. Para um laço de CPU, isso parece uma execução com picos. Para uma requisição HTTP, cada requisição que cai no meio da pausa ganha a espera inteira. O resultado é latência alta, com CPU média baixa, e sem nenhuma exceção no log.
Medi isso em dois lugares. Na seção 4, o contêiner de meio núcleo foi estrangulado em 46 de 52 períodos. Na seção 8, o pod de 250m foi estrangulado em 50 de 54. Em ambos os casos, o processo rodou o laço por completo e nunca reportou erro. Quem olha só para o uso médio de CPU vê um valor baixo e conclui que o serviço não está sobrecarregado, quando o que acontece é o contrário.
A aula de carga da CPU deu as ferramentas para ler o problema de fora. O load average mostra a fila de tarefas prontas, e o ps mostra quem consome. Com um contêiner gastando CPU sem limite, medi a máquina enquanto o laço rodava, em 3 de novembro de 2026, às 11h49:
$ uptime
11:49 up 30 days, 2:21, 1 user, load averages: 12.14 27.89 31.45
$ docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}' carga-teste
NAME CPU %
carga-teste 103.65%
$ ps -A -o pcpu,comm -r | head -n 12 | grep -i 'virtual\|docker\|PCPU'
112.4 .../com.apple.Virtualization.VirtualMachine (caminho do executável encurtado)
O ps do Mac mostra uma linha só, a do processo da VM, com 112,4% de CPU. O docker stats, que enxerga por dentro da VM, mostra 103,65% para o contêiner. A diferença é a soma de tudo o que roda dentro da VM além do laço. O caminho de leitura é esse: se a máquina está lenta, o primeiro sinal é a linha da VM no ps; o segundo é o docker stats dentro da VM, que separa os contêineres; o terceiro é o cpu.stat de cada contêiner, que mostra se o limite está apertando. Os três juntos respondem à pergunta da aula de carga: quem está consumindo, e se é o limite que está atrasando o serviço.
Uma ressalva sobre estes números. A carga da máquina estava alta durante todo o trabalho, com load average de 26,83 no início, 41 no meio e 48 durante os testes com o cluster. Os contadores nr_periods e nr_throttled contam períodos, e não sofrem com a variação do relógio que afeta a medição de tempo. Já a medição de tempo, como 2,41 segundos de espera, varia com a carga. Por isso a leitura da seção deve ser feita pelos contadores, e não por um número de latência tirado de uma única rodada.
A memória tem outra dinâmica. Enquanto a CPU estrangula, a memória mata. A seção 8 mostrou o pod com OOMKilled e código 137, e a diferença é o sintoma: a CPU degrada devagar e sem erro, e a memória derruba o processo de uma vez. Por isso o limite de memória deve ser escolhido com folga em relação ao uso real, e o limite de CPU deve ser escolhido com a latência em mente. Um limite de CPU baixo demais não derruba nada e, por isso, passa despercebido por mais tempo.
A figura abaixo mostra o efeito sobre o uso de CPU ao longo do tempo. O gráfico é ilustrativo: os valores foram escolhidos para mostrar o mecanismo, e não vieram de uma medição. A linha tracejada laranja é a demanda do app, a linha contínua é o uso real, e a faixa rosa marca os trechos em que a demanda passa do limite.
O gráfico mostra o ponto da seção. Nos trechos com faixa rosa, a demanda supera o limite de meio núcleo, e o uso real fica achatado nesse teto. O app quer mais CPU, não consegue, e a requisição espera. A linha de request, em 0,25, serve para lembrar que o scheduler reservou só um quarto de núcleo para esse contêiner, e que o limite pode ser maior que o request. Nessa configuração, o pod pode usar mais que o request quando o nó tem folga, e é justamente a folga que falta quando o cluster está cheio.
10. Erros comuns e antipadrões
Os erros desta seção aparecem em quase todo projeto que começa a usar contêineres. São escolhas comuns, e também as mais fáceis de fazer quando o foco é fazer a aplicação rodar. Os sintomas só aparecem depois.
O primeiro erro é usar a tag latest em produção. A tag latest não significa a versão mais recente. Ela é só a tag padrão que o Docker usa quando nenhuma é informada, e quem publica pode mudar o conteúdo dela a qualquer momento. Um deploy que hoje roda a versão 3 pode amanhã rodar a 4 sem que ninguém tenha pedido. A alternativa é uma tag de versão, como 1.0.0, e, quando o controle precisar ser total, o digest. Quem usa latest está confiando que o publicador nunca vai mudar de ideia, e isso é uma aposta.
O segundo erro é colocar segredo no Dockerfile. Parece inofensivo porque o segredo está só no build, mas cada instrução vira uma camada, e as camadas são guardadas. Para mostrar, criei uma imagem com uma variável ENV contendo um valor descartável:
$ docker history --no-trunc --format '{{.CreatedBy}}' segredo-teste:1
ENV SEGREDO_DE_TESTE=valor-descartavel-123
CMD ["/bin/sh"]
ADD alpine-minirootfs-3.20.10-aarch64.tar.gz / # buildkit
$ docker run --rm segredo-teste:1 env
SEGREDO_DE_TESTE=valor-descartavel-123
A linha de docker history mostra o valor em texto claro, e qualquer um que puxe a imagem o lê. A documentação do Docker diz que, mesmo que a variável seja removida numa camada posterior, ela continua persistida na camada anterior, e recomenda definir, usar e remover a variável no mesmo RUN. Para segredos de build, o caminho melhor é um mount de segredo, que não entra em camada nenhuma. Os segredos de execução vêm do Secret do Kubernetes ou de um cofre, com acesso controlado.
O terceiro erro é rodar sem limites. Um contêiner sem limite pode consumir a máquina inteira, e no Kubernetes um pod sem request pode ser agendado num nó que não tem folga para ele. O efeito é o vizinho barulhento: um serviço com um vazamento de memória derruba outros que estavam funcionando. Os limites foram medidos nas seções 4 e 8. O limite de memória protege a máquina, e o de CPU protege os vizinhos. Os dois precisam de um valor escolhido com base em medição, não em chute.
O quarto erro é não ter readiness probe. Sem ela, o Service envia tráfego para qualquer pod que esteja rodando, mesmo que ele ainda esteja carregando o cache ou conectando ao banco. Os clientes recebem erros durante a janela de subida, e o rollout parece bem-sucedido porque o processo existe. A seção 8 mostrou o contrário: com a readiness certa, o pod novo ficou fora do tráfego até estar pronto.
O quinto erro é colocar o banco dentro do contêiner sem volume. A seção 4 mostrou que dados gravados na camada gravável somem com o contêiner. Um banco rodando assim funciona enquanto o contêiner vive, e some na primeira remoção ou atualização. A saída é o volume, e no Kubernetes, um PersistentVolumeClaim. Esta aula não testou o PersistentVolumeClaim, mas o princípio é o mesmo do volume nomeado do Docker.
O sexto erro é uma liveness probe que depende de uma dependência externa. Se a liveness checa o banco e o banco fica lento, todas as réplicas falham a liveness ao mesmo tempo, o kubelet as reinicia em cascata, e o serviço, que estava só lento, fica fora do ar. A documentação oficial alerta para esse caso. A regra é checar o processo na liveness e as dependências na readiness.
O sétimo erro é rodar como root. O processo em um contêiner que roda como root tem, dentro do contêiner, poder sobre os arquivos que o usuário root enxerga. A seção 3 mostrou o Dockerfile criando um usuário sem privilégios e trocando para ele com USER app, e o docker exec mostrou uid=10001. A documentação do Docker recomenda o mesmo: usar USER para mudar para um usuário sem privilégios, com UID explícito, e evitar sudo dentro da imagem.
11. Exercícios
Os exercícios usam os mesmos comandos das seções anteriores. Quando um exercício pede uma previsão, a resposta de referência traz a previsão e a justificativa, e não uma saída que o leitor deve copiar.
Exercício 1: prever o OOM
Rode um contêiner com --memory=64m que aloque 40 MB e depois mais 40 MB, em dois passos com sh. Antes de rodar, diga se o processo sobrevive e qual código de saída ele deve ter se morrer.
Resposta de referência: o processo deve sobreviver ao primeiro passo, porque 40 MB ficam abaixo de 64 MiB, e o segundo passo leva o total a cerca de 80 MB, acima do limite. Se o kernel matar o processo, o código de saída será 137, e docker inspect deve mostrar OOMKilled=true. Para verificar, use head -c 40000000 /dev/zero | tr '\0' a em cada passo e guarde os valores em variáveis do shell. Note que a shell pode usar mais memória que o dado bruto, então vale medir antes de afirmar o número exato em que o kernel age.
Exercício 2: reordenar o Dockerfile
Um Dockerfile copia a pasta inteira com COPY . . logo no início, depois roda go build. Mude uma linha de comentário em main.go e diga quais camadas são refeitas. Depois reordene para copiar go.mod antes, e repita a mudança.
Resposta de referência: no primeiro caso, o COPY . . muda com qualquer arquivo da pasta, e o go build roda de novo, mesmo sem dependência nova. No segundo caso, o COPY go.mod main.go ./ só muda quando esses arquivos mudam, então uma alteração de comentário invalida o COPY do código e o go build, mas um go.mod que não mudou continua em cache. A conclusão é que as dependências devem ser copiadas e resolvidas antes do código que muda com frequência. Na prática, uma aplicação com dependências pesadas economiza minutos a cada build com essa ordem.
Exercício 3: calcular o HPA
Um Deployment tem 4 réplicas, o alvo de utilização de CPU é 50% e a utilização média atual é 100% do request. Quantas réplicas o HPA pede? Depois, diga o que acontece se a utilização média for 52%.
Resposta de referência: desiredReplicas = ceil(4 × 100 / 50) = 8. Com 52%, a relação é 52 / 50 = 1,04. A diferença é de 4%, dentro da tolerância de 10% por padrão, então o HPA não escala. A conta mostra que o HPA é proporcional: dobrar a utilização dobra as réplicas, e a tolerância evita oscilar por pequenas variações. Este exercício usa a fórmula da documentação e não foi executado no cluster.
Exercício 4: rolling update com 4 réplicas
Um Deployment tem 4 réplicas, maxSurge: 25% e maxUnavailable: 25%. Quantos pods podem existir no máximo durante o rollout, e quantos precisam estar disponíveis no mínimo?
Resposta de referência: maxSurge arredonda para cima, então 25% de 4 é 1, e o total máximo é 5. maxUnavailable arredonda para baixo, então 25% de 4 é 1, e o mínimo disponível é 3. A regra de arredondamento é a que a documentação descreve. Com maxUnavailable: 0, como no manifesto desta aula, o mínimo disponível seria 4, e a troca avança um pod de cada vez.
Exercício 5: limite de CPU e concorrência
Um pod tem limit de 500m e uma aplicação com quatro threads que fazem trabalho de CPU ao mesmo tempo. Quanto tempo de CPU o pod recebe por período de 100 ms, e como isso afeta a latência de cada thread?
Resposta de referência: 500m é meio núcleo, então a quota é 50 ms por período de 100 ms. As quatro threads dividem essa quota. Se todas estão ocupadas, o pod esgota os 50 ms em cerca de 12,5 ms de tempo de relógio, e fica parado pelo resto do período. Cada thread recebe, em média, um oitavo de núcleo, enquanto sem limite teria um núcleo inteiro. Para verificar, rode quatro laços de CPU num pod com esse limite e leia o cpu.stat. O valor de nr_throttled deve aumentar a cada período, e a soma de throttled_usec deve ser alta. Este exercício é uma previsão baseada na quota, e a medição fica como tarefa do leitor.
Exercício 6: diagnosticar uma readiness
Um pod roda com readiness em /ready na porta 8080, e o endpoint de /ready retorna 500 por causa de um banco lento. A liveness aponta para o mesmo /ready, com failureThreshold: 3. O que acontece em um minuto de banco lento?
Resposta de referência: a readiness falha, e os pods saem do Service, o que é correto porque eles não conseguem atender. A liveness também falha, e depois de três falhas o kubelet reinicia os contêineres. Como todas as réplicas dependem do mesmo banco, todas reiniciam juntas, e o serviço fica fora do ar mesmo depois que o banco volta, porque os pods reiniciados começam a subir do zero. A correção é trocar a liveness para um endpoint que só diz se o processo responde, por exemplo /healthz, e manter a dependência na readiness.
12. Glossário
- Contêiner: processo isolado por namespaces e limitado por cgroups, que compartilha o kernel do host.
- Imagem: pilha de camadas somente leitura, com metadados de execução, usada para criar contêineres.
- Camada: conjunto de arquivos criado por uma instrução do Dockerfile, empilhado sobre as anteriores.
- Union filesystem: sistema de arquivos que monta várias camadas como se fossem uma só. No Docker desta máquina, o driver é o overlayfs.
- Namespace: recorte da visão do sistema, que controla o que um processo enxerga, como PID, rede e montagens.
- cgroup: grupo de processos ao qual o kernel aplica limites e contabiliza consumo de CPU, memória e outros recursos.
- Throttling: estrangulamento de um grupo que esgotou a quota de CPU no período, obrigado a esperar o próximo período.
- OOMKilled: estado de um contêiner morto pelo kernel por ultrapassar o limite de memória, com código de saída 137.
- Digest: hash do conteúdo de uma imagem, imutável, em oposição à tag, que pode ser reapontada.
- Volume: área de armazenamento montada no contêiner e independente da camada gravável.
- Compose: ferramenta que descreve e sobe um conjunto de contêineres a partir de um arquivo YAML.
- Control plane: conjunto de componentes que decidem o estado do cluster, com API server, etcd, scheduler e controllers.
- etcd: armazenamento chave-valor que guarda o estado do cluster do Kubernetes.
- kubelet: agente que roda em cada nó e garante que os contêineres dos pods estejam de pé.
- CRI: Container Runtime Interface, a API padronizada pela qual o kubelet conversa com o runtime de contêineres.
- Pod: menor unidade agendada pelo Kubernetes, com um ou mais contêineres que dividem rede e volumes.
- ReplicaSet: objeto que mantém um número fixo de pods iguais.
- Deployment: objeto que gerencia ReplicaSets para atualizar versões e voltar atrás.
- Service: objeto que dá um nome estável e distribui tráfego entre pods prontos.
- Ingress: objeto de roteamento HTTP de fora para dentro do cluster, que exige um controlador para funcionar.
- ConfigMap: objeto que guarda configuração que não é segredo.
- Secret: objeto que guarda dados sensíveis, armazenados sem criptografia por padrão no etcd.
- Probe: checagem periódica do kubelet, em três tipos: liveness, readiness e startup.
- Request: quantidade de recurso que o pod pede e que o scheduler usa para escolher o nó.
- Limit: teto de recurso que o kubelet e o kernel aplicam ao contêiner.
- HPA: HorizontalPodAutoscaler, objeto que ajusta o número de réplicas com base em uma métrica.
- Rolling update: troca gradual de versão, em que pods novos sobem e pods antigos descem aos poucos.
- Rollback: volta a uma revisão anterior do Deployment, trocando qual ReplicaSet tem as réplicas.
13. O que não foi confirmado
Algumas afirmações desta aula não foram testadas nem confirmadas nas fontes consultadas. Elas ficam listadas aqui para que o leitor saiba o que verificar.
- O HPA não foi executado. Os valores da fórmula, do intervalo de 15 segundos, da tolerância de 10% e das janelas de estabilização vêm da documentação, mas não foram medidos nesta máquina.
- A Gateway API e a migração do ingress-nginx não foram testadas. O prazo exato do fim da manutenção foi dado pelo anúncio como março de 2026, e a página consultada não trouxe a data do dia exato.
- O gerenciador de VM padrão do Docker Desktop no macOS não foi confirmado pela documentação consultada. Esta máquina usa uma VM Linux, mas não verifiquei qual gerenciador ela usa.
- O remapeamento de usuários (user namespace) não está ativo nesta instalação, segundo o
docker info. Não testei outra configuração, e não afirmo como ela se comportaria. - A análise de risco de compartilhar o kernel entre contêineres de tenants diferentes, e a recomendação de combinar contêineres com VM, não foram estudadas em fonte primária nesta aula.
- O backup e a recuperação do etcd não foram testados. A aula não afirma procedimento de backup.
- A criptografia em repouso de Secrets e o RBAC com privilégio mínimo foram citados pela documentação, mas não configurados nem testados neste cluster.
- A medição de CPU e de tempo de resposta foi feita numa máquina com load average entre 12 e 48 durante a coleta. Os contadores do cgroup são confiáveis para a contagem de estrangulamento, mas o tempo absoluto de espera varia com a carga. Não houve repetição das rodadas, e não há faixa de valores.
- A figura de gráfico de CPU da seção 9 é ilustrativa. Os valores foram escolhidos para mostrar o mecanismo, e não vieram de uma medição.
- O comportamento do CrashLoopBackOff, com a espera crescente entre reinícios, não foi observado nesta aula. Ele é mencionado como nome de estado, sem números.
- A correção da nota Container sobre o namespace de usuário é uma pendência de revisão, e não foi aplicada na nota.
- A parte 15 (uso profissional) tem a sua própria lista de itens não executados, no fim dela.
14. Fontes
Todas as consultas foram feitas em 3 de novembro de 2026.
- Kubernetes, releases: versão estável mais recente listada (1.37.1, de 15 de setembro de 2026) e fim de suporte da 1.34 em 27 de outubro de 2026.
- Kubernetes, Ingress NGINX Retirement: What You Need to Know: anúncio de 11 de novembro de 2025 sobre o fim da manutenção do ingress-nginx em março de 2026. Consultado por busca, porque a página completa não foi lida inteira.
- Kubernetes, Componentes: funções do control plane e dos nós.
- Kubernetes, Gerenciar recursos de contêineres: requests, limits, throttling de CPU e OOM kill de memória.
- Kubernetes, Probes de liveness, readiness e startup: tipos de probe, padrões, efeito sobre endpoints e alerta sobre liveness.
- Kubernetes, Deployments: rolling update, padrões de maxSurge e maxUnavailable, revisionHistoryLimit de 10, progressDeadlineSeconds de 600 e comandos de rollout.
- Kubernetes, HorizontalPodAutoscaler: fórmula do algoritmo, período de 15 segundos, tolerância de 10% e janelas de estabilização.
- Kubernetes, Secrets: armazenamento sem criptografia por padrão no etcd e passos recomendados.
- Kubernetes, Ingress: aviso de que a API está congelada e de que Gateway é recomendada, e necessidade de um controlador.
- Kubernetes, Container runtimes: remoção do dockershim na versão 1.24 e exigência de CRI.
- Docker, Virtual Machine Manager for Docker Desktop: contêineres rodando em VM Linux e gerenciadores de VM. O padrão no macOS não é informado.
- Docker, Resource constraints: opções de CPU e memória, período de 100 ms e OOM killer.
- Docker, Build cache: invalidação de camadas a partir da que mudou.
- Docker, Best practices: tags mutáveis, digest, ENV persistido em camada, multi-stage, .dockerignore e USER.
- Linux kernel, cgroup v2: arquivos cpu.max, cpu.stat, memory.max e memory.current.
- Linux man-pages, namespaces(7): tipos de namespace. Versão man-pages 6.19, de 8 de fevereiro de 2026, consultada em 3 de novembro de 2026.
- Medições desta aula: comandos rodados em macOS 27.0.0 arm64, Docker Desktop com servidor 28.3.2 e minikube com Kubernetes 1.34.0, em 3 de novembro de 2026, com saídas coladas nas seções correspondentes.
15. Do uso básico ao uso profissional
Até aqui a aula seguiu uma máquina, uma imagem e um cluster montado à mão. No trabalho de todo dia, a escala é outra. As imagens precisam chegar a outras máquinas, a arquitetura do servidor costuma ser diferente da do notebook, os dados têm de sobreviver a uma atualização, a rede precisa dizer quem fala com quem, e o cluster é atualizado por alguém que talvez não seja você. Esta parte percorre esses assuntos na ordem em que eles costumam aparecer num projeto. Cada subseção começa do zero e diz o que foi executado.
Os exemplos desta parte rodaram com o Docker em execução, com o cliente na versão 28.1.1 e o buildx na v0.26.1-desktop.1. O cluster minikube não foi iniciado nesta execução. Por isso, o que depende de um API server aparece como exemplo, com a marca “não executado”, e a lista completa do que faltou está no fim da seção.
Imagens e registry
Pense numa imagem como uma caixa com etiqueta. O nome completo tem três partes: o endereço do registry, o nome do repositório e a tag. Em docker.io/library/alpine:3.20, o registry é docker.io, o repositório é alpine e a tag é 3.20. Quando o endereço é omitido, o Docker assume o padrão, e por isso alpine:3.20 funciona sem explicação. O registry é o depósito que guarda as imagens e as entrega a quem pede. O Docker Hub é o depósito público padrão, e há registries privados que rodam dentro da empresa ou na nuvem.
O docker tag não copia nada. Ele cria outro nome que aponta para a mesma imagem. Rodei isso com a imagem desta aula:
$ docker tag exemplo/app:1.0.0 127.0.0.1:5099/exemplo/app:1.0.0
$ docker image ls --format '{{.Repository}}:{{.Tag}} {{.ID}}' | grep -E 'exemplo/app|5099'
exemplo/app:amd64 292bedbbd8b7
exemplo/app:multi 1c44e356da1f
exemplo/app:1.0.0 3011648f0599
127.0.0.1:5099/exemplo/app:1.0.0 3011648f0599
Os dois nomes têm o mesmo ID, 3011648f0599, porque são a mesma imagem. As outras duas linhas são imagens que criei nos testes desta parte.
O docker push envia ao registry as camadas que ele ainda não tem e registra a tag. O docker pull faz o caminho inverso e pula as camadas que já estão na máquina. Os dois exigem um registry e uma conta com permissão de escrita, e nesta máquina não há nenhum. Por isso, o trecho abaixo é um exemplo não executado:
$ docker push registro.exemplo.com/equipe/app:1.0.0
$ docker pull registro.exemplo.com/equipe/app:1.0.0
O login é o ponto em que o erro mais comum aparece. A tentação é colar a senha na linha de comando com -p, para “só testar”. O problema é que a linha fica no histórico do shell, e ninguém lembra de apagá-la. A alternativa é ler a senha da entrada padrão com --password-stdin, e a documentação do Docker diz que isso impede que a senha pare no histórico do shell ou em arquivos de log. Em automação, a senha vem de um cofre ou de uma variável secreta do próprio serviço de CI:
$ printf '%s' "$SENHA_DO_REGISTRY" | docker login registro.exemplo.com --username robo --password-stdin
Este exemplo também não foi executado. Testei a forma com um endereço que não responde, para ver o comportamento:
$ printf '%s' 'senha-descartavel' | docker login 127.0.0.1:5099 --username descartavel --password-stdin
Error response from daemon: Get "http://127.0.0.1:5099/v2/": dial tcp 127.0.0.1:5099: connect: connection refused
Nada foi gravado, porque o login só guarda a credencial quando o registry aceita. Quando aceita, o Docker grava o resultado no arquivo config.json. Se esse arquivo tiver a chave credsStore, a senha vai para o cofre do sistema operacional, que no Mac é o Keychain. Sem o programa auxiliar, o config.json guarda a credencial em base64. Base64 é uma codificação, e qualquer pessoa com acesso ao arquivo a desfaz em um comando. Por isso, nunca escreva a senha num Dockerfile, num script versionado ou num compose.yaml.
Os registries públicos são práticos para software aberto, mas a imagem fica visível para qualquer um, a menos que o repositório seja privado. Os privados dão controle de acesso e histórico, e exigem que alguém cuide da infraestrutura. A escolha depende de quem pode puxar a imagem e de quanto a equipe confia no caminho até a máquina que vai rodá-la.
A tag latest merece atenção. Quando nenhuma tag é informada, o Docker usa latest, e a tag não significa “a versão mais nova”. Ela significa apenas “a que o publicador marcou assim por último”. Em produção, use uma tag de versão e, se o controle precisar ser total, o digest, como a seção 3 mostrou. A seção 10 já tratou desse erro, e a regra continua valendo aqui.
Build multiarquitetura
Imagine uma fábrica que produz o mesmo carregador em duas tomadas, uma para cada padrão de rede elétrica. A caixa tem o mesmo nome, mas dentro há peças diferentes, e cada cliente precisa da sua. Uma imagem funciona de forma parecida. O que muda entre as arquiteturas é o binário, porque um programa compilado para x86_64 usa as instruções dos processadores Intel e AMD, e um compilado para arm64 usa as instruções dos chips ARM. O kernel executa só as instruções da arquitetura para a qual o binário foi feito.
O Mac com Apple Silicon roda arm64 nativamente. Muitos servidores em nuvem são amd64, embora também existam máquinas arm64. Por isso, uma imagem construída no Mac, sem nenhuma opção especial, é arm64, e um servidor amd64 não consegue executá-la diretamente. O sintoma clássico é a mensagem exec format error, que aparece quando o kernel recebe um binário que não reconhece. Não reproduzi esse erro nesta máquina, e o resultado real está logo abaixo.
O docker buildx é o plugin que constrói imagens com o BuildKit e aceita vários pedidos de plataforma num só comando. A opção --platform escolhe as arquiteturas. Rodei o build com as duas:
$ docker buildx build --platform linux/amd64,linux/arm64 -t exemplo/app:multi .
...
#24 exporting manifest list sha256:1c44e356... done
#24 naming to docker.io/exemplo/app:multi done
#24 DONE 0.5s
O resultado é um manifest list. A tag aponta para uma lista, e cada item da lista é a imagem de uma plataforma. Quando alguém roda docker pull, o Docker lê a lista e escolhe a entrada compatível com a máquina. Nos trechos de log acima, os hashes foram encurtados com reticências.
Para ver a variante amd64 sozinha, o caminho é o --load, que coloca a imagem na loja local:
$ docker buildx build --platform linux/amd64 --load -t exemplo/app:amd64 .
$ docker image inspect exemplo/app:amd64 --format 'arch={{.Architecture}} os={{.Os}}'
arch=amd64 os=linux
Ao rodar essa imagem no Mac, o Docker avisa que a plataforma não bate com a máquina, e executa mesmo assim, por emulação:
$ docker run -d --name aula-amd64 -p 127.0.0.1:18081:8080 -e VERSAO=amd64-teste exemplo/app:amd64
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
$ curl -s http://127.0.0.1:18081/
ola do conteiner, versao amd64-teste
$ docker exec aula-amd64 uname -m
x86_64
A emulação funcionou, e o programa respondeu. Não medi o custo dela, e num build pesado ela costuma ser bem mais lenta que a execução nativa. Quando a emulação é lenta demais, há dois caminhos. O primeiro é um builder com nós nativos de cada arquitetura. O segundo é a compilação cruzada dentro do Dockerfile, usando as variáveis BUILDPLATFORM e TARGETPLATFORM, que só funciona em linguagens com suporte a isso, como Go. O CGO_ENABLED=0 do Dockerfile desta aula já deixa o binário independente de bibliotecas C.
Há uma pegadinha no outro sentido. Pedi uma plataforma que a imagem local não tinha:
$ docker run --rm --platform linux/amd64 exemplo/app:1.0.0
Unable to find image 'exemplo/app:1.0.0' locally
docker: Error response from daemon: pull access denied for exemplo/app, repository does not exist or may require 'docker login'
O Docker procurou a variante amd64 na máquina, não a encontrou e tentou puxá-la do registry. O erro veio de um lugar que não parece relacionado a arquitetura. Pelo mesmo motivo, docker buildx imagetools inspect exemplo/app:multi falhou com pull access denied: esse comando consulta o registry, e a imagem multiplataforma só existe na máquina.
Na instalação desta aula, o build com duas plataformas funcionou no builder padrão. A documentação do buildx descreve o driver docker-container como o caminho geral para builds multiplataforma, e diz que as imagens dele não entram na loja local do Docker Engine. A diferença depende da versão e da configuração do image store, e não investiguei além do que foi testado aqui. Num pipeline de entrega, o caminho usual é --push, que manda o resultado direto ao registry.
A figura abaixo mostra o caminho de um build com duas plataformas. O código sai do Dockerfile, o buildx gera uma variante por arquitetura, o push leva as duas ao registry, e a lista decide, no pull, qual variante cada máquina recebe. A cor verde marca as variantes que deram certo, e a cor azul marca as máquinas que puxam.
Repare que a figura não mostra o caso de falha. Uma imagem feita só para arm64 e puxada num servidor amd64 não tem caminho que leve até lá, porque nenhuma variante amd64 existe para ela. O erro comum de quem constrói só no Mac é achar que a imagem “vai funcionar em qualquer lugar”. Não vai, a menos que a lista tenha as duas entradas.
Imagem segura
Uma imagem leva para dentro do contêiner tudo o que está no Dockerfile, inclusive as permissões que ninguém revisou. Pense nela como uma encomenda: o que você embala vai junto, e quem abrir a caixa vê o conteúdo inteiro. Cinco práticas reduzem o estrago.
A primeira é não rodar como root. A seção 3 criou um usuário com adduser e trocou para ele com USER app, e o docker exec mostrou uid=10001. O processo que roda como root dentro do contêiner tem poder sobre tudo o que o usuário root enxerga lá dentro, e uma falha no programa vira um problema maior. No Kubernetes, a mesma regra vira runAsNonRoot no securityContext, e a seção 15.9 mostra o campo.
A segunda é o HEALTHCHECK, uma instrução do Dockerfile que roda um comando periodicamente e marca o contêiner como saudável ou não saudável. Um exemplo, não executado, seria:
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/healthz || exit 1
Vale lembrar que o Kubernetes não usa essa instrução. Nele, as probes da seção 8 fazem esse papel. O HEALTHCHECK é útil quando o contêiner roda sem orquestrador, por exemplo com o Docker puro ou com o Compose.
A terceira é o .dockerignore, que impede que arquivos indesejados entrem no contexto do build. O da aplicação de exemplo exclui .git e os arquivos *.md, como a seção 3 explicou. A razão de fundo é que um COPY . . sem filtro leva junto um .env com senhas, e a senha passa a morar numa camada da imagem, com o problema que a seção 10 já mostrou.
A quarta é preferir imagens mínimas. Cada pacote a mais é uma superfície a mais para ser atacada e um item a mais para ser atualizado. A imagem alpine:3.20 tem 13,6 MB, como a seção 3 mediu. Para um binário estático, a imagem scratch, que é vazia, serve, mas ela não tem adduser, então o usuário sem privilégios precisa ser criado à mão, copiando um arquivo de contas para a imagem. Imagens sem shell, como as chamadas distroless, reduzem ainda mais o que existe para ser explorado, com a contrapartida de que depurar dentro delas fica mais difícil.
A quinta são os scanners de vulnerabilidade. Eles comparam os pacotes de uma imagem com bancos de vulnerabilidades públicos, e costumam rodar no pipeline para bloquear um build com falha crítica. Não instalei nenhum scanner nesta aula, e não cito versão de ferramenta. O Docker Scout, da própria Docker, é um exemplo da categoria. Um scanner só enxerga o que está catalogado, então ele reduz o risco sem eliminá-lo, e a imagem continua precisando de atualização.
Limpeza e disco
Pense no disco de uma máquina que roda contêineres como a gaveta de cabos de um escritório. Cada build deixa camadas e cache, cada contêiner parado deixa um resto, e os volumes guardam dados que ninguém quer perder. Uma limpeza mal feita apaga o cabo que faz falta, e uma limpeza que não acontece nunca deixa a gaveta transbordando.
O primeiro passo é olhar antes de apagar. O docker system df mostra o uso de disco por tipo. Esta é a saída real desta máquina, que hospeda dados de várias aplicações, e por isso os números não são de uma instalação limpa:
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 78 37 56.21GB 33.37GB (59%)
Containers 37 8 209.3MB 4.674MB (2%)
Local Volumes 101 23 7.265GB 5.161GB (71%)
Build Cache 514 0 21.46GB 21.46GB
A coluna RECLAIMABLE mostra o que pode ser recuperado sem tocar no que está em uso. Ela é um bom ponto de partida, mas ela não diz o que é seguro apagar. Os volumes aparecem como 71% recuperável, e essa porcentagem sozinha é o motivo mais comum de perda de dados em máquinas de desenvolvimento.
Os comandos de limpeza têm escopos diferentes, e a documentação do Docker descreve o que cada um remove por padrão:
docker container pruneremove todos os contêineres parados.docker image pruneremove só as imagens sem tag, chamadas de dangling. Com-a, remove todas as imagens que não estão em uso por contêiner.docker volume pruneremove só os volumes anônimos sem uso. Os nomeados exigem--all.docker builder prunelimpa o cache de build. No help desta máquina, o comando aparece comodocker buildx prune.docker system prunejunta contêineres parados, redes sem uso, imagens dangling e cache de build sem uso. Os volumes só entram com--volumes, e mesmo assim só os anônimos.
Os textos de ajuda desta máquina confirmam essas opções. O docker system prune --help traz --volumes Prune anonymous volumes, e o docker volume prune --help traz -a, --all Remove all unused volumes, not just anonymous ones. A regra prática é que docker volume prune --all apaga o banco que ficou parado durante a semana, e o cenário da seção 4, com o volume dados-db, é exatamente esse. Antes de rodar qualquer limpeza com volumes, liste o que está lá e confirme que nenhum deles guarda dados que você não tem em outro lugar.
Nenhum comando de limpeza foi executado nesta parte, porque a máquina guarda dados de outros projetos. O único filtro de leitura que usei foi docker image ls --filter dangling=true, que no momento da consulta listava 7 imagens sem tag.
Kubernetes no dia a dia com kubectl
O kubectl é o controle remoto do cluster. Cada verbo responde a uma pergunta diferente, e a maior parte do trabalho de quem opera está em cinco perguntas: o que existe, o que está errado, o que o processo disse, como entro lá dentro e o que vai mudar.
O get responde “o que existe”. Com kubectl get pods -o wide, você vê o nó e o IP de cada pod. Ele é rápido, mas mostra pouco.
O describe responde “o que está errado”. Ele mostra tudo sobre um objeto, e a parte que importa costuma ficar no fim, na seção Events. Quando um pod não sobe, é ali que aparece o motivo: falha de agendamento, imagem que não pôde ser puxada, memória estourada. Por isso, a regra de quem opera é ler o describe antes de tudo.
O logs responde “o que o processo disse”. Com kubectl logs --previous, você lê a saída do contêiner anterior, depois de um reinício, que é justamente o caso em que a saída atual está vazia. O exec abre um comando dentro do contêiner, como fizemos na seção 4 com o Docker. O port-forward cria um túnel da sua máquina até um pod ou um Service, para testar sem expor nada para a rede.
O diff responde “o que vai mudar”. Ele compara o manifesto local com o estado vivo do cluster antes de aplicar. Num pipeline, ele é a última olhada antes de uma mudança irreversível. O rollout status acompanha uma atualização, como a seção 8 mostrou.
A diferença entre apply e create é a que mais confunde. O create é imperativo: cria o objeto e falha se ele já existe. O apply é declarativo: compara o arquivo com o cluster, aplica a diferença e guarda a última configuração aplicada numa anotação do próprio objeto. Por isso, o apply pode rodar várias vezes sem erro, e é a forma que pipelines usam. Um create repetido, ao contrário, quebra na segunda execução.
Para ler eventos em ordem de tempo, o comando é kubectl get events --sort-by=.lastTimestamp. Cada linha traz o motivo (Reason) e a mensagem. Motivos como FailedScheduling, ImagePullBackOff e OOMKilled são os que aparecem com mais frequência, e cada um aponta para uma seção desta aula: agendamento, registry e limites de memória.
Nenhum desses comandos foi executado com um cluster nesta parte. Eles aparecem aqui como exemplos. O que rodou de fato foi uma tentativa de validação client-side, e ela mostra por que o cluster importa até para um teste sem efeito:
$ kubectl apply --dry-run=client -f rede/netpol.yaml
error: error validating "rede/netpol.yaml": error validating data: failed to download openapi: Get "http://localhost:8080/openapi/v2?timeout=32s": dial tcp [::1]:8080: connect: connection refused; if you choose to ignore these errors, turn validation off with --validate=false
O --dry-run=client não dispensa o servidor nesse caso. O kubectl baixa o schema da API para validar o arquivo, e sem um API server a validação para. A opção --validate=false também falhou, porque o kubectl ainda precisa do servidor para mapear os tipos de objeto.
Organizar manifestos
Imagine um restaurante que tem um prato base e ajustes por unidade: mais queijo numa, sem glúten em outra. Copiar a receita inteira para cada cozinha e editar à mão é o jeito mais rápido de descobrir, numa sexta-feira à noite, qual das três receitas é a verdadeira. Os dois instrumentos principais para evitar isso são o Kustomize e o Helm, e cada um resolve um problema diferente.
O Kustomize trabalha com uma base e sobreposições. A base é o manifesto comum, e cada ambiente declara o que muda: namespace, tag da imagem, número de réplicas, ou um patch específico. Não há linguagem de template, e o resultado é um YAML normal. Ele já vem embutido no kubectl como kubectl kustomize, e rodou aqui sem cluster, porque apenas monta o texto. A base desta aula tem um Deployment com replicas: 2, VERSAO=1.0.0 e image: exemplo/app:1.0.0. A sobreposição de produção declara namespace producao, tag 2.0.0 e quatro réplicas. O resultado, em trechos:
$ kubectl kustomize kustomize/overlays/producao
...
replicas: 4
...
image: exemplo/app:2.0.0
...
- name: VERSAO
value: 1.0.0
...
A saída traz a réplica, a imagem e o namespace como foram pedidos, mas a variável VERSAO continua em 1.0.0. O campo images troca a imagem, não as variáveis de ambiente. Para mudar VERSAO, é preciso um patch, e esse detalhe pega muita gente que esperava que a tag e a versão mudassem juntas.
O Helm resolve outro problema. Ele é um gerenciador de pacotes que usa templates. Um chart é o pacote, com os templates e os valores padrão. Uma release é uma instalação concreta de um chart, com nome próprio e histórico de versões. Um repositório é o lugar onde os charts ficam publicados. A documentação oficial, consultada em 3 de novembro de 2026, mostra a versão 4.3.1 do Helm, e ela descreve o Helm como o gerenciador de pacotes do Kubernetes. O Helm não está instalado nesta máquina, então o comando abaixo é um exemplo não executado:
$ helm install app ./chart --set image.tag=2.0.0
A regra simples é esta. Use Kustomize quando a configuração é sua, com poucas variações e manifestos legíveis. Use Helm quando precisa parametrizar muito, distribuir um pacote para outras equipes, ou quando vai consumir um chart pronto de um banco ou de um controlador de entrada. Os dois podem conviver, e muitas equipes usam o Helm para instalar componentes de terceiros e o Kustomize para a aplicação própria.
Os manifestos desta parte, em todas as seções seguintes, foram validados como YAML, com o parser do Ruby, e a sobreposição de produção foi montada pelo kubectl kustomize. Nenhum deles foi validado contra o schema do Kubernetes nem aplicado num cluster.
Outros tipos de carga
O Deployment não é o único jeito de rodar um pod. Cada tipo de carga responde a uma necessidade diferente, e escolher o errado cria problemas que só aparecem depois.
O Deployment serve para aplicações sem estado, em que cada réplica é intercambiável. O garçom que atende qualquer mesa serve. Se uma réplica cai, outra assume, e o nome do pod muda sem que ninguém se importe.
O StatefulSet serve quando a identidade importa. Cada pod tem um nome estável e ordenado, como banco-0, banco-1, e cada um recebe o próprio volume. Bancos de dados e filas costumam precisar disso, porque um membro de um cluster de banco tem papel próprio e dados próprios. O StatefulSet dá nome e disco estáveis, mas não replica o banco. A replicação continua sendo tarefa do software do banco.
O DaemonSet garante um pod por nó, ou por um conjunto de nós escolhidos. É o modelo de agentes que precisam estar em toda máquina, como um coletor de logs ou um agente de métricas. Quando um nó entra no cluster, o DaemonSet já põe o agente lá.
O Job executa uma tarefa até ela terminar com sucesso. O backoffLimit define quantas tentativas são permitidas, e o ttlSecondsAfterFinished apaga o objeto depois de concluído, para que pods finalizados não se acumulem. O CronJob é um Job agendado com sintaxe cron. O campo concurrencyPolicy: Forbid impede que duas execuções se sobreponham, o que importa quando a tarefa demora mais do que o intervalo. O horário segue o fuso do controlador do cluster, a menos que o campo timeZone seja definido.
Um trecho do arquivo de cargas desta parte mostra o CronJob:
apiVersion: batch/v1
kind: CronJob
metadata:
name: limpeza-sessoes
namespace: producao
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
containers:
- name: limpeza
image: exemplo.com/limpeza:1.0.0
command: ["./limpa", "--dias", "30"]
Esse arquivo foi validado só como YAML. Não rodou num cluster, e a imagem exemplo.com/limpeza é um nome de exemplo. O erro mais comum com Jobs é esquecer o ttlSecondsAfterFinished e descobrir, semanas depois, centenas de pods concluídos ocupando o nó sem motivo.
Armazenamento persistente
Um pod é descartável, e a seção 4 mostrou que dados gravados na camada gravável somem com o contêiner. Para guardar dados de verdade, o Kubernetes separa três objetos. O PersistentVolumeClaim, ou PVC, é o pedido: “preciso de 10 GB, que sirvam para leitura e escrita por um nó”. O PersistentVolume, ou PV, é o armazenamento que atende ao pedido, um objeto do cluster ligado a um disco real. A StorageClass é o cardápio de tipos de armazenamento, e ela diz como criar um PV quando nenhum existente serve.
Imagine um balcão de locação de armários. O cliente chega com um pedido, que é o PVC, e o balcão entrega um armário, que é o PV. O cardápio de armários, com tamanhos e regras de devolução, é a StorageClass. O pod não conhece o armário. Ele só cita o pedido pelo nome, e isso permite trocar o armário sem mexer no pod.
Quando a StorageClass está configurada para provisionamento dinâmico, o cluster cria o PV e o disco no momento em que o PVC é criado. Um provisionador CSI faz essa parte, e o disco real fica no provedor de infraestrutura. Quando o PVC é removido, a política de reclaim decide o destino. Com Retain, o PV passa ao estado Released, e o disco e os dados continuam lá, esperando limpeza manual. Com Delete, o PV e o disco são apagados junto com os dados. A documentação diz que os PVs criados dinamicamente herdam a política da StorageClass, e o padrão é Delete. Para um banco de produção, essa diferença é a mais importante de toda a parte de armazenamento.
No StatefulSet, o volumeClaimTemplates cria um PVC para cada réplica, com nomes derivados do pedido e do pod. O pod banco-0 recebe o PVC dados-banco-0, e o pod banco-1 receberia dados-banco-1. A documentação do StatefulSet é clara sobre o que acontece quando ele some: deletar ou reduzir o StatefulSet não remove os volumes associados, porque a prioridade é a segurança dos dados. Essa escolha protege contra perda acidental, e também cria um risco silencioso. Os PVCs continuam ocupando espaço e cobrando, e um PVC órfão com política Delete só é apagado quando alguém remove o próprio PVC.
Um pedido de disco, sem o armário, fica no estado Pending, e a documentação diz que a reivindicação permanece sem vínculo enquanto não existir um volume correspondente. Os arquivos da aula trazem a StorageClass, o PVC e o StatefulSet, mas nenhum deles foi aplicado.
A figura mostra o caminho de cima para baixo. O pod monta o PVC, o PVC aponta para a StorageClass, e a StorageClass cria o PV e o disco. A losango da parte de baixo é a decisão que importa: a política de reclaim, que vem da StorageClass, define se o disco sobrevive quando o PVC é removido. A cor verde marca o caminho seguro, e a cor rosa marca o que apaga os dados.
O risco concreto é um banco em StatefulSet com reclaimPolicy: Delete e um kubectl delete -f apontando para a pasta inteira do ambiente. Esse comando remove os objetos do arquivo, inclusive o PVC, e a política de reclaim decide o destino do disco a partir daí.
Rede e segurança
Por padrão, todo pod consegue falar com todo pod do cluster. Isso é cômodo e perigoso ao mesmo tempo. A NetworkPolicy funciona como o porteiro de um prédio com lista de visitantes por andar: ela define quem pode entrar em cada pod, e tudo o que não está na lista é barrado. Segundo a documentação, sem nenhuma policy todo o tráfego é permitido. Quando uma policy seleciona um pod e declara o tipo Ingress, só entra o que estiver nas regras.
Uma policy só tem efeito se o plugin de rede do cluster, o CNI, a aplicar. A documentação é direta nisso: criar uma NetworkPolicy sem um controlador que a implemente não tem efeito nenhum, e o objeto é aceito sem reclamar. O erro é confiar numa regra que existe no papel e não no tráfego. Antes de contar com uma policy, confirme que o cluster tem um CNI que aplica o recurso.
Com essa ressalva, o manifesto de exemplo fica assim:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-aceita-so-frontend
namespace: producao
spec:
podSelector:
matchLabels:
papel: api
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
papel: frontend
ports:
- protocol: TCP
port: 8080
Esta regra seleciona os pods com papel: api e permite entrada apenas de pods com papel: frontend, na porta 8080. Para fechar o namespace inteiro por padrão, a prática comum é uma segunda policy com podSelector: {}, que seleciona todos os pods, e um policyTypes: [Ingress] sem nenhuma regra. As policies se somam: uma conexão passa se alguma regra permitir, e a de default deny bloqueia o resto. O arquivo com as duas policies foi validado como YAML e não foi aplicado.
A figura mostra o namespace com quatro pods e a policy. A seta verde é o tráfego permitido, do frontend para a api. As setas vermelhas, com o X, mostram os pods batch e banco, que não estão nas regras e são negados. A linha pontilhada da NetworkPolicy para a api indica que a policy seleciona esse pod, e a linha do CNI indica que é ele quem aplica a regra.
Para o Ingress e o Gateway API, basta uma frase de situação cada um. O Ingress serve quando o cluster já tem um controlador instalado e precisa receber HTTP de fora com roteamento por nome e caminho. Para projetos novos, a documentação recomenda o Gateway API, que cobre roteamento por cabeçalho e divisão de tráfego entre versões sem anotações específicas de cada controlador. Os objetos principais do Gateway API são GatewayClass, Gateway e HTTPRoute. Nenhum dos dois foi testado nesta aula.
O RBAC controla quem pode fazer o quê na API. Um Role define verbos sobre recursos dentro de um namespace, e um RoleBinding liga esse Role a um usuário ou a uma conta de serviço. O princípio é o privilégio mínimo: uma aplicação que só lê logs não precisa de acesso a Secrets. Um exemplo ilustrativo, que não foi aplicado:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: leitor-de-pods
namespace: producao
rules:
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list"]
O securityContext define como o processo roda dentro do pod. Os campos que mais importam são runAsNonRoot, que impede a execução como root, allowPrivilegeEscalation: false, que bloqueia ganho de privilégio, capabilities com drop: ["ALL"], que remove as capacidades do Linux que a aplicação não usa, e readOnlyRootFilesystem, que torna o sistema de arquivos do contêiner somente leitura. A base do Kustomize desta parte traz todos esses campos, e a saída do kubectl kustomize mostrou runAsNonRoot: true, readOnlyRootFilesystem: true e drop: ALL no resultado. O cuidado com o sistema de arquivos somente leitura é que a aplicação precisa de um volume para escrever em /tmp ou em logs. Sem isso, ela falha na primeira escrita.
O imagePullPolicy controla quando o nó consulta o registry. Com IfNotPresent, o nó usa a imagem que já tem, e só puxa se ela faltar. Com Always, consulta o registry a cada início de contêiner. Com Never, nunca puxa. Quando a política não é definida, o padrão é Always para a tag latest e IfNotPresent para as demais. A base desta aula usa IfNotPresent, e isso tem uma consequência: se alguém republicar a mesma tag 1.0.0 com outro conteúdo, o nó continuará usando a imagem antiga. Esse é mais um motivo para tratar tags como apontadores que podem mudar, como a seção 10 disse.
Agendamento avançado
O scheduler escolhe um nó para cada pod, e a decisão pode ser influenciada de vários jeitos. Pense nisso como a escolha de mesa num restaurante, com placas e preferências.
Os taints são placas colocadas num nó: “reservado para o time de dados”, ou “não recebe carga comum”. O efeito NoSchedule impede que pods comuns sejam agendados ali. A toleration é a permissão que um pod declara para ignorar a placa. Repare que o taint fica no nó e a toleration fica no pod. A toleration permite que o pod vá para aquele nó, mas não o obriga a ir. Para forçar o pod para um lugar, é preciso combinar com affinity.
A affinity expressa preferências ou obrigações sobre os rótulos dos nós. A anti-affinity faz o contrário: diz que um pod não deve ficar junto de outros com determinado rótulo. O arquivo de agendamento desta parte traz uma anti-affinity obrigatória, com topologyKey: kubernetes.io/hostname. Isso significa que cada réplica precisa estar num nó diferente. Com três réplicas, são necessários três nós. Se houver só um, as réplicas extras ficam em Pending, esperando um nó que não existe. Por isso, anti-affinity obrigatória deve combinar com o número real de nós, e não com um número que parece bom no papel.
O PodDisruptionBudget, ou PDB, protege a disponibilidade durante manutenção voluntária, como um drain de nó para atualizar o sistema operacional. Ele diz quantos pods precisam continuar disponíveis enquanto o cluster remove outros. O campo minAvailable define o mínimo, e maxUnavailable define o máximo de pods indisponíveis. O PDB não protege contra uma falha de máquina ou um crash, só contra remoções planejadas.
Aqui está o detalhe que mais causa dor de cabeça. Um PDB com minAvailable: 3 para um Deployment de três réplicas não permite que nenhum pod saia, e o drain do nó fica travado esperando. Para três réplicas, um maxUnavailable: 1 costuma ser o ponto de partida mais seguro. O arquivo desta parte tem o PDB com minAvailable: 3, e isso é justamente o que esta seção quer que você evite. O arquivo foi validado como YAML e não foi aplicado.
O erro mais comum é configurar o PDB para proteger tanto que a manutenção nunca termina. Nesse caso, o drain repete a tentativa de remover o pod, com a mensagem de que o orçamento de interrupção não permite a remoção, e o nó continua ocupado.
Operação
Operar um cluster é mais do que aplicar manifestos. Três assuntos organizam esse trabalho: como as mudanças chegam ao cluster, quem cuida da infraestrutura e como saber o que está acontecendo.
No modelo GitOps, o repositório Git é a fonte da verdade. Um agente dentro do cluster, como as ferramentas Argo CD e Flux, compara o que está no repositório com o que está rodando e aplica a diferença. Uma mudança entra por pull request, passa por revisão e fica registrada no histórico. Desfazer uma mudança é reverter um commit. Esse modelo evita o kubectl apply feito de um notebook às pressas, que é justamente o que deixa o cluster diferente do que está documentado.
A integração contínua constrói a imagem, roda os testes, faz o scan de vulnerabilidades e envia a imagem ao registry. A entrega contínua leva a nova tag para o ambiente, seja alterando o repositório de manifestos, seja num passo do pipeline. Nenhum desses passos foi executado nesta aula.
Os serviços gerenciados de Kubernetes, como EKS, GKE e AKS, tiram do seu time a operação do control plane: o API server, o etcd e o scheduler são mantidos pelo provedor, que cuida da disponibilidade desses componentes e oferece atualizações. O que sobra depende do produto. Em geral, você ainda decide os nós, as redes, as políticas de segurança, os dados e os manifestos. A lista exata do que o provedor assume deve ser lida na documentação de cada produto, e não foi conferida nesta aula.
A observabilidade tem três pilares. Logs são o que o processo escreveu. Métricas são números ao longo do tempo, como taxa de requisições, latência e uso de CPU. Traces mostram o caminho de uma requisição entre serviços, com o tempo gasto em cada um. A seção 9 mostrou que o throttling de CPU não aparece nos logs, e só os contadores do cgroup revelam o problema. Por isso, a métrica de throttling precisa estar no painel, junto com a latência, e um alerta só de uso médio de CPU deixa passar o caso mais traiçoeiro.
Atualizar a versão do cluster exige uma ordem. A documentação de compatibilidade de versões, consultada em 3 de novembro de 2026, diz que o control plane deve ser atualizado antes dos nós. Os kubelets podem estar até três versões menores atrás do API server, e os componentes do control plane podem estar uma versão menor atrás. O kubectl pode ficar uma versão acima ou abaixo. Uma atualização in place de kubelet entre versões menores não é suportada, e o nó precisa ser esvaziado antes. A seção 6 registrou que a versão 1.34 chegou ao fim de suporte em 27 de outubro de 2026. Um cluster que ainda roda essa versão deve entrar na fila de atualização com prioridade, porque deixa de receber correções de segurança.
Roteiro de estudo
Quem começa do zero pode seguir esta ordem. Cada passo depende do anterior, e a maior parte dele cabe numa máquina só.
- Leia as seções 1 e 2 desta aula, sobre namespaces, cgroups e a diferença para a máquina virtual, e depois a seção 3, sobre o Dockerfile.
- Monte a aplicação de exemplo, rode os comandos da seção 4 e pratique as partes 15.1 e 15.2: tags, login sem senha no histórico e build com duas plataformas.
- Suba um cluster local com minikube ou kind e rode os exemplos das seções 5 a 8. Pratique
get,describe,logs,rollout statuserollout undoaté ler odescribesem pensar. - Escreva manifestos à mão e depois com Kustomize, como nas partes 15.5 e 15.6. Só então leia um chart pronto do Helm e instale algo de teste.
- Crie um StatefulSet de teste, apague o pod e confira se o PVC continua lá. Depois aplique uma NetworkPolicy num cluster com CNI que aplica a regra, e teste a conexão antes e depois.
- Estude taints, affinity e PDB, e faça um
drainnum cluster de teste com mais de um nó, para ver o PDB funcionar ou travar. - Por fim, leia a documentação do provedor de nuvem e da ferramenta de GitOps que a sua equipe usa, porque é ali que estão as responsabilidades que valem na prática.
Fontes desta parte
Consultas feitas em 3 de novembro de 2026.
- Docker, Build multi-platform images: uso de
--platform, manifest list e observação sobre o driver docker-container. - Docker, Prune unused objects: o que cada comando de limpeza remove, e o aviso de que volumes não são removidos automaticamente.
- Docker, docker login:
--password-stdin, credential store e armazenamento de credenciais emconfig.json. - Kubernetes, Network Policies: comportamento padrão, isolamento por direção e dependência de um controlador. A página foi lida até o trecho sobre CNI, e o restante foi truncado.
- Kubernetes, Gateway API: objetos GatewayClass, Gateway e HTTPRoute. A seção de migração a partir do Ingress não foi lida, por truncamento da página.
- Helm, documentação: versão 4.3.1 documentada e a descrição do Helm como gerenciador de pacotes. As definições de chart e release não foram encontradas na página consultada.
- Kubernetes, Version Skew Policy: ordem de atualização e diferenças permitidas entre componentes.
- Kubernetes, Persistent Volumes: PV, PVC, StorageClass e provisionamento dinâmico. A seção de reclaim foi truncada na leitura, e os valores de
RetaineDeleteforam conferidos pelo texto de resumo, não pelo trecho completo. - Kubernetes, StatefulSets: a regra de que deletar ou reduzir o StatefulSet não remove os volumes. A seção sobre
persistentVolumeClaimRetentionPolicynão foi lida.
O que não foi confirmado nesta parte
Esta lista reúne tudo que não foi executado, ou que foi executado só em parte, nesta parte da aula.
- Nenhum cluster foi iniciado. Por isso, não rodaram:
get,describe,logs,exec,port-forward,diff,rollout status,applyecreatecontra um servidor,kubectl get events, e os objetos StatefulSet, DaemonSet, Job, CronJob, PVC, NetworkPolicy, Role e RoleBinding, taints, affinity e PDB. - O
kubectl apply --dry-run=clientfalhou sem servidor, e a validação de schema não aconteceu. Os manifestos foram validados só como YAML, com o parser do Ruby, e a sobreposição do Kustomize foi montada offline. - O Helm não está instalado. O
helm installe o chart de exemplo não foram executados. - Nenhum registry real foi usado. Os comandos de
pushepullsão exemplos. O login foi testado só até a conexão recusada, e nenhuma credencial foi gravada. - Nenhum scanner de vulnerabilidade foi instalado, e nenhuma versão de ferramenta foi verificada.
- O
HEALTHCHECKfoi descrito, e o exemplo comwgetnão foi executado. - Nenhum comando de limpeza foi executado. Só foram lidos
docker system df,docker image ls --filter dangling=truee os textos de ajuda. - O build amd64 por emulação funcionou, mas o custo de tempo não foi medido. A mensagem
exec format errornão foi reproduzida. O erro observado com--platformfoi outro, e ele está descrito no texto. - O build com duas plataformas funcionou no builder padrão desta instalação. A documentação descreve o driver docker-container como caminho geral, e a diferença não foi investigada em outras versões ou configurações.
- O campo
persistentVolumeClaimRetentionPolicye a seção de reclaim do PV não foram lidos inteiros, porque as páginas foram truncadas na consulta. - A página do Gateway API consultada não trouxe uma data ou versão de estabilidade, e a recomendação sobre Ingress não foi lida no trecho recebido.
- As responsabilidades de EKS, GKE e AKS foram descritas em alto nível, sem consulta à documentação de cada produto.
- Nenhuma ferramenta de GitOps foi instalada, e nenhum pipeline de CI/CD foi executado.
- Nenhuma atualização de versão do cluster foi executada. A regra de skew veio da documentação, e o comportamento real não foi observado.
- A saída de
docker system dfreflete os dados de todas as aplicações desta máquina, e não é uma medição de instalação limpa.
Relacionados
- Trilha de Hardware: mapa da trilha em que esta aula fecha a fase 8.
- Carga da CPU no Mac: aula anterior, com as ferramentas de leitura de carga usadas na seção 9.
- Docker: visão geral do Docker, com a história e os comandos principais.
- Container: conceito de contêiner, citado na seção 1, com a pendência sobre o namespace de usuário.
- Namespaces: detalhes dos namespaces do Linux, complemento da seção 1.
- CGroups: detalhes dos cgroups, complemento da seção 1 e da seção 8.
- Isolamento: como o Docker isola processos, rede e sistema de arquivos.
- Imagens: o que é uma imagem e como ela é distribuída.
- Dockerfile: instruções do Dockerfile, complemento da seção 3.
- Multi-Stage Build: o padrão de dois estágios usado no exemplo da seção 3.
- Volumes: o detalhe de volumes, complemento da seção 4.
- Compose: o arquivo de serviços usado na seção 4.
- Alpine: a imagem base usada nos exemplos.
- Docker HUB: registry público padrão, citado na seção 15.1 sobre registries.
- Dockerfile: instruções
USEReHEALTHCHECK, usadas na seção 15.3.