Skip to content

Mise en place du backup Restic

Choisir le serveur de backup

Rendez vous sur https://board.bleu122.com/appBackup/index pour déterminer le serveur de backup pour votre projet. (Typiquement, choisir un serveur sur lequel il reste de l'espace disque)

Créer les répertoires sur le serveur de backup

cd /backups
Créer un utilisateur ainsi que le répertoire pour les backups (définir le répertoire comme son home).
sudo adduser --home /backups/{{projet}} backup_{{projet}}
sudo chmod 700 /backups/{{projet}}

Sur le serveur du projet

Nous aurons besoin de clés SSH pour faire fonctionner le script de backup.

Génération des clés SSH

sudo ssh-keygen -t ed25519 (choisir les valeurs par défaut)
Inscrire le serveur de backup dans la config SSH.
sudo nano /root/.ssh/config
Contenu :

Host backup_server
    HostName {{ip_serveur_backup}}
    Port 122
    IdentityFile /root/.ssh/id_ed25519

Puis transférer la clé publique vers le serveur distant
sudo ssh-copy-id -p 122 backup_{{projet}}@backup_server Remplacer "122" par le port SSH du serveur de backup si ce n'est pas 122

Restic

Installation de Restic

sudo apt update
sudo apt install restic
restic version

Mise en place de l'environnement Restic

sudo mkdir -p /etc/restic
sudo mkdir -p /var/log/restic
sudo chmod 700 /etc/restic
sudo mkdir -p /data/restic
sudo mkdir -p /data/restic/tmp
sudo mkdir -p /data/restic/cache
sudo chown -R root:root /data/restic
sudo chmod 700 /data/restic /data/restic/tmp /data/restic/cache
sudo openssl rand -base64 48 | sudo tee /etc/restic/{{projet}}.password
sudo chmod 600 /etc/restic/{{projet}}.password

⚠️⚠️⚠️ Enregistrer le mot de passe dans /etc/restic/{{projet}}.password sur le keepass, si ce mot de passe est perdu, on perd les backups ! ⚠️⚠️⚠️

Définition des commandes Restic dans le bashrc

nano ~/.bashrc
Ajouter à la fin :

#Définition des commandes Restic pour les backups
alias restic-{{projet}}='sudo RESTIC_REPOSITORY="sftp:backup_{{projet}}@backup_server:/backups/{{projet}}" RESTIC_PASSWORD_FILE="/etc/restic/{{projet}}.password" restic'

Puis :
source ~/.bashrc
Enfin initialiser le restic :
restic-{{projet}} init

MySQL

⚠️ A ne faire qu'une fois par serveur :
Créer un utilisateur de backup pour la base de données. Penser à enregistrer le mdp dans le keepass des projets.
sudo mysql -u root -p
CREATE USER 'backup_user'@'localhost' IDENTIFIED BY 'password';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES ON {{db_name}}.* TO 'backup_user'@'localhost';
FLUSH PRIVILEGES;
exit
Créer un fichier de config qui permet à root d'accéder à cet utilisateur sans mdp
sudo nano /root/.my.cnf
Contenu :

[client]
user=backup_user
password='password'

sudo chown root:root /root/.my.cnf
sudo chmod 600 /root/.my.cnf

⚠️ Sur un serveur qui a déjà un backup_user, faire :
sudo mysql -u root -p
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES ON {{db_name}}.* TO 'backup_user'@'localhost';
exit

Créer les scripts de backup

cd /srv/{{projet}}
sudo mkdir backup
sudo chmod 700 backup

D'abord on défini les variables des scripts
sudo nano backup/restic.env

export RESTIC_REPOSITORY="sftp:backup_{{projet}}@backup_server:/backups/{{projet}}"
export RESTIC_PASSWORD_FILE="/etc/restic/{{projet}}.password"
export RESTIC_CACHE_DIR="/data/restic/cache"
export TMPDIR="/data/restic/tmp"

export PROJECT_NAME="{{projet}}"
export DB_NAME="{{db_name}}"
export DATA_DIR="/data/${PROJECT_NAME}"
export MYSQL_CNF="/root/.my.cnf"
export LOG_DIR="/var/log/restic"
export LOG_FILE="${LOG_DIR}/${PROJECT_NAME}.log"
export LOCK_FILE="/tmp/restic-${PROJECT_NAME}.lock"
export FILES_MIN_SIZE_RATIO="0.5"

