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 DUMP
ing 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:
One of the things that I’m most happy with is how this library turned out are the Trait
s that fell during the design phase mostly holding up:
/// Type of executor
#[derive(Debug, PartialEq)]
pub enum BackupExecutorType {
DirectV1
}
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)?;
Ok(backup)
}
}
BackupExecutor
s 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)?;
Ok(backup)
}
}
The types got a bit more complicated than I’d first imagined with the Box
es and Iterator
s 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.
main.rs
, lib.rs
, executor
/store
modulesIt’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)?;
store.init()?;
// Build executor
println!("[main] building executor [{:?}]...", executor_type);
let mut executor = build_backup_executor(executor_type, env_map)?;
executor.init(uri)?;
// 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
store.cleanup()?;
executor.cleanup()?;
Ok(backup)
}
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> {
Ok(
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.
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 {
client_port,
container_name: None,
process: Command::new("redis-server")
.args(&[
"--port", format!("{}", client_port).as_str(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.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 {
client_port,
container_name: Some(container_name.clone()),
process: Command::new(docker_bin_path)
.args(&[
"run",
"-p", format!("{}:{}", client_port, REDIS_DEFAULT_PORT).as_str(),
"--name", container_name.as_str(),
redis_image.as_str(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.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") {
break
}
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);
Ok(instance_info)
}
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>
where
PreCheckFn: FnOnce() -> Result<(), String>,
PostCheckFn: FnOnce() -> Result<(), String>,
StoreBuilder: FnOnce() -> Result<Box<dyn BackupStore>, String>
{
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);
cmd.args(&[
"--executor-type", executor_type.to_string().as_str(),
"--store-type", store_type.to_string().as_str(),
"backup",
"--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
.output()
.map_err(|e| format!("Failed to spawn backup command: {:#?}", e))?;
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
linux-vdso.so.1 (0x00007ffe5b5b1000)
libsqlite3.so.0 => /usr/lib/libsqlite3.so.0 (0x00007f36cec94000)
libm.so.6 => /usr/lib/libm.so.6 (0x00007f36ceb4f000)
libdl.so.2 => /usr/lib/libdl.so.2 (0x00007f36ceb49000)
libz.so.1 => /usr/lib/libz.so.1 (0x00007f36ceb2f000)
libpthread.so.0 => /usr/lib/libpthread.so.0 (0x00007f36ceb0d000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007f36ce946000)
/lib/ld64.so.1 => /usr/lib64/ld-linux-x86-64.so.2 (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:
rusqlite
’s bundled
feature that compiles the SQLite shared libThis 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.
Cargo.toml
rusqlite = { version = "0.23.1", features = ["bundled"] }
x86_64-unknown-linux-musl
build targetOf 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
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).
redis-bootleg-backup/.cargo/config
[build]
## 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
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.
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 https://dl.min.io/server/minio/release/linux-amd64/minio
RUN chmod +x minio && mv minio /bin/minio
# Set up rust MUSL target
# (https://doc.rust-lang.org/edition-guide/rust-2018/platform-and-target-support/musl-support-for-fully-static-binaries.html)
RUN rustup target add x86_64-unknown-linux-musl
# Copy a version of the code that should be overwritten in later layers
COPY . /app
WORKDIR /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/github.com-1ecc6299db9ec823/libc-0.2.69/src/lib.rs", 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/github.com-1ecc6299db9ec823/libc-0.2.69/build.rs", 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/github.com-1ecc6299db9ec823/libc-0.2.69/build.rs", 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/github.com-1ecc6299db9ec823/humantime-1.3.0/src/lib.rs", 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:
gitlab-ci.yml
image: registry.gitlab.com/mrman/redis-bootleg-backup/builder:0.2.0
variables:
# Use the cargo target dir that the builder image built to
CARGO_TARGET_DIR: "/app/target"
# ... rest of gitlab-ci.yml ... #
Makefile
# ... rest of makefile ... #
CARGO_TARGET_DIR ?= target
RELEASE_BUILT_BIN_PATH = $(CARGO_TARGET_DIR)/$(BUILD_TARGET)/release/$(PROJECT_NAME)
# ... 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!