Implementing an Application Container in Rust

Hey there, Rustaceans! šŸ¦€

Implementing an Application Container in Rust

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

  1. nix Crate: The nix crate provides a friendly interface to various Unix-like system calls and data structures. It's an abstraction over the libc crate, offering a more Rust-like experience. For our container application, we used the nix crate to unshare namespaces and handle process forking. By leveraging nix, we can achieve lower-level operations in a safe and idiomatic manner in Rust.
  2. libc Crate: The libc crate 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 on nix, the libc crate could be used for more granular control over system calls and interfacing with parts of the OS not covered by nix.
  3. std::process::Command (from the Rust Standard Library): Not a separate crate, but an integral part of Rust's standard library, std::process::Command provides a way to spawn and interact with external processes. In our container setup, we used this functionality to run the ip and iptables commands, facilitating the configuration of the network interfaces and IP forwarding rules. Using Command, 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:

  1. Determine the Deployment Destination:
  • The function sets the destination to the ./newroot/bin directory, which is the bin directory inside the container's root filesystem (newroot).

2. Ensure the Deployment Directory Exists:

  • The function checks if the newroot/bin directory 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 path argument) to the newroot/bin directory.

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:

  1. 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 unshare function 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_rootfs function 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 execvp to replace the current process image with the desired command, effectively running it inside the isolated container environment.

Download Now!


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"); 
    } 
}

Hereā€™s a breakdown of its tasks:

  1. Change to the New Root Directory:
  • The function first changes the current working directory to the specified new_root. This is a preparation step before the actual chroot operation.

2. Perform chroot:

  • It uses the chroot system call, which changes the perceived root directory for the current running process (and its children). After this call, any reference to / will be treated as a reference to the new root, ensuring filesystem isolation.

3. Adjust Current Directory:

  • After the chroot, the function ensures that the current directory is set to /, which, due to the previous chroot, is now the root of the new_root directory. This step ensures that any relative path operations start from the container's root.

4. Set Up /proc:

  • The function ensures that thereā€™s a /proc directory inside the container. The /proc filesystem is a virtual filesystem provided by the Linux kernel, offering an interface to kernel data structures. It's commonly used by various system utilities and applications.

5. Mount the /proc Filesystem:

  • If /proc isn't already mounted, the function mounts the proc filesystem to /proc. This ensures that processes inside the container have access to their own isolated view of the proc filesystem, separate from the host.

Putting it all together

Thatā€™s our main function:

fn main() { 
 
    let binary_path = "/your/path/here/";  // adjust this path 
    let target_dir = "/your/path/here/newroot/bin";  // adjust to your container's `bin` directory 
 
    let matches = App::new("Simple Container CLI") 
        .subcommand( 
            SubCommand::with_name("run") 
                .about("Runs a command in an isolated container") 
                .arg(Arg::with_name("COMMAND").required(true).index(1)) 
                .arg(Arg::with_name("ARGS").multiple(true).index(2)), 
        ) 
        .subcommand( 
            SubCommand::with_name("deploy") 
                .about("Deploys a file or directory to the container root") 
                .arg(Arg::with_name("PATH").required(true).index(1)), 
        ) 
        .get_matches(); 
 
    if let Some(matches) = matches.subcommand_matches("run") { 
        let cmd = matches.value_of("COMMAND").unwrap(); 
        let args: Vec<&str> = matches.values_of("ARGS").unwrap_or_default().collect(); 
        unsafe { run_container(cmd, args); } 
    }  else if let Some(matches) = matches.subcommand_matches("deploy") { 
        let path = matches.value_of("PATH").unwrap(); 
        deploy_container(path); 
    } 
 
    let proc_cstring = CString::new("/proc").unwrap(); 
}

Time to test our Container!

Letā€™s keep it simple. Weā€™ve got a basic Rust app called ā€œHelloContainerā€. All it does? Prints out ā€œHello, World!ā€. Thatā€™s it. Itā€™s basic, but itā€™s perfect to show off how our container works.

Weā€™ll make it statically linked so that it doesnā€™t rely on external libraries at runtime, ensuring that we donā€™t have to manage any dependencies when itā€™s run inside our container.

Why Static Linking?

By creating a statically linked binary, we embed all the dependencies within the binary itself, eliminating the need for any external libraries. This ensures our application will run in the container without any ā€œmissing libraryā€ issues.

Letā€™s dive in!

1. Setting Up Your Rust Environment:

First, make sure you have Rust and Cargo installed. If not, you can get them from rustup.rs.

2. Creating the Rust Project:

In your terminal:

cargo new containerclient 
cd containerclient