sudo chmod 600 backup/restic.env

Ensuite le scrit du backup
sudo nano backup/backup_restic.sh

#!/bin/bash
set -euo pipefail

# =========================
# Config
# =========================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/restic.env"

mkdir -p "${LOG_DIR}"

# Empêche deux backups simultanés
exec 200>"${LOCK_FILE}"
flock -n 200 || {
    echo "[$(date -Is)] Backup déjà en cours"
    exit 1
}

echo "==================================================" >> "${LOG_FILE}"
echo "[$(date -Is)] Début backup Restic ${PROJECT_NAME}" >> "${LOG_FILE}"

# =========================
# Backup MySQL
# =========================

mysqldump \
  --defaults-extra-file="${MYSQL_CNF}" \
  --single-transaction \
  --no-tablespaces \
  "${DB_NAME}" \
| restic backup \
    --stdin \
    --stdin-filename "mysql/${DB_NAME}.sql" \
    --tag mysql \
>> "${LOG_FILE}" 2>&1

echo "[$(date -Is)] Backup MySQL terminé" >> "${LOG_FILE}"

# =========================
# Backup fichiers
# =========================

restic backup "${DATA_DIR}" \
  --tag files \
>> "${LOG_FILE}" 2>&1

echo "[$(date -Is)] Backup fichiers terminé" >> "${LOG_FILE}"

# =========================
# Rétention
# =========================

restic forget \
  --keep-daily 7 \
  --keep-weekly 3 \
  --keep-monthly 3 \
  --prune \
>> "${LOG_FILE}" 2>&1

echo "[$(date -Is)] Rétention appliquée" >> "${LOG_FILE}"

# =========================
# Vérification
# =========================

restic check \
>> "${LOG_FILE}" 2>&1

echo "[$(date -Is)] Backup Restic ${PROJECT_NAME} terminé OK" >> "${LOG_FILE}"

Rendre le script exécutable.
sudo chmod 700 backup/backup_restic.sh

Enfin le script de tests
sudo nano backup/check_backup_restic.sh

#!/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/restic.env"

OUTPUT_DIR="/var/www/backup-status"
OUTPUT_FILE="${OUTPUT_DIR}/${PROJECT_NAME}.json"

mkdir -p "${RESTIC_CACHE_DIR}" "${TMPDIR}" "${OUTPUT_DIR}"

SNAPSHOTS_JSON="$(mktemp)"
ERROR_FILE="$(mktemp)"
TMP_OUTPUT="$(mktemp)"
MYSQL_CHECK_RESULT_FILE="$(mktemp)"
FILES_CHECK_RESULT_FILE="$(mktemp)"

cleanup() {
    rm -f "${SNAPSHOTS_JSON}" "${ERROR_FILE}" "${TMP_OUTPUT}" "${MYSQL_CHECK_RESULT_FILE}" "${FILES_CHECK_RESULT_FILE}"
}
trap cleanup EXIT

json_escape() {
    python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'
}

check_mysql_dump() {
    local dump_path="/mysql/${DB_NAME}.sql"

    MYSQL_CHECK_OUTPUT="$(
        restic dump latest --tag mysql "${dump_path}" 2>/dev/null | python3 -c '
import sys

has_header = False
has_structure = False
has_data_marker = False
line_count = 0
byte_count = 0

for raw_line in sys.stdin.buffer:
    line_count += 1
    byte_count += len(raw_line)
    line = raw_line.decode("utf-8", errors="ignore")

    if line_count <= 5 and (
        line.startswith("-- MySQL dump") or
        line.startswith("-- MariaDB dump")
    ):
        has_header = True

    if "CREATE TABLE" in line or "CREATE DATABASE" in line:
        has_structure = True

    if "INSERT INTO" in line or "Dumping data for table" in line or "LOCK TABLES" in line:
        has_data_marker = True

if byte_count < 1024:
    print("DUMP_TOO_SMALL")
    sys.exit(1)

if not has_header:
    print("MISSING_MYSQL_HEADER")
    sys.exit(1)

if not has_structure:
    print("MISSING_SQL_STRUCTURE")
    sys.exit(1)

if not has_data_marker:
    print("MISSING_DATA_MARKER")
    sys.exit(1)

print("OK")
sys.exit(0)
'
    )" && MYSQL_CHECK_STATUS="OK" || MYSQL_CHECK_STATUS="NOK"

    MYSQL_SIZE="$(
        restic ls latest --tag mysql --json 2>/dev/null | DUMP_PATH="${dump_path}" python3 -c '
