Creating Container Subprocess
Enter the ~/project
directory and create a file named docker.hpp
. In this file, we will first create a namespace named docker
that can be called by our external code.
//
// docker.hpp
// cpp_docker
//
// Header files for system calls
#include <sys/wait.h> // waitpid
#include <sys/mount.h> // mount
#include <fcntl.h> // open
#include <unistd.h> // execv, sethostname, chroot, fchdir
#include <sched.h> // clone
// C Standard Library
#include <cstring>
// C++ Standard Library
#include <string> // std::string
#define STACK_SIZE (512 * 512) // Define the size of the child process space
namespace docker {
// .. where the docker magic starts
}
Let's start by defining some variables to enhance readability:
// Defined within the `docker` namespace
typedef int proc_status;
proc_status proc_err = -1;
proc_status proc_exit = 0;
proc_status proc_wait = 1;
Before defining the container class, let's analyze the parameters required to create a container. We will not consider network-related configuration for now. To create a Docker container from an image, we only need to specify the hostname and the location of the image. Therefore:
// Docker container startup configuration
typedef struct container_config {
std::string host_name; // Hostname
std::string root_dir; // Root directory of the container
} container_config;
Now, let's define the container
class and have it perform the necessary configuration for the container in the constructor:
class container {
private:
// Enhances readability
typedef int process_pid;
// Child process stack
char child_stack[STACK_SIZE];
// Container configuration
container_config config;
public:
container(container_config &config) {
this->config = config;
}
};
Before thinking about the specific methods in the container
class, let's first think about how we would use this container
class. For this, let's create a main.cpp
file in the ~/project
folder:
//
// main.cpp
// cpp_docker
//
#include "docker.hpp"
#include <iostream>
int main(int argc, char** argv) {
std::cout << "...start container" << std::endl;
docker::container_config config;
// Configure the container
// ...
docker::container container(config);// Construct the container based on the config
container.start(); // Start the container
std::cout << "stop container..." << std::endl;
return 0;
}
In main.cpp
, to make the container startup concise and easy to understand, let's assume that the container is started using a start()
method. This provides a foundation for writing the docker.hpp
file later.
Now, let's go back to docker.hpp
and implement the start()
method:
void start() {
auto setup = [](void *args) -> int {
auto _this = reinterpret_cast<container *>(args);
// Perform relevant configurations for the container
// ...
return proc_wait;
};
process_pid child_pid = clone(setup, child_stack+STACK_SIZE, // Move to the bottom of the stack
SIGCHLD, // Send a signal to the parent process when the child process exits
this);
waitpid(child_pid, nullptr, 0); // Wait for the child process to exit
}
The docker::container::start()
method uses the clone()
system call in Linux. To pass the docker::container
instance object to the callback function setup
, we can pass it using the fourth argument of clone()
. Here, we pass the this
pointer.
As for the setup
function, we create a lambda expression for it. In C++, a lambda expression with an empty capture list can be passed as a function pointer. Therefore, setup
becomes the callback function passed to clone()
.
You can also use a static member function defined in the class instead of a lambda expression, but that would make the code less elegant.
In the constructor of this container
class, we define a child process handling function to be called by the clone()
system call. We use typedef
to change the return type of this function to proc_status
. When this function returns proc_wait
, the child process cloned by clone()
will wait to exit.
However, this is not enough because we have not performed any configuration within the process. As a result, our program will exit immediately as there is nothing else to do once the process is started. As we know, in Docker, to keep a container running, we can use:
docker run -it ubuntu:14.04 /bin/bash
This binds STDIN to the container's /bin/bash
. So, let's add a start_bash()
method to the docker::container
class:
private:
void start_bash() {
// Safely convert C++ std::string to C-style string char *
// Starting from C++14, this direct assignment is prohibited: `char *str = "test";`
std::string bash = "/bin/bash";
char *c_bash = new char[bash.length()+1]; // +1 for '\0'
strcpy(c_bash, bash.c_str());
char* const child_args[] = { c_bash, NULL };
execv(child_args[0], child_args); // Execute /bin/bash in the child process
delete []c_bash;
}
And call it within setup
:
auto setup = [](void *args) -> int {
auto _this = reinterpret_cast<container *>(args);
_this->start_bash();
return proc_wait;
}
Now, we can see the following actions:
labex:project/ $ hostname
iZj6cboigynrxh4mn2oo16Z
labex:project/ $ g++ main.cpp -std=c++11
labex:project/ $ ./a.out
...start container
labex@iZj6cboigynrxh4mn2oo16Z:~/project$ mkdir test
labex@iZj6cboigynrxh4mn2oo16Z:~/project$ ls
a.out docker.hpp main.cpp test
labex@iZj6cboigynrxh4mn2oo16Z:~/project$ exit
exit
stop container...
In the steps above, we first check the current hostname
, compile the code we have written so far, run it, and enter our container. We can see that after entering the container, the bash prompt changes, which is what we expected.
However, it is easy to notice that this is not the result we want, as it is exactly the same as our host system. Any operations performed within this "container" will directly affect the host system.
This is where we introduce the required namespaces in the clone
API.