Site menu Snapshots Fibonacci
e-mail icon
Site menu

Snapshots Fibonacci

2017.08.03

Este artigo expressa a opinião do autor na época da sua redação. Não há qualquer garantia de exatidão, ineditismo ou atualidade nos conteúdos. É proibida a cópia na íntegra. A citação de trechos é permitida mediante referência ao autor e este sítio de origem.

Como os mais chegados talvez lembrem, comentei no Facebook que estava cogitando comprar um NAS e depois optei por uma solução caseira: um mini-PC rodando Linux. Estou ciente que é um sumidouro de tempo mas ainda aprecio o metiê e é uma forma de manter uma destreza mínima. Devia fazer uns 13 anos que não configurava um servidor Samba...

O esquema ficou bem barroco e anal-retentivo. O servidor NAS principal usa o polêmico sistema de arquivos BTRFS em RAID 1 (*) com um disco interno e um externo. Um segundo servidor-espelho está em "lugar incerto e não sabido" com sistema de arquivos EXT4, e obviamente as partições de dados são criptografadas. Arquivos alterados ou deletados são diariamente informados por e-mail.

Por que usar BTRFS? Primeiro, pelos snapshots. Todo mundo já quis ou precisou fazer o equivalente a

cd ~/.yesterday
para desfazer uma gauchada. Não só humanos mas também programas cometem erros e.g. o espelhamento do Google Drive "comeu" a pasta-alvo por algum bug obscuro, e copiá-la do snapshot economizou 100GB de download.

Outro motivo para usar BTRFS ou ZFS é a proteção contra corrupção de dados, o temido bit rot (**). É um problema sério e ninguém deveria estar usando sistemas de arquivos sem proteção contra ele, nem em computadores, nem em celulares. (Acontece algo similar com memória RAM: todo dispositivo com memória na faixa do gigabyte deveria estar usando ECC-RAM.)

Voltando aos snapshots, meu "NAS" faz snapshots de hora em hora, porém não faz muito sentido manter todos esses snapshots para sempre. Conforme precisamos voltar mais no tempo, usamos grandezas mais "redondas": uma hora, um dia, um mês, um ano. Dificilmente precisaríamos voltar exatamente 11 meses, 25 dias e 15 horas. A ideia é manter os snapshots segundo uma progressão estilo Time Machine, porém com um pouco mais de resolução.

A série de Fibonacci, a meu ver, representa bem essa resolução decrescente frente a uma grandeza crescente. Por exemplo, em SCRUM estimamos o esforço de uma atividade usando "pesos" segundo a seqüência 2, 3, 5, 8, 13, 20, 40... Quanto mais pesada a atividade, maior a incerteza — não é possível determinar a priori que uma atividade peso 40 é na verdade 39 ou 43. Já uma seqüência 2, 4, 8, 16, 32... daria margem a esse tipo de discussão pois tem resolução insuficiente.

A beleza de uma série geométrica, como é a de Fibonacci, é que a resolução é igualmente decrescente para qualquer unidade. Mesmo que montemos a série usando horas (ou minutos, ou picos de maré alta, não interessa!) teremos snapshots de 1 semana atrás, 2, 3, 5, 8... Ou então: 1 mês atrás, 2, 3, 5, 8...

Então o negócio era escrever um script que faxine os snapshots, mantendo apenas alguns, segundo uma série de Fibonacci.

O script deve funcionar independente do número de vezes e da periodicidade de execução (seja várias vezes seguidas, ou uma vez ao dia, ou uma vez por semana). Para atingir este objetivo:

1) O script deve começar a série a partir do snapshot mais recente, não a partir da data/hora atual. Suponha que o NAS ficou desligado por três meses. Ou pior, suponha que a data/hora seja configurada erradamente para 10 anos no futuro.

2) Dentro de cada faixa de idade, o snapshot mais velho é o que deve ser mantido. Por qualquer outro critério, todo snapshot acabaria sendo eliminado antes de migrar para a próxima faixa.

