← Disaster Recovery: preparar, recuperar e validar
09 / 12 · 60 MIN

Snapshot, identidade e ponto recuperado

Executar um restauro etcd isolado, interpretar o ponto recuperado e distinguir integridade, identidade e aceitação de negócio.

Começar pela linha temporal

O laboratório cria um cluster descartável, grava três chaves e guarda um snapshot. Depois altera uma chave, elimina outra e confirma vinte escritas adicionais. Só então para a origem e restaura o artefacto anterior. Esta ordem permite prever o resultado antes de o observar: a atualização posterior fica ausente e a chave entretanto eliminada reaparece. Regista quais operações pertencem a cada lado do ponto capturado. Num serviço de fundos, uma confirmação de negócio posterior continua a ser evidência relevante, mesmo que os dados restaurados não a contenham. A reconciliação precisa de uma fonte autorizada para explicar essa diferença.

Inventariar o artefacto e a versão

Em etcd 3.6, o exercício usa etcdctl snapshot save para obter o artefacto através de um endpoint e etcdutl snapshot status para inspecionar revisão e número de chaves. O restauro também pertence a etcdutl. O script exige as três ferramentas na versão 3.6.15 e regista o seu próprio digest no relatório. Guarda ainda o digest do snapshot para confirmar que as operações posteriores não o alteraram. Estes controlos não autenticam sozinhos o produtor nem ligam uma revisão a uma hora de negócio. Um manifesto operacional deve acrescentar origem, âmbito, versão e ponto recuperável conhecido, com responsáveis pela sua interpretação.

Ensaiar as recusas antes do arranque

O exercício cria uma cópia do snapshot e altera apenas um byte do trailer de integridade. O restauro normal rejeita essa cópia; não é usado skip-hash-check e nenhum processo arranca a partir do resultado. A observação não afirma que nenhum ficheiro parcial foi criado. Um segundo controlo tenta restaurar para um diretório não vazio com um sentinela conhecido: a tentativa falha e o sentinela mantém-se. Analisa os dois resultados separadamente. Um verifica a inconsistência do artefacto escolhido, o outro a proteção do destino ocupado. Nenhum autoriza apagar dados existentes nem transformar uma rejeição em sucesso ignorando o controlo.

Reconstruir uma composição a partir do mesmo ponto

Os três membros de destino são restaurados a partir do mesmo snapshot, em diretórios novos, com novos endpoints locais e um token próprio. O restauro reescreve identidades de membros e de cluster. O laboratório compara essas identidades e verifica que os três membros devolvem o estado esperado. Não combina snapshots de horas diferentes nem pede à eleição que reconcilie histórias independentes. Na preparação de uma mudança, confirma também quem controla os antigos escritores e quais endpoints os clientes realmente usam. Uma identidade nova distingue o cluster recuperado, mas não altera automaticamente um agendador ou impede uma aplicação de contactar a origem antiga.

Executar e interpretar o laboratório

Guarda o código integral apresentado abaixo como run.py e usa uma diretoria que contenha etcd, etcdctl e etcdutl 3.6.15. Executa python3 run.py --bin-dir /caminho/para/binarios --output evidence.json. É necessária permissão para abrir portas locais. O script não aceita endpoints existentes nem diretórios de dados reais: cria recursos temporários, usa chaves sintéticas e termina os processos que iniciou. O relatório esperado contém dez grupos passados e zero falhados, incluindo os exercícios da aula seguinte. Se surgir uma falha, conserva o resultado e investiga a fase concreta. Não substituas uma observação falhada por uma contagem manual de sucesso.

Aceitar a função e registar o que falta

O cluster restaurado recebe uma nova escrita sintética, que é lida através de outro membro. Este resultado confirma uma capacidade limitada de operação após o restauro. Não demonstra credenciais reais, capacidade com carga, funcionamento de APIs de negócio ou cumprimento do RTO. Em pares, prepara um registo com ponto recuperado, operações ausentes, controlos passados, dependências por validar e responsável pela aceitação. Um participante representa APS e outro o negócio. Reserva dez minutos para discutir se é possível retomar em modo degradado e quais efeitos exigem reconciliação antes da próxima janela. Mantém os critérios definidos antes do ensaio visíveis durante a decisão.

"""Original snapshot recovery lab: only its own disposable loopback etcd processes."""
import argparse
import base64
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import time
import uuid


