Files
Cloud-Tour-to-Libo/scripts/backup_mysql_encrypted.sh

141 lines
6.3 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
BACKUP_ROOT="${DATA_BACKUP_ROOT:-}"
PASSPHRASE_FILE="${DATA_BACKUP_PASSPHRASE_FILE:-}"
DATA_DIR="${MYSQL_DATA_DIR:-}"
STORAGE_ID="${MYSQL_STORAGE_ID:-unknown}"
RETENTION_DAYS="${DATA_BACKUP_RETENTION_DAYS:-30}"
MYSQL_CONTAINER_NAME="${MYSQL_CONTAINER_NAME:-travel-kg-mysql}"
fail() {
echo "错误:$*" >&2
exit 2
}
path_is_within() {
local candidate="$1"
local parent="$2"
[[ "${candidate}" == "${parent}" || "${candidate}/" == "${parent}/"* ]]
}
secure_secret_file() {
local secret_file="$1"
local permissions permission_digits permission_value
[[ "${secret_file}" == /* ]] || fail "DATA_BACKUP_PASSPHRASE_FILE 必须是绝对路径"
[[ ! -L "${secret_file}" ]] || fail "备份加密口令文件不能是符号链接"
[[ -f "${secret_file}" && -r "${secret_file}" ]] || fail "备份加密口令文件不存在或不可读"
secret_file="$(realpath -e -- "${secret_file}")"
path_is_within "${secret_file}" "${PROJECT_DIR}" && fail "备份加密口令不能存放在代码仓库内"
path_is_within "${secret_file}" "${BACKUP_ROOT}" && fail "加密口令不能与备份文件存放在同一目录"
if [[ -n "${DATA_DIR}" ]] && path_is_within "${secret_file}" "${DATA_DIR}"; then
fail "加密口令不能存放在MySQL运行数据目录内"
fi
[[ "$(wc -c < "${secret_file}" | tr -d '[:space:]')" -ge 32 ]] || fail "备份加密口令不能少于32个字符"
if permissions="$(stat -c '%a' -- "${secret_file}" 2>/dev/null)"; then
:
else
permissions="$(stat -f '%Lp' -- "${secret_file}")"
fi
permission_digits="${permissions: -3}"
[[ "${permission_digits}" =~ ^[0-7]{3}$ ]] || fail "无法识别备份口令文件权限"
permission_value=$((8#${permission_digits}))
(( (permission_value & 8#077) == 0 )) || fail "备份口令文件不能允许组用户或其他用户访问,请执行 chmod 600"
PASSPHRASE_FILE="${secret_file}"
}
for command_name in docker find gzip openssl realpath stat; do
command -v "${command_name}" >/dev/null 2>&1 || fail "缺少命令:${command_name}"
done
[[ "${BACKUP_ROOT}" == /* ]] || fail "DATA_BACKUP_ROOT 必须指向代码仓库外的绝对目录"
[[ "${BACKUP_ROOT}" != "/" && ! -L "${BACKUP_ROOT}" && -d "${BACKUP_ROOT}" ]] || fail "备份目录必须预先创建,且不能是根目录或符号链接"
BACKUP_ROOT="$(realpath -e -- "${BACKUP_ROOT}")"
path_is_within "${BACKUP_ROOT}" "${PROJECT_DIR}" && fail "拒绝将生产备份写入代码仓库"
[[ -w "${BACKUP_ROOT}" ]] || fail "DATA_BACKUP_ROOT 当前不可写"
if [[ -n "${DATA_DIR}" ]]; then
[[ "${DATA_DIR}" == /* && ! -L "${DATA_DIR}" && -d "${DATA_DIR}" ]] || fail "MYSQL_DATA_DIR 必须是现有绝对目录"
DATA_DIR="$(realpath -e -- "${DATA_DIR}")"
if path_is_within "${BACKUP_ROOT}" "${DATA_DIR}" || path_is_within "${DATA_DIR}" "${BACKUP_ROOT}"; then
fail "MySQL运行数据目录和备份目录不能相互包含"
fi
fi
[[ "${RETENTION_DAYS}" =~ ^[0-9]+$ ]] || fail "DATA_BACKUP_RETENTION_DAYS 必须是整数"
(( RETENTION_DAYS >= 7 && RETENTION_DAYS <= 3650 )) || fail "备份保留天数必须在73650天之间"
[[ "${MYSQL_CONTAINER_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]] || fail "MYSQL_CONTAINER_NAME格式错误"
secure_secret_file "${PASSPHRASE_FILE}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
archive_name="mysql-all-${timestamp}.sql.gz.enc"
archive_path="${BACKUP_ROOT}/${archive_name}"
checksum_path="${archive_path}.sha256"
manifest_path="${archive_path}.json"
work_dir="$(mktemp -d "${BACKUP_ROOT}/.mysql-backup.${timestamp}.XXXXXX")"
temporary_archive="${work_dir}/${archive_name}"
temporary_checksum="${work_dir}/${archive_name}.sha256"
temporary_manifest="${work_dir}/${archive_name}.json"
cleanup() {
rm -f -- "${temporary_archive}" "${temporary_checksum}" "${temporary_manifest}"
rmdir -- "${work_dir}" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
[[ "$(docker inspect --format '{{.State.Running}}' "${MYSQL_CONTAINER_NAME}" 2>/dev/null)" == "true" ]] || fail "MySQL容器未运行${MYSQL_CONTAINER_NAME}"
docker exec -i "${MYSQL_CONTAINER_NAME}" sh -eu -c '
MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump \
--user=root \
--all-databases \
--single-transaction \
--quick \
--routines \
--events \
--triggers \
--hex-blob \
--source-data=2 \
--set-gtid-purged=OFF
' | gzip -9 | openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
-pass "file:${PASSPHRASE_FILE}" -out "${temporary_archive}"
[[ -s "${temporary_archive}" ]] || fail "备份文件为空"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass "file:${PASSPHRASE_FILE}" -in "${temporary_archive}" | gzip -t
if command -v sha256sum >/dev/null 2>&1; then
checksum="$(sha256sum "${temporary_archive}" | awk '{print $1}')"
else
checksum="$(shasum -a 256 "${temporary_archive}" | awk '{print $1}')"
fi
printf '%s %s\n' "${checksum}" "${archive_name}" > "${temporary_checksum}"
printf '{\n "created_at": "%s",\n "file": "%s",\n "sha256": "%s",\n "encrypted": true,\n "cipher": "AES-256-CBC/PBKDF2",\n "compression": "gzip",\n "scope": "all-databases",\n "storage_id": "%s",\n "retention_days": %s\n}\n' \
"${timestamp}" "${archive_name}" "${checksum}" "${STORAGE_ID}" "${RETENTION_DAYS}" > "${temporary_manifest}"
chmod 600 "${temporary_archive}" "${temporary_checksum}" "${temporary_manifest}"
# Sidecars are published first and the archive last, so backup scanners never
# observe a final .enc file without its checksum and manifest.
mv -- "${temporary_checksum}" "${checksum_path}"
mv -- "${temporary_manifest}" "${manifest_path}"
mv -- "${temporary_archive}" "${archive_path}"
DATA_BACKUP_PASSPHRASE_FILE="${PASSPHRASE_FILE}" \
"${SCRIPT_DIR}/verify_mysql_backup.sh" "${archive_path}"
deleted_count=0
while IFS= read -r -d '' expired_archive; do
rm -f -- "${expired_archive}" "${expired_archive}.sha256" "${expired_archive}.json"
deleted_count=$((deleted_count + 1))
done < <(find "${BACKUP_ROOT}" -maxdepth 1 -type f -name 'mysql-all-*.sql.gz.enc' -mtime "+${RETENTION_DAYS}" -print0)
echo "加密备份已生成并验证:${archive_path}"
echo "已清理 ${deleted_count} 组超过 ${RETENTION_DAYS} 天的历史备份。"
echo "还必须将 .enc、.sha256 和 .json 自动同步到另一台服务器或对象存储。"