Saltar a contenido

Backup automático de base de datos

Sistema de backup automático de MySQL que corre cada 24 horas via Ansible + cron en el servidor de backup. Aplica a la gran mayoría de clientes que usan Histrix.

Qué hace en cada ejecución

  1. Genera una contraseña del día con 3 palabras en español separadas por punto (ej: perro.gato.casa)
  2. Notifica la contraseña nueva por email a los administradores y por Google Chat
  3. Dump de MySQL — se conecta a cada cliente, saca el dump, lo comprime (.sql.gz) y lo trae al servidor de backup. Los clientes se procesan en paralelo
  4. Rota la contraseña del usuario histrix en la base de producción con la nueva clave del día
  5. Empaqueta todos los dumps en un .tgz con la fecha
  6. Sube a S3 (mundoit.sitios)
  7. Limpia backups locales con más de 10 días
  8. Sube las bases al MySQL secundario

Backups desde Docker

Si el cliente tiene MySQL dockerizado el proceso es el mismo — el script de dump se conecta al host del contenedor con las credenciales configuradas en targets.txt. No hace falta hacer nada especial, alcanza con que el puerto MySQL esté accesible desde el servidor de backup via SSH tunnel.

Configurar un cliente nuevo

1. Acceso SSH

Agregar la clave pública del servidor de backup al servidor del cliente:

# En el servidor de backup, copiar la clave pública al cliente
ssh-copy-id usuario@servidor-cliente

Y configurar el ~/.ssh/config del servidor de backup para que reconozca el host.

2. MySQL externo o remoto

Si la base de datos no está en el servidor del cliente ni dockerizada (ej: DO Managed DB), instalar el cliente MySQL en el servidor del cliente para que el dump pueda conectarse:

sudo apt install mysql-client -y

3. Agregar a targets.txt

En el servidor de backup editar /home/automata/ansible-db-backup/targets.txt:

cliente mysql_password=contraseña mysql_host=host mysql_user=usuario mysql_port=puerto

Valores por defecto

Si usás localhost, puerto 3306, usuario root y password genus2041 no hace falta declararlo, se asume automáticamente. La línea mínima sería solo el nombre del cliente:

    nombre-cliente

4. Listo

El backup se ejecuta automáticamente en el próximo ciclo (cada 24 horas). No hay que reiniciar nada.

Archivos del sistema

Repo: ansible-db-backup

Todo vive en /home/automata/ansible-db-backup/ en el servidor de backup:

Archivo Descripción
mysql_dump.yml Playbook principal de Ansible
targets.txt Lista de clientes a backupear
generate_pass.sh Genera la contraseña del día
dump.sh Script que ejecuta el mysqldump en cada cliente
updpass.sh Rota la contraseña del usuario histrix
uploadmysql.sh Sube las bases al MySQL secundario
dumps/ Dumps temporales antes de empaquetar
backups/ .tgz listos para subir a S3

Playbook completo

# Play 1) Generar la clave en localhost, loguear y avisar por mail
- name: Generate today's password, log it, and email it
  hosts: localhost
  gather_facts: true
  vars:
    password_file: /tmp/todaypass.log
  tasks:
    - name: Generate password (script returns the password on stdout)
      ansible.builtin.command: ./generate_pass.sh
      register: genpass
      changed_when: genpass.rc == 0
      tags: [password]
    - name: Set todaypass fact
      ansible.builtin.set_fact:
        todaypass: "{{ genpass.stdout | trim }}"
      tags: [password]
    - name: Save password to temp file
      ansible.builtin.copy:
        dest: "{{ password_file }}"
        content: "{{ todaypass }}\n"
        mode: '0600'
      tags: [password]
    - name: Send notification email
      ansible.builtin.mail:
        host: smtp.mailgun.org
        port: 587
        username: "postmaster@YOUR_DOMAIN_HERE"
        password: "MAILGUN_API_KEY_HERE"
        from: "postmaster@YOUR_DOMAIN_HERE"
        to: "TO_EMAIL_HERE"
        subject: "[PASSWD] Nueva Clave Generada: {{ ansible_date_time.date }}"
        subtype: html
        body: |
          <h1>Nueva Clave Generada:</h1><br />
          <h2>{{ todaypass }}</h2>
          <br/> por Ansible backup.
      delegate_to: localhost
      tags: [notify, password, email]
    - name: send Google chat notification
      ansible.builtin.uri:
        url: "URL_DEL_WEBHOOK_DE_GOOGLE_CHAT"
        method: POST
        headers:
          Content-Type: application/json
        body_format: json
        body:
          text: |-
            🔐 *Nueva Clave Generada*
            *Fecha:* {{ ansible_date_time.date }}
            *Password:* `{{ todaypass }}`
            *Host:* {{ ansible_hostname }}
            Backup automático iniciado por Ansible
        status_code: 200
      delegate_to: localhost
      tags: [notify, password, chat]

