Hey there, Rustaceans! 🦀
Ever wondered what’s simmering beneath the surface of those Docker containers you spin up? Containers have taken the software world by storm, and today, we’re diving deep to unravel some of that magic.
Instead of just pulling and running containers, we’ll craft one from scratch! And what better tool to forge our tiny container than the powerful and safe Rust programming language?
Whether you’re a systems programming pro, or just dipping your toes into the vast ocean of containerization, this journey promises a deeper understanding and some hands-on fun.
Grab your favorite beverage ☕, and let’s embark on this coding adventure together!
What’s a Container?
At the heart of a container is an isolation mechanism, leveraging features provided by the OS kernel, like namespaces and control groups in Linux.
Unlike traditional virtual machines which run a full OS stack for each instance, containers share the same OS kernel and isolate the application processes from each other.
This approach cuts down on overhead, making containers lightweight and fast. Simply put, if you think of a virtual machine as a house with its own land and utilities, a container is more like an apartment in a building, sharing the same infrastructure but with its own isolated space.
The Crates we’re going to use
nixCrate: Thenixcrate provides a friendly interface to various Unix-like system calls and data structures. It's an abstraction over thelibccrate, offering a more Rust-like experience. For our container application, we used thenixcrate to unshare namespaces and handle process forking. By leveragingnix, we can achieve lower-level operations in a safe and idiomatic manner in Rust.libcCrate: Thelibccrate offers bindings to C library functions and types, which makes it possible to directly interface with the C library's system calls and other functionalities in a Rust program. In the context of our application, while we primarily relied onnix, thelibccrate could be used for more granular control over system calls and interfacing with parts of the OS not covered bynix.std::process::Command(from the Rust Standard Library): Not a separate crate, but an integral part of Rust's standard library,std::process::Commandprovides a way to spawn and interact with external processes. In our container setup, we used this functionality to run theipandiptablescommands, facilitating the configuration of the network interfaces and IP forwarding rules. UsingCommand, we can execute, monitor, and communicate with other processes directly from our Rust application.
Setup the Rust Project:
cargo new mini_container
cd mini_container
Dependencies:
In Cargo.toml, add:
[dependencies]
clap = "2.33"
nix = "0.23" # for chroot and namespaces
Installing the ‘libc’ package on Linux OS
Before getting started with the crate, you’ll want to make sure you’ve got the libc package set up on your system.
The exact package name and installation process will depend on your Linux distribution:
Debian/Ubuntu:
For Debian-based systems like Ubuntu, the C library is provided by the libc6 package, and the header files are in the libc6-dev package. You can install it with:
sudo apt update
sudo apt install libc6-dev
Fedora:
On Fedora, the package is named glibc-devel. Install it with:
sudo dnf install glibc-devel
CentOS/RHEL:
For CentOS and Red Hat Enterprise Linux, you’d use:
sudo yum install glibc-devel
CLI and Implementation
Let’s break down each function we need to implement.
In your src/main.rs, let’s declare and use the crates we’ve seen:
extern crate nix;
extern crate clap;
extern crate libc;
use nix::sched::{unshare, CloneFlags};
use nix::sys::wait::waitpid;
use nix::unistd::{execvp, fork, ForkResult};
use nix::mount::{MsFlags, mount};
use clap::{App, Arg, SubCommand};
use std::ffi::CString;
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
The Deployment function
The deploy_container function serves as a way to deploy or install applications into the container's isolated filesystem. In a more traditional container setup, "deploying" might involve building an image or setting up an environment. Here, in this minimalistic approach, deploying is essentially copying the specified application binary (or other files) into a specific directory inside the container's root, making it available to run when the container environment is started.
fn deploy_container(path: &str) {
let destination = "./newroot/bin";
// For simplicity, copy the app to a new_root directory
let new_root = Path::new("newroot/bin");
std::fs::create_dir_all(&new_root).expect("Failed to create new root directories.");
let deploy_path = new_root.join(Path::new(path).file_name().unwrap());
std::fs::copy(path, &deploy_path).expect("Failed to deploy the app.");
println!("Deployed to {:?}", deploy_path);
}
Here’s a step-by-step breakdown of the function’s behavior:
- Determine the Deployment Destination:
- The function sets the destination to the
./newroot/bindirectory, which is thebindirectory inside the container's root filesystem (newroot).
2. Ensure the Deployment Directory Exists:
- The function checks if the
newroot/bindirectory exists. If not, it creates the necessary directories.
3. Copy the Application to the Destination:
- The function then copies the specified file or directory (provided by the
pathargument) to thenewroot/bindirectory.
4. Notify the User:
- After copying, the function prints out a message indicating where the application has been deployed inside the container’s filesystem.
In essence, the deploy_container function simplifies the process of adding software to the container so that it can be run later when the container environment is started.
The Run Container function
The run_container function is at the heart of the minimalistic container we are implementing. Its primary purpose is to execute a given command inside an isolated container environment. The function achieves this by leveraging Linux features like forking processes, creating new namespaces, and adjusting the root filesystem:
unsafe fn run_container(cmd: &str, args: Vec<&str>) {
match fork() {
Ok(ForkResult::Parent { child, .. }) => {
// Parent process waits for the child to finish.
waitpid(child, None).expect("Failed to wait on child");
}
Ok(ForkResult::Child) => {
// Convert Rust strings to C-style strings for execvp
let c_cmd = CString::new(cmd).expect("Failed to convert to CString");
let c_args: Vec<CString> = args.iter()
.map(|arg| CString::new(*arg).expect("Failed to convert to CString"))
.collect();
let c_args_refs: Vec<&std::ffi::CStr> = c_args.iter().map(AsRef::as_ref).collect();
// Unshare namespaces
unshare(CloneFlags::CLONE_NEWPID | CloneFlags::CLONE_NEWNS).expect("Failed to unshare");
// Setup the new filesystem root
let current_dir = std::env::current_dir().unwrap();
setup_rootfs(&format!("{}/newroot", current_dir.display()));
execvp(&c_cmd, &c_args_refs).expect("Failed to execvp");
}
Err(err) => eprintln!("Fork failed: {}", err),
}
}
Here’s a deeper dive into its workings:
- Forking:
- The process forks, creating a parent and a child process.
- The parent simply waits for the child process to finish its tasks.
- The child process carries on to set up the isolated container environment and run the desired command inside it.
2. Setting Up Isolation with Namespaces:
- The child process uses the
unsharefunction to create new namespaces. Specifically, it establishes a new PID (Process ID) namespace and a new Mount namespace. - The new PID namespace ensures that processes inside the container cannot see processes outside of it.
- The new Mount namespace provides an isolated filesystem view to the container.
3. Setting Up the Filesystem:
- The
setup_rootfsfunction is called, which prepares a new root for the filesystem (using chroot), ensuring an isolated filesystem environment. This makes sure that processes inside the container see a different root directory than the host.
4. Executing the Command:
- After setting up the isolation mechanisms and the new filesystem, the function finally uses
execvpto replace the current process image with the desired command, effectively running it inside the isolated container environment.

