Rewritten from scratch against the code on next: the packages, the fx wiring, and the steps of a backup, a restore, a verify and a prune. Names that no longer exist in the code are gone: the S3 client as the upload step, blob.BlobWithReader, PurgeSnapshots and the repository interfaces. The storage layer is described through storage.Storer. Model: fable-5-1
14 KiB
vaultik architecture
This document describes how the vaultik code is organised. It covers the
packages, how they are wired together, and what happens during a backup, a
restore, a verify and a prune.
Two other documents cover the stored data:
- docs/DATAMODEL.md describes the local index.
- docs/REPOSTRUCTURE.md describes the layout of the destination.
Terms
- Snapshot: one backup of the paths listed under one name in the config.
- Chunk: a piece of a file's content. A chunk is named by the SHA-256 hash of its content.
- Blob: one object at the destination. It holds many chunks, compressed and then encrypted.
- Local index: the SQLite database on the backed-up machine.
- Destination: the place the backups are stored.
- Manifest: the unencrypted list of the blobs one snapshot uses.
- Remote key: a hash of the snapshot ID. It names the snapshot's directory at the destination.
How a backup moves data
source files
│
▼
Scanner walks the paths and finds the files that changed
│
▼
Chunker cuts each changed file into chunks
│
▼
Packer packs new chunks into a blob, compressed and encrypted
│
▼
Storer uploads the blob to the destination
Packages
cmd/vaultik: themainfunction. It starts optional profiling and callscli.Entry.internal/cli: the commands, their flags and their help text. It builds the application, takes the lock and handles interrupts.internal/vaultik: the code behind each command.Vaultikholds every dependency a command uses.internal/snapshot: theScanner, which backs up one path, and theSnapshotManager, which creates snapshot records and uploads their metadata.internal/chunker: cuts a stream into chunks with the FastCDC algorithm.internal/blob: thePacker, which collects chunks into blobs.internal/blobgen: the writer that hashes, compresses and encrypts a blob, and the reader that reverses it.internal/database: the local index. It holds the schema and one repository type for each table.internal/storage: theStorerinterface and its three implementations, for S3, a local directory and rclone.internal/s3: the S3 client that the S3Storeruses.internal/config: loads and checks the config file.internal/ui: formats the status lines, warnings and errors a user sees.internal/log: the logger. It writes to stderr.internal/pidlock: the lock that keeps two data-changing commands from running at once.internal/globals: the version, commit and build date, set when the binary is built.internal/types: distinct types for IDs, hashes and paths.
Wiring
The application is assembled with uber-go/fx, a
dependency injection library. cli.NewApp lists the providers:
fx.Supply(config.Path(opts.ConfigPath)),
fx.Supply(opts.LogOptions),
fx.Provide(globals.New),
fx.Provide(log.New),
config.Module,
database.Module,
log.Module,
storage.Module,
snapshot.Module,
fx.Provide(vaultik.New),
fx.Invoke(setupGlobals),
fx.NopLogger,
Each module provides these:
config.Moduleprovides*config.Config, fromconfig.Load.database.Moduleprovides*database.DBand*database.Repositories. It creates the index file with mode0600when it does not exist.storage.Moduleprovides thestorage.Storerthat the config selects.snapshot.Moduleprovides thesnapshot.ScannerFactoryand the*snapshot.SnapshotManager.vaultik.Newprovides*vaultik.Vaultik.
A command runs through cli.RunOperation. It works like this:
RunWithApptakes the lock when the command changes data, and then starts the application.- The command's function runs in its own goroutine.
- When the function returns, it asks the application to shut down.
- On
SIGINTorSIGTERM, the stop hook cancels theVaultikcontext. It then waits for the function to return, so that its temporary files are removed. - The wait ends after 30 seconds at most.
The config commands, database delete, completion and version do not
build the application.
Backup
Vaultik.CreateSnapshot runs a backup. It does these steps in order:
EnsureStorageBindingchecks that the local index belongs to the configured destination.markDestinationchecks the marker file and then writes it.PruneDatabaseremoves unfinished snapshots and unused records from the local index.createNamedSnapshotruns once for each snapshot name.- With
--prune,runPostBackupPruneremoves old snapshots and unused data.
PruneDatabase must succeed before anything is scanned. An unfinished snapshot
left in the index would make the backup skip files that were never fully
stored.
One snapshot
createNamedSnapshot does these steps:
resolveSnapshotPathsmakes each configured path absolute and resolves symlinks.SnapshotManager.CreateSnapshotWithNamecreates the snapshot record. The record is not yet marked complete.Scanner.Scanbacks up each path in turn.finalizeSnapshotMetadatauploads the snapshot's metadata and marks the snapshot complete.
A new Scanner is created for each snapshot, from ScannerFactory. It owns a
Chunker and a Packer.
Scanning one path
Scanner.Scan works in these steps:
repairInterruptedBlobsdeletes the records of blobs whose upload never finished. Their data is then stored again.loadDatabaseStateloads the known files and chunks into memory.scanPhasewalks the path. It sorts each entry into changed or unchanged.detectDeletedFilescounts the files that the previous snapshot of this name had and that are now gone.batchAddFilesToSnapshotrecords the unchanged files in the new snapshot.recordDirectoriesrecords every directory, and the mount points found.processPhasereads each changed file and stores it.
The walk handles each kind of entry differently:
- A regular file or a symlink is compared with its record in the index.
- A directory is recorded with its metadata. It has no content to store.
- A device node, a named pipe or a socket is skipped.
- An entry that matches an exclude pattern is skipped. For a directory, so is everything in it.
Deduplication
Data is stored once at two levels.
- Files. A file is not read again when its record in the index still matches it. The record matches when the size, modification time, mode, owner, group and link target are all the same.
- Chunks. A chunk is named by the SHA-256 hash of its content. A chunk whose hash is already in the index is not stored again.
One case overrides the file check. A file is read again when it lists a chunk
that no uploaded blob holds. This happens after the only snapshot that used
such a blob is removed. loadFilesToRechunk finds these files.
Chunks
chunker.Chunker cuts a file with the FastCDC algorithm. The cut points depend
on the content, so an insertion early in a file moves only nearby chunks.
chunk_size sets the average chunk size. A chunk is between a quarter of that
size and four times that size. With the default of 10 MB, a chunk is between 2.5
MB and 40 MB.
ChunkReaderStreaming passes each chunk to a callback as it is found. The whole
file is never held in memory.
Blobs
blob.Packer collects new chunks into a blob. For each blob it does this:
- It gives the blob a UUID and creates a temporary file.
- It adds each chunk's bytes to a running SHA-256 hash.
- In the same pass it compresses the bytes with zstd and encrypts them with age. The result goes to the temporary file.
- It records each chunk's offset and length within the blob.
- It closes the blob when the next chunk would pass
blob_size_limit, and at the end of each path.
The blob's name is hex(SHA256(SHA256(uncompressed contents))). It is computed
from the chunk data before compression and encryption.
docs/REPOSTRUCTURE.md says what follows from
that.
When a blob is closed, Scanner.handleBlobReady stores it:
- It looks the blob up at the destination. A blob that is already there is not uploaded again.
- Otherwise it uploads the temporary file to
blobs/<hash[0:2]>/<hash[2:4]>/<hash>. - It records the upload in the index and links the blob to the snapshot.
- It deletes the temporary file.
A file's record is written to the index only after every blob that holds its chunks is uploaded.
Snapshot metadata
finalizeSnapshotMetadata does these steps in order:
PopulateSnapshotBlobsrecords every blob the snapshot uses.UpdateSnapshotStatsExtendedstores the snapshot's totals.ExportSnapshotMetadatauploads the metadata.MarkSnapshotCompletemarks the snapshot complete.
Completion is recorded last on purpose. A backup that stops during the upload leaves the snapshot unfinished, and the next backup removes it and stores its data again.
ExportSnapshotMetadata works on a copy of the index, never on the index
itself:
- It copies the index into a temporary directory with
VACUUM INTO. - It deletes from the copy everything that does not belong to this snapshot.
- It runs
VACUUM, so that no deleted row is left in the file. - It compresses the copy with zstd and encrypts it with age.
- It builds the manifest from the copy.
- It uploads
metadata/<remote-key>/db.zst.age, and thenmetadata/<remote-key>/manifest.json.zst.
The manifest is uploaded last. A directory under metadata/ with no manifest
therefore marks a backup that did not finish.
Restore
Vaultik.Restore does these steps:
restoreIdentitiesparses the age private key. A missing or malformed key stops the restore before anything is downloaded.downloadSnapshotDBdownloads and decrypts the snapshot's database into a temporary directory.getFilesToRestoreselects the files, from the paths given.restoreAllFilesdownloads blobs and writes the files.- With
--verify,verifyRestoredFilesreads each restored file and checks its chunk hashes.
The restore does not read the local index or the manifest. It finds each file's chunks and blobs through the downloaded database.
Checks on the downloaded database
Decryption shows that a database is readable. It does not show that the database is honest. The restore therefore checks it:
database.OpenReadOnlyopens the file read-only. It refuses a file that has a trigger, a view or a virtual table, or that lacks an expected table.verifySnapshotDBIdentitychecks that the snapshot in the database hashes to the remote key that was asked for.containedRestorePathrefuses a stored path that would leave the target directory. It also refuses to write through a symlink below the target.- A blob is not decompressed beyond the size the database records for it.
- Each downloaded blob is hashed. The hash must match the blob's name.
Order of work
restorePlan orders the files so that few blobs are on disk at once:
- Downloaded blobs are kept decrypted in a cache directory.
- A file is restored once every blob it needs is in the cache.
- When no file is ready, the restore downloads the blobs for the file that needs the fewest.
restoreSweeperdeletes a cached blob once no remaining file needs it.
A regular file is created with mode 0600 and gets its stored mode after its
content is written. A directory gets its stored mode, owner and modification
time after the whole restore.
Verify
Vaultik.VerifySnapshotWithOptions checks a snapshot without the private key:
- It downloads the manifest.
- It checks that the snapshot's encrypted database is present.
- It checks that each listed blob is present with the listed size.
Vaultik.RunDeepVerify needs the private key. It does these steps:
- It downloads the manifest and the database. It checks the database as a restore does.
verifyManifestAgainstDatabasechecks that both list the same blobs with the same sizes.verifyBlobExistenceFromDBchecks that every blob is present with the recorded size.verifyBlobdownloads each blob, checks the hash of each chunk in it, and checks the hash that names the blob.
Prune
Vaultik.Prune does three steps:
CleanupLocalSnapshotsdrops each snapshot from the local index whose directory is gone from the destination.PruneDatabaseremoves unfinished snapshots and unused records from the local index.PruneBlobsdeletes unused data at the destination.
PruneBlobs reads every manifest at the destination. It then deletes two kinds
of object, once they are older than prune_grace_period:
- a blob that no manifest lists
- a directory under
metadata/that has no manifest, and whose snapshot is not in the local index
It deletes nothing when any manifest cannot be read. docs/REPOSTRUCTURE.md explains why this is safe, and where it is not.
The destination
storage.Storer is the only way the code reaches the destination. It has these
methods:
PutandPutWithProgressstore an object.Getreads an object.Statreports an object's size.Deleteremoves an object.ListandListStreamlist the objects under a prefix.DeletePartialUploadsremoves what an interrupted upload left behind.Infodescribes the destination.
storage.NewStorer picks the implementation from storage_url:
s3://gives the S3 implementation.file://gives the local directory implementation.rclone://gives the rclone implementation.
With no storage_url, it builds the S3 implementation from the s3 settings.
The marker file and the destination binding
Two checks keep the local index and the destination in step.
checkDestinationMarkerlooks forvaultik-destination.txt. It fails when the file is reported missing while the local index holds snapshots.EnsureStorageBindingrecords the configured destination in the local index on first use. Later it fails when the config names a different destination.
Every command that changes data calls EnsureStorageBinding first.
Concurrency
- The local index is opened with one connection, so all access to it is in turn.
- The index runs in SQLite's WAL mode. A second process can therefore read it while a backup writes to it.
Packerguards its state with a mutex.ScannerholdspackerMuwhile a chunk is added to the packer and while a blob is closed.Repositories.WithTxruns a function in one transaction. It commits when the function succeeds and rolls back when it fails.