#!/bin/bash # ============================================================================= # WOOO AIOps - SigNoz ClickHouse native backup orchestrator # Version: 2.0.0 # # ClickHouse is backed up online through its native BACKUP engine. This script # never reads or archives the live ClickHouse/SQLite Docker volumes. SigNoz # metadata/SQLite remains an explicit application-consistent export gap and is # recorded in the Restic payload without being misreported as completed. # ============================================================================= set -euo pipefail source "$(dirname "$0")/common.sh" SERVICE="signoz" LOCAL_REPO="${BACKUP_BASE}/signoz" DUMP_DIR="/tmp/signoz-backup-$$" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CLICKHOUSE_NATIVE_BACKUP_SCRIPT="${CLICKHOUSE_NATIVE_BACKUP_SCRIPT:-${SCRIPT_DIR}/clickhouse-native-backup.sh}" COLLECTOR_NAME="${SIGNOZ_COLLECTOR_NAME:-signoz-otel-collector}" COLLECTOR_DOCKER_TIMEOUT_SECONDS="${COLLECTOR_DOCKER_TIMEOUT_SECONDS:-10}" COLLECTOR_CONTINUITY_POLL_SECONDS="${COLLECTOR_CONTINUITY_POLL_SECONDS:-1}" TIMEOUT_KILL_AFTER_SECONDS="${TIMEOUT_KILL_AFTER_SECONDS:-30}" BACKUP_SKIP_RETENTION_CLEANUP="${BACKUP_SKIP_RETENTION_CLEANUP:-0}" RESTIC_RECEIPT_ROOT="${SIGNOZ_BACKUP_RECEIPT_ROOT:-/backup/logs/signoz-backup}" RESTIC_INIT_TIMEOUT_SECONDS="${SIGNOZ_RESTIC_INIT_TIMEOUT_SECONDS:-300}" RESTIC_BACKUP_TIMEOUT_SECONDS="${SIGNOZ_RESTIC_BACKUP_TIMEOUT_SECONDS:-1800}" RESTIC_READBACK_TIMEOUT_SECONDS="${SIGNOZ_RESTIC_READBACK_TIMEOUT_SECONDS:-120}" RESTIC_CHECK_TIMEOUT_SECONDS="${SIGNOZ_RESTIC_CHECK_TIMEOUT_SECONDS:-1800}" BACKUP_ID_SEED="$(date -u '+%Y%m%dT%H%M%SZ')-$$" TRACE_ID="${TRACE_ID:-signoz-backup-${BACKUP_ID_SEED}}" RUN_ID="${RUN_ID:-signoz-${BACKUP_ID_SEED}}" WORK_ITEM_ID="${WORK_ITEM_ID:-SIGNOZ-BACKUP}" export TRACE_ID RUN_ID WORK_ITEM_ID COLLECTOR_ID_BEFORE="" COLLECTOR_STARTED_AT_BEFORE="" COLLECTOR_RESTARTS_BEFORE="" COLLECTOR_HEALTH_BEFORE="" COLLECTOR_OBSERVER_PID="" COLLECTOR_OBSERVATIONS="${DUMP_DIR}/collector-continuity.tsv" CLEANUP_COMPLETE=0 FAILURE_NOTIFIED=0 run_collector_docker() { timeout --kill-after="${TIMEOUT_KILL_AFTER_SECONDS}" \ "${COLLECTOR_DOCKER_TIMEOUT_SECONDS}" docker "$@" } collector_identity_state() { run_collector_docker inspect --format $'{{.Id}}\t{{.State.Running}}\t{{.State.StartedAt}}\t{{.RestartCount}}\t{{if (index .State "Health")}}{{(index .State "Health").Status}}{{else}}none{{end}}' \ "${COLLECTOR_NAME}" 2>/dev/null } notify_failure_once() { local detail="$1" if [ "${FAILURE_NOTIFIED}" -eq 1 ]; then return 0 fi FAILURE_NOTIFIED=1 notify_clawbot "failed" "${SERVICE}" "${detail}" || true } observe_collector_continuity() { local epoch observation while true; do epoch="$(date +%s)" if observation="$(collector_identity_state)"; then printf '%s\t%s\n' "${epoch}" "${observation}" >> "${COLLECTOR_OBSERVATIONS}" else printf '%s\tunknown\tunknown\tunknown\tunknown\tunknown\n' "${epoch}" >> "${COLLECTOR_OBSERVATIONS}" fi sleep "${COLLECTOR_CONTINUITY_POLL_SECONDS}" done } start_collector_observer() { : > "${COLLECTOR_OBSERVATIONS}" observe_collector_continuity & COLLECTOR_OBSERVER_PID=$! } stop_collector_observer() { if [ -n "${COLLECTOR_OBSERVER_PID}" ] \ && kill -0 "${COLLECTOR_OBSERVER_PID}" 2>/dev/null; then kill -TERM "${COLLECTOR_OBSERVER_PID}" 2>/dev/null || true wait "${COLLECTOR_OBSERVER_PID}" 2>/dev/null || true fi COLLECTOR_OBSERVER_PID="" } verify_collector_continuity() { local observation final_id final_running final_started_at final_restarts final_health stop_collector_observer [ -s "${COLLECTOR_OBSERVATIONS}" ] || { log_error "${COLLECTOR_NAME} continuity receipt 為空" return 1 } if awk -F '\t' -v expected_id="${COLLECTOR_ID_BEFORE}" \ -v expected_started="${COLLECTOR_STARTED_AT_BEFORE}" \ -v expected_restarts="${COLLECTOR_RESTARTS_BEFORE}" \ -v expected_health="${COLLECTOR_HEALTH_BEFORE}" \ '$2 == "unknown" || $3 != "true" || $2 != expected_id || \ $4 != expected_started || $5 != expected_restarts || $6 != expected_health { exit 1 }' \ "${COLLECTOR_OBSERVATIONS}"; then : else log_error "${COLLECTOR_NAME} 在 native backup window 發生停止或 identity drift" return 1 fi observation="$(collector_identity_state)" || return 1 IFS=$'\t' read -r final_id final_running final_started_at final_restarts final_health <<< "${observation}" [ "${final_id}" = "${COLLECTOR_ID_BEFORE}" ] \ && [ "${final_running}" = "true" ] \ && [ "${final_started_at}" = "${COLLECTOR_STARTED_AT_BEFORE}" ] \ && [ "${final_restarts}" = "${COLLECTOR_RESTARTS_BEFORE}" ] \ && [ "${final_health}" = "${COLLECTOR_HEALTH_BEFORE}" ] } cleanup() { local exit_code=$? if [ "${CLEANUP_COMPLETE}" -eq 1 ]; then return "${exit_code}" fi CLEANUP_COMPLETE=1 trap '' HUP INT TERM stop_collector_observer rm -rf -- "${DUMP_DIR}" return "${exit_code}" } on_signal() { local signal_number="$1" exit "$((128 + signal_number))" } run_native_clickhouse_backup() { local mode="$1" [ -f "${CLICKHOUSE_NATIVE_BACKUP_SCRIPT}" ] \ && [ -x "${CLICKHOUSE_NATIVE_BACKUP_SCRIPT}" ] \ && [ ! -L "${CLICKHOUSE_NATIVE_BACKUP_SCRIPT}" ] \ || { log_error "ClickHouse native backup adapter 缺失、不可執行或為 symlink" return 1 } CLICKHOUSE_NATIVE_OUTPUT_DIR="${DUMP_DIR}" \ TRACE_ID="${TRACE_ID}" RUN_ID="${RUN_ID}" WORK_ITEM_ID="${WORK_ITEM_ID}" \ "${CLICKHOUSE_NATIVE_BACKUP_SCRIPT}" "${mode}" } write_metadata_gap_receipt() { cat > "${DUMP_DIR}/signoz-metadata-gap.json" < "${receipt_tmp}" chmod 600 "${receipt_tmp}" mv "${receipt_tmp}" "${receipt_file}" } main() { local start_time end_time duration snapshot_id snapshot_json tags local initial_observation initial_running final_id final_running local final_started_at final_restarts final_health local artifact_file manifest_file inventory_file artifact_hash manifest_hash inventory_hash start_time="$(date +%s)" trap cleanup EXIT trap 'on_signal 1' HUP trap 'on_signal 2' INT trap 'on_signal 15' TERM log_info "========== 開始 SigNoz ClickHouse native 備份 ==========" [[ "${COLLECTOR_CONTINUITY_POLL_SECONDS}" =~ ^[0-9]+([.][0-9]+)?$ ]] \ && [[ "${COLLECTOR_CONTINUITY_POLL_SECONDS}" != "0" ]] \ && [[ "${COLLECTOR_CONTINUITY_POLL_SECONDS}" != "0.0" ]] || { notify_failure_once "COLLECTOR_CONTINUITY_POLL_SECONDS 無效" exit 1 } for timeout_value in "${RESTIC_INIT_TIMEOUT_SECONDS}" "${RESTIC_BACKUP_TIMEOUT_SECONDS}" \ "${RESTIC_READBACK_TIMEOUT_SECONDS}" "${RESTIC_CHECK_TIMEOUT_SECONDS}"; do [[ "${timeout_value}" =~ ^[0-9]+$ ]] && [ "${timeout_value}" -gt 0 ] || { notify_failure_once "Restic timeout contract 無效" exit 1 } done mkdir -p "${DUMP_DIR}" # Check-mode must pass before the backup window. It writes only durable # receipts and does not submit BACKUP or create a server artifact. if ! run_native_clickhouse_backup --check; then log_error "ClickHouse native BACKUP check-mode 失敗;沒有 volume tar fallback" notify_failure_once "SigNoz ClickHouse native BACKUP runtime contract 未就緒" exit 1 fi initial_observation="$(collector_identity_state)" || { notify_failure_once "無法讀取 ${COLLECTOR_NAME} 初始 identity/state" exit 1 } IFS=$'\t' read -r COLLECTOR_ID_BEFORE initial_running COLLECTOR_STARTED_AT_BEFORE \ COLLECTOR_RESTARTS_BEFORE COLLECTOR_HEALTH_BEFORE <<< "${initial_observation}" [ -n "${COLLECTOR_ID_BEFORE}" ] && [ "${initial_running}" = "true" ] || { notify_failure_once "${COLLECTOR_NAME} 初始狀態不是 running" exit 1 } [ -n "${COLLECTOR_STARTED_AT_BEFORE}" ] \ && [[ "${COLLECTOR_RESTARTS_BEFORE}" =~ ^[0-9]+$ ]] \ && [[ "${COLLECTOR_HEALTH_BEFORE}" =~ ^(healthy|none)$ ]] || { notify_failure_once "${COLLECTOR_NAME} 初始 runtime metadata 不完整" exit 1 } start_collector_observer # Online native backup: no collector stop and no live-volume read. if ! run_native_clickhouse_backup --apply; then notify_failure_once "SigNoz ClickHouse native BACKUP 失敗" exit 1 fi if ! verify_collector_continuity; then notify_failure_once "${COLLECTOR_NAME} no-downtime continuity verifier 失敗" exit 1 fi log_success "${COLLECTOR_NAME} no-downtime continuity verifier 通過" write_metadata_gap_receipt artifact_file="$(find "${DUMP_DIR}" -maxdepth 1 -type f -name 'clickhouse-native-*.zip' -print)" manifest_file="$(find "${DUMP_DIR}" -maxdepth 1 -type f -name 'clickhouse-native-*.zip.manifest.json' -print)" inventory_file="$(find "${DUMP_DIR}" -maxdepth 1 -type f -name 'clickhouse-native-*.source-inventory.tsv' -print)" [ "$(printf '%s\n' "${artifact_file}" | awk 'NF {count++} END {print count+0}')" -eq 1 ] \ && [ "$(printf '%s\n' "${manifest_file}" | awk 'NF {count++} END {print count+0}')" -eq 1 ] \ && [ "$(printf '%s\n' "${inventory_file}" | awk 'NF {count++} END {print count+0}')" -eq 1 ] || { log_error "ClickHouse native artifact set 不唯一或不完整" exit 1 } artifact_hash="$(sha256sum "${artifact_file}" | awk '{print $1}')" manifest_hash="$(sha256sum "${manifest_file}" | awk '{print $1}')" inventory_hash="$(sha256sum "${inventory_file}" | awk '{print $1}')" if [ ! -d "${LOCAL_REPO}/data" ]; then log_info "初始化 Restic 倉庫: ${LOCAL_REPO}" timeout --kill-after="${TIMEOUT_KILL_AFTER_SECONDS}" "${RESTIC_INIT_TIMEOUT_SECONDS}" \ restic -r "${LOCAL_REPO}" init --password-file "${RESTIC_PASSWORD_FILE}" 2>&1 || { log_error "Restic 倉庫初始化失敗" exit 1 } fi log_info "建立 ClickHouse native artifact 的 Restic 備份..." tags="$(build_tags "${SERVICE}")" timeout --kill-after="${TIMEOUT_KILL_AFTER_SECONDS}" "${RESTIC_BACKUP_TIMEOUT_SECONDS}" \ restic -r "${LOCAL_REPO}" backup "${DUMP_DIR}" \ --password-file "${RESTIC_PASSWORD_FILE}" ${tags} \ --tag "trace:${TRACE_ID}" --tag "run:${RUN_ID}" \ --tag "work-item:${WORK_ITEM_ID}" 2>&1 snapshot_json="$(timeout --kill-after="${TIMEOUT_KILL_AFTER_SECONDS}" \ "${RESTIC_READBACK_TIMEOUT_SECONDS}" restic -r "${LOCAL_REPO}" snapshots \ --tag "run:${RUN_ID}" --latest 1 --json \ --password-file "${RESTIC_PASSWORD_FILE}" 2>/dev/null)" snapshot_id="$(printf '%s\n' "${snapshot_json}" \ | sed -n 's/.*"short_id":"\([^"]*\)".*/\1/p' | head -1)" [[ "${snapshot_id}" =~ ^[A-Za-z0-9]+$ ]] || { log_error "Restic same-run snapshot durable readback 缺失" exit 1 } timeout --kill-after="${TIMEOUT_KILL_AFTER_SECONDS}" "${RESTIC_CHECK_TIMEOUT_SECONDS}" \ restic -r "${LOCAL_REPO}" check --read-data-subset=1% \ --password-file "${RESTIC_PASSWORD_FILE}" 2>&1 || { log_error "Restic repository/data-subset verifier 失敗" exit 1 } write_restic_receipt "${snapshot_id}" "${artifact_hash}" \ "${manifest_hash}" "${inventory_hash}" || { log_error "Restic durable receipt writeback 失敗" exit 1 } log_success "Restic 備份完成: ${snapshot_id}" if [ "${BACKUP_SKIP_RETENTION_CLEANUP}" = "1" ]; then log_info "略過 SignOz retention cleanup (BACKUP_SKIP_RETENTION_CLEANUP=1)" else cleanup_old_backups "${LOCAL_REPO}" fi # Final readback is separate from the continuity observer receipt. initial_observation="$(collector_identity_state)" || { notify_failure_once "${COLLECTOR_NAME} final readback 失敗" exit 1 } IFS=$'\t' read -r final_id final_running final_started_at final_restarts final_health <<< "${initial_observation}" [ "${final_id}" = "${COLLECTOR_ID_BEFORE}" ] \ && [ "${final_running}" = "true" ] \ && [ "${final_started_at}" = "${COLLECTOR_STARTED_AT_BEFORE}" ] \ && [ "${final_restarts}" = "${COLLECTOR_RESTARTS_BEFORE}" ] \ && [ "${final_health}" = "${COLLECTOR_HEALTH_BEFORE}" ] || { notify_failure_once "${COLLECTOR_NAME} final identity/start/restart/health verifier 失敗" exit 1 } end_time="$(date +%s)" duration=$((end_time - start_time)) log_success "========== SigNoz ClickHouse native 備份完成 (${duration}s) ==========" notify_clawbot "warning" "${SERVICE}" \ "ClickHouse native backup 完成;metadata/SQLite application-consistent export 仍 pending" \ "${duration}" } main "$@"