Skip to content

Płaciłem Backblaze za 497 GB backupów, które nie istniały. Fosyle Duplicacy na GridPane

Published: at 08:36

Czyszczenie fosyli Duplicacy z lokalnego dysku backupowego to jedno z cyklicznych zadań, które robię na serwerach GridPane. Nic wielkiego: trzeba to tylko wpasować w odpowiednie okno, żeby nie wjechać w działający backup. Backblaze traktowałem jak miejsce, którym nie trzeba się przejmować. Bucket się nie zapełnia, nic nigdy nie pada, a wszystko, co dało się w nim zobaczyć, sumowało się do jakichś 110 GB.

Faktura z B2 mówiła 607 GB.

Brakujące 497 GB to 501 495 ukrytych wersji plików: fosyle, które Duplicacy odłożyło w B2 i nigdy po nie nie wróciło. Dla duplicacy backup ukryty plik nie istnieje. Dla rozliczeń Backblaze istnieje jak najbardziej. Poniżej: skąd się biorą, jak policzyć swoje (read-only, 30 linii Pythona) i jedna reguła, która sprząta to bez żadnego okna serwisowego.

Spis treści

Rozwiń spis treści

Skąd Duplicacy bierze fosyle

Wszystkie strony na serwerze GridPane dzielą jedną pulę chunków na storage. Skasowanie chunka od razu jest ryzykowne, bo backup innej strony, który właśnie leci, mógł przed chwilą uznać, że ten chunk już jest. Dlatego prune działa jak kosz z paragonem:

  1. Chunk, do którego nie odwołuje się żaden snapshot, staje się fosylem (lokalnie: zmiana nazwy na <hash>.fsl; na B2: b2_hide_file). Lista fosyli, czyli paragon, ląduje w kolekcji w cache tej strony, która zrobiła prune: /var/www/<site>/.duplicacy/cache/<storage>/fossils/N, razem z numerem ostatniej rewizji każdego snapshot ID.
  2. Kolejny prune czyta paragon. Jeśli każdy aktywny snapshot ID (ostatni backup młodszy niż 7 dni) zrobił od tego czasu nową rewizję, fosyle idą do kasacji. Jeśli jakiś snapshot zaczął w międzyczasie z któregoś korzystać, jest “wskrzeszany”.

Na konkretnym przykładzie. Poniedziałek 01:45, prune znajduje 3 000 chunków, których używała już tylko wygasła rewizja 40 sklepu shop-a. Stają się fosylami, powstaje kolekcja 7 z zapisem {shop-a: 52, shop-b: 118, shop-c: 9}, czyli najnowszymi rewizjami w tej chwili. Wtorek: shop-a ma już 53, shop-b 119, ale shop-c backupuje się raz w miesiącu i dalej stoi na rewizji 9 sprzed czterech dni. Aktywny, bez nowej rewizji: kolekcja 7 czeka. I czeka, aż shop-c zrobi backup albo jego ostatnia rewizja skończy 7 dni i przestanie się liczyć. Każda kolekcja z tego tygodnia czeka razem z nią.

A teraz usuń shop-c, wyczyść .duplicacy/cache na stronie, która robiła prune, albo ubij prune w połowie. Fosyle dalej leżą na dysku; paragonu nie ma. W źródłach Duplicacy jest na to komentarz (“unreferenced fossil can be a result of failing to save the fossil collection file”), a szuka ich tylko tryb -exhaustive. Prune GridPane (gpbup -run-prune-all o 01:45, per strona, z retencją -keep, raz dla lokalnego storage i raz dla Backblaze) go nie używa. Skrypt próbuje nawet dodać -exclusive, kiedy nic innego nie działa, ale dopisuje flagę do zmiennej, której nigdy nie wykonuje, więc to też nie działa. Te fosyle są już na stałe.

Lokalnie: znany zabieg

