Arquivo de etiquetas: sysadmin

Docker e backups

Tempo de leitura: 8 min

Introdução

O Docker tornou-se essencial hoje em dia… quase todos os produtos são distribuídos como containers e até muitas vezes nem há outra opção (os scripts de instalação começam a ser raros). O Docker Compose leva isto ainda mais longe, permitindo instalar e executar aplicações constituídas por múltiplos containers.

E qual é o problema com isto?

Bem, o problema é que quando um upgrade docker compose falha já numa fase avançada, por exemplo quando falham as migrações da base de dados, ficamos sem forma de voltar à versão anterior… se alguma coisa nos dados foi alterada pela nova versão, como fazemos para voltar à versão anterior que até funcionava tão bem!???

Este post discute o problema, fornece uma solução manual utilizando funcionalidades padrão do Docker, e apresenta uma solução mais avançada e automatizada para fazer backup de stacks Docker Compose. Aviso – este é o 1º artigo que escrevi com a ajuda do meu assistente pessoal virtual (neste artigo usei o chatgpt, e penso usar outros no futuro para comparar). Fiz alterações no texto, mas acho que se nota bem as secções que são 100% artificiais porque parecem anúncios e o português é um pouco “abrasileirado”.

Por Que o Docker Compose é Tão Popular

O Docker Compose ganhou popularidade significativa devido a várias razões principais:

  1. Configuração Simplificada de Multi-Containers: O Docker Compose permite definir e executar aplicações Docker multi-container usando um único ficheiro docker-compose.yml. Isso simplifica o processo de gerir aplicações complexas com múltiplos serviços.
  2. Facilidade de Uso: Com o Docker Compose, você pode facilmente iniciar, parar e configurar todos os seus serviços de aplicação usando comandos simples. Isso reduz a complexidade envolvida na gestão de containers individuais.
  3. Replicação de Ambiente: O Docker Compose facilita a replicação do mesmo ambiente em diferentes estágios de desenvolvimento, teste e produção. Isso garante consistência e reduz a probabilidade de problemas específicos do ambiente.
  4. Networking: O Docker Compose configura automaticamente a rede entre containers, permitindo que eles se comuniquem sem problemas. Isso é particularmente útil para arquiteturas de microserviços.
  5. Escalabilidade: O Docker Compose suporta a escalabilidade dos serviços para cima e para baixo com um único comando, facilitando o gerenciamento de cargas de trabalho variáveis.

O Problema

O problema é simples: como podemos fazer um backup de todos os volumes usados por todos os containers num docker-compose.yml? Vamos tentar resolver estas 4 questões:

  1. Consistência de Dados: Garantir que os dados sejam guardados num estado consistente , que é crucial especialmente para bases de dados e outros serviços em que o estado em disco é relevante na recuperação. O que isto quer dizer é que temos de parar os containers antes de fazer o backup. Vamos fazer stop em vez de down – explicação mais adiante.
  2. Recuperação de Desastres: Ter backups confiáveis é essencial para a recuperação de desastres. Em caso de perda ou corrupção de dados, você pode restaurar rapidamente seus serviços. “Obviously”…
  3. Rastreamento de Versões: Manter o controle das versões exatas das imagens Docker e das configurações usadas nos seus serviços garante que você possa recriar o mesmo ambiente se necessário. Ou seja, para voltarmos à “versão anterior” temos de guardar os ids das imagens que estavam em uso em cada container quando fizemos o backup.
  4. Eficiência: Usar compressão multi-thread acelera o processo de backup, economizando tempo e recursos. Isto porque hoje em dia, toda a gente que usa docker tem multi-cores… fazer uma compressão do backup usando apenas 1 thread é esperar mais tempo desnecessariamente.

O Processo Manual

Tanto quanto sei o Docker não fornece uma solução abrangente out-of-the-box (OOTB) para funcionalidades avançadas de backup, mas tem os blocos de construção básicos necessários para criar backups. Vamos ver este processo passo-a-passo…

Se alguém souber de um processo “oficial” backup-restore ou snapshot por favor deixe aqui um comentário.

Passo 1: Parar os Serviços

Primeiro, paramos os serviços para garantir a consistência dos dados.

docker compose stop

Também podemos fazer docker compose down. A diferença do comando stop é que não destrói os containers… ou seja, ao fazermos stop/start os containers mantém o estado. Por exemplo, se alterámos alguma coisa num container como instalar um pacote, essa alteração mantém-se ao fazer start. Se fizermos down/up os containers são recriados no seu estado inicial, perdendo-se todas as alterações que tenhamos feito (exceto claro o que estiver guardado em volumes).

Passo 2: Backup dos Volumes

Em seguida, usamos os comandos docker run e tar para criar backups dos seus volumes.

Dado que os containers Docker são como máquinas temporárias, em cada reinicio voltam ao seu estado inicial e tudo o que lá pusemos de novo ou atualizado desaparece. Os Volumes são áreas de ficheiros onde podemos guardar dados que são mantidos mesmo quando reiniciamos os containers.

Ora, como explica a AI o que são volumes?