def until(action, timeout=15):
    end = time.monotonic() + timeout
    last = None
    while time.monotonic() < end:
        try:
            result = action()
            if result:
                return result
        except (AssertionError, RuntimeError, subprocess.TimeoutExpired) as exc:
            last = str(exc)
        time.sleep(.15)
    raise AssertionError('Condition not observed before deadline: ' + str(last))


class Cluster:
    def __init__(self, folder, binary_dir):
        self.root, self.bin = Path(folder), Path(binary_dir)
        self.token = 'dr-local-' + uuid.uuid4().hex
        self.procs, self.logs, self.clients, self.peers = {}, [], [], []
        reservations = []
        for _ in range(6):
            s = socket.socket(); s.bind(('127.0.0.1', 0)); reservations.append(s)
        self.clients = ['http://127.0.0.1:' + str(s.getsockname()[1]) for s in reservations[:3]]
        self.peers = ['http://127.0.0.1:' + str(s.getsockname()[1]) for s in reservations[3:]]
        for s in reservations:
            s.close()
        self.members = ','.join(f'n{i}={self.peers[i]}' for i in range(3))
        # Ignore inherited etcd configuration; never address external endpoints.
        self.env = {k: v for k, v in os.environ.items() if not k.startswith(('ETCD_', 'ETCDCTL_'))}

    def start(self, i):
        assert i not in self.procs or self.procs[i].poll() is not None
        log = open(self.root/f'n{i}.log', 'ab'); self.logs.append(log)
        args = [str(self.bin/'etcd'), '--name', f'n{i}', '--data-dir', str(self.root/f'n{i}'),
                '--listen-client-urls', self.clients[i], '--advertise-client-urls', self.clients[i],
                '--listen-peer-urls', self.peers[i], '--initial-advertise-peer-urls', self.peers[i],
                '--initial-cluster', self.members, '--initial-cluster-token', self.token,
                '--initial-cluster-state', 'new', '--heartbeat-interval', '100', '--election-timeout', '1000',
                '--log-level', 'error']
        self.procs[i] = subprocess.Popen(args, env=self.env, stdout=log, stderr=log)

    def stop(self, i, abrupt=False):
        p = self.procs[i]
        if p.poll() is None:
            p.kill() if abrupt else p.terminate()
            p.wait(timeout=5)

    def ctl(self, i, *args, raw=False, data=None, expect=True):
        r = subprocess.run([str(self.bin/'etcdctl'), '--endpoints='+self.clients[i], '--dial-timeout=1s',
                            '--command-timeout=2s', '--write-out=json', *args], input=data,
                           text=True, capture_output=True, timeout=5, env=self.env)
        if expect and r.returncode:
            raise RuntimeError(r.stderr[-600:])
        return r if raw else json.loads(r.stdout)

    def get(self, i, key, serial=False):
        args = ('get', key, '--consistency=s') if serial else ('get', key)
        data = self.ctl(i, *args)
        kv = data.get('kvs', [])
        return None if not kv else base64.b64decode(kv[0]['value']).decode()

    def status(self, i):
        return self.ctl(i, 'endpoint', 'status')[0]['Status']

    def leader(self, members):
        def find():
            states = [(i, self.status(i)) for i in members]
            ids = {s.get('leader', 0) for _, s in states}
            if len(ids) != 1 or 0 in ids:
                return False
            return next(([i] for i, s in states if s['header']['member_id'] == s['leader']), False)
        return until(find)[0]

    def close(self):
        for i in self.procs:
            self.stop(i)
        for log in self.logs:
            log.close()