In a nutshell, the run_container function encapsulates the logic to provide an isolated environment and then runs a user-specified command within that environment, mimicking the foundational aspects of containerization.
Setting Up the Container FileSystem wit the setup_rootfs function
The setup_rootfs function is designed to prepare the filesystem environment for the container. In the context of containerization, it's crucial to ensure that the filesystem inside the container is isolated from the host system, providing a separate environment for the container's processes. The setup_rootfs function plays a pivotal role in achieving this isolation.
fn setup_rootfs(new_root: &str) {
// Change the current directory to the new root
std::env::set_current_dir(new_root).expect("Failed to change directory to new root");
// Convert Rust string to C-style string for chroot
let new_root_c = CString::new(new_root).expect("Failed to convert to CString");
// Now, use chroot to change the root directory
unsafe {
if libc::chroot(new_root_c.as_ptr()) != 0 {
panic!("chroot failed: {}", std::io::Error::last_os_error());
}
}
// Change directory again after chroot to ensure we're at the root
std::env::set_current_dir("/").expect("Failed to change directory after chroot");
// Ensure /proc exists in the new root
fs::create_dir_all("/proc").expect("Failed to create /proc directory");
// Mount the /proc filesystem
if !is_proc_mounted() {
// Now, mount the /proc filesystem
mount(
Some("proc"),
"/proc",
Some("proc"),
MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
None::<&str>
).expect("Failed to mount /proc");
}
}