import json
import os
import sys

dump_path = os.environ["DUMP_PATH"]

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue

    entry = json.loads(line)

    if entry.get("type") == "file" and entry.get("path") == dump_path:
        print(int(entry.get("size", 0)))
        sys.exit(0)

print("0")
sys.exit(1)
'
    )" || MYSQL_SIZE="0"

    if [ "${MYSQL_CHECK_STATUS}" = "OK" ]; then
        cat > "${MYSQL_CHECK_RESULT_FILE}" <<JSON
{"status":"OK","sizeBytes":${MYSQL_SIZE}}
JSON
    else
        ERROR_JSON="$(printf '%s' "${MYSQL_CHECK_OUTPUT:-MYSQL_DUMP_CHECK_FAILED}" | json_escape)"
        cat > "${MYSQL_CHECK_RESULT_FILE}" <<JSON
{"status":"NOK","error":${ERROR_JSON},"sizeBytes":${MYSQL_SIZE}}
JSON
    fi
}

check_files_backup() {
    FILES_CHECK_OUTPUT="$(
        restic ls latest --tag files --json 2>/dev/null | python3 -c '
import json
import os
import subprocess
import sys

data_dir = os.environ.get("DATA_DIR")
ratio = float(os.environ.get("FILES_MIN_SIZE_RATIO", "0.5"))

restic_size = 0
file_count = 0
root_path_found = False

try:
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue

        entry = json.loads(line)

        path = entry.get("path")
        entry_type = entry.get("type")

        if path == data_dir:
            root_path_found = True

        if entry_type == "file":
            restic_size += int(entry.get("size", 0))
            file_count += 1

except Exception:
    print(json.dumps({
        "status": "NOK",
        "error": "RESTIC_LS_JSON_INVALID",
        "sizeBytes": restic_size,
        "fileCount": file_count
    }))
    sys.exit(1)

try:
    local_size_output = subprocess.check_output(["du", "-sb", data_dir], text=True)
    local_size = int(local_size_output.split()[0])
except Exception:
    print(json.dumps({
        "status": "NOK",
        "error": "LOCAL_SIZE_FAILED",
        "sizeBytes": restic_size,
        "fileCount": file_count
    }))
    sys.exit(1)

minimum_expected_size = int(local_size * ratio)

if local_size <= 0:
    print(json.dumps({
        "status": "NOK",
        "error": "LOCAL_SIZE_EMPTY",
        "sizeBytes": restic_size,
        "fileCount": file_count
    }))
    sys.exit(1)

if not root_path_found:
    print(json.dumps({
        "status": "NOK",
        "error": "RESTIC_ROOT_PATH_NOT_FOUND",
        "sizeBytes": restic_size,
        "fileCount": file_count
    }))
    sys.exit(1)

if file_count <= 0:
    print(json.dumps({
        "status": "NOK",
        "error": "RESTIC_FILE_COUNT_EMPTY",
        "sizeBytes": restic_size,
        "fileCount": file_count
    }))
    sys.exit(1)

if restic_size < minimum_expected_size:
    print(json.dumps({
        "status": "NOK",
        "error": f"RESTIC_SIZE_TOO_SMALL restic_size={restic_size} local_size={local_size} min_ratio={ratio}",
        "sizeBytes": restic_size,
        "fileCount": file_count
    }))
    sys.exit(1)

print(json.dumps({
    "status": "OK",
    "sizeBytes": restic_size,
    "fileCount": file_count
}))
sys.exit(0)
'
    )" && FILES_CHECK_STATUS="OK" || FILES_CHECK_STATUS="NOK"

    echo "${FILES_CHECK_OUTPUT}" > "${FILES_CHECK_RESULT_FILE}"
}