“Volumes são áreas de armazenamento persistente usadas por containers Docker para armazenar dados. Eles permitem que os dados sejam armazenados fora do ciclo de vida dos containers, garantindo que as informações permaneçam intactas mesmo após reinicializações ou recriações dos containers.”

Por exemplo, os Volumes têm de ser usados em containers de bases de dados. De outra forma, quando se reinicializasse esse container a bd voltava ao 0.

Ou seja, um backup de um container é na verdade apenas o backup dos seus volumes. Fazer backup de um container não faz sentido algum…

# Listar volumes
docker volume ls
# Fazer backup de cada volume
docker run --rm -v <nome_do_volume>:/data -v <pasta_local_para_backup>:/backup busybox tar czf /backup/volume_name.tar.gz -C /data .

Aqui temos muito sumo para analisar… usamos 2 capacidades do docker: i) corremos um container temporário que é apagado assim que for terminado (opção -rm) e ii) ligamos este container temporário ao volume que queremos copiar (opção -v). Este é um truque elegante que tem várias coisas importantes:

  1. montamos o volume queremos guardar à pasta interna /data no container temporário;
  2. montamos a pasta local onde queremos guardar o backup na pasta interna /backup
  3. vamos usar o comando tar para comprimir a pasta interna /data para a pasta /backup;
  4. os ficheiros na pasta interna /backup vão aparecer na <pasta_local_para_backup> fora do container, no próprio host.

Pois é… um comando tão simples e afinal cheio de truques…

No script final, mais à frente, vamos fazer algumas coisas adicionais – vamos listar os volumes de cada container referido no docker-compose, incluindo volumes anónimos (sem nome). Isto permite automatizar o backup do grupo de containers.

Passo 3: Guardar o Ficheiro Docker Compose

Copiamos manualmente o ficheiro Docker Compose para o local do backup.

cp docker-compose.yml <pasta_local_para_backup>docker-compose_<data>.yml

Num processo de recriação do estado funcional anterior ao desastre vamos precisar do docker-compose.yml tal como estava antes de qualquer alteração. Também vamos precisar de saber a versão exata de cada container que estava em uso…

Passo 4: Registrar as Versões das Imagens

Registamos manualmente as versões das imagens usadas nos seus containers.

# Listar todos os containers em execução docker ps
# Para cada container, obter o ID da imagem
docker inspect --format='{{.Name}} {{.Image}}' container_id

Isto é fundamental… há containers com um ritmo acelerado de lançamento de novas versões. Para voltarmos ao estado inicial temos de saber as versões exatas, e alterar o docker-compose.yml para “tagar” essas versões, garantindo que puxamos as mesmas versões que estavam em uso no momento do backup (em geral são puxadas as versões mais recentes). Isto obrigará à edição do .yml antes de o usar.

Passo 5: Reiniciar os Serviços

Finalmente, voltamos a iniciar os containers:

docker-compose start

Isto serve apenas para voltar a ter os containers a funcionar. Uma vez que já fizemos o backup, podemos então prosseguir com o update dos containers.

Solução Automatizada com Script

Obviamente fazer isto tudo antes de fazermos um update aos nossos containers é absurdo… e a mim irrita-me ligeiramente que o docker não tenha um comando docker compose snapshot… mas enfim…

Já que vamos criar um script então mais vale usar compressão multi-thread e uma opção de dry run. Além disso procuramos volumes com e sem nome (anonymous). O nosso script vai chamar-se compose_snapshot.sh.

Funcionalidades do Script

  1. Análise de Argumentos: O script aceita argumentos para o ficheiro Docker Compose, pasta de saída, número de threads para compressão e uma opção de dry run.
  2. Consistência de Dados: O script para todos os containers em execução antes de realizar o backup para garantir a consistência dos dados.
  3. Compressão Multi-Thread: O script usa pigz para compressão multi-thread, tornando o processo de backup mais rápido e eficiente.
  4. Rastreamento de Versões: O script salva os IDs exatos das imagens e o ficheiro Docker Compose usado para o backup, permitindo a recriação precisa do ambiente.
  5. Funcionalidade de Dry Run: O script inclui uma opção de dry run para listar os volumes que seriam backupados sem realizar o backup real.

O Script

Aqui está o script compose_snapshot.sh, escrito a meias por mim e pelo meu novo assistente virtual (tenho de lhe arranjar um nome… ART (Asshole Research Transport)*):

#!/bin/bash

# Função para exibir informações de uso
usage() {
  echo "Uso: $0 -f <compose_file> -o <output_folder> -p <num_threads> [--dry-run]"
  exit 1
}

DRY_RUN=false
NUM_THREADS=4

# Analisar argumentos
while getopts ":f:o:p:-:" opt; do
  case ${opt} in
    f )
      COMPOSE_FILE=$OPTARG
      ;;
    o )
      OUTPUT_FOLDER=$OPTARG
      ;;
    p )
      NUM_THREADS=$OPTARG
      ;;
    - )
      case "${OPTARG}" in
        dry-run)
          DRY_RUN=true
          ;;
        *)
          usage
          ;;
      esac
      ;;
    \? )
      usage
      ;;
  esac
