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.