Awesome FOSS Logo
Discover awesome open source software
Launched 🚀🧑‍🚀

Announcing redis-bootleg-backup (rbb)

Rust logo + Redis logo

tl;dr - I wrote a tool called rbb (redis-bootleg-backup) in Rust for taking backups of redis instances when all you have is a client connection. It does redis backups the dumbest way possible – enumerating all the keys (via the KEYS command) and dumping them all (via the DUMP command).

A little while ago I had a friend run into a rather interesting problem – he had access to a Redis cluster but did not control the hosting/instance and could not use the normal redis backup mechanisms (--rdb, etc). After lots of head scratching and trying different approaches to change the configuration of the server without access to it, we realized that we could make the transfer the dumb(est) way – running KEYS * and DUMPing every single key that surfaced. While that day we solved it with a very very janky script, I thought that making this into a re-usable binary would be a great excuse to write some more rust and see how easy a tool like this is to make.

For those who are wondering; Yes, this is basically a <4 line python script (definitely less if you’re into code golf or have heard of perl), but any excuse is good enough to write some Rust these days – lately a bunch of my projects/work has been in Typescript and Haskell. I created the folder and ran git init two months ago and now the project is finally at a point where I think it might be worth showing to anyone.

So if you’re in the mood to abuse your redis instance/cluster, feel free to use the tool. If you don’t need the tool maybe what made the project interesting to me will be interesting to you too:

Design to implementation: trait-based extensibility

One of the things that I’m most happy with is how this library turned out are the Traits that fell during the design phase mostly holding up:

/// Type of executor
#[derive(Debug, PartialEq)]
pub enum BackupExecutorType {

pub trait BackupExecutor {
    /// Get the backup executor type
    fn get_type(&self) -> BackupExecutorType;

    /// Get the version of the executor itself
    fn get_executor_version(&self) -> Result<Version, BackupExecutorError>;

    /// Initialize the executor, performing any necessary setup/locking/etc
    fn init(&mut self, uri: RedisURI) -> Result<(), BackupExecutorError>;

    /// Cleanup the backend
    fn cleanup(&mut self) -> Result<(), BackupExecutorError>;

The BackupExecutor can be almost anything that professes it can take backups. Right now it’d be very hard to extend this – I considered adding BackupExecutorType::Custom(String) to make it easier but decided against it for now. More importantly, if you want to be a BackupExecutor and actually be useful, you might want to implement the CanBackup interface:

/// Executors that can perform backups
pub trait CanBackup: BackupExecutor {
    /// Retrieve the available keys that could be backed up
    fn get_keys(&self) -> Result<Box<dyn Iterator<Item=RedisKey>>, BackupError>;

    /// Perform a partial backup of a given subset of keys
    fn partial_backup(&self, keys: Box<dyn Iterator<Item=RedisKey>>, backup: Box<dyn Backup>) -> Result<Box<dyn Backup>, BackupError>;