if ! restic snapshots --json > "${SNAPSHOTS_JSON}" 2> "${ERROR_FILE}"; then
    ERROR_MSG="$(tr '\n' ' ' < "${ERROR_FILE}" | sed 's/"/\\"/g' | cut -c1-300)"

    cat > "${TMP_OUTPUT}" <<JSON
{
  "project": "${PROJECT_NAME}",
  "status": "NOK",
  "checkedAt": "$(date -Is)",
  "backupTypes": [],
  "errors": ["restic snapshots failed: ${ERROR_MSG}"]
}
JSON

    mv "${TMP_OUTPUT}" "${OUTPUT_FILE}"
    chown root:www-data "${OUTPUT_FILE}"
    chmod 640 "${OUTPUT_FILE}"
    exit 0
fi

check_mysql_dump
MYSQL_CHECK_RESULT="$(cat "${MYSQL_CHECK_RESULT_FILE}")"

check_files_backup
FILES_CHECK_RESULT="$(cat "${FILES_CHECK_RESULT_FILE}")"

python3 - "${SNAPSHOTS_JSON}" "${PROJECT_NAME}" "${MYSQL_CHECK_RESULT}" "${FILES_CHECK_RESULT}" > "${TMP_OUTPUT}" <<'PY'
import json
import sys
from datetime import datetime, timezone

snapshots_file = sys.argv[1]
project_name = sys.argv[2]
mysql_check_result = json.loads(sys.argv[3])
files_check_result = json.loads(sys.argv[4])

expected_tags = ["mysql", "files"]

with open(snapshots_file, "r", encoding="utf-8") as f:
    snapshots = json.load(f)

def parse_datetime(value):
    if value.endswith("Z"):
        value = value.replace("Z", "+00:00")
    if "." in value:
        date_part, rest = value.split(".", 1)

        if "+" in rest:
            fraction, tz = rest.split("+", 1)
            value = f"{date_part}.{fraction[:6]}+{tz}"
        elif "-" in rest:
            fraction, tz = rest.rsplit("-", 1)
            value = f"{date_part}.{fraction[:6]}-{tz}"
        else:
            value = f"{date_part}.{rest[:6]}"

    return datetime.fromisoformat(value)

now = datetime.now(timezone.utc)
backup_types = []
errors = []

for tag in expected_tags:
    matching = [
        snapshot for snapshot in snapshots
        if tag in snapshot.get("tags", [])
    ]

    if not matching:
        backup_types.append({
            "type": tag,
            "status": "NOK",
            "lastSnapshotAt": None,
            "ageSeconds": None,
            "snapshotId": None,
            "path": None,
            "sizeBytes": None,
            "fileCount": None if tag == "files" else None
        })
        errors.append(f"No snapshot found for tag {tag}")
        continue

    latest = max(matching, key=lambda snapshot: parse_datetime(snapshot["time"]))
    latest_datetime = parse_datetime(latest["time"]).astimezone(timezone.utc)
    age_seconds = int((now - latest_datetime).total_seconds())

    item_status = "OK"

    item = {
        "type": tag,
        "status": item_status,
        "lastSnapshotAt": latest_datetime.isoformat(),
        "ageSeconds": age_seconds,
        "snapshotId": latest.get("short_id") or latest.get("id"),
        "path": latest.get("paths", [None])[0]
    }

    if tag == "mysql":
        item["sizeBytes"] = mysql_check_result.get("sizeBytes")

        if mysql_check_result.get("status") != "OK":
            item["status"] = "NOK"
            errors.append(
                f"MySQL dump integrity check failed: {mysql_check_result.get('error', 'MYSQL_DUMP_CHECK_FAILED')}"
            )

    if tag == "files":
        item["sizeBytes"] = files_check_result.get("sizeBytes")
        item["fileCount"] = files_check_result.get("fileCount")

        if files_check_result.get("status") != "OK":
            item["status"] = "NOK"
            errors.append(
                f"Files backup size check failed: {files_check_result.get('error', 'FILES_BACKUP_CHECK_FAILED')}"
            )

    backup_types.append(item)