Lokalnie lekarstwo to duplicacy prune -all -exclusive -exhaustive -storage default. -exhaustive znajduje fosyle bez paragonu, -exclusive kasuje je od razu, a bez -keep żaden snapshot nie jest ruszany. Jedyny twardy warunek -exclusive: w trakcie nikt inny nie pisze do storage, więc tak naprawdę chodzi o wybór okna. Każdy harmonogram GridPane to plik w /etc/cron.d/backup-*; to zwija je wszystkie do “co i kiedy”:

for f in /etc/cron.d/backup-*; do
  awk -v n="${f##*/}" '!/^#/ && NF {
    t = (n ~ /backblaze/) ? "B2   " : (n ~ /prune/) ? "PRUNE" : "local";
    printf "%s %2s:%02d dom=%s dow=%s\n", t, $2, $1, $3, $5 }' "$f"
done | sort | uniq -c
  12 B2     *:30 dom=* dow=*      # co godzinę do Backblaze
   9 B2     0:30 dom=* dow=*
   2 local  *:00 dom=* dow=*      # co godzinę, lokalnie
   1 local  *:20 dom=* dow=*
  15 local  0:00 dom=* dow=*      # dzienne lokalne o północy
   1 local  0:00 dom=* dow=1      # tygodniowy
   1 local  0:00 dom=15 dow=*     # miesięczny
   1 PRUNE  1:45 dom=* dow=*      # własny prune GridPane

Dla lokalnego storage liczą się tylko linie local i PRUNE, czyli: dowolna godzina, minuty :25 do :55, poza 00:00 do 02:30. W oknie: upilnuj je i jedź:

pgrep -af '^/usr/local/bin/duplicacy' && exit 1      # coś właśnie działa
tsp -L backups | grep -q running && exit 1           # GridPane kolejkuje backupy przez task-spooler
mkdir -p /root/cron-paused
mv /etc/cron.d/backup-hourly-local-* /root/cron-paused/
trap 'mv /root/cron-paused/* /etc/cron.d/' EXIT      # odłóż crony nawet, jeśli prune padnie
cd /var/www/<dowolna-strona>                         # hasło storage siedzi w .duplicacy/preferences
duplicacy prune -all -exclusive -exhaustive -storage default -threads 8
duplicacy check -a -storage default -threads 8       # każdy chunk każdej rewizji ma istnieć

Dwa szczegóły, które warto znać: ps aux | grep duplicacy łapie własną linię poleceń, jeśli puszczasz to przez ssh host 'bash -c ...', stąd pgrep -f; a -dry-run dla sierot loguje tylko Found unreferenced fossil i nigdy nie mówi, że by je usunął. Prawdziwy przebieg usuwa. Tym razem: 155 462 chunki skasowane, 222 GB → 82 GB, 8 sekund, wszystkie rewizje na miejscu.

Zwróć uwagę na jeszcze jedną rzecz w tej tabelce: 23 joby do Backblaze, 12 z nich co godzinę. Wróci za chwilę.

Backblaze: fosyle, których nie widać

B2 nie ma rename, więc Duplicacy robi fosyl przez ukrycie pliku. Ukrytej wersji nie zwraca b2_list_file_names, nie ma jej w przeglądarce plików w panelu, a rozliczana jest jak każdy inny bajt. Widać ją tylko przez b2_list_file_versions, jako action: "hide". Licznik bucket size w panelu ją wlicza i dlatego licznik i lista plików rozjeżdżają się o setki gigabajtów.

Policzenie ich to robota read-only i wystarczy do niej Python, który i tak jest na serwerze. GridPane trzyma klucz B2 i nazwę bucketu w /var/www/<site>/.duplicacy/preferences każdej strony, więc:

import json, base64, urllib.request, collections
p = [s for s in json.load(open("/var/www/<site>/.duplicacy/preferences")) if s["name"] == "backblaze"][0]
bucket_name = p["storage"].removeprefix("b2://").split("/")[0]

req = urllib.request.Request("https://api.backblazeb2.com/b2api/v2/b2_authorize_account",
    headers={"Authorization": "Basic " + base64.b64encode(f'{p["keys"]["b2_id"]}:{p["keys"]["b2_key"]}'.encode()).decode()})
