Implementing an Application Container in Rust
Hey there, Rustaceans! š¦
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
nix
Crate: Thenix
crate provides a friendly interface to various Unix-like system calls and data structures. It's an abstraction over thelibc
crate, offering a more Rust-like experience. For our container application, we used thenix
crate to unshare namespaces and handle process forking. By leveragingnix
, we can achieve lower-level operations in a safe and idiomatic manner in Rust.libc
Crate: Thelibc
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 onnix
, thelibc
crate 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::Command
provides a way to spawn and interact with external processes. In our container setup, we used this functionality to run theip
andiptables
commands, 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/bin
directory, which is thebin
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 thenewroot/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:
- 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.
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:
- 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 actualchroot
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 previouschroot
, is now the root of thenew_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 theproc
filesystem to/proc
. This ensures that processes inside the container have access to their own isolated view of theproc
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!
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:
- 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