def run(binary_dir):
    binary_dir=Path(binary_dir)
    version=subprocess.run([str(binary_dir/'etcd'),'--version'],text=True,capture_output=True,check=True).stdout
    utility=subprocess.run([str(binary_dir/'etcdutl'),'version'],text=True,capture_output=True,check=True).stdout
    assert 'etcd Version: 3.6.15' in version and 'etcdutl version: 3.6.15' in utility
    checks=[]
    def record(name,**values):checks.append(dict(name=name,passed=True,observations=values))
    with tempfile.TemporaryDirectory(prefix='dr-recovery-etcd-')as folder:
        root=Path(folder);clusters=[]
        def cluster(name):
            p=root/name;p.mkdir();c=Cluster(p,binary_dir);clusters.append(c);return c
        def utl(*args,expect=True):
            result=subprocess.run([str(binary_dir/'etcdutl'),*map(str,args)],text=True,capture_output=True,timeout=20)
            if expect and result.returncode:raise RuntimeError(result.stderr[-1000:])
            return result
        def restore(c,snapshot,bump=None):
            for i in range(3):
                extra=[]if bump is None else ['--bump-revision',str(bump),'--mark-compacted']
                utl('snapshot','restore',snapshot,'--name',f'n{i}','--data-dir',c.root/f'n{i}',
                    '--initial-cluster',c.members,'--initial-cluster-token',c.token,
                    '--initial-advertise-peer-urls',c.peers[i],*extra)
            for i in range(3):c.start(i)
            until(lambda:all(c.ctl(i,'endpoint','health')[0]['health']for i in range(3)))
        try:
            source=cluster('source')
            for i in range(3):source.start(i)
            until(lambda:all(source.ctl(i,'endpoint','health')[0]['health']for i in range(3)))
            leader=source.leader(range(3))
            source.ctl(leader,'put','/dr/day','2026-10-04')
            source.ctl(leader,'put','/dr/config','before-snapshot')
            source.ctl(leader,'put','/dr/retained','present-at-snapshot')
            snapshot=root/'snapshot.db';source.ctl(leader,'snapshot','save',str(snapshot),raw=True)
            digest=hashlib.sha256(snapshot.read_bytes()).hexdigest()
            status=json.loads(utl('snapshot','status',snapshot,'--write-out=json').stdout)
            revision=status['revision'];assert revision>=4
            record('live-snapshot-and-status',snapshotCreated=True,revisionObserved=True,atLeastThreeKeys=status['totalKey']>=3,sha256Recorded=True)
            source.ctl(leader,'put','/dr/config','after-snapshot')
            source.ctl(leader,'del','/dr/retained')
            for i in range(20):source.ctl(leader,'put','/dr/after/'+str(i),'acknowledged-after-snapshot')
            source_state=source.status(leader);latest=source_state['header']['revision'];original_cluster=source_state['header']['cluster_id']
            original_members={m['ID']for m in source.ctl(leader,'member','list')['members']}
            assert latest>revision and hashlib.sha256(snapshot.read_bytes()).hexdigest()==digest
            record('source-advances-after-snapshot',laterRevisionGreater=True,postSnapshotWriteAcknowledged=True,snapshotUnchanged=True)
            for i in range(3):source.stop(i)
            corrupt=root/'corrupt.db';data=bytearray(snapshot.read_bytes());data[-1]^=1;corrupt.write_bytes(data)
            bad=utl('snapshot','restore',corrupt,'--data-dir',root/'rejected-corrupt',expect=False)
            assert bad.returncode!=0 and ('hash' in bad.stderr.lower()or'sha256'in bad.stderr.lower()),bad.stderr
            record('corrupted-integrity-trailer-rejected',restoreSucceeded=False,hashCheckBypassed=False,processStarted=False)
            occupied=root/'occupied';occupied.mkdir();sentinel=occupied/'keep.txt';sentinel.write_text('owned-fixture-must-survive')
            exists=utl('snapshot','restore',snapshot,'--data-dir',occupied,expect=False)
            assert exists.returncode!=0 and sentinel.read_text()=='owned-fixture-must-survive',exists.stderr
            record('existing-nonempty-target-rejected',restoreSucceeded=False,sentinelPreserved=True)
            plain=cluster('plain');restore(plain,snapshot)
            for i in range(3):
                assert plain.get(i,'/dr/config')=='before-snapshot'
                assert plain.get(i,'/dr/retained')=='present-at-snapshot'
                assert plain.get(i,'/dr/after/0')is None
            state=plain.status(0);members=plain.ctl(0,'member','list')['members']
            assert state['header']['cluster_id']!=original_cluster and not original_members.intersection(m['ID']for m in members)
            record('same-snapshot-restores-new-logical-cluster',members=3,originalMemberIdsAbsent=True,newClusterIdentity=True,preSnapshotValueRecovered=True,postSnapshotWriteAbsent=True,postSnapshotDeletionNotReplayed=True)
            assert state['header']['revision']==revision and state['header']['revision']<latest
            future=plain.ctl(0,'get','/dr/config','--rev='+str(latest),raw=True,expect=False)
            assert future.returncode!=0 and 'future revision'in future.stderr.lower(),future.stderr
            record('plain-restore-regresses-revision',restoredAtSnapshotRevision=True,belowPreviouslyObservedRevision=True,futureRevisionReadRejected=True)
            plain.ctl(0,'put','/dr/isolated-probe','plain-new-write')
            assert plain.get(1,'/dr/isolated-probe')=='plain-new-write'
            record('ordinary-restored-cluster-accepts-new-operation',syntheticWriteAcknowledged=True,readFromAnotherMember=True)
            for i in range(3):plain.stop(i)
            bumped=cluster('bumped');restore(bumped,snapshot,bump=1000)
            current=bumped.status(0)['header']['revision']
            assert current>=revision+1000 and current>latest
            assert bumped.get(0,'/dr/config')=='before-snapshot' and bumped.get(0,'/dr/after/0')is None
            record('revision-bump-does-not-recover-later-data',revisionAbovePriorObservation=True,bump=1000,preSnapshotValueRecovered=True,postSnapshotWriteAbsent=True)
            historic=bumped.ctl(0,'get','/dr/config','--rev='+str(latest),raw=True,expect=False)
            assert historic.returncode!=0 and 'compacted'in historic.stderr.lower(),historic.stderr
            args=[str(binary_dir/'etcdctl'),'--endpoints='+bumped.clients[0],'--write-out=json','watch','/dr/config','--rev='+str(latest)]
            watch=subprocess.Popen(args,text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=bumped.env)
            try:
                try:out,err=watch.communicate(timeout=4)
                except subprocess.TimeoutExpired:
                    watch.terminate();out,err=watch.communicate(timeout=3)
                assert 'compacted'in(out+err).lower(),(out,err)
            finally:
                if watch.poll()is None:watch.kill();watch.wait(timeout=3)
            record('compacted-history-signals-consumer-reset',historicReadRejected=True,oldRevisionWatchReportedCompaction=True,realKubernetesInformerExecuted=False)
            write=bumped.ctl(0,'put','/dr/isolated-probe','bumped-new-write')
            assert write['header']['revision']>current
            until(lambda:all(bumped.get(i,'/dr/isolated-probe')=='bumped-new-write'for i in range(3)))
            assert hashlib.sha256(snapshot.read_bytes()).hexdigest()==digest
            record('bumped-cluster-new-write-and-snapshot-preserved',newWriteRevisionIncreased=True,threeMemberReadAgreement=True,originalArtifactUnchanged=True)
        finally:
            for c in clusters:c.close()
    return dict(executedAt=datetime.now(timezone.utc).isoformat(),version=version.strip(),utilityVersion=utility.strip(),passed=len(checks),failed=0,checks=checks,scriptSha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),scope='Original local snapshot save, integrity rejection, ordinary three-member restore, revision-bumped restore and compacted-watch observations with etcd/etcdctl/etcdutl 3.6.15. Sequential disposable clusters on one Darwin ARM64 host, loopback HTTP, synthetic keys. No existing cluster, real disaster, Kubernetes informer, physical fencing, production workload, credential/key recovery, business API or RPO/RTO benchmark.')