auth = json.load(urllib.request.urlopen(req))
def api(name, body):
    r = urllib.request.Request(f'{auth["apiUrl"]}/b2api/v2/{name}', data=json.dumps(body).encode(),
        headers={"Authorization": auth["authorizationToken"], "Content-Type": "application/json"})
    return json.load(urllib.request.urlopen(r))

bucket = [b for b in api("b2_list_buckets", {"accountId": auth["accountId"]})["buckets"] if b["bucketName"] == bucket_name][0]
print("lifecycle rules:", bucket["lifecycleRules"])

versions = collections.defaultdict(list)          # fileName -> [(action, size, ts)], od najnowszej
body = {"bucketId": bucket["bucketId"], "prefix": "chunks/", "maxFileCount": 10000}
while True:
    r = api("b2_list_file_versions", body)
    for f in r["files"]:
        versions[f["fileName"]].append((f["action"], f["contentLength"], f["uploadTimestamp"]))
    if not r.get("nextFileName"): break
    body.update(startFileName=r["nextFileName"], startFileId=r["nextFileId"])

stats = collections.Counter()
for name, vs in versions.items():
    size = sum(s for a, s, _ in vs if a == "upload")
    kind = "HIDDEN" if vs[0][0] == "hide" else "visible"   # najnowsza wersja to znacznik hide => fosyl
    stats[kind + " files"] += 1; stats[kind + " GB"] += size / 1e9
print({k: round(v, 1) for k, v in stats.items()})

b2_list_file_versions zwraca każdą wersję każdej nazwy, od najnowszej. Chunk, którego najnowsza wersja to znacznik hide, jest fosylem; bajty siedzą w starszej wersji upload pod spodem i za nie płacisz. U mnie:

{'visible files': 39323, 'visible GB': 109.3, 'HIDDEN files': 501495, 'HIDDEN GB': 497.4}

Dwie rzeczy, które trzeba trafić. GridPane zakłada jeden bucket na serwer, o niemal identycznych nazwach (gridpane-backups-<konto>-<uuid-serwera>...), więc dopasowuj dokładną nazwę z preferences, nie prefiks; za pierwszym razem policzyłem bucket innego serwera. I klucz, który GridPane wpisuje do preferences, jest kluczem do całego konta (writeBuckets, deleteBuckets, writeKeys…). Przy okazji warto go podmienić na klucz do jednego bucketu.

Czemu nie ten sam exclusive prune na B2?

Można: -storage backblaze działa identycznie. Nie zrobiłem tego z trzech powodów. To 500 tysięcy wywołań b2_delete_file_version, godzina lub dwie przy 8 wątkach. Przez cały ten czas wszystkie 23 crony Backblaze muszą stać, w tym 12 godzinowych, czyli tuzin sklepów zostaje bez backupu poza serwerem. A cokolwiek, co w międzyczasie zapisze do bucketu, choćby backup odpalony ręcznie z panelu GridPane, to dokładnie ten wyścig, przed którym dwuetapowe kasowanie ma chronić. Reguła lifecycle nie ma żadnego z tych problemów.

Rozwiązanie: jedna reguła lifecycle

Reguły lifecycle w B2 potrafią kasować ukryte wersje po N dniach. Jedna reguła na bucket, bez prefiksu:

[
  {
    "fileNamePrefix": "",
    "daysFromHidingToDeleting": 30,
    "daysFromUploadingToHiding": null
  }
]

W panelu: Buckets → Bucket Settings → Lifecycle Settings → Use custom lifecycle rules → Add Lifecycle Rules. File Path i Days Till Hide zostaw puste, w Days Till Delete wpisz 30, Update Bucket. (Radio Keep prior versions for this number of days: 30 to ta sama reguła pod przyjaźniejszą etykietą.)

Okno Lifecycle Settings w Backblaze B2 z regułą custom: puste File Path, puste Days Till Hide, Days Till Delete ustawione na 30

Albo, z tym samym api() co wyżej:

api("b2_update_bucket", {"accountId": auth["accountId"], "bucketId": bucket["bucketId"],
    "lifecycleRules": [{"fileNamePrefix": "", "daysFromHidingToDeleting": 30, "daysFromUploadingToHiding": None}]})

Dlaczego to bezpieczne. Dwa osobne argumenty. Po pierwsze, dla duplicacy backup ukryty chunk już nie istnieje: przed wgraniem chunka backend B2 pyta b2_list_file_names, czy plik jest, a to wywołanie nigdy nie zwraca ukrytych wersji. Jeśli backup znowu potrzebuje tej treści, wgrywa ją ponownie. Po drugie, i to jest ten argument, który naprawdę się liczy: duplicacy check -a -storage backblaze przechodzi każdą rewizję każdego snapshot ID i traktuje ukryty chunk jak brakujący (na ukryte wersje patrzy dopiero check -fossils). Puściłem go przed ustawieniem reguły i jeszcze raz po pierwszych kasowaniach: 27 snapshot ID, 1 202 rewizje, każdy potrzebny chunk obecny jako widoczny plik. Nic, co da się przywrócić, nie zależy od ukrytej wersji, więc ich skasowanie nie może zepsuć restore. Przy okazji reguła zmiata nadpisane stare wersje (u mnie 220), które są czystą stratą.

Dlaczego 30 dni, a nie 1. Jedyny przypadek, w którym ukryta wersja jeszcze się liczy, to backup, który sprawdził istnienie chunka sekundy przed jego ukryciem i mimo to się do niego odwołał. Duplicacy załatwia to samo: przy przetwarzaniu kolekcji fosyl jest odkrywany (znacznik hide leci), a restore, który trafi na taki chunk, też odkrywa go w locie. Oba przypadki wymagają, żeby ukryta wersja jeszcze istniała. Na tym serwerze kolekcje Backblaze są przetwarzane w ciągu dni (GridPane robi prune bucketu co noc i dodatkowo po każdym zdalnym backupie; najstarsza kolekcja, jaką znalazłem, miała 4,4 dnia), a reguła 7 dni dla uśpionych snapshotów ogranicza, jak długo rzadko backupowana strona może ją trzymać, więc 30 dni to wygodny zapas. “Keep only the last version” (kasowanie dobę po ukryciu) już nie. A gdyby check -a kiedyś zgłosił brakujący chunk, check -a -fossils -resurrect przywraca go, o ile zmieścisz się w tych 30 dniach.

Czego się spodziewać. B2 przetwarza reguły raz na dobę, przy pół milionie ukrytych wersji zaległość schodzi w kilku przebiegach, a licznik rozmiaru w panelu jest opóźniony względem API. Dobę po zapisaniu reguły panel pokazywał 607 → 292 GB; API: 252,8 GB rozliczane i 501 495 → 146 008 ukrytych wersji, z resztkami z 2025 czekającymi na kolejne przebiegi. Drugi serwer GridPane na tym samym koncie: 40 → 19 GB. Podłoga to to, co zostało ukryte w ostatnich 30 dniach (tu ~33 GB), i to zniknie samo.

Żeby nie wracało

Ukryta wersja kosztuje dokładnie tyle samo co widoczna. Jeśli Twoje serwery GridPane backupują do Backblaze i nikt nigdy nie puścił b2_list_file_versions na bucket, jest spora szansa, że większość tego, za co tam płacisz, to pliki, których Duplicacy już nigdy nie odczyta. Dziesięć minut ze skryptem wyżej wystarczy, żeby to sprawdzić.

Pełny artykuł po angielsku: I Was Paying Backblaze for 497 GB of Backups That Didn’t Exist.

Mateusz Zadorożny
Mateusz Zadorożny

Od 2012 roku moja praca przeplata się z WordPressem. Od 2017 pracuję z WooCommerce, bardzo dużo czasu poświęcając na testowanie różnych konfiguracji i rozwiązań. Łączę development z digital marketingiem tak, aby w e-commerce osiągnąć maksymalną skuteczność.

O mnie →
Zmień ustawienia prywatności Built with Astro