A estratégia acima tem um defeito: de vez em quando, deixa lacunas — faixas de tempo sem um snapshot — embora nunca em faixas seguidas. A posição das lacunas depende da freqüência de execução. Se o script é rodado a cada semana, a primeira lacuna pode aparecer na quinta semana (e também na 25a semana, 125a, etc.).

A lacuna não é desejável, e um comportamento que varia conforme o script é executado assim ou assado é menos desejável ainda. Demorei um pouco para notar o problema, e mais um pouco para achar a solução, que é

3) O snapshot mais novo também é mantido, para garantir que há pelo menos um snapshot na faixa quando o snapshot mais velho for promovido à próxima faixa.

Finalmente, meu script baseia-se no próprio nome do snapshot para determinar sua data, e não toca em nomes fora do formato esperado e.g. um snapshot feito manualmente. Segue o script:

#!/usr/bin/env python3

import time
import datetime
import os

mask = "%Y-%m-%d:%H:%M:%S"
folder = "/nas/snapshots"
remove_cmd = "btrfs subvolume delete /nas/snapshots/"

def hr(timestamp):
    timestamp = time.localtime(timestamp)
    return time.strftime("%Y-%m-%d %H:%M:%S", timestamp)

items = {}
newest_general = 0

for item in os.listdir(folder):
    try:
        items[item] = \
            datetime.datetime.strptime(item, mask).timestamp()
        newest_general = max(items[item], newest_general)
        # print("file %s time %s" % (item, hr(items[item])) )
    except ValueError:
        print("Ignored file %s" % item)
        pass

# print("now is %s" % hr(time.time()))

# generate Fibonacci sequence in hours
dirty = True
a = 1
b = 1
while dirty:
    dirty = False
    a, b = b, a + b
    x0 = newest_general - b * 3600
    x1 = newest_general - a * 3600
    print("%d..%d - between %s and %s" % (a, b, hr(x0), hr(x1)))
    
    oldest = 99999999999999999999999
    newest = 0
    candidates = {}
    oldest_candidate = None
    newest_candidate = None

    for k in items:
        if items[k] <= x0:
            # print("there is older")
            dirty = True
            continue
        elif items[k] > x1:
            continue

        # print("candidate found")
        dirty = True
        candidates[k] = items[k]
        if items[k] < oldest:
            oldest = items[k]
            oldest_candidate = k
        if items[k] > newest:
            newest = items[k]
            newest_candidate = k

    if not candidates:
        continue

    print("    keeping %s" % oldest_candidate)
    del candidates[oldest_candidate]
    if newest_candidate != oldest_candidate:
        print("    keeping %s" % newest_candidate)
        del candidates[newest_candidate]

    for k in candidates:
        print("    deleting file %s" % k)
        os.system(remove_cmd + k)

Notas de rodapé

(*) RAID 1 "de verdade" é um perfeito espelhamento de dois discos numa camada abaixo do sistema de arquivos. O modo raid1 do BTRFS funciona diferente: ele garante que quaisquer dados estejam em pelo menos dois discos. O número e tamanho dos discos que compõem o sistema de arquivos é livre. O usual em raid1 é empregar 2 a 4 discos iguais, idealmente 3 ou 4 para alta disponibilidade — a perda de um disco quando há apenas dois coloca o BTRFS em "modo degradado", somente-leitura.

(**) Os discos têm uma pequena taxa fixa de erros irrecuperáveis e não-detectados (UBER), algo como 1 a cada 10 14 bits lidos. Como unidades com mais de um terabyte (10 13 bits) são comuns hoje em dia, basta ler todo o conteúdo de um disco para ser brindado com um bit corrompido. O principal problema nem é a corrupção em si, é não saber em que ponto ela aconteceu. Sistemas de arquivos como BTRFS e ZFS mantêm checksums dos dados e a corrupção é detectada. Se estiver operando com redundância ("RAID") o erro é corrigido silenciosamente.