global_status = "OK"
if errors:
    global_status = "NOK"

print(json.dumps({
    "project": project_name,
    "status": global_status,
    "checkedAt": now.isoformat(),
    "backupTypes": backup_types,
    "errors": errors
}, ensure_ascii=False, indent=2))
PY

mv "${TMP_OUTPUT}" "${OUTPUT_FILE}"
chown root:www-data "${OUTPUT_FILE}"
chmod 640 "${OUTPUT_FILE}"

Rendre le script exécutable.
sudo chmod 700 backup/check_backup_restic.sh

Vérifier la bonne execution des scripts

Faire un test:
sudo backup/backup_restic.sh
On vérifie maintenant si tout s'est bien déroulé.
restic-{{projet}} snapshots
Cette commande vous liste les derniers backups, si tout s'est bien passé vous devriez voir :

repository 9128e30f opened successfully, password is correct
ID        Time                 Host        Tags        Paths
----------------------------------------------------------------------------
XXXXXXXX  YYYY-MM-DD HH:mm:SS  sd-93001    mysql       /mysql/{{projet}}.sql
YYYYYYYY  YYYY-MM-DD HH:mm:SS  sd-93001    files       /data/{{projet}}

Ce qui signifie qu'on a bien le backup fichiers et le backup mysql qui se sont executés.
Ensuite on test les restaurations, d'abord les fichiers :
sudo mkdir -p /data/restic-restore-files
restic-{{projet}} restore latest --tag files --target /data/restic-restore-files
Vérifier que les fichiers correspondent bien, par exemple en faisant ls -hl /data/{{projet}} et ls -hl /data/restic-restore-files/data/{{projet}}
Puis MySQL :
sudo mkdir -p /data/restic-restore-mysql
restic-{{projet}} dump latest --tag mysql mysql/projet.sql > /data/restic-restore-mysql/projet.sql
Vérifier l'intégrité de /data/restic-restore-mysql/projet.sql soit en faisant un simple nano dedans, ou carrément en restaurant dans une db temporaire.

⚠️ Une fois que tous les tests sont fait, penser à supprimer :
sudo rm -rf /data/restic-restore-files
sudo rm -rf /data/restic-restore-mysql

Faire un test du script de check :
sudo backup/check_backup_restic.sh
Si aucune erreur, vérifier le json de résultat :
sudo cat /var/www/backup-status/{{projet}}.json

Mise en place du CRON job (pour lancer le backup automatiquement tous les jours)

Ouvrir la table des cron jobs
sudo crontab -e
Ajouter l'éxécution du script tous les jours à 0h22:

# m h  dom mon dow   command
22 00 * * * /srv/{{projet}}/backup/backup_restic.sh
22 05 * * * /srv/{{projet}}/backup/check_backup_restic.sh

Les logs s'enregistrent dans /var/log/restic

Surveillance du board

Rendre accessible le json au Board :
sudo nano /etc/apache2/sites-available/{{projet}}-le-ssl.conf (Aller dans la config HTTPS du {{projet}})
Et rajouter ces lignes

Entre ServerName projet.bleu122.com et JkMount /* {{projet}}_worker

Alias /backup-status.json /var/www/backup-status/{{projet}}.json
    <Directory /var/www/backup-status>
        Options -Indexes
        AllowOverride None
        Require ip 51.159.17.221
    </Directory>
    JkUnMount /backup-status.json {{projet}}_worker

Entre RewriteEngine On et RewriteCond %{HTTP_USER_AGENT} "Mozilla|Chrome|Safari|Firefox|Edge|Opera" [NC]

    RewriteCond %{REQUEST_URI} !^/backup-status\.json$

Enregistrer et mettre en ligne
sudo apache2ctl configtest
sudo systemctl reload apache2

Configurer le backup sur le board

[http://board.bleu122.com/appInfo/index]
Si elle n'existe pas déjà, ajouter l'application dans la liste.
Sélectionner l'application, cliquer sur "modifier" et renseigner les infos du backup.
Pour le type de backup on sélectionnera RESTIC.
L'URL sera l'URL de base du projet suivi de /backup-status.json
Les fichiers mettre "mysql" et "files" si c'est le script par défaut.