if __name__=='__main__':
    p=argparse.ArgumentParser();p.add_argument('--bin-dir',required=True,type=Path);p.add_argument('--output',type=Path);a=p.parse_args();result=run(a.bin_dir);payload=json.dumps(result,indent=2)+'\n'
    if a.output:a.output.write_text(payload)
    print(payload)
NA PRÁTICA

Às 18:00 é capturada uma regra; às 18:04 a regra é eliminada. O restauro das 18:00 volta a apresentá-la. A equipa usa o registo de alterações autorizado para decidir a reconciliação, sem tratar o reaparecimento como nova instrução de negócio.

Armadilhas comuns

Confundir hash com proveniência autenticada; usar snapshots diferentes por membro; ignorar eliminações posteriores; aceitar uma escrita sintética como validação de todo o serviço.

Tópicos relacionados: Objetivos e dependências · Restauro: ponto recuperado e integridade · Retoma: replay e aceitação operacional

Leva esta ideia contigo

Um restauro deve demonstrar que artefacto foi usado, que ponto produziu, que identidade criou e que função foi validada. A concordância das réplicas não recupera informação posterior ao snapshot.

Criar conta

Referência: Disaster recovery · DR recovery 2026-09; PostgreSQL 18, etcd 3.6 and selected AWS/Azure behavior