3. Writing the Application Code:

In src/main.rs, input the following code:

fn main() { 
    println!("Hello from the container!"); 
}

4. Statically Linking the Application:

To statically link our Rust application, weā€™ll use the MUSL target. It's a C library that facilitates static linking.

a. Install the MUSL Target:

rustup target add x86_64-unknown-linux-musl

b. Build the Statically Linked Binary:

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

This will produce a statically linked binary in the target/x86_64-unknown-linux-musl/release/ directory.

Deploying and Running ContainerClient in Our Simple Container

Letā€™s get your ContainerClient app up and running inside our container in no time!


Download Now!


1. Deploying the ContainerClient Application:

Before deploying, make sure youā€™ve built the ContainerClient application as a statically linked binary. Navigate to where the ContainerClient binary is located (target/x86_64-unknown-linux-musl/release/containerclient), then:

sudo ./rustcontainer deploy ./containerclient

The deploy subcommand takes care of copying the containerclient binary to our container's filesystem.

2. Running the ContainerClient Application Inside the Container:

With the app deployed, itā€™s time to run it:

sudo ./rustcontainer run ./containerclient

If all went well, you should see:

Hello from the container!

And thatā€™s it! With these simple steps, youā€™ve deployed and executed your containerclient application inside our minimalist container environment. Enjoy experimenting with it!

Hereā€™s a summary of the features we implemented for your minimalist container system:

Mini Container System Features:

  1. Basic Container CLI:
  • A command-line interface that allows users to deploy, run, and interact with applications inside a rudimentary container-like environment.

2. Deployment:

  • Allows users to deploy (or install) an application into the containerā€™s isolated filesystem. This essentially involves copying the application binary to a specified directory (new_root/bin).

3. Isolation with Namespaces:

  • Uses Linux namespaces to isolate processes. This ensures that processes inside the container cannot see processes outside of it.
  • The container gets its own PID and network namespaces, providing basic process and network isolation.

4. Filesystem Isolation with chroot:

  • Uses chroot to change the root directory of the container, providing a form of filesystem isolation. This ensures that processes inside the container cannot access the host's filesystem outside of the designated root.

There we have it, folks!

You can check out the full (basic version) implementation in my GitHub repository at https://github.com/luishsr/rust-container.

Dive into the code, play around with it, and perhaps even contribute!

Stay tuned, and weā€™ll catch you in the next part of our Rust-powered journey.

Thanks for sticking around, and happy coding!

Check out some interesting hands-on Rust articles:

šŸŒŸ Developing a Fully Functional API Gateway in Rustā€Šā€”ā€ŠDiscover how to set up a robust and scalable gateway that stands as the frontline for your microservices.

šŸŒŸ Implementing a Network Traffic Analyzerā€Šā€”ā€ŠEver wondered about the data packets zooming through your network? Unravel their mysteries with this deep dive into network analysis.

šŸŒŸ Building an Application Container in Rustā€Šā€”ā€ŠJoin us in creating a lightweight, performant, and secure container from scratch! Dockerā€™s got nothing on this. šŸ˜‰

šŸŒŸ Crafting a Secure Server-to-Server Handshake with Rust & OpenSSLā€Šā€”ā€Š
If youā€™ve been itching to give your servers a unique secret handshake that only they understand, youā€™ve come to the right place. Today, weā€™re venturing into the world of secure server-to-server handshakes, using the powerful combo of Rust and OpenSSL.

šŸŒŸBuilding a Function-as-a-Service (FaaS) in Rust: If youā€™ve been exploring cloud computing, youā€™ve likely come across FaaS platforms like AWS Lambda or Google Cloud Functions. In this article, weā€™ll be creating our own simple FaaS platform using Rust.

šŸŒŸ Rusting Up Your Own Self-Signed Certificate Generatorā€Šā€”ā€ŠLetā€™s walk through the process of crafting your very own self-signed certificate generator, all using the versatile Rust programming language and the rust-openssl crate.Happy coding, and keep those Rust gears turning! šŸ¦€

šŸŒŸ Implementing a Secret Vault in Rust: Weā€™re about to dive into some cryptography, play around with key derivations, and even wrestle with user input, all to keep our most prized digital possessions under lock and key.

Happy coding, and keep those Rust gears turning!

Read more articles about Rust in my Rust Programming Library!

Visit my Blog for more articles, news, and software engineering stuff!

Follow me on Medium, LinkedIn, and Twitter.

Leave a comment, and drop me a message!

All the best,

Luis Soares

CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust šŸ¦€ | Golang | Java | ML AI & Statistics | Web3 & Blockchain

Read more