# Play 2) En los hosts MySQL: dump en paralelo y traer los .sql.gz
- name: Dump MySQL databases on DB hosts (in parallel) and fetch artifacts
  hosts: mysql
  strategy: free
  gather_facts: false
  vars:
    todaypass: "{{ lookup('file', '/tmp/todaypass.log') }}"
    remote_dump: "/tmp/{{ inventory_hostname }}.sql.gz"
  tasks:
    - name: Copy dump script
      ansible.builtin.copy:
        src: dump.sh
        dest: /tmp/dump.sh
        mode: '0755'
      tags: [mysql]
    - name: Start dump script asynchronously
      ansible.builtin.command: >
        bash /tmp/dump.sh {{ inventory_hostname }}
        MYSQL_USER={{mysql_user | default('root')}}
        MYSQL_PASS={{ mysql_password | default('genus2041') }}
        MYSQL_HOST={{ mysql_host | default('localhost') }}
        MYSQL_PORT={{ mysql_port | default('3306') }}
        TODAY_PASS={{ todaypass }}
      async: 7200
      poll: 0
      register: dump_job
      tags: [mysql]
    - name: Wait for dump to complete
      ansible.builtin.async_status:
        jid: "{{ dump_job.ansible_job_id }}"
      register: dump_wait
      until: dump_wait.finished
      retries: 750
      delay: 5
      tags: [mysql]
    - name: Fetch dump artifact
      ansible.builtin.fetch:
        src: "{{ remote_dump }}"
        dest: "dumps/"
        flat: true
      tags: [mysql]

# Play 3) Rotar pass, empaquetar, subir a S3, limpiar viejos, subir a MySQL secundario
- name: Rotate local tasks, archive and upload
  hosts: localhost
  gather_facts: true
  vars:
    backups_dir: backups
    dumps_glob: "dumps/*.gz"
    tar_name: "mysql-{{ ansible_date_time.date }}.tgz"
  environment:
    AWS_ACCESS_KEY: ACCESS_KEY_HERE
    AWS_SECRET_KEY: SECRET_KEY_HERE
  tasks:
    - name: Ensure Python3 path
      ansible.builtin.set_fact:
        ansible_python_interpreter: "/usr/bin/python3"
    - name: Change 'histrix' password
      ansible.builtin.command: bash updpass.sh
    - name: Ensure backups directory exists
      ansible.builtin.file:
        path: "{{ backups_dir }}/"
        state: directory
        mode: '0755'
    - name: Archive dumps
      ansible.builtin.archive:
        path: "{{ dumps_glob }}"
        dest: "{{ backups_dir }}/{{ tar_name }}"
      when: lookup('ansible.builtin.fileglob', dumps_glob) | length > 0
    - name: Upload archives to S3
      community.aws.s3_sync:
        bucket: mundoit.sitios
        file_root: "{{ backups_dir }}/"
    - name: Find backup files older than 10 days
      ansible.builtin.find:
        paths: "{{ backups_dir }}/"
        age: 10d
        recurse: yes
      register: old_backups
    - name: Remove backup files older than 10 days
      ansible.builtin.file:
        path: "{{ item.path }}"
        state: absent
      loop: "{{ old_backups.files }}"
    - name: Upload Databases to secondary MySQL (custom script)
      ansible.builtin.command: bash uploadmysql.sh
      tags: [mysql]