Banco de dados em Docker ou no host?
Veja por que PostgreSQL, MySQL, MongoDB e Redis funcionam em Docker na produção, e como volumes, upgrades, backups e limites de memória causam problemas.
A base de dados deve correr em Docker ou no host?
Execute a base de dados em Docker. Para uma única stack de aplicação num único VPS, PostgreSQL, MySQL, MongoDB ou Redis em contentores é uma escolha normal em produção, e a discussão habitual sobre este tema parte geralmente do argumento errado. Um contentor é um processo Linux com namespaces e cgroups à sua volta, não uma máquina virtual. Por isso, não existe um hypervisor entre a base de dados e o disco. Com um bind mount ou um volume local nomeado, as leituras e escritas chegam ao sistema de ficheiros do host, o mesmo que seria utilizado por uma instalação através de pacotes.
O custo real é operacional. Quatro aspetos determinam se esta configuração funciona bem ou se se transforma num problema lento e grave: onde os dados ficam, quem é o proprietário desse diretório, como será feita uma atualização de versão principal e se alguma vez restaurou uma cópia de segurança. Se tratar destes pontos corretamente, o contentor é apenas um detalhe. Se os ignorar, será o contentor que irá culpar.
Esta é a mesma decisão para qualquer base de dados de servidor. Os exemplos abaixo usam PostgreSQL, MySQL, MongoDB e Redis. As diferenças específicas de cada produto são indicadas quando forem relevantes.
O que um container realmente altera
Não o caminho de armazenamento, desde que monte um. O kernel é o mesmo, assim como a page cache e o sistema de ficheiros.
Existe uma armadilha real de desempenho: não montar nada. Sem um volume, o diretório de dados fica na camada gravável do container, que é um sistema de ficheiros overlay sobreposto à imagem. As escritas nessa camada são mais lentas, e toda a camada é eliminada quando o container é removido. É daí que vem a mensagem "a minha base de dados estava vazia esta manhã".
O que realmente muda:
- O ciclo de vida.
docker compose downdestrói o container. Tudo o que não estiver num volume é eliminado com ele. - A versão. A tag da imagem define a versão. Não existe nenhum
apt upgradedentro de um container de base de dados que sobreviva ao próximodocker compose pull. - A contabilização de memória. Um limite de cgroup é uma barreira rígida imposta pelo kernel, e a base de dados não sabe que ela existe.
- O utilizador. O processo é executado com um ID de utilizador numérico dentro do container, que pode não ser proprietário de nada no host.
Onde os dados ficam determina tudo
Há duas opções adequadas e um erro comum.
- Um volume nomeado:
pgdata:/var/lib/postgresql/data. O Docker cria o diretório em/var/lib/docker/volumes/<project>_pgdata/_data, e o entrypoint da imagem define o proprietário na primeira execução. Esta é a opção padrão. - Um bind mount:
/srv/appname/pg:/var/lib/postgresql/data. Você escolhe o caminho, portanto é responsável pelo problema das permissões. - Nenhum mount. Consulte acima. Os dados ficam no container.
A análise completa das vantagens e desvantagens é um tópico à parte, e bind mounts em comparação com volumes nomeados aborda esse tema. Para uma base de dados, a versão curta é: use um volume nomeado, exceto se tiver uma razão específica para conhecer o caminho no host. Se usar um bind mount, coloque-o num local estável, como /srv/appname/pg, e não dentro do diretório do projeto, onde um git clean pode alcançá-lo.
Há um limite importante: não coloque o diretório de dados de uma base de dados em NFS (sistema de ficheiros de rede) nem em qualquer mount de rede cujo comportamento de locking e fsync não tenha sido testado. As bases de dados assumem que um fsync bem-sucedido significa que os bytes estão num armazenamento persistente. Quando essa suposição é incorreta, ocorre corrupção que só aparece semanas mais tarde.
Fixe o nome do volume antes de ele desaparecer
O Compose atribui um nome a um volume <project>_<volume>, e o nome do projeto usa por predefinição o nome do diretório. Assim, a identidade do volume depende de um nome de diretório, algo que as pessoas alteram sem pensar.
Mova /srv/app para /srv/app-old ou altere o nome da chave pgdata no ficheiro Compose, e o próximo docker compose up -d cria um volume vazio completamente novo. O Postgres inicializa um cluster novo nesse volume. O contentor está saudável, a aplicação arranca e todas as tabelas desaparecem. O volume antigo continua no disco com o nome antigo, o que é uma boa notícia.
docker volume ls
docker volume inspect app_pgdataFixe os nomes para impedir que isto aconteça. Defina explicitamente o nome do projeto e o nome do volume:
name: myapp
services:
db:
image: postgres:17
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
name: myapp_pgdataSe um volume perdido já contiver os seus dados, copie-os enquanto a base de dados estiver parada:
docker compose stop db
docker run --rm -v app_pgdata:/from -v myapp_pgdata:/to alpine sh -c 'cp -a /from/. /to/'
docker compose start dbSe fizer a cópia enquanto a base de dados estiver em execução, obterá uma cópia inconsistente dos ficheiros que estavam a ser escritos. Pare-a primeiro.
Quem é o proprietário do diretório de dados
As imagens oficiais de Postgres, MySQL e MongoDB executam o servidor com um id de utilizador sem privilégios, normalmente 999. Quando o contentor é iniciado como root, o entrypoint altera o proprietário do diretório de dados para esse utilizador e depois abandona os privilégios. Por isso, um bind mount vazio normalmente funciona à primeira tentativa.
Isto deixa de funcionar assim que define user: no ficheiro compose, porque o entrypoint deixa de ter privilégios para corrigir alguma coisa. O Postgres indica isso diretamente:
initdb: error: could not change permissions of directory "/var/lib/postgresql/data": Operation not permittedUm diretório de dados existente com o modo incorreto produz uma mensagem diferente. É importante reconhecê-la, porque a correção é chmod, e não chown:
FATAL: data directory "/var/lib/postgresql/data" has invalid permissions
DETAIL: Permissions should be u=rwx (0700) or u=rwx,g=rx (0750).No MongoDB, um bind mount pertencente a root falha no ficheiro de bloqueio:
Unable to create/open the lock file: /data/db/mongod.lock (Permission denied). Ensure the user executing mongod is the owner of the lock file and has the appropriate permissions.A correção consiste em usar chown no diretório do host com o id numérico, e não com um nome:
sudo chown -R 999:999 /srv/appname/pg
sudo chmod 700 /srv/appname/pg
ls -ldn /srv/appname/pgls -ldn mostra números em vez de nomes e deve mostrar 999 999. A conta chamada postgres no host e a conta chamada postgres dentro da imagem não estão relacionadas: o kernel compara números e os nomes são consultados separadamente em cada lado. como PUID e PGID associam utilizadores do host a um contentor explica corretamente esse mapeamento. Com Docker rootless ou com o remapeamento de user namespaces, os números mudam novamente. Por isso, leia os ids a partir do contentor em execução, em vez de assumir 999.
Os volumes nomeados fazem com que toda esta secção deixe de ser necessária na primeira execução, porque o Docker cria um diretório vazio e o entrypoint torna-se o seu proprietário.
Atualizações: uma atualização de pacote em comparação com a alteração de uma tag de imagem
No host, apt upgrade avança dentro de uma versão menor. A sua distribuição não altera automaticamente a versão principal de uma base de dados. Quando decide fazer essa alteração, os dois conjuntos de binários podem ser instalados ao mesmo tempo. É exatamente disso que pg_upgrade precisa.
Num contentor, a tag representa a versão. Por isso, uma atualização consiste em editar uma linha. As atualizações menores tornam-se triviais, mas as atualizações principais exigem um procedimento.
Altere postgres:16 para postgres:17, execute docker compose up -d e o contentor termina imediatamente:
PostgreSQL Database directory appears to contain a database; Skipping initialization
FATAL: database files are incompatible with server
DETAIL: The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.Nada fica danificado. Os novos binários recusam-se a ler o formato antigo do catálogo em disco, que muda entre versões principais. Restaure a tag postgres:16 e o contentor volta a arrancar. Essa reversão é a única vantagem real que os contentores oferecem nas atualizações.
O procedimento suportado é fazer um dump e restaurá-lo. O PostgreSQL prefere que o dump seja criado pelo cliente mais recente. Por isso, execute-o a partir da nova imagem contra o servidor antigo, que ainda está em execução na rede do compose:
docker run --rm --network myapp_default -e PGPASSWORD="$POSTGRES_PASSWORD" \
postgres:17 pg_dumpall -h db -U postgres > /srv/backups/all.sql
ls -lh /srv/backups/all.sql
tail -n 2 /srv/backups/all.sqlO ficheiro deve ter pelo menos dezenas de kilobytes e terminar com uma linha que contenha PostgreSQL database cluster dump complete. Um ficheiro com algumas centenas de bytes indica que o dump falhou e que está prestes a eliminar um volume sem necessidade. Só depois dessa verificação:
docker compose down
docker volume rm myapp_pgdata
# edit the compose file: image: postgres:17
docker compose up -d db
docker compose exec -T db psql -U postgres -f /dev/stdin < /srv/backups/all.sqlOs outros motores têm comportamentos diferentes:
- O MySQL 8 atualiza o seu próprio dicionário de dados no arranque. Por isso, uma alteração menor da tag normalmente requer apenas um reinício. Leia as notas da versão antes de passar para outra série de versões e faça primeiro um dump em qualquer caso.
- O MariaDB espera que
mariadb-upgradeseja executado depois de o servidor arrancar com a nova versão. - O MongoDB tem de ser atualizado uma versão principal de cada vez. Depois de cada etapa, defina a versão de compatibilidade de funcionalidades antes de continuar. Ignorar uma versão significa que
mongodse recusa a arrancar e regista uma linhaUPGRADE PROBLEMque identificafeatureCompatibilityVersion. A partir do MongoDB 7.0, o comando requer um sinalizador de confirmação explícito:db.adminCommand({ setFeatureCompatibilityVersion: "8.0", confirm: true }). - O Redis carrega sem problemas ficheiros de snapshot antigos, mas não ficheiros mais recentes. Por isso, uma atualização requer um reinício, enquanto uma reversão pode falhar ao carregar os dados.
A regra geral é: um contentor facilita a reversão, mas não torna a atualização mais simples.
Por que o meu contentor da base de dados termina com o código 137?
Porque o kernel terminou o processo pelo mecanismo OOM (out of memory). 137 é 128 mais o sinal 9.
docker compose ps
docker inspect myapp-db-1 | grep -i oomkilled
journalctl -k | tail -n 20docker compose ps mostra Exited (137), a linha de inspeção contém "OOMKilled": true e o log do kernel tem uma entrada correspondente:
Memory cgroup out of memory: Killed process 4711 (postgres) total-vm:2170416kBEste é o mecanismo, e ele surpreende muitas pessoas. PostgreSQL e MySQL dimensionam os respetivos buffers com base na memória total reportada pelo host. Um limite de cgroup não altera esse valor. Num host com 16 GB e um limite de 2 GB, a base de dados calcula os recursos como se tivesse 16 GB, e o cgroup termina-a muito antes de o próprio host ficar sob pressão. Por isso, um limite de memória, por si só, não é suficiente. Também tem de indicar à base de dados quanta memória pode utilizar:
- PostgreSQL: defina
shared_bufferse preste atenção awork_mem.work_memé alocado para cada operação de ordenação e para cada ligação, por isso um valor generoso multiplicado por cinquenta ligações é a causa habitual de um contentor que termina sob carga, em vez de terminar no arranque. - MySQL e MariaDB: defina
innodb_buffer_pool_size, que tem o valor predefinido 128M. Mantenhainnodb_dedicated_serverdesativado num contentor, porque a sua única função é dimensionar-se com base na memória detetada na máquina. - MongoDB: defina explicitamente o tamanho da cache WiredTiger, em vez de permitir que seja calculado com base na memória do host.
- Redis:
maxmemorytem o valor predefinido ilimitado, por isso o Redis cresce até o cgroup o terminar. Definamaxmemoryconfortavelmente abaixo do limite do contentor e escolha ummaxmemory-policy.
O Postgres também regista o evento do seu próprio lado, e estas duas linhas são as que encontrará no log:
LOG: server process (PID 123) was terminated by signal 9: Killed
LOG: terminating any other active sessions due to crash of another server processA terminação de um backend força todos os outros backends a reiniciar, porque a memória partilhada pode estar inconsistente. Isto provoca uma tempestade de ligações na aplicação, não um evento silencioso. definir limites de memória no Docker Compose explica a sintaxe e a diferença entre mem_limit e a forma deploy.resources.
Nada disto desaparece no host. O problema apenas muda de lugar. Sem um cgroup, a base de dados compete com tudo o resto no servidor, e o OOM killer do host escolhe uma vítima com base na pontuação, que pode ser o sshd. Um limite que termina a base de dados de forma previsível é mais fácil de operar do que um OOM do host que bloqueia o seu acesso.
Backups: dump dentro, backup fora
Não faça backup de uma base de dados em execução copiando o respetivo diretório de dados. Uma cópia ao nível dos ficheiros feita enquanto o servidor escreve é uma cópia inconsistente. Só descobrirá o problema no momento do restauro.
Há dois métodos corretos: fazer um dump com a ferramenta própria da base de dados enquanto ela está em execução e fazer backup desse dump, ou parar o contentor e copiar o volume a frio.
docker compose exec -T db pg_dump -U postgres -Fc appdb > /srv/backups/appdb.dump
docker compose exec -T db mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --all-databases > /srv/backups/mysql.sql
docker compose exec -T db mongodump --archive --gzip --db appdb > /srv/backups/appdb.archive.gz
docker compose exec -T redis redis-cli BGSAVEO -T é importante. Sem ele, docker compose exec pode anexar um terminal ao comando, e a camada do terminal adiciona retornos de carro ao fluxo de saída. Um dump de texto é restaurado depois com erros estranhos, e um dump binário fica simplesmente corrompido. A falha ocorre silenciosamente durante o backup e manifesta-se de forma evidente um mês depois.
--single-transaction fornece a mysqldump um snapshot consistente das tabelas InnoDB sem bloquear o servidor inteiro.
Esses comandos escrevem um ficheiro cada. Não constituem um sistema de backup: não há retenção, cópia fora do servidor nem verificação. Entregue o diretório de dumps a uma ferramenta que faça as três coisas. É esse o objetivo de backups restic a partir de um VPS. Faça backup de /srv/backups, não de /var/lib/docker/volumes.
Depois, execute o restauro, porque um backup que nunca foi restaurado não é um backup:
docker compose exec -T db createdb -U postgres restore_test
docker compose exec -T db pg_restore -U postgres -d restore_test < /srv/backups/appdb.dump
docker compose exec -T db psql -U postgres -d restore_test -c '\dt'\dt deve listar as tabelas da aplicação. Um resultado vazio ou Did not find any relations. significa que o dump não é o que pensa que é. Remova restore_test quando terminar.
O comando que apaga tudo
docker compose down -v.
O down simples remove os contentores e a rede. O -v também remove todos os volumes nomeados declarados nesse ficheiro Compose, além de todos os volumes anónimos associados a esses contentores. Não existe confirmação nem forma de desfazer a operação. Esta é a forma mais comum de destruir uma base de dados alojada localmente. Normalmente acontece durante a resolução de um problema não relacionado, porque uma resposta num fórum recomendou executar o comando.
Quatro medidas reduzem o impacto:
- Declare o volume da base de dados como
external: true. O Compose não remove um volume que não lhe pertença, por isso-vnão consegue alcançá-lo. Crie-o uma vez comdocker volume create myapp_pgdata. - Use
docker compose stopedocker compose startpara reinícios de rotina. down e stop no Compose explica o que cada um remove. - Mantenha os dumps num caminho do host fora de todos os volumes geridos pelo Compose.
- Nunca cole
-v, retirado de uma resposta de troubleshooting, numa stack que contenha dados importantes.
Não publique a porta da base de dados
Esta linha coloca a base de dados na Internet pública:
ports:
- "5432:5432"Ela associa a porta a todas as interfaces. O Docker publica uma porta reescrevendo o destino do pacote antes de as regras de entrada da firewall serem processadas. As regras do ufw ficam na cadeia de entrada, por isso ufw deny 5432 não tem qualquer efeito. por que as portas publicadas pelo Docker contornam o ufw mostra o percurso pelas cadeias.
Uma aplicação no mesmo projeto Compose acede à base de dados pelo nome do serviço na rede do Compose. Por isso, não precisa de uma porta publicada. Elimine o bloco. Se quiser ligar a partir de um cliente no host, associe a porta apenas ao loopback:
ports:
- "127.0.0.1:5432:5432"Verifique o que está efetivamente a escutar:
sudo ss -ltnp | grep 5432127.0.0.1:5432 é o resultado pretendido. 0.0.0.0:5432 significa que qualquer pessoa pode tentar descobrir a sua palavra-passe.
O que executar e onde
Uma aplicação em um VPS. Use um contentor. Crie um volume nomeado com um nome fixo, não publique nenhuma porta, defina um limite de memória compatível com as definições da base de dados e faça um dump noturno para um caminho no host que o restic recolha. Comece por uma instalação limpa do Docker num VPS e mantenha a stack num único ficheiro compose que fica versionado. A vantagem é concreta: a versão da base de dados passa a ser uma linha no git que pode ser revista.
Um host com vários serviços. Use contentores, com uma base de dados por aplicação, e não um único servidor partilhado por todas. Um servidor partilhado prende todas as aplicações ao mesmo calendário de atualizações, e uma query descontrolada provoca uma indisponibilidade para todas. Defina um limite de memória próprio para cada contentor, para que uma query problemática fique contida na aplicação que a executou. Várias instâncias pequenas de Postgres custam um pouco mais de disco e exigem muito menos coordenação.
A base de dados é o produto. Execute-a no host a partir do repositório de pacotes do fornecedor ou use um serviço gerido. pg_upgrade precisa das duas versões principais dos binários instaladas ao mesmo tempo, algo que os pacotes fornecem e uma imagem com uma única versão não fornece. A replicação e a recuperação para um ponto no tempo com arquivo WAL (write ahead log) são ambas mais simples quando a base de dados controla a máquina e os respetivos discos. Escolha o caminho mais previsível para o sistema que o irá alertar às 03:00.
A aplicação é pequena. Considere não executar nenhum servidor de base de dados. Uma aplicação web com um único escritor num VPS é frequentemente melhor servida por SQLite em produção num VPS, em que a cópia de segurança é um único ficheiro e o caminho de atualização é uma versão da biblioteca.
FAQ
É seguro executar uma base de dados de produção no Docker?
Sim, numa stack de aplicações num único servidor. Um contentor é um processo Linux com namespaces e cgroups à sua volta. Com um volume montado, a base de dados grava no mesmo sistema de ficheiros do host que usaria se fosse instalada a partir de um pacote. Os riscos são operacionais, não estão relacionados com a velocidade: um volume cujo nome não está fixado, um bind mount pertencente ao ID de utilizador errado, um restore que nunca foi testado e docker compose down -v. Resolva estes quatro pontos e o contentor funciona corretamente. Opte por uma instalação no host quando a base de dados for a carga de trabalho principal e precisar de pg_upgrade, replicação ou recuperação point-in-time.
Devo usar um bind mount ou um volume nomeado para os dados da base de dados?
Use um volume nomeado, exceto se tiver uma razão específica para conhecer o caminho no host. O Docker cria o diretório e o entrypoint da imagem define o proprietário no primeiro arranque, pelo que o problema de permissões não ocorre. Fixe o volume com um name: explícito ou marque-o como external: true. Caso contrário, mudar o nome do diretório do projeto cria silenciosamente um volume novo e vazio e uma base de dados vazia. Um bind mount é adequado se fizer chown do diretório no host para o ID de utilizador numérico com que a imagem é executada, que é 999 nas imagens oficiais de Postgres, MySQL e MongoDB. Verifique-o com ls -ldn, porque ls -l mostra o nome atribuído pelo host a esse número, e esse nome não tem significado dentro do contentor.
O que é eliminado por docker compose down -v?
Este comando remove os contentores e a rede, tal como um down simples. Além disso, -v remove todos os volumes nomeados declarados nesse ficheiro compose e todos os volumes anónimos associados a esses contentores. Isso inclui a base de dados. Não existe confirmação nem recuperação. Os volumes marcados como external: true não são removidos, que é a principal razão para marcar um volume de base de dados como externo. Para um reinício normal, use docker compose stop e docker compose start.
Como atualizo o PostgreSQL para uma nova versão principal no Docker?
Faça dump e restore. Alterar postgres:16 para postgres:17 e reiniciar produz FATAL: database files are incompatible with server com uma linha DETAIL que indica ambas as versões, porque os novos binários não conseguem ler o formato de catálogo antigo. Nada fica danificado: volte a colocar a tag antiga e o serviço arranca. Faça um pg_dumpall com o cliente da nova versão contra o contentor antigo em execução. Confirme que o ficheiro termina com PostgreSQL database cluster dump complete. Depois, inicie a nova tag num volume vazio e carregue o dump. As atualizações menores dentro da mesma versão principal exigem apenas um pull e um reinício.
Porque é que o meu contentor da base de dados termina com o código 137?
137 é 128 mais o sinal 9, por isso algum processo terminou o processo à força. Execute docker inspect <container> | grep -i oomkilled. Um valor de true significa que o contentor atingiu o limite de memória do seu cgroup. A causa habitual é o PostgreSQL e o MySQL lerem a memória total do host e não detetarem o limite do contentor. Assim, planeiam usar 16 GB quando estão limitados a 2 GB. Defina shared_buffers e work_mem, ou innodb_buffer_pool_size, para respeitar o limite atribuído ao contentor. Consulte journalctl -k para encontrar a linha Memory cgroup out of memory correspondente e confirmar qual foi o processo terminado pelo kernel.