diff --git a/.github/workflows/test_clickhouse_backup.yml b/.github/workflows/test_clickhouse_backup.yml new file mode 100644 index 00000000..f660b978 --- /dev/null +++ b/.github/workflows/test_clickhouse_backup.yml @@ -0,0 +1,26 @@ +# Tests the ClickHouse backup role, its alerts and their Alertmanager routes +# (ansible/roles/clickhouse_backup/tests), only when one of them changes +name: Test ClickHouse backups +on: + pull_request: + paths: + - "ansible/roles/clickhouse_backup/**" + - "ansible/roles/prometheus/files/alert_rules.yml" + - "ansible/roles/prometheus_alertmanager/**" + - ".github/workflows/test_clickhouse_backup.yml" + push: + branches: [main] + paths: + - "ansible/roles/clickhouse_backup/**" + - "ansible/roles/prometheus/files/alert_rules.yml" + - "ansible/roles/prometheus_alertmanager/**" + - ".github/workflows/test_clickhouse_backup.yml" + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ansible-core python3-boto3 + - run: ansible/roles/clickhouse_backup/tests/run.sh diff --git a/ansible/deploy-clickhouse.yml b/ansible/deploy-clickhouse.yml index 630d1736..b40c3ec7 100644 --- a/ansible/deploy-clickhouse.yml +++ b/ansible/deploy-clickhouse.yml @@ -11,3 +11,16 @@ tags: [node_exporter] - role: oonidata_clickhouse tags: [clickhouse] + +# data3 backs up the replicated tables: they hold the same data on every +# node, and data3 isn't behind clickhouseproxy and doesn't run Airflow. +# data1 backs up the plain MergeTree tables whose data only it holds (see +# clickhouse_backup_data1_tables). The jobs are set in host_vars. +- name: Deploy ClickHouse backups + hosts: + - data1.htz-fsn.prod.ooni.nu + - data3.htz-fsn.prod.ooni.nu + become: true + roles: + - role: clickhouse_backup + tags: [clickhouse_backup] diff --git a/ansible/group_vars/clickhouse/vars.yml b/ansible/group_vars/clickhouse/vars.yml index 73275001..9b5ad668 100644 --- a/ansible/group_vars/clickhouse/vars.yml +++ b/ansible/group_vars/clickhouse/vars.yml @@ -411,6 +411,27 @@ clickhouse_prometheus: asynchronous_metrics: true status_info: true +# For textfile metrics, e.g. from clickhouse_backup +node_exporter_textfile_dir: /var/lib/node_exporter/textfile_collector + +# clickhouse_backup: Cloudflare R2. The endpoint names the account, so it's +# kept in SSM with the token's keys. +clickhouse_backup_s3_endpoint: "{{ lookup('amazon.aws.aws_ssm', '/oonidevops/secrets/clickhouse_backup_r2_endpoint', profile='oonidevops_user_prod') }}" +clickhouse_backup_s3_access_key_id: "{{ lookup('amazon.aws.aws_ssm', '/oonidevops/secrets/clickhouse_backup_r2_access_key_id', profile='oonidevops_user_prod') }}" +clickhouse_backup_s3_secret_access_key: "{{ lookup('amazon.aws.aws_ssm', '/oonidevops/secrets/clickhouse_backup_r2_secret_access_key', profile='oonidevops_user_prod') }}" +# Tables with personal or unpublished data, backed up to the private bucket +clickhouse_backup_private_tables: + - msmt_feedback # account ids and free-text comments + - faulty_measurements # until its contents are reviewed + - url_priorities # until it's decided whether it's published +# Plain MergeTree tables whose data only data1 holds (2026-10-20). Remove a +# table from here once it's replicated, and data3 picks it up. +clickhouse_backup_data1_tables: + - labeling_frames + - event_detector_cusums + - event_detector_v2_changepoints + - changepoint_label + prometheus_nginx_proxy_config: - location: /metrics/node_exporter proxy_pass: http://127.0.0.1:8100/metrics diff --git a/ansible/host_vars/data1.htz-fsn.prod.ooni.nu b/ansible/host_vars/data1.htz-fsn.prod.ooni.nu index 74f6d22f..a0b90b64 100644 --- a/ansible/host_vars/data1.htz-fsn.prod.ooni.nu +++ b/ansible/host_vars/data1.htz-fsn.prod.ooni.nu @@ -2,3 +2,10 @@ tls_cert_dir: /var/lib/dehydrated/certs ssl_domains: - "data1.htz-fsn.prod.ooni.nu" - "airflow.prod.ooni.io" + +# Only the tables whose data only data1 holds; data3 backs up the rest +clickhouse_backup_jobs: + - name: data1 + s3_bucket: ooni-clickhouse-backup + include_tables: "{{ clickhouse_backup_data1_tables }}" + exclude_tables: [] diff --git a/ansible/host_vars/data3.htz-fsn.prod.ooni.nu b/ansible/host_vars/data3.htz-fsn.prod.ooni.nu index bf1b24a0..34959203 100644 --- a/ansible/host_vars/data3.htz-fsn.prod.ooni.nu +++ b/ansible/host_vars/data3.htz-fsn.prod.ooni.nu @@ -1,2 +1,20 @@ non_admin_usernames: [] clickhouse_base_path: /data/clickhouse + +# The replicated tables, minus the ones only data1 holds data for. Private +# tables go to their own bucket: R2 tokens grant access per bucket, and +# researchers may be given read access to the measurement tables. +clickhouse_backup_jobs: + - name: measurements + s3_bucket: ooni-clickhouse-backup + include_tables: ["*"] + exclude_tables: "{{ clickhouse_backup_private_tables + clickhouse_backup_data1_tables }}" + - name: private + s3_bucket: ooni-clickhouse-backup-private + include_tables: "{{ clickhouse_backup_private_tables }}" + exclude_tables: [] + +# After each run, report the tables on data1-3 that no backup covers; +# data1's own tables are backed up by data1 +clickhouse_backup_check_cluster: oonidata_cluster +clickhouse_backup_backed_up_elsewhere: "{{ clickhouse_backup_data1_tables }}" diff --git a/ansible/roles/clickhouse_backup/defaults/main.yml b/ansible/roles/clickhouse_backup/defaults/main.yml new file mode 100644 index 00000000..6ae23389 --- /dev/null +++ b/ansible/roles/clickhouse_backup/defaults/main.yml @@ -0,0 +1,78 @@ +--- +# Back up the ooni ClickHouse database to S3-compatible object storage +# (Cloudflare R2) with ClickHouse's native BACKUP. Deploy where the data is: +# data3 for the replicated tables, data1 for the tables only data1 holds +# (see deploy-clickhouse.yml and host_vars). +# Restore and upgrade procedure: docs/Runbooks.md, "ClickHouse backup". + +# false stops and disables the timer, e.g. while upgrading the cluster +clickhouse_backup_enabled: true +# Weekly, on Sundays at a quiet time (UTC): a full backup of everything takes +# more than a day. A run backs up every job's tables, one job after the other; +# a run still going delays the next one instead of overlapping it. +clickhouse_backup_on_calendar: "Sun *-*-* 02:30:00" + +clickhouse_backup_database: ooni +clickhouse_backup_clickhouse_url: "http://127.0.0.1:8123/" +clickhouse_backup_clickhouse_user: "{{ clickhouse_admin_user }}" +clickhouse_backup_clickhouse_password: "{{ clickhouse_admin_password }}" + +# Cloudflare R2: the endpoint names the account, +# https://.r2.cloudflarestorage.com. One API token with Object +# Read & Write on every bucket the jobs below use. +clickhouse_backup_s3_endpoint: "" +clickhouse_backup_s3_region: auto +clickhouse_backup_s3_prefix: prod +clickhouse_backup_s3_access_key_id: "" +clickhouse_backup_s3_secret_access_key: "" + +# Query settings for ClickHouse's uploads. R2 rejects a multipart upload +# whose parts (all but the last) differ in size, and ClickHouse grows the +# part size as an upload goes on unless the factor is 1. R2 also allows at +# most 10,000 parts, so with 128 MiB parts one file can reach 1.25 TiB. +clickhouse_backup_s3_settings: + s3_upload_part_size_multiply_factor: 1 + s3_min_upload_part_size: 134217728 + s3_max_upload_part_size: 134217728 + +# One job per bucket. A job backs up the tables matching include_tables ("*" +# for every table; an empty list backs up none) minus the excludes below and +# its own. +clickhouse_backup_jobs: + - name: measurements + s3_bucket: "" + include_tables: ["*"] + exclude_tables: [] + +# Complete chains kept per table, besides chains pinned with --keep: the +# current one and the one before, so a month or two of restore points +clickhouse_backup_keep_chains: 2 +# Bytes/s passed to ClickHouse as max_backup_bandwidth: at most 50 MB/s, 40% +# of the 1 Gbit/s link, leaving the rest for replication and queries. +# Against MinIO in testing, 24.8 uploaded at about a third of this value; check +# the real rate on the first backups (ooni_clickhouse_backup_last_size_bytes / +# ooni_clickhouse_backup_last_duration_seconds) and raise it if they're too slow. +clickhouse_backup_max_bandwidth: 50000000 + +# fnmatch patterns, for every job. Materialized views' .inner tables are +# always skipped: they're backed up with their view. +# Tables that are never backed up, for every job, and why +clickhouse_backup_exclude_tables: + - "*_tmp" # updater staging tables + - msmt_feedback_old + - fingerprints_dns # the hourly fingerprints updater rebuilds them + - fingerprints_http + - session_expunge # empty, no longer used (backend #972) + +# The cluster whose tables are checked after each run: any table on any of its +# nodes that no job here backs up, that isn't excluded above and that isn't in +# clickhouse_backup_backed_up_elsewhere is reported (ClickHouseTableNotBackedUp). +# Set on one host, the one taking most backups; empty checks nothing. +clickhouse_backup_check_cluster: "" +# fnmatch patterns of the tables another host backs up +clickhouse_backup_backed_up_elsewhere: [] + +# Results for Prometheus, read by node_exporter's textfile collector +# (node_exporter_textfile_dir in the prometheus_node_exporter role) +clickhouse_backup_metrics_file: "{{ node_exporter_textfile_dir | default('/var/lib/node_exporter/textfile_collector') }}/ooni_clickhouse_backup.prom" +clickhouse_backup_state_file: /var/lib/ooni-clickhouse-backup/state.json diff --git a/ansible/roles/clickhouse_backup/files/ooni_clickhouse_backup.py b/ansible/roles/clickhouse_backup/files/ooni_clickhouse_backup.py new file mode 100644 index 00000000..e743fb5c --- /dev/null +++ b/ansible/roles/clickhouse_backup/files/ooni_clickhouse_backup.py @@ -0,0 +1,828 @@ +#!/usr/bin/env python3 +""" +Back up a ClickHouse database to S3-compatible object storage using +ClickHouse's native BACKUP, with one incremental chain per table. + +Configured by /etc/ooni/clickhouse-backup.json (see the clickhouse_backup +Ansible role). Runs from a systemd timer. The config lists one or more jobs, +run one after the other; each job backs up a set of tables to its own bucket +(R2 tokens grant access per bucket, so private tables get a bucket of their +own). + +Layout in each bucket: + + ///// a native backup + //
///data.native.zst + data export, for + EmbeddedRocksDB and + Join tables only + //
//KEEP-
/""" + out = {} + for key in keys(bucket, f"prod/ooni/{table}/"): + chain, name = key.split("/")[3:5] + backups = out.setdefault(chain, {}) + backups[name] = backups.get(name, False) or key.endswith(f"/{name}/.backup") + return out + + +def copy_prefix(bucket, src, dst): + for key in keys(bucket, src): + s3.copy_object(Bucket=bucket, Key=dst + key[len(src):], CopySource={"Bucket": bucket, "Key": key}) + + +def delete_prefix(bucket, prefix): + for key in keys(bucket, prefix): + s3.delete_object(Bucket=bucket, Key=key) + + +def wait_for(what, get, timeout=150): + """Poll get() until it returns something truthy""" + t0 = time.monotonic() + while time.monotonic() - t0 < timeout: + try: + value = get() + except OSError: + value = None + if value: + return value + time.sleep(2) + check(False, f"{what}, within {timeout}s") + + +def slack(): + """(title, text) of every message the Slack stand-in has received""" + return [(a["title"].strip(), a.get("text", "").strip()) for m in json.loads(http("http://127.0.0.1:19980/")) + for a in m.get("attachments", [])] + + +def email_attempts(): + lines = http("http://127.0.0.1:19993/metrics").splitlines() + return sum(float(l.split()[-1]) for l in lines + if l.startswith('alertmanager_notifications_total{integration="email"')) + + +def months_ago(n): + now = datetime.now(timezone.utc) + y, m = divmod(now.year * 12 + now.month - 1 - n, 12) + return f"{y}{m + 1:02}01T023000Z" + + +def setup(): + print("== waiting for the services") + wait_for("ClickHouse", lambda: ch("SELECT 1") == "1") + wait_for("ClickHouse's other node", lambda: ch("SELECT 1", port=19912) == "1") + wait_for("MinIO", lambda: http("http://127.0.0.1:19910/minio/health/live") == "") + wait_for("Prometheus", lambda: "Ready" in http("http://127.0.0.1:19990/-/ready")) + wait_for("Alertmanager", lambda: "OK" in http("http://127.0.0.1:19993/-/ready")) + wait_for("Slack stand-in", lambda: http("http://127.0.0.1:19980/") == "[]") + for bucket in (MEASUREMENTS, PRIVATE): + s3.create_bucket(Bucket=bucket) + for sql in [ + "CREATE DATABASE ooni", + "CREATE TABLE ooni.fastpath (measurement_uid String, input String, anomaly LowCardinality(String)) " + "ENGINE = MergeTree ORDER BY measurement_uid", + "INSERT INTO ooni.fastpath SELECT toString(number), 'https://example.org/' || toString(number % 100), " + "if(number % 7 = 0, 't', 'f') FROM numbers(200000)", + # ~20 MB that doesn't compress: takes ~20 s to back up with work/slow.json + "CREATE TABLE ooni.obs_web (measurement_uid String, blob String) ENGINE = MergeTree ORDER BY measurement_uid", + "INSERT INTO ooni.obs_web SELECT toString(number), randomString(4096) FROM numbers(5000)", + "CREATE TABLE ooni.test_groups (name String, pattern String) ENGINE = EmbeddedRocksDB PRIMARY KEY name", + "INSERT INTO ooni.test_groups VALUES ('a', '1.2.3.4'), ('b', '5.6.7.8')", + "CREATE TABLE ooni.msmt_feedback (measurement_uid String, comment String) ENGINE = MergeTree ORDER BY measurement_uid", + "INSERT INTO ooni.msmt_feedback VALUES ('m1', 'blocked?')", + "CREATE TABLE ooni.labeling_frames (measurement_uid String) ENGINE = MergeTree ORDER BY measurement_uid", + "INSERT INTO ooni.labeling_frames VALUES ('x')", + "CREATE TABLE ooni.asnmeta_tmp (asn UInt32) ENGINE = MergeTree ORDER BY asn", # excluded: *_tmp + ]: + ch(sql) + ch("CREATE DATABASE ooni", port=19912) + + +def test_first_run(): + print("== 1. first run: a full backup of every table") + r = backup("backup", "run") + check(r.returncode == 0, "the run succeeds", r.stderr) + for bucket, table in [(MEASUREMENTS, "fastpath"), (MEASUREMENTS, "obs_web"), + (MEASUREMENTS, "test_groups"), (PRIVATE, "msmt_feedback")]: + c = chains(bucket, table) + check(len(c) == 1 and all(all(b.values()) for b in c.values()), f"{table}: one complete chain in {bucket}") + check(any(k.endswith("/data.native.zst") for k in keys(MEASUREMENTS, "prod/ooni/test_groups/")), + "test_groups (EmbeddedRocksDB): its rows are exported too") + check(not keys(MEASUREMENTS, "prod/ooni/msmt_feedback/"), "msmt_feedback isn't in the measurements bucket") + check(not keys(MEASUREMENTS, "prod/ooni/asnmeta_tmp/"), "asnmeta_tmp (excluded) isn't backed up") + check(not keys(MEASUREMENTS, "prod/ooni/labeling_frames/"), "labeling_frames (data1's) isn't backed up") + + +def test_completed_alert(emails_before): + print("== 2. ClickHouseBackupCompleted") + for job, tables in (("measurements", 3), ("private", 1)): + title = f"ClickHouse backup {job} on {DATA3} completed" + msgs = wait_for(f"a Slack message: {title}", lambda: [m for m in slack() if m[0].startswith(title)]) + check(len(msgs) == 1, f"one Slack message for {job}: {msgs[0][0]!r}") + check(f"Tables backed up: {tables}, in full: {tables}" in msgs[0][1], f"it says: {msgs[0][1]!r}") + check(not any(t.startswith("[FIRING]") for t, _ in slack()), "no [FIRING] in the titles") + check(email_attempts() == emails_before, "no email sent") + + + +def digest(table): + """row count and a checksum of every row""" + return ch(f"SELECT count(), sum(cityHash64(*)) FROM {table}") + + +def backup_url(bucket, table, chain, name): + return f"http://minio:9000/{bucket}/prod/ooni/{table}/{chain}/{name}" + + +def test_incremental_and_restore(): + print("== 3. incrementals, and restoring each backup of a chain") + # for step 4: a table created on data1 alone, which no job backs up + ch("CREATE TABLE ooni.made_on_data1 (x UInt8) ENGINE = MergeTree ORDER BY x", port=19912) + # fastpath as at the first run's full, then after each of two more runs + states = [digest("ooni.fastpath")] + for first in (200000, 201000): + ch(f"INSERT INTO ooni.fastpath SELECT toString(number), 'https://example.org/new', 'f' FROM numbers({first}, 1000)") + r = backup("backup", "run") + check(r.returncode == 0, "the run succeeds", r.stderr) + states.append(digest("ooni.fastpath")) + (chain, backups), = chains(MEASUREMENTS, "fastpath").items() + names = sorted(backups) + check(len(names) == 3 and all(backups.values()), f"fastpath: a full and two incrementals in chain {chain}") + ch("CREATE DATABASE restored") + for i, (name, state) in enumerate(zip(names, states)): + what = "the full" if i == 0 else f"incremental {i}" + ch(f"RESTORE TABLE ooni.fastpath AS restored.fastpath_{i} FROM S3('{backup_url(MEASUREMENTS, 'fastpath', chain, name)}')") + check(digest(f"restored.fastpath_{i}") == state, f"{what} restores fastpath as it was then ({state.split()[0]} rows)") + + # EmbeddedRocksDB: BACKUP keeps only the schema, the rows are in data.native.zst (the runbook's steps 2 and 3) + c = chains(MEASUREMENTS, "test_groups") + chain = max(ch_ for ch_, b in c.items() if any(b.values())) + url = backup_url(MEASUREMENTS, "test_groups", chain, max(n for n, ok in c[chain].items() if ok)) + ch(f"RESTORE TABLE ooni.test_groups AS restored.test_groups FROM S3('{url}')") + ch(f"INSERT INTO restored.test_groups SELECT * FROM s3('{url}/data.native.zst', 'Native')") + check(digest("restored.test_groups") == digest("ooni.test_groups"), + "test_groups (EmbeddedRocksDB) restores with its exported rows") + + +def test_not_backed_up_alert(): + print("== 4. a table no backup covers: ClickHouseTableNotBackedUp") + title = "[FIRING] ClickHouse table made_on_data1 is not backed up (on data1)" + msgs = wait_for(f"a Slack message: {title}", lambda: [m for m in slack() if m[0] == title]) + check("Add it to a job" in msgs[0][1], f"it says what to do: {msgs[0][1][:90]!r}...") + others = [t for t, _ in slack() if "is not backed up" in t and t != title] + check(not others, f"no other table is reported (labeling_frames is data1's, asnmeta_tmp excluded): {others}") + + +def test_failure_alert(emails_before): + print("== 5. a run that fails: ClickHouseBackupFailed") + r = backup("broken", "run") + check(r.returncode == 1, "the run exits 1", r.stderr) + title = f"[FIRING] ClickHouse backup of labeling_frames (broken) failed on {DATA3}" + msgs = wait_for(f"a Slack message: {title}", lambda: [m for m in slack() if m[0] == title]) + check("Nothing was deleted" in msgs[0][1], f"it says what was done: {msgs[0][1]!r}") + wait_for("an email attempt for it", lambda: email_attempts() > emails_before) + check(not any(t.startswith("ClickHouse backup broken") for t, _ in slack()), "no 'completed' message for it") + + +def test_interrupted_full(): + print("== 6. a new month's full, interrupted, then completed") + (chain, backups), = chains(MEASUREMENTS, "obs_web").items() + full = f"prod/ooni/obs_web/{chain}/{min(backups)}/" + old, previous = months_ago(2), months_ago(1) + # obs_web's chains as at the start of a month: the full of two months ago and of last month + for c in (old, previous): + copy_prefix(MEASUREMENTS, full, f"prod/ooni/obs_web/{c}/{c}/") + delete_prefix(MEASUREMENTS, f"prod/ooni/obs_web/{chain}/") + + run = subprocess.Popen(COMPOSE + ["exec", "-T", "runner", "python3", "/role/ooni_clickhouse_backup.py", + "--config", "/work/slow.json", "run", "--table", "obs_web"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + wait_for("the new full's first files", lambda: set(chains(MEASUREMENTS, "obs_web")) - {old, previous}, 60) + # killed, not stopped: ClickHouse waits for running backups when it stops + # (shutdown_wait_backups_and_restores), so a clean stop would finish this one + subprocess.run(COMPOSE + ["kill", "clickhouse"], check=True, capture_output=True) + subprocess.run(COMPOSE + ["start", "clickhouse"], check=True, capture_output=True) + out, _ = run.communicate(timeout=900) + check(run.returncode == 1, "the interrupted run exits 1", out) + c = chains(MEASUREMENTS, "obs_web") + new = sorted(set(c) - {old, previous}) + check(new and not any(c[new[0]].values()), f"the new chain {new[0]} has no .backup") + check(all(c[x][x] for x in (old, previous)), f"both older chains, {old} and {previous}, are still there") + + wait_for("ClickHouse after it was killed", lambda: ch("SELECT 1") == "1") + r = backup("backup", "run", "--table", "obs_web") + check(r.returncode == 0, "the next run succeeds", r.stderr) + check("removed incomplete chain" in r.stderr, "it removed the interrupted full") + c = chains(MEASUREMENTS, "obs_web") + check(previous in c and old not in c and len(c) == 2 and all(all(b.values()) for b in c.values()), + f"it took the full and pruned {old}: chains {sorted(c)}") + new = max(c) + ch(f"RESTORE TABLE ooni.obs_web AS restored.obs_web FROM S3('{backup_url(MEASUREMENTS, 'obs_web', new, new)}')") + check(digest("restored.obs_web") == digest("ooni.obs_web"), f"the new full {new} restores obs_web") + + +def main(): + setup() + emails_before = email_attempts() + test_first_run() + test_completed_alert(emails_before) + test_incremental_and_restore() + test_not_backed_up_alert() + test_failure_alert(emails_before) + test_interrupted_full() + print("PASS") + + +if __name__ == "__main__": + main() diff --git a/ansible/roles/clickhouse_backup/tests/test_job.py b/ansible/roles/clickhouse_backup/tests/test_job.py new file mode 100644 index 00000000..51417fc1 --- /dev/null +++ b/ansible/roles/clickhouse_backup/tests/test_job.py @@ -0,0 +1,211 @@ +"""How a job runs and what it reports, with ClickHouse stubbed; test_e2e.py runs the real thing. + python3 -m unittest test_job""" +import importlib.util +import json +import os +import tempfile +import types +import unittest +import urllib.error +from unittest import mock + +spec = importlib.util.spec_from_file_location( + "backup", os.path.join(os.path.dirname(__file__), "..", "files", "ooni_clickhouse_backup.py") +) +backup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(backup) +backup.log.disabled = True # the tests check results, not log lines + + +class FlakyClickHouse: + """Fails the first `failures` calls with a connection error, then answers""" + + def __init__(self, failures=0, status="BACKUP_CREATED"): + self.failures, self.status, self.calls = failures, status, 0 + + def query(self, sql, settings=None, timeout=600): + self.calls += 1 + if self.calls <= self.failures: + raise urllib.error.URLError("Connection refused") + return "" + + def rows(self, sql, **kw): + self.query(sql) + if "system.backups WHERE id" in sql: + return [{"status": self.status, "error": "", "num_files": 1, "total_size": 1, "uncompressed_size": 1}] + return [] + + +def job(name="measurements", include=(), exclude=(), metrics=None, ch=None): + j = backup.BackupJob.__new__(backup.BackupJob) + j.name, j.include, j.exclude = name, list(include), list(exclude) + j.metrics = metrics or backup.Metrics() + j.ch = ch or FlakyClickHouse() + j.poll_s, j.clickhouse_wait_s = 0, 0 + return j + + +class TestRunningSince(unittest.TestCase): + def setUp(self): + d = tempfile.mkdtemp() + self.metrics = backup.Metrics(os.path.join(d, "out.prom"), os.path.join(d, "state.json")) + + def test_running_since_is_set_while_a_job_runs(self): + self.metrics.job_running("measurements", True) + self.assertGreater(self.metrics.state["running_since"]["measurements"], 0) + with open(self.metrics.metrics_file) as f: + self.assertIn('ooni_clickhouse_backup_running_since_timestamp_seconds{backup="measurements"}', f.read()) + self.metrics.job_running("measurements", False) + self.assertEqual(self.metrics.state["running_since"]["measurements"], 0) + + def test_old_state_files_still_load(self): + with open(self.metrics.state_file, "w") as f: + json.dump({"tables": {}, "running": {"measurements": 0}}, f) + m = backup.Metrics(self.metrics.metrics_file, self.metrics.state_file) + m.job_running("measurements", True) + self.assertIn("measurements", m.state["running_since"]) + + +class TestRunTable(unittest.TestCase): + def test_only_the_jobs_that_back_up_the_tables(self): + jobs = [job("measurements", include=["*"], exclude=["msmt_feedback"]), job("private", include=["msmt_feedback"])] + selected, orphans = backup.jobs_for_tables(jobs, ["msmt_feedback"]) + self.assertEqual([j.name for j in selected], ["private"]) + self.assertEqual(orphans, []) + + def test_tables_no_job_backs_up(self): + jobs = [job("private", include=["msmt_feedback"])] + self.assertEqual(backup.jobs_for_tables(jobs, ["fastpath"]), ([], ["fastpath"])) + + +class TestIncludeTables(unittest.TestCase): + def test_star_is_every_table(self): + self.assertTrue(job(include=["*"]).includes("fastpath")) + + def test_an_empty_list_is_no_table(self): + # once data1's list of its own tables is emptied, data1 mustn't back up everything + self.assertFalse(job(include=[]).includes("msmt_feedback")) + + def test_a_config_without_jobs_backs_up_every_table(self): + [conf] = backup.job_confs({"exclude_tables": ["*_tmp"]}, []) + self.assertEqual(conf["include_tables"], ["*"]) + + +class TestForget(unittest.TestCase): + def setUp(self): + d = tempfile.mkdtemp() + self.metrics = backup.Metrics(os.path.join(d, "out.prom"), os.path.join(d, "state.json")) + for job_, table in [("data1", "labeling_frames"), ("data1", "changepoint_label"), ("private", "msmt_feedback")]: + self.metrics.table_result(job_, table, True, {"total_size": 1, "num_files": 1}) + self.metrics.job_running("old_job", False) + + def tables(self): + return sorted(self.metrics.state["tables"]) + + def test_a_table_the_job_no_longer_selects(self): + # labeling_frames was replicated, and moved from data1's job to data3's + self.metrics.forget_tables("data1", {"changepoint_label"}) + self.assertEqual(self.tables(), ["data1/changepoint_label", "private/msmt_feedback"]) + + def test_a_job_no_longer_configured(self): + self.metrics.forget_jobs({"data1"}) + self.assertEqual(self.tables(), ["data1/changepoint_label", "data1/labeling_frames"]) + self.assertNotIn("old_job", self.metrics.state["running"]) + + def test_a_run_with_table_forgets_nothing(self): + j = job("data1", include=["*"], metrics=self.metrics) + j.running_backups = lambda: [] + j.tables = lambda only: ([("changepoint_label", "MergeTree")], []) + j.backup_table = lambda *a: False + j.run(["changepoint_label"], False, "") + self.assertIn("data1/labeling_frames", self.tables()) + j.run([], False, "") + self.assertNotIn("data1/labeling_frames", self.tables()) + + +class TestStoreAndState(unittest.TestCase): + def test_only_this_buckets_running_backups(self): + j = job() + j.store = types.SimpleNamespace(ch_base="https://r2.example/ooni-clickhouse-backup", prefix="prod") + j.ch = types.SimpleNamespace(rows=lambda sql: [ + {"id": "1", "name": "S3('https://r2.example/ooni-clickhouse-backup-private/prod/ooni/msmt_feedback/a/b')"}, + {"id": "2", "name": "S3('https://r2.example/ooni-clickhouse-backup/prod/ooni/fastpath/a/b')"}, + ]) + self.assertEqual([r["id"] for r in j.running_backups()], ["2"]) + + def test_objects_not_deleted_are_an_error(self): + store = backup.Store.__new__(backup.Store) + store.bucket, store.prefix = "b", "prod" + store.s3 = types.SimpleNamespace( + get_paginator=lambda name: types.SimpleNamespace(paginate=lambda **kw: [{"Contents": [{"Key": "prod/ooni/t/c/x"}]}]), + delete_objects=lambda **kw: {"Errors": [{"Key": "prod/ooni/t/c/x", "Code": "AccessDenied", "Message": "no"}]}, + ) + with self.assertRaisesRegex(RuntimeError, "1 objects not deleted"): + store.delete_prefix("prod/ooni/t/c/") + + def test_an_unreadable_state_file_starts_empty(self): + d = tempfile.mkdtemp() + state = os.path.join(d, "state.json") + with open(state, "w") as f: + f.write("") # e.g. truncated by a power loss + m = backup.Metrics(os.path.join(d, "out.prom"), state) + m.job_running("measurements", True) + self.assertEqual(m.state["running"], {"measurements": 1}) + + +class TestNotBackedUp(unittest.TestCase): + def test_tables_no_job_covers(self): + jobs = [job("measurements", include=["*"], exclude=["msmt_feedback", "labeling_frames", "*_tmp"]), + job("private", include=["msmt_feedback"])] + tables = { + "fastpath": ["data1", "data2", "data3"], # replicated: measurements + "msmt_feedback": ["data1", "data2", "data3"], # private + "labeling_frames": ["data1", "data2", "data3"], # data1 backs it up + "asnmeta_tmp": ["data1"], # excluded + "fingerprints_dns": ["data1", "data2"], # excluded: rebuilt hourly + "made_on_data1": ["data1"], # nothing: data3 doesn't have it + } + missing = backup.not_backed_up(tables, "data3", jobs, ["*_tmp", "fingerprints_*"], ["labeling_frames"]) + self.assertEqual(missing, {"made_on_data1": ["data1"]}) + + def test_the_metric_names_the_table_and_its_hosts(self): + d = tempfile.mkdtemp() + m = backup.Metrics(os.path.join(d, "out.prom"), os.path.join(d, "state.json")) + m.not_backed_up({"made_on_data1": ["data2", "data1"]}) + with open(m.metrics_file) as f: + self.assertIn('ooni_clickhouse_backup_table_not_backed_up{table="made_on_data1",hosts="data1,data2"} 1', f.read()) + m.not_backed_up({}) + with open(m.metrics_file) as f: + self.assertNotIn("made_on_data1", f.read()) + + +class TestClickHouseErrors(unittest.TestCase): + def setUp(self): + d = tempfile.mkdtemp() + self.metrics = backup.Metrics(os.path.join(d, "out.prom"), os.path.join(d, "state.json")) + + def test_waits_for_clickhouse_at_start(self): + j = job(metrics=self.metrics, ch=FlakyClickHouse(failures=2)) + j.clickhouse_wait_s = 60 + with mock.patch.object(backup.time, "sleep"): + j.wait_for_clickhouse() + self.assertEqual(j.ch.calls, 3) + + def test_a_job_that_cant_start_marks_its_tables_failed(self): + self.metrics.table_result("measurements", "fastpath", True, {"total_size": 1, "num_files": 1}) + self.metrics.table_result("private", "msmt_feedback", True, {"total_size": 1, "num_files": 1}) + j = job(metrics=self.metrics, ch=FlakyClickHouse(failures=1000)) + self.assertEqual(j.run([], False, ""), 1) + tables = self.metrics.state["tables"] + self.assertEqual(tables["measurements/fastpath"]["last_failed"], 1) + self.assertEqual(tables["private/msmt_feedback"]["last_failed"], 0, "another job's table") + self.assertNotIn("measurements", self.metrics.state["runs"], "no completed run to report") + + def test_a_failed_poll_is_retried(self): + j = job(ch=FlakyClickHouse(failures=2)) + self.assertEqual(j.wait("op", "obs_web")["status"], "BACKUP_CREATED") + self.assertEqual(j.ch.calls, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/ansible/roles/clickhouse_backup/tests/test_schedule.py b/ansible/roles/clickhouse_backup/tests/test_schedule.py new file mode 100644 index 00000000..b7ea3146 --- /dev/null +++ b/ansible/roles/clickhouse_backup/tests/test_schedule.py @@ -0,0 +1,120 @@ +"""When ooni_clickhouse_backup.py starts a full backup and what it prunes, over months of daily or weekly runs, +some of them failing. ClickHouse and the bucket are stubbed; test_e2e.py runs the real thing. + python3 -m unittest test_schedule""" +import importlib.util +import json +import os +import unittest +from datetime import datetime, timedelta, timezone + +spec = importlib.util.spec_from_file_location( + "backup", os.path.join(os.path.dirname(__file__), "..", "files", "ooni_clickhouse_backup.py") +) +backup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(backup) + +START = datetime(2026, 10, 23, 2, 30, tzinfo=timezone.utc) # a Friday + + +class FakeStore: + """The bucket: {chain: {"backups": {backup: complete}, "keep": [labels]}}""" + + def __init__(self): + self.c = {} + + def table_prefix(self, db, table): + return f"{db}/{table}/" + + def ch_s3(self, key): + return f"'{key}'" + + def chains(self, db, table): + return {k: {"backups": dict(e["backups"]), "keep": list(e["keep"])} for k, e in self.c.items()} + + def delete_prefix(self, prefix): + parts = prefix.rstrip("/").split("/") + if parts[-2] in self.c: # one backup of a chain + self.c[parts[-2]]["backups"].pop(parts[-1], None) + else: + self.c.pop(parts[-1], None) + return 1 + + def put_empty(self, key): + pass + + +class FakeClickHouse: + """Takes a backup, or fails one part-way: files uploaded, no .backup""" + + def __init__(self, store): + self.store, self.fail = store, False + + def query(self, sql, settings=None, timeout=600): + chain, name = sql.split("TO S3('")[1].split("'")[0].split("/")[-2:] + self.store.c.setdefault(chain, {"backups": {}, "keep": []})["backups"][name] = not self.fail + if self.fail: + raise RuntimeError("BACKUP_FAILED") + return json.dumps({"id": "op"}) + + +def runs(every_days, until, failing=(), jitter=True): + """Back up a table at 02:30 UTC daily or on Sundays from START. Yields (day, full, complete chains).""" + job = backup.BackupJob.__new__(backup.BackupJob) + job.db, job.name, job.keep_chains, job.settings = "ooni", "t", 2, {} + job.store = FakeStore() + job.ch = FakeClickHouse(job.store) + job.metrics = backup.Metrics() + job.wait = lambda op, what: {"num_files": 1, "uncompressed_size": 1} + t, n = START, 0 + while t < until: + # a table's backup starts a few minutes earlier or later each time + now = t + timedelta(minutes=9 if n % 2 else -7) + backup.utcnow = lambda now=now: now + job.ch.fail = now.date().isoformat() in failing + try: + full = job.backup_table("obs_web", "ReplicatedMergeTree", False, "") + except RuntimeError: + full = None + complete = sorted(c for c, e in job.store.c.items() if any(e["backups"].values())) + yield now.date().isoformat(), full, complete + n += 1 + if every_days == 7: + t += timedelta(days=(6 - t.weekday()) % 7 or 7) # the next Sunday + else: + t += timedelta(days=1) + + +class TestSchedule(unittest.TestCase): + def fulls(self, every_days): + return [day for day, full, _ in runs(every_days, datetime(2027, 4, 1, tzinfo=timezone.utc)) if full] + + def test_weekly_fulls_on_the_first_sunday_of_each_month(self): + self.assertEqual( + self.fulls(7), ["2026-10-23", "2026-11-01", "2026-12-06", "2027-01-03", "2027-02-07", "2027-03-07"] + ) + + def test_daily_fulls_on_the_first_of_each_month(self): + self.assertEqual( + self.fulls(1), ["2026-10-23", "2026-11-01", "2026-12-01", "2027-01-01", "2027-02-01", "2027-03-01"] + ) + + def test_two_complete_chains_kept(self): + *_, (day, _, complete) = runs(7, datetime(2027, 4, 1, tzinfo=timezone.utc)) + self.assertEqual([c[:8] for c in complete], ["20270207", "20270307"]) + + def test_failed_fulls_prune_nothing_and_are_retried(self): + failing = {"2026-12-06", "2026-12-13", "2027-02-07", "2027-02-14", "2027-02-21", "2027-02-28", "2027-03-07"} + before = [] + for day, full, complete in runs(7, datetime(2027, 5, 1, tzinfo=timezone.utc), failing): + month = day[:7].replace("-", "") + if not any(c.startswith(month) for c in complete): + # no complete full this month yet: every complete chain from before is still there + self.assertTrue(set(before) <= set(complete), f"{day}: deleted {set(before) - set(complete)}") + if day in failing: + self.assertIsNone(full, day) + before = complete + self.assertEqual([c[:8] for c in complete], ["20270314", "20270404"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ansible/roles/clickhouse_backup/tests/vars.yml b/ansible/roles/clickhouse_backup/tests/vars.yml new file mode 100644 index 00000000..33957689 --- /dev/null +++ b/ansible/roles/clickhouse_backup/tests/vars.yml @@ -0,0 +1,38 @@ +# Role variables for the test environment in compose.yml, with data3's two +# jobs: the measurement tables and the private tables, each in its own bucket. +clickhouse_admin_user: admin +clickhouse_admin_password: admin +clickhouse_backup_clickhouse_url: http://clickhouse:8123/ +clickhouse_backup_s3_endpoint: http://minio:9000 +clickhouse_backup_s3_access_key_id: testkey +clickhouse_backup_s3_secret_access_key: testsecret123 + +test_jobs: + - name: measurements + s3_bucket: ooni-clickhouse-backup + include_tables: ["*"] + exclude_tables: [msmt_feedback, labeling_frames] + - name: private + s3_bucket: ooni-clickhouse-backup-private + include_tables: [msmt_feedback] + exclude_tables: [] + +# data1 backs up its own labeling_frames +clickhouse_backup_backed_up_elsewhere: [labeling_frames] + +# One script config per entry, in work/.json +test_configs: + - name: backup # as deployed on data3 + bandwidth: 0 + jobs: "{{ test_jobs }}" + check_cluster: test_cluster + - name: slow # ~1 MB/s, so a full backup can be interrupted + bandwidth: 3000000 + jobs: "{{ test_jobs }}" + - name: broken # its bucket doesn't exist, so every backup fails + bandwidth: 0 + jobs: + - name: broken + s3_bucket: no-such-bucket + include_tables: [labeling_frames] + exclude_tables: [] diff --git a/ansible/roles/prometheus/files/alert_rules.yml b/ansible/roles/prometheus/files/alert_rules.yml index 1e704a93..a2f33105 100644 --- a/ansible/roles/prometheus/files/alert_rules.yml +++ b/ansible/roles/prometheus/files/alert_rules.yml @@ -99,9 +99,10 @@ groups: annotations: summary: 'Broken `textfile` at {{ $labels.instance }}' - # all the textfile are updated at least hourly + # all the textfile are updated at least hourly, except the ClickHouse + # backup results, which change after each backup (see ClickHouseBackupStale) - alert: TextfileJam - expr: (node_textfile_mtime - ON(instance,job) GROUP_LEFT() node_time) / -3600 > 2 + expr: (node_textfile_mtime{file!~".*/ooni_clickhouse_backup\\.prom"} - ON(instance,job) GROUP_LEFT() node_time) / -3600 > 2 annotations: summary: 'Stale `textfile` at {{ $labels.instance }}' description: '`{{ $labels.file }}` is {{ printf "%.0f" $value }}h old.' @@ -144,4 +145,65 @@ groups: expr: rate(alertmanager_notifications_failed_total[1m]) > 0 annotations: summary: 'Alertmanager {{ $labels.instance }} fails {{ $labels.integration }} notifications.' + +# Results written by ooni-clickhouse-backup (ansible role clickhouse_backup) +# to node_exporter's textfile directory on data1 and data3 +- name: ClickHouse backups + rules: + - alert: ClickHouseBackupFailed + expr: ooni_clickhouse_backup_last_attempt_failed == 1 + annotations: + summary: 'ClickHouse backup of {{ $labels.table }} ({{ $labels.backup }}) failed on {{ $labels.instance }}' + description: 'Nothing was deleted: the older backups of {{ $labels.table }} are kept. The next run retries it. See `journalctl --identifier ooni-clickhouse-backup` on {{ $labels.instance }}.' + + # A table on the cluster that no backup job covers, and that isn't excluded: + # e.g. a new table created on one node only (data3 checks after each run) + - alert: ClickHouseTableNotBackedUp + expr: ooni_clickhouse_backup_table_not_backed_up == 1 + annotations: + summary: 'ClickHouse table {{ $labels.table }} is not backed up (on {{ $labels.hosts }})' + description: 'No backup job covers it and it is not excluded. Add it to a job (clickhouse_backup_data1_tables if only data1 holds its data, clickhouse_backup_private_tables if private), or to clickhouse_backup_exclude_tables if it needs no backup. See the runbook, "ClickHouse backup".' + + # A job's run finished with every table backed up. One Slack message per + # job run, no email and no "resolved" message: Alertmanager routes it to + # slack-notice. It clears an hour after the run. + - alert: ClickHouseBackupCompleted + expr: | + ooni_clickhouse_backup_last_run_duration_seconds + and on (instance, backup) (time() - ooni_clickhouse_backup_last_run_finished_timestamp_seconds < 3600) + and on (instance, backup) ooni_clickhouse_backup_last_run_failed_tables == 0 + annotations: + summary: 'ClickHouse backup {{ $labels.backup }} on {{ $labels.instance }} completed in {{ $value | humanizeDuration }}' + description: 'Tables backed up: {{ with printf `ooni_clickhouse_backup_last_run_tables{instance="%s",backup="%s"}` $labels.instance $labels.backup | query }}{{ . | first | value }}{{ end }}, in full: {{ with printf `ooni_clickhouse_backup_last_run_full_backups{instance="%s",backup="%s"}` $labels.instance $labels.backup | query }}{{ . | first | value }}{{ end }}. Nothing to do.' + + # Backups run weekly, so a table's last one is normally up to a week old. Not + # while any job on the host is in a run that started in the last 5 days: on + # the first Sunday of a month the full backups take more than a day, and the + # jobs run one after the other. A run older than that is stuck (e.g. the + # script was killed and left running at 1), and no longer holds the alert back. + - alert: ClickHouseBackupStale + expr: | + time() - ooni_clickhouse_backup_last_success_timestamp_seconds > 8 * 86400 + unless on (instance) (time() - (ooni_clickhouse_backup_running_since_timestamp_seconds != 0) < 5 * 86400) + annotations: + summary: 'No ClickHouse backup of {{ $labels.table }} ({{ $labels.backup }}) for {{ $value | humanizeDuration }}' + description: 'Check `systemctl status ooni-clickhouse-backup.timer` and the journal on {{ $labels.instance }}.' + + # The job hasn't written any result since its host was set up, or the file is + # gone. Backups run weekly, so a new host has none until its first Sunday. + - alert: ClickHouseBackupMissing + expr: absent(ooni_clickhouse_backup_running{instance="data3.htz-fsn.prod.ooni.nu"}) or absent(ooni_clickhouse_backup_running{instance="data1.htz-fsn.prod.ooni.nu"}) + for: 8d + annotations: + summary: 'No ClickHouse backup results from {{ $labels.instance }} for over a week' + + # data3 takes the backups. Above half its CPUs (load per CPU > 0.5), the + # backup or merges are crowding the node; lower clickhouse_backup_max_bandwidth + # or move the backup to another replica. + - alert: ClickHouseBackupHostLoadHigh + expr: node_load5{instance="data3.htz-fsn.prod.ooni.nu"} / on (instance) count by (instance) (node_cpu_seconds_total{mode="idle", instance="data3.htz-fsn.prod.ooni.nu"}) > 0.5 + for: 15m + annotations: + summary: 'data3 load is {{ $value | humanizePercentage }} of its CPUs' + description: 'If a backup is running (ooni_clickhouse_backup_running on data3), lower clickhouse_backup_max_bandwidth; otherwise check merges and queries.' ... diff --git a/ansible/roles/prometheus_alertmanager/files/templates/ooni.tmpl b/ansible/roles/prometheus_alertmanager/files/templates/ooni.tmpl index c8ad084a..1268b5f2 100644 --- a/ansible/roles/prometheus_alertmanager/files/templates/ooni.tmpl +++ b/ansible/roles/prometheus_alertmanager/files/templates/ooni.tmpl @@ -4,3 +4,7 @@ {{ define "slack.ooni.title" }} {{ range $i, $el := .Alerts }}{{ if gt $i 0 }} {{ end }}{{ if eq $el.Status "resolved" }}[RESOLVED] {{ else }}[FIRING] {{ end }}{{ $el.Annotations.summary }}{{ end }}{{ end }} + +{{ define "slack.ooni.notice.title" }} +{{ range $i, $el := .Alerts }}{{ if gt $i 0 }} +{{ end }}{{ $el.Annotations.summary }}{{ end }}{{ end }} diff --git a/ansible/roles/prometheus_alertmanager/templates/alertmanager.yml b/ansible/roles/prometheus_alertmanager/templates/alertmanager.yml index eb36cf18..6d0136f4 100755 --- a/ansible/roles/prometheus_alertmanager/templates/alertmanager.yml +++ b/ansible/roles/prometheus_alertmanager/templates/alertmanager.yml @@ -42,6 +42,11 @@ route: # All the above attributes are inherited by all child routes and can # overwritten on each. routes: + # Notices of things that went right: Slack only, posted once + - receiver: slack-notice + match: {alertname: ClickHouseBackupCompleted} + group_by: ['alertname', 'instance', 'backup'] + repeat_interval: 24h - receiver: team-email repeat_interval: 24h match: {severity: info} @@ -69,6 +74,12 @@ receivers: title: '{% raw %}{{ template "slack.ooni.title" . }}{% endraw %}' channel: '#ooni-bots' +- name: 'slack-notice' # no email, no "resolved" message + slack_configs: + - send_resolved: false + text: '{% raw %}{{ template "slack.ooni.text" . }}{% endraw %}' + title: '{% raw %}{{ template "slack.ooni.notice.title" . }}{% endraw %}' + channel: '#ooni-bots' - name: 'team-email' # no slack email_configs: {% for u in ['arturo', 'mehul'] %} diff --git a/ansible/roles/prometheus_node_exporter/defaults/main.yml b/ansible/roles/prometheus_node_exporter/defaults/main.yml index 0cdc0011..bb75dd55 100644 --- a/ansible/roles/prometheus_node_exporter/defaults/main.yml +++ b/ansible/roles/prometheus_node_exporter/defaults/main.yml @@ -14,6 +14,9 @@ node_exporter_host: 'localhost' node_exporter_port: 8100 node_exporter_htpasswd: '/etc/ooni/prometheus_passwd' node_exporter_options: '' +# Directory for the textfile collector (*.prom files written by other jobs, +# e.g. clickhouse_backup). Empty: no textfile collector directory. +node_exporter_textfile_dir: '' node_exporter_state: started node_exporter_enabled: true diff --git a/ansible/roles/prometheus_node_exporter/tasks/install.yml b/ansible/roles/prometheus_node_exporter/tasks/install.yml index 1ff74425..6e042260 100644 --- a/ansible/roles/prometheus_node_exporter/tasks/install.yml +++ b/ansible/roles/prometheus_node_exporter/tasks/install.yml @@ -68,6 +68,18 @@ - node_exporter - secrets +- name: Create the textfile collector directory. + file: + path: "{{ node_exporter_textfile_dir }}" + state: directory + owner: root + group: root + mode: 0755 + when: node_exporter_textfile_dir | length > 0 + tags: + - prometheus_node_exporter + - config + - name: Copy the node_exporter systemd unit file. template: src: node_exporter.service.j2 diff --git a/ansible/roles/prometheus_node_exporter/templates/node_exporter.service.j2 b/ansible/roles/prometheus_node_exporter/templates/node_exporter.service.j2 index 42cb98cc..31a822b0 100644 --- a/ansible/roles/prometheus_node_exporter/templates/node_exporter.service.j2 +++ b/ansible/roles/prometheus_node_exporter/templates/node_exporter.service.j2 @@ -4,7 +4,7 @@ Description=NodeExporter [Service] TimeoutStartSec=0 User=node_exporter -ExecStart={{ node_exporter_bin_path }} --web.listen-address={{ node_exporter_host }}:{{ node_exporter_port }} {{ node_exporter_options }} +ExecStart={{ node_exporter_bin_path }} --web.listen-address={{ node_exporter_host }}:{{ node_exporter_port }} {{ node_exporter_options }}{% if node_exporter_textfile_dir %} --collector.textfile.directory={{ node_exporter_textfile_dir }}{% endif %} Restart={{ node_exporter_restart }} [Install] diff --git a/docs/Runbooks.md b/docs/Runbooks.md index c1b14ffa..aa0fa228 100644 --- a/docs/Runbooks.md +++ b/docs/Runbooks.md @@ -176,6 +176,198 @@ Future implementation is tracked in: [Implement Grafana dashboard and alarms backup](#implement-grafana-dashboard-and-alarms-backup) 🐞 +## ClickHouse backup runbook +The `ooni` database on the ClickHouse cluster is backed up to Cloudflare R2 +by the `clickhouse_backup` Ansible role, using ClickHouse's native `BACKUP`. +Each host runs the jobs set in its host_vars, one after the other: + +| Host | Job | Bucket | Tables | +|---|---|---|---| +| data3 | `measurements` | `ooni-clickhouse-backup` | every replicated table, minus the private ones and data1's | +| data3 | `private` | `ooni-clickhouse-backup-private` | `clickhouse_backup_private_tables`: `msmt_feedback`, `faulty_measurements`, `url_priorities` | +| data1 | `data1` | `ooni-clickhouse-backup` | `clickhouse_backup_data1_tables`: plain MergeTree tables whose data only data1 holds | + +data3 takes the replicated tables because it isn't behind clickhouseproxy +and doesn't run Airflow; replicated tables hold the same data on every node. +Private tables have their own bucket because R2 tokens grant access per +bucket, and researchers may be given read access to the measurement tables. +When a data1-only table becomes replicated, remove it from +`clickhouse_backup_data1_tables` and data3 picks it up. + +- `ooni-clickhouse-backup.timer` runs `/usr/local/bin/ooni-clickhouse-backup run` + weekly, on Sundays at 02:30 UTC (`clickhouse_backup_on_calendar`). +- Each table has its own chain: a full backup, then weekly incrementals, each + on top of the one before. A table starts a new chain with its first backup + of each calendar month (UTC): the first Sunday of the month. + Once a new chain is complete, older chains are deleted unless pinned. +- EmbeddedRocksDB and Join tables (`test_groups`): `BACKUP` saves only their + schema, so their rows are also exported to `data.native.zst` inside each + backup. +- Not backed up: `fingerprints_dns` and `fingerprints_http`, which are only on + data1 and data2 and which the hourly updater rebuilds, and `session_expunge`, + which is empty and no longer used. They're in + `clickhouse_backup_exclude_tables`, with the other tables that are never + backed up. +- After each run, data3 lists the tables on every node of the cluster + (`clickhouse_backup_check_cluster`) and reports any that no job backs up and + that aren't excluded, e.g. a new table created on data1 only. +- R2 rejects multipart uploads whose parts (all but the last) differ in + size, so `clickhouse_backup_s3_settings` fixes ClickHouse's part size at + 128 MiB (`s3_upload_part_size_multiply_factor: 1`). R2 allows 10,000 parts, + so one backup file can reach 1.25 TiB. +- Two complete chains are kept per table (`clickhouse_backup_keep_chains`), + so a month or two of restore points. +- Bandwidth is capped with `clickhouse_backup_max_bandwidth`, at 50 MB/s. A + new full backup of everything (~5.6 TB) takes about 31 hours at that rate. + Against MinIO in testing, 24.8 uploaded at only about a third of the cap, + which would make it about 4 days: check the rate of the first backups + (`ooni_clickhouse_backup_last_size_bytes / ooni_clickhouse_backup_last_duration_seconds`) + and raise the cap if they're that slow. + +Layout in the bucket: + +``` +prod/ooni/
/// native backup (first one is full) +prod/ooni/
//KEEP-