    /// A full backup
    fn full_backup(&self, backup: Box<dyn Backup>) -> Result<Box<dyn Backup>, BackupError> {
        let backup = self.partial_backup(self.get_keys()?, backup)?;

BackupExecutors can usually perform a restores as well, so there’s a trait for that as well:

/// Executors that can perform restores
pub trait CanRestore: BackupExecutor {
    /// Retrieve the available keys that could be restored from a given backup
    fn get_keys_from_backup(&self, backup: Box<dyn Backup>) -> Result<Box<dyn Iterator<Item=RedisKey>>, RestoreError>;

    /// Perform a partial restore of a given subset of keys
    fn partial_restore(&self, keys: Box<dyn Iterator<Item=RedisKey>>, backup: Box<dyn Backup>) -> Result<Box<dyn Backup>, RestoreError>;

    /// A full restore
    fn full_restore(&self, backup: Box<dyn Backup>) -> Result<Box<dyn Backup>, RestoreError> {
        let keys = backup.get_keys()
            .map_err(|e| RestoreError::UnexpectedError(format!("Failed to get keys from backup: {:#?}", e)))?;
        let backup = self.partial_restore(keys, backup)?;

The types got a bit more complicated than I’d first imagined with the Boxes and Iterators but once it was all done it was quite obvious why they were necessary. These traits serve as a nice concise description and is one of the parts I love most about Rust (and Haskell via typeclasses). Running through the implementations for the alternate plugins for some of these was a real treat – adhering to the relevant traits and getting the functionality/support for “free”. This is how you’d expect everything to work, but there’s a special joy to taking it from design to implementation and watching it work as you’d always expected it could, with an expressive codebase.

Smooth code reuse between,, executor/store modules

It’s always nice to see architecture/code/tooling come together really nicely. The code in main is very simple thanks to the magic of structopt:

    // build a map of the environment variables (serving as a flexible grab-bag of configuration)
    let env_map = std::env::vars().collect();

    // Build args for the actual command line
    let opt = RedisBootlegBackupOpts::from_args();

    match opt.cmd {
        SubCommand::Backup{uri, name} => {
            let _ = backup(opt.executor_type, opt.store_type, uri, name, &env_map)?;

        // ... other subscommands elided ...

This calls code in the libraries (in case you’re not familiar, a rust “crate” can hold build a binary and a library) that looks like this:

/// Perform a redis backup, performing a cleanup
pub fn backup(
    executor_type: BackupExecutorType,
    store_type: BackupStoreType,
    uri: RedisURI,
    name: BackupName,
    env_map: &HashMap<String, String>,
) -> Result<Box<dyn Backup>, String> {
    // Build store
    println!("[main] building store [{:?}]...", store_type);
    let mut store = build_backup_store(store_type, env_map)?;

    // Build executor
    println!("[main] building executor [{:?}]...", executor_type);
    let mut executor = build_backup_executor(executor_type, env_map)?;

    // Create a backup
    println!("[main] get/create backup [{}]...", &name);
    let backup = Box::from(store.get_or_create_backup(name)?);

    // Perform a full backup with the executor
    println!("[main] starting full backup...");
    let backup = executor.full_backup(backup)?;

    println!("[main] successfully completed full backup");

    // Cleanup


The code above makes use of some manual dispatch code at the Executor/Store module level, here’s what the store looks like:

pub fn build_backup_store(
    store_type: BackupStoreType,
    env_map: &HashMap<String, String>,
) -> Result<Box<dyn BackupStore>, String> {
        match store_type {
            BackupStoreType::FolderV1 => Box::from(folder::v1::FolderStore::new(env_map)?),
            BackupStoreType::SQLiteV1 => Box::from(sqlite::v1::SQLiteStore::new(env_map)?),
            BackupStoreType::S3V1 => Box::from(s3::v1::S3Store::new(env_map)?),

While initially I wanted to use the program’s arguments as the structopt was a little rigid for this use case so I went with ENV variables instead, and passing an environment mapping (a simple HashMap<String, String>) made everything work great and stay easy to test.

Implementing the usual dockerized dependency bracketing pattern in Rust

One of the things I find myself doing very often is utilizing linux containers (in this case docker) to spin up dependencies and write meaningful large tests. While it looks a little different every time I do it in different languages, getting to do it in Rust was good experience. Here’s an example for spinning up redis instances to use for individual tests.

/// Helper function for starting a dockerized redis sub-process
pub fn start_redis_test_instance(maybe_instance_type: Option<InstanceType>) -> Result<InstanceInfo, String> {
    // Generate a random port for redis to run on
    let client_port: u32 = pick_random_tcp_port()?;

    // Determine which type of redis instance to make
    let instance_type = maybe_instance_type.unwrap_or(
        env::var("CI").map(|_| InstanceType::LaunchedBinary).unwrap_or(InstanceType::Dockerized)

    let mut instance_info: InstanceInfo;
    // If we're in the GitLab CI environment, we have to use a launched-binary redis-server
    if instance_type == InstanceType::LaunchedBinary {
        instance_info = InstanceInfo {
            container_name: None,
            process: Command::new("redis-server")
                    "--port", format!("{}", client_port).as_str(),
                .map_err(|e| format!("Failed to start child process: {:#?}", e))?

    } else {
        let docker_bin_path = get_docker_bin_path();
        let redis_image = env::var("REDIS_IMAGE")
            .unwrap_or(format!("{}:{}", REDIS_IMAGE_NAME, REDIS_DEFAULT_VERSION));
        let container_name = format!("rbb-test-redis-{}", Uuid::new_v4().to_simple().to_string());

    // For local development we can use `docker` to avoid polluting host systems with redis-server
        instance_info = InstanceInfo {
            container_name: Some(container_name.clone()),
            process: Command::new(docker_bin_path)
                "-p", format!("{}:{}", client_port, REDIS_DEFAULT_PORT).as_str(),
                "--name", container_name.as_str(),
                .map_err(|e| format!("Failed to start child process: {:#?}", e))?

    // Get the stdout
    if instance_info.process.stdout.is_none() {
        return Err(String::from("Process didn't have any stdout to read"));
    let mut stdout = instance_info.process.stdout.unwrap();

    // Wait for startup message
    let now =  SystemTime::now();
    let mut buffered_output = BufReader::new(&mut stdout);
    let mut line = String::new();
    loop {
        // WARNING: this blocks, so if you pick the wrong
        buffered_output.read_line(&mut line)
            .map_err(|e| format!("failed to read line: {}", e))?;

        // Stop waiting if we see the line
        if line.contains("Ready to accept connections") {

        let elapsed = SystemTime::now().duration_since(now)
            .map_err(|e| format!("failed to get current time, during timeout checks: {:#?}", e))?;
        let timed_out = elapsed.as_secs() >= CONTAINER_DEFAULT_START_TIMEOUT_SECONDS;
        if timed_out {
            return Err(String::from("timed out waiting for start up line from redis container"));

    // Put the stdout object back in the process
    instance_info.process.stdout = Some(stdout);


pub fn stop_test_instance(mut instance_info: InstanceInfo) -> Result<(), String> {
    // You can imagine what this looks like

I even got a little wild with it when refactoring some tests with similar structure – I’ll spare you the implementation but show the signature at least:

/// Shared test suite that performs a basic backup for an E2E test
/// this test adds a couple keys and makes sure the backup contains them
/// but importantly runs the built binary itself, "from the outside"
pub fn basic_backup_e2e_test<PreCheckFn, PostCheckFn, StoreBuilder>(
    env_map: HashMap<String, String>,
    executor_type: BackupExecutorType,
    store_type: BackupStoreType,
    pre_check: PreCheckFn,
    post_check: PostCheckFn,
    store_builder: StoreBuilder,
) -> Result<(), String>
    PreCheckFn: FnOnce() -> Result<(), String>,
    PostCheckFn: FnOnce() -> Result<(), String>,
    StoreBuilder: FnOnce() -> Result<Box<dyn BackupStore>, String>

Exploring E2E testing a binary

Anyone can tell you that an extensive E2E test suite is very important, but I hold the believe that they are way more important than scores of unit and integration tests (property based or not). E2E tests ensuring that the built end-product binary (rbb) worked as expected and actually did backups was really important. Not many people will care if none of my functions allow for a panic caused by division by 0 if they can’t actually perform a backup/restore in the usual case.

That said, the current suite isn’t too extensive (I’m missing large usecases, other platforms, and lots more) but feeling around the simple backup & restore tests in rust was fun and the relevant code for launching the binary was veery simple to write:

    // Perform a backup via CLI
    let mut cmd = Command::new(RELEASE_BIN_PATH);
            "--executor-type", executor_type.to_string().as_str(),
            "--store-type", store_type.to_string().as_str(),
            "--name", backup_name.as_str(),
            "--uri", &uri.clone(),

    // Add all the provided env variables
    for (k,v) in env_map {
        cmd.env(k, v);

    let output = cmd
        .map_err(|e| format!("Failed to spawn backup command: {:#?}", e))?;

Revisiting binary builds again

Right around the time I added SQLite support via [rusqlite][cargo-rusqlite] (a fantastic library btw) to the project I noticed that my binary was no longer static – a simple ldd revealed that libraries were being loaded:

$ ldd releases/rbb (0x00007ffe5b5b1000) => /usr/lib/ (0x00007f36cec94000) => /usr/lib/ (0x00007f36ceb4f000) => /usr/lib/ (0x00007f36ceb49000) => /usr/lib/ (0x00007f36ceb2f000) => /usr/lib/ (0x00007f36ceb0d000) => /usr/lib/ (0x00007f36ce946000)
    /lib/ => /usr/lib64/ (0x00007f36cee03000)

Normally the rust edition guide’s section on fully static binaries is all you need, but for some reason I was still getting a binary that wasn’t static.

It turns out there were three things I needed to do:

Not using rusqlite’s bundled feature that compiles the SQLite shared lib

This was very easy to fix, as the excellent devs behind rusqlite already made it very easy with the “bundled” feature flag which builds and bundles the libsqlite3 library for the target architecture.


rusqlite = { version = "0.23.1", features = ["bundled"] }

Using the x86_64-unknown-linux-musl build target

Of course you’re going to want to have the x86_64-unknown-linux-musl build target installed:

$ cargo build --target x86_64-unknown-linux-musl

You can install this with rustup:

$ rustup target add x86_64-unknown-linux-musl

Linking dladdr by setting RUSTFLAGS in .cargo/config

From what I understand dladdr is actually used to link the built libsqlite that is shipped inside the rbb binary itself. Doing this bundling requires one more library that doesn’t get linked by default – we need the dynamic linking library, to load the built library (this github issue reinforced it for me).


## Flag notes
# -ldl -> link dladdr
rustflags = ["-Ctarget-feature=-crt-static -ldl"]

After getting this done I got a properly static binary:

$ ldd releases/rbb
        not a dynamic executable

And here’s the size of the binary when all is said and done:

$ du -hs releases/rbb
7.1M    releases/rbb

Bonus issue: S3/Minio support and rust-openssl

Another static build issue I ran into was the openssl’s crate vendored feature – originally rust-s3 gave me some issues because openssl needed to be binary linked, but they’ve solved it nicely for me just like rusqlite. The following issues/websites were very instructive:

I ended up going with the slightly more obviously configurable and simpler rust-s3 crate for my S3 support. I took a look at rusoto_s3 which seems to be the standard but the documentation was just about as unwelcoming as it could have been, and the lack of anything close to explicit programmatic configuration (which to be fair is probably what you’d want as a user in any other case) made it somewhat harder for me to test.

I should probably revisit this and support for both libraries – rusoto is likely the better long term bet.

Revisiting efficient CI builds for Rust

For CI purposes I use a fairly simple builder image to basically “force” caching:

FROM rust:1.42.0-slim-buster

# Install build essentials
RUN apt-get update && apt-get install -y make pkg-config libssl-dev ca-certificates redis-server libsqlite3-dev musl-tools wget

# Install minio
RUN wget
RUN chmod +x minio && mv minio /bin/minio

# Set up rust MUSL target
# (
RUN rustup target add x86_64-unknown-linux-musl

# Copy a version of the code that should be overwritten in later layers
COPY . /app

# Warm build cache by running regular and testing builds
RUN make build build-test build-release

The builder pattern is outdated since now docker multi-stage builds exist, but I still use it anyway because it’s a less magical and easier to control the caching.

I’ve written about efficient CI builds in Rust before, but I had to revisit some of that knowledge once I watched my build times explode after some changes. While most of the advice is still applicable, the importance of CARGO_TARGET_DIR could not be underestimated. After some digging and enabling more detailed logging for cargo, I found that my builds were actually missing the build cache the majority of the time:

/usr/local/cargo/bin/cargo build --target x86_64-unknown-linux-musl
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] fingerprint at: /builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/.fingerprint/redis-bootleg-backup-c8ed316feec0f58e/lib-redis_bootleg_backup-c8ed316feec0f58e
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] old local fingerprints deps
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] failed to get mtime of "/builds/mrman/redis-bootleg-backup/target/debug/build/libc-1ec1b6293a17948f/build_script_build-1ec1b6293a17948f": failed to stat `/builds/mrman/redis-bootleg-backup/target/debug/build/libc-1ec1b6293a17948f/build_script_build-1ec1b6293a17948f`
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] failed to get mtime of "/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/build/libc-ce969a5d7b1a7994/output": failed to stat `/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/build/libc-ce969a5d7b1a7994/output`
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] failed to get mtime of "/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/deps/liblibc-7cd51332831eef4c.rlib": failed to stat `/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/deps/liblibc-7cd51332831eef4c.rlib`
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] failed to get mtime of "/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/deps/libatty-8c541f213fea9475.rlib": failed to stat `/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/deps/libatty-8c541f213fea9475.rlib`
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] failed to get mtime of "/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/deps/libquick_error-61324a73c9ba9f14.rlib": failed to stat `/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/deps/libquick_error-61324a73c9ba9f14.rlib`

.... more of those lines ...

    Caused by:
        No such file or directory (os error 2)
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] fingerprint at: /builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/.fingerprint/libc-7cd51332831eef4c/lib-libc-7cd51332831eef4c
[2020-06-24T03:32:55Z INFO  cargo::core::compiler::fingerprint] fingerprint error for libc v0.2.69/Build/Target { ..: lib_target("libc", ["lib"], "/usr/local/cargo/registry/src/", Edition2015) }
[2020-06-24T03:32:55Z INFO  cargo::core::compiler::fingerprint]     err: failed to read `/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/.fingerprint/libc-7cd51332831eef4c/lib-libc-7cd51332831eef4c`

    Caused by:
        No such file or directory (os error 2)
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] fingerprint at: /builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/.fingerprint/libc-ce969a5d7b1a7994/run-build-script-build_script_build-ce969a5d7b1a7994
[2020-06-24T03:32:55Z INFO  cargo::core::compiler::fingerprint] fingerprint error for libc v0.2.69/RunCustomBuild/Target { ..: custom_build_target("build-script-build", "/usr/local/cargo/registry/src/", Edition2015) }
[2020-06-24T03:32:55Z INFO  cargo::core::compiler::fingerprint]     err: failed to read `/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/.fingerprint/libc-ce969a5d7b1a7994/run-build-script-build_script_build-ce969a5d7b1a7994`

    Caused by:
        No such file or directory (os error 2)
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] fingerprint at: /builds/mrman/redis-bootleg-backup/target/debug/.fingerprint/libc-1ec1b6293a17948f/build-script-build_script_build-1ec1b6293a17948f
[2020-06-24T03:32:55Z INFO  cargo::core::compiler::fingerprint] fingerprint error for libc v0.2.69/Build/Target { ..: custom_build_target("build-script-build", "/usr/local/cargo/registry/src/", Edition2015) }
[2020-06-24T03:32:55Z INFO  cargo::core::compiler::fingerprint]     err: failed to read `/builds/mrman/redis-bootleg-backup/target/debug/.fingerprint/libc-1ec1b6293a17948f/build-script-build_script_build-1ec1b6293a17948f`

    Caused by:
        No such file or directory (os error 2)
[2020-06-24T03:32:55Z DEBUG cargo::core::compiler::fingerprint] fingerprint at: /builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/.fingerprint/humantime-5015eb2abdc120cd/lib-humantime-5015eb2abdc120cd
[2020-06-24T03:32:55Z INFO  cargo::core::compiler::fingerprint] fingerprint error for humantime v1.3.0/Build/Target { ..: lib_target("humantime", ["lib"], "/usr/local/cargo/registry/src/", Edition2015) }
[2020-06-24T03:32:55Z INFO  cargo::core::compiler::fingerprint]     err: failed to read `/builds/mrman/redis-bootleg-backup/target/x86_64-unknown-linux-musl/debug/.fingerprint/humantime-5015eb2abdc120cd/lib-humantime-5015eb2abdc120cd`

... more of these new lines ...

When I had seen shorter CI build times in the past what was happening was that GitLab’s caching features were saving me time – i.e. the /builds/mrman/redis-bootleg-backup/target folder being cached. The fact that I was using a builder image was actually not benefitting me despite going through the trouble of building one. The basic idea behind using a builder image is that the fresher the builder image is, the less building you should do (if no new libs are added, you should do no new building). After that I realized that while the builder was building the project in /app, GitLab was building in /builds/<username>/<project> and this meant that the target dir from the builder was not being used.

The fix is simple, set CARGO_TARGET_DIR to the target/resources that the builder image created, and modify some of my scripts to use this value instead of the hardcoded relative path target/ that I was using:



  # Use the cargo target dir that the builder image built to
  CARGO_TARGET_DIR: "/app/target"

# ... rest of gitlab-ci.yml ... #


# ... rest of makefile ... #


# ... rest of makefile ... #

NOTE: The ?= operator in Makefile parlance means that target will be used if the environment variable is not already assigned. So inside the CI build process the ENV variable will be set, and while building locally it won’t, so the relative path target should work.


It took way too long but it was a lot of fun to finally finish this project. As always, writing Rust is fun, powerful, and safe, and I feel like I’ve worked out my itch for writing a somewhat modular and flexible CLI tool in it.

While most almost certainly shouldn’t actually use this tool ([look at the redis documentation on persistence (and resultingly backups) instead][redis-docs-backups]), if you find you have to I do hope it works for you!