done

# Verificar se todos os argumentos necessários são fornecidos
if [ -z "$COMPOSE_FILE" ] || [ -z "$OUTPUT_FOLDER" ] || [ -z "$NUM_THREADS" ]; then
  usage
fi

# Verificar se o ficheiro Docker Compose existe
if [ ! -f "$COMPOSE_FILE" ]; then
  echo "Erro: Ficheiro Compose $COMPOSE_FILE não encontrado."
  exit 1
fi

# Criar pasta de saída se não existir
mkdir -p "$OUTPUT_FOLDER"

# Obter o timestamp atual
timestamp=$(date +%Y%m%d%H%M%S)

# Copiar o ficheiro Docker Compose para a pasta de saída com o timestamp
cp "$COMPOSE_FILE" "$OUTPUT_FOLDER/$(basename "$COMPOSE_FILE" .yml)_${timestamp}.yml"

# Verificar se os serviços Docker Compose estão ativos
if ! docker compose -f "$COMPOSE_FILE" ps | grep -q "Up"; then
  echo "Erro: Serviços Docker Compose não estão em execução. Por favor, inicie os serviços usando 'docker compose -f $COMPOSE_FILE up -d' e tente novamente."
  exit 1
fi

# Obter todos os IDs dos containers do projeto Docker Compose
CONTAINER_IDS=$(docker compose -f "$COMPOSE_FILE" ps -q)

# Função para fazer backup de um volume
backup_volume() {
  local volume_name=$1
  local output_folder=$2
  local timestamp=$3
  local num_threads=$4
  local backup_file="$output_folder/${volume_name}_${timestamp}.tar.gz"

  if [ "$DRY_RUN" = true ]; then
    echo "Faria backup do volume $volume_name para $backup_file"
  else
    echo "Fazendo backup do volume $volume_name para $backup_file"
    docker run --rm -v "$volume_name:/mnt/volume" -v "$output_folder:/backup" alpine \
      sh -c "apk add --no-cache pigz && tar cvf - -C /mnt/volume . | pigz -p $num_threads > /backup/${volume_name}_${timestamp}.tar.gz"
  fi
}

if [ "$DRY_RUN" = false ]; then
  # Parar containers
  echo "Parando todos os containers..."
  docker compose -f "$COMPOSE_FILE" stop
fi

# Fazer backup dos volumes de cada container
for container_id in $CONTAINER_IDS; do
  # Obter os volumes montados de cada container
  VOLUMES=$(docker inspect --format '{{ range .Mounts }}{{ .Name }} {{ end }}' $container_id)

  for volume in $VOLUMES; do
    # Ignorar nomes de volumes vazios (montagens não de volumes)
    if [ -n "$volume" ]; then
      backup_volume "$volume" "$OUTPUT_FOLDER" "$timestamp" "$NUM_THREADS"
    fi
  done

  # Listar o ID da imagem de cada container
  IMAGE_ID=$(docker inspect --format '{{.Image}}' $container_id)
  CONTAINER_NAME=$(docker inspect --format '{{.Name}}' $container_id | cut -c2-)

  echo "Container $CONTAINER_NAME está a usar a imagem ID $IMAGE_ID" >> "$OUTPUT_FOLDER/image_ids_${timestamp}.txt"
done

if [ "$DRY_RUN" = false ]; then
  # Reiniciar containers
  echo "Iniciando todos os containers..."
  docker compose -f "$COMPOSE_FILE" start
fi

if [ "$DRY_RUN" = true ]; then
  echo "Dry run concluído. Nenhum volume foi backupado e nenhum container foi parado."
else
  echo "Backup concluído. IDs das imagens salvos em $OUTPUT_FOLDER/image_ids_${timestamp}.txt."
  echo "Ficheiro Docker Compose salvo em $OUTPUT_FOLDER/$(basename "$COMPOSE_FILE" .yml)_${timestamp}.yml."
fi

Conclusão

O script compose_snapshot.sh é uma ferramenta para facilitar o backup de stacks Docker Compose de forma rápida, com garantia de consistência dos dados, compressão multi-thread, rastreamento de versões e com uma opção de dry run. Implementar uma solução de backup como esta não só protege os seus dados, mas também garante que você pode rapidamente recuperar e recriar o seu ambiente quando necessário. Mas pessoalmente é algo que quero fazer sempre e rapidamente antes de qualquer update a um stack docker-compose.

Nota 1: falta o script de restore! Que pretendo em breve publicar…

Nota 2: o método que uso atualmente é colocar o docker dentro de um container LXD. Antes de fazer um update aos containers, faço apenas 1 comando: lxc snapshot <container> <nome_do_snapshot>. Fácil e rápido.
Mas há coisas que não funcionam bem nesta abordagem, como usar GPUs. Daí o script…

Nota 3: há ferramentas que devem fazer backup de containers, como o portainer. Mas mascaram a mecânica das coisas, impedindo que aprendamos como a tecnologia funciona, e substituindo essa aprendizagem por outra que me parece menos útil.

*ART dos fantásticos livros do MurderBot.