Crear un contenedor Docker simple en C++

C++C++Beginner
Practicar Ahora

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

La esencia de Docker es utilizar LXC para lograr una funcionalidad similar a la de una máquina virtual, ahorrando así recursos de hardware y proporcionando a los usuarios más recursos de cálculo. Este proyecto combina C++ con las tecnologías Namespace y Control Group de Linux para implementar un contenedor Docker simple.

Finalmente, lograremos las siguientes funcionalidades para el contenedor:

  1. Sistema de archivos independiente
  2. Soporte para acceso a la red

👀 Vista previa

$ make
make container
make[1]: Entering directory '/home/labex/project'
gcc -c network.c nl.c
g++ -std=c++11 -o docker-run main.cpp network.o nl.o
make[1]: Leaving directory '/home/labex/project'
$ sudo./docker-run
...start container
root@labex:/## ifconfig
eth0      Link encap:Ethernet  HWaddr 00:16:3e:da:01:72
          inet6 addr: fe80::dc15:18ff:fe43:53b9/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:38 errors:0 dropped:0 overruns:0 frame:0
          TX packets:9 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:5744 (5.7 KB)  TX bytes:726 (726.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

🎯 Tareas

En este proyecto, aprenderás:

  • Cómo crear un contenedor Docker simple utilizando C++ y la tecnología Namespace de Linux
  • Cómo implementar un sistema de archivos independiente para el contenedor
  • Cómo habilitar el acceso a la red para el contenedor

🏆 Logros

Después de completar este proyecto, podrás:

  • Crear un contenedor Docker simple utilizando C++ y la tecnología Namespace de Linux
  • Implementar un sistema de archivos independiente para el contenedor
  • Habilitar el acceso a la red para el contenedor

Tecnología Linux Namespace

En C++, estamos familiarizados con la palabra clave namespace. En C++, cada espacio de nombres (namespace) aísla los mismos nombres en diferentes códigos, por lo que siempre que los nombres de los espacios de nombres sean diferentes, los nombres de los códigos en los espacios de nombres pueden ser los mismos, resolviendo así el problema de conflictos de nombres en el código.

Por otro lado, Linux Namespace es una tecnología proporcionada por el kernel de Linux que ofrece una solución para el aislamiento de recursos de las aplicaciones, similar al concepto de namespace en C++. Sabemos que recursos como PID, IPC y redes deben ser administrados por el sistema operativo en sí, pero Linux Namespace puede hacer que estos recursos ya no sean globales y los asigne a espacios de nombres específicos.

En el mundo de la tecnología Docker, a menudo escuchamos términos como LXC y virtualización a nivel de sistema operativo (OS-level virtualization), y LXC utiliza la tecnología Namespace para lograr el aislamiento de recursos entre diferentes contenedores. Al utilizar la tecnología Namespace, los procesos dentro de diferentes contenedores pertenecen a diferentes espacios de nombres y no se interfieren entre sí. En resumen, la tecnología Namespace proporciona una forma ligera de virtualización que nos permite operar propiedades a nivel de sistema desde diferentes perspectivas.

En Linux, la llamada al sistema más importante relacionada con Namespace es clone(). El propósito de clone() es restringir los hilos a un espacio de nombres específico al crear procesos.

Encapsulación de llamadas al sistema

Dado que las llamadas al sistema de Linux están escritas en C, necesitamos escribir código en C++ para nuestro proyecto. Para mantener un estilo de codificación consistente y puramente en C++, primero encapsularemos estas API necesarias en una forma de C++, lo que también nos permitirá entender más profundamente cómo se utilizan estas API.

Utilizaremos las siguientes API:

clone()

Tanto la llamada al sistema clone como fork se utilizan para crear procesos en Linux. Sin embargo, fork es solo una pequeña parte de clone. La diferencia entre ellos radica en que fork solo crea un proceso hijo que es una copia exacta del proceso padre, mientras que clone es más potente ya que permite la copia selectiva de los recursos del proceso padre al proceso hijo. Los recursos que no se copian se comparten entre los procesos a través de la copia de punteros (arg). Los recursos específicos a copiar se pueden especificar utilizando flags, y la función devuelve el PID del proceso hijo.

Sabemos que un proceso consta de cuatro elementos principales:

  1. Un segmento de código a ejecutar
  2. Un espacio de pila privado para el proceso
  3. Un bloque de control de proceso (PCB, por sus siglas en inglés: Process Control Block)
  4. Espacios de nombres específicos del proceso

Los dos primeros elementos corresponden a los parámetros fn y child_stack en clone. El bloque de control de proceso es controlado por el kernel y no necesitamos preocuparnos por ello. Por lo tanto, los espacios de nombres están asociados con el parámetro flags. Para lograr nuestro objetivo de crear un contenedor Docker, los principales parámetros que necesitamos son los siguientes:

Clasificación de espacio de nombres Parámetro de llamada al sistema


    UTS         CLONE_NEWUTS
   Montaje      CLONE_NEWNS
    PID         CLONE_NEWPID
  Red           CLONE_NEWNET

Por los nombres, se puede ver que CLONE_NEWNS proporciona montaje relacionado con el sistema de archivos para la copia y recursos relacionados con el sistema de archivos, CLONE_NEWUTS proporciona la capacidad de establecer el nombre de host, CLONE_NEWPID proporciona soporte para un espacio de proceso independiente y CLONE_NEWNET proporciona soporte relacionado con la red.

execv()

int execv(const char *path, char *const argv[]);

execv ejecuta el archivo ejecutable especificado por path. Esta llamada al sistema permite que nuestro proceso hijo ejecute /bin/bash para mantener el contenedor en ejecución.

sethostname()

int sethostname(const char *name, size_t len);

Como su nombre indica, esta llamada al sistema se utiliza para establecer el nombre de host. Vale la pena mencionar que, dado que las cadenas de estilo C utilizan punteros y la longitud de la cadena no se puede determinar directamente desde dentro, el parámetro len se utiliza para obtener la longitud de la cadena.

chdir()

int chdir(const char *path);

Sabemos que cualquier programa se ejecuta en un directorio específico. Cuando necesitamos acceder a recursos, podemos utilizar rutas relativas en lugar de rutas absolutas para acceder a los recursos relevantes. chdir nos proporciona la comodidad de cambiar el directorio de trabajo de nuestro programa, que se puede utilizar para ciertos fines no divulgados.

chroot()

Esta llamada al sistema se utiliza para cambiar el directorio raíz:

int chroot(const char *path);

mount()

Esta llamada al sistema se utiliza para montar sistemas de archivos, similar al comando mount.

int mount(const char *source, const char *target,
                 const char *filesystemtype, unsigned long mountflags,
                 const void *data);

Creación de un subproceso de contenedor

Ingresa al directorio ~/project y crea un archivo llamado docker.hpp. En este archivo, primero crearemos un espacio de nombres (namespace) llamado docker que puede ser llamado por nuestro código externo.

//
// docker.hpp
// cpp_docker
//

// Archivos de cabecera para llamadas al sistema
#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

// Biblioteca estándar de C
#include <cstring>

// Biblioteca estándar de C++
#include <string>       // std::string

#define STACK_SIZE (512 * 512) // Define el tamaño del espacio del proceso hijo

namespace docker {
    //.. donde comienza la magia de Docker
}

Comencemos definiendo algunas variables para mejorar la legibilidad:

// Definidas dentro del espacio de nombres `docker`
typedef int proc_status;
proc_status proc_err  = -1;
proc_status proc_exit = 0;
proc_status proc_wait = 1;

Antes de definir la clase de contenedor, analicemos los parámetros necesarios para crear un contenedor. Por ahora, no consideraremos la configuración relacionada con la red. Para crear un contenedor Docker a partir de una imagen, solo necesitamos especificar el nombre de host y la ubicación de la imagen. Por lo tanto:

// Configuración de arranque del contenedor Docker
typedef struct container_config {
    std::string host_name;      // Nombre de host
    std::string root_dir;       // Directorio raíz del contenedor
} container_config;

Ahora, definamos la clase container y hagamos que realice la configuración necesaria para el contenedor en el constructor:

class container {
private:
    // Mejora la legibilidad
    typedef int process_pid;

    // Pila del proceso hijo
    char child_stack[STACK_SIZE];

    // Configuración del contenedor
    container_config config;
public:
    container(container_config &config) {
        this->config = config;
    }
};

Antes de pensar en los métodos específicos de la clase container, primero pensemos en cómo usaríamos esta clase container. Para ello, creemos un archivo main.cpp en la carpeta ~/project:

//
// 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;

    // Configurar el contenedor
    //...

    docker::container container(config);// Construir el contenedor basado en la configuración
    container.start();                  // Iniciar el contenedor
    std::cout << "stop container..." << std::endl;
    return 0;
}

En main.cpp, para hacer el arranque del contenedor conciso y fácil de entender, asumamos que el contenedor se inicia utilizando un método start(). Esto proporciona una base para escribir el archivo docker.hpp más adelante.

Ahora, volvamos a docker.hpp e implementemos el método start():

void start() {
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);

        // Realizar configuraciones relevantes para el contenedor
        //...

        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack+STACK_SIZE, // Mover al final de la pila
                        SIGCHLD,      // Enviar una señal al proceso padre cuando el proceso hijo finaliza
                        this);
    waitpid(child_pid, nullptr, 0); // Esperar a que el proceso hijo finalice
}

El método docker::container::start() utiliza la llamada al sistema clone() de Linux. Para pasar el objeto de instancia docker::container a la función de devolución de llamada setup, podemos pasarlo utilizando el cuarto argumento de clone(). Aquí, pasamos el puntero this.

En cuanto a la función setup, creamos una expresión lambda para ella. En C++, una expresión lambda con una lista de captura vacía se puede pasar como un puntero a función. Por lo tanto, setup se convierte en la función de devolución de llamada pasada a clone().

También puedes usar una función miembro estática definida en la clase en lugar de una expresión lambda, pero eso haría que el código fuera menos elegante.

En el constructor de esta clase container, definimos una función de manejo de proceso hijo que será llamada por la llamada al sistema clone(). Usamos typedef para cambiar el tipo de retorno de esta función a proc_status. Cuando esta función devuelve proc_wait, el proceso hijo clonado por clone() esperará a salir.

Sin embargo, esto no es suficiente porque no hemos realizado ninguna configuración dentro del proceso. Como resultado, nuestro programa saldrá inmediatamente ya que no hay nada más que hacer una vez que el proceso se inicia. Como sabemos, en Docker, para mantener un contenedor en ejecución, podemos usar:

docker run -it ubuntu:14.04 /bin/bash

Esto enlaza STDIN con el /bin/bash del contenedor. Entonces, agreguemos un método start_bash() a la clase docker::container:

private:
void start_bash() {
    // Convertir de forma segura std::string de C++ a cadena de estilo C char *
    // A partir de C++14, esta asignación directa está prohibida: `char *str = "test";`
    std::string bash = "/bin/bash";
    char *c_bash = new char[bash.length()+1];   // +1 para '\0'
    strcpy(c_bash, bash.c_str());

    char* const child_args[] = { c_bash, NULL };
    execv(child_args[0], child_args);           // Ejecutar /bin/bash en el proceso hijo
    delete []c_bash;
}

Y llámalo dentro de setup:

auto setup = [](void *args) -> int {
    auto _this = reinterpret_cast<container *>(args);
    _this->start_bash();
    return proc_wait;
}

Ahora, podemos ver las siguientes acciones:

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...

En los pasos anteriores, primero verificamos el hostname actual, compilamos el código que hemos escrito hasta ahora, lo ejecutamos y entramos en nuestro contenedor. Podemos ver que después de entrar en el contenedor, el indicador de bash cambia, lo cual era lo esperado.

Sin embargo, es fácil notar que este no es el resultado que queremos, ya que es exactamente el mismo que nuestro sistema host. Cualquier operación realizada dentro de este "contenedor" afectará directamente al sistema host.

Aquí es donde introducimos los espacios de nombres necesarios en la API clone.

✨ Revisar Solución y Practicar

Habilitar que el contenedor tenga su propio nombre de host

Como se mencionó anteriormente en la sección sobre llamadas al sistema, es bastante sencillo establecer el nombre de host de un proceso hijo utilizando una llamada al sistema. Por lo tanto, creamos un método privado para la clase docker::container:

private:
// Establecer el nombre de host del contenedor
void set_hostname() {
    sethostname(this->config.host_name.c_str(), this->config.host_name.length());
}

También realizamos cambios en el método start() de la siguiente manera:

void start() {
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);

        // Configurar el contenedor
        _this->set_hostname();
        _this->start_bash();

        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack+STACK_SIZE,
                        CLONE_NEWUTS| // Agregar espacio de nombres UTS
                        SIGCHLD,      // Enviar señal al padre cuando el proceso hijo finaliza
                        this);
    waitpid(child_pid, nullptr, 0); // Esperar a que el proceso hijo finalice
}

En el archivo main.cpp, configuramos el nombre del nombre de host:

int main(int argc, char** argv) {
    std::cout << "...start container" << std::endl;
    docker::container_config config;
    config.host_name = "labex";
    ……

Ahora, recompilamos el código:

labex:project/ $ g++ main.cpp -std=c++11
labex:project/ $./a.out
...start container
stop container...

Se observa que nuestro contenedor se cierra inmediatamente. Esto se debe a que una vez que introducimos el espacio de nombres, nuestro programa requiere privilegios de superusuario. Por lo tanto, necesitamos ejecutar el programa con sudo:

labex:project/ $ sudo./a.out
...start container
root@labex:/home/labex/project## hostname
labex
root@labex:/home/labex/project## exit
exit
stop container...
labex:project/ $ hostname
iZj6cboigynrxh4mn2oo16Z

Sin embargo, esto aún no logra el efecto deseado del contenedor porque, como se puede ver con el comando ls, todavía podemos acceder al directorio de la máquina host.

✨ Revisar Solución y Practicar

Habilitar que el contenedor tenga su propio sistema de archivos

En la tecnología Docker, los contenedores se crean a partir de imágenes. Dado que queremos implementar un contenedor, naturalmente necesitamos crearlo a partir de una imagen. Afortunadamente, hemos preparado una imagen de Docker para ti. Puedes obtenerla descargándola desde:

cd ~/project
wget https://file.labex.io/lab/171925/docker-image.tar

Luego, extráela en la carpeta ~/project/labex:

mkdir labex
tar -xf docker-image.tar --directory labex/
rm docker-image.tar

Aquí, es posible que encuentres algunos errores de extracción. Esto se debe a que en el entorno, algunos archivos están prohibidos de ser creados externamente. Esto no afecta nuestra implementación de nuestro propio contenedor, así que simplemente ignóralo.

tar: dev/agpgart: Cannot mknod: Operation not permitted
tar: dev/audio: Cannot mknod: Operation not permitted
tar: dev/audio1: Cannot mknod: Operation not permitted
tar: dev/audio2: Cannot mknod: Operation not permitted
tar: dev/audio3: Cannot mknod: Operation not permitted
tar: dev/audioctl: Cannot mknod: Operation not permitted
……

Después de que se complete la extracción, podremos ver un directorio de Linux casi completo bajo labex:

labex:project/ $ ls labex
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

Ahora, queremos que docker::container ingrese a este directorio y lo use como directorio raíz, enmascarando el acceso externo del subproceso al iniciarse:

private:
// Establecer el directorio raíz
void set_rootdir() {

    // Llamada al sistema chdir, cambiar a un cierto directorio
    chdir(this->config.root_dir.c_str());

    // Llamada al sistema chroot, establecer el directorio raíz, ya que
    // ya nos hemos cambiado al directorio actual antes
    // simplemente podemos usar el directorio actual como directorio raíz
    chroot(".");
}

Luego, complete la configuración relevante en main.cpp:

#include "docker.hpp"
#include <iostream>

int main(int argc, char** argv) {
    std::cout << "...start container" << std::endl;
    docker::container_config config;
    config.host_name = "labex";
    config.root_dir  = "./labex";
    ……

Y habilite CLONE_NEWNS en la llamada clone() para activar el espacio de nombres de montaje (Mount Namespace):

void start() {
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);
        _this->set_hostname();
        _this->set_rootdir();
        _this->start_bash();
        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack+STACK_SIZE,
                      CLONE_NEWUTS| // Espacio de nombres UTS
                      CLONE_NEWNS|  // Espacio de nombres de montaje
                      SIGCHLD,      // Se envía una señal al proceso padre cuando el proceso hijo finaliza
                      this);
    waitpid(child_pid, nullptr, 0); // Esperar a que el proceso hijo finalice
}

Ahora, recompilémoslo:

labex:project/ $ g++ main.cpp -std=c++11
labex:project/ $ sudo./a.out
...start container
root@labex:/## ls
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
root@labex:/## hostname
labex

Al ejecutar ls, podemos ver que el subproceso ahora se encuentra en un directorio linux completo.

✨ Revisar Solución y Practicar

Habilitar que el contenedor tenga su propio sistema de procesos

Sin embargo, todavía hay un problema. Si usamos comandos como ps o top, todavía podemos observar todos los procesos en el proceso padre. Este no es el efecto deseado. Por ejemplo, podemos ver a.out en la salida de ps, y el valor del ID de proceso también es muy grande.

Para resolver este problema, necesitamos introducir el espacio de nombres PID (PID Namespace) para aislar el espacio de PID de los procesos hijos del proceso padre.

private:
// Configurar un espacio de nombres de procesos independiente
void set_procsys() {
    // Montar el sistema de archivos proc
    mount("none", "/proc", "proc", 0, nullptr);
    mount("none", "/sys", "sysfs", 0, nullptr);
}

De manera similar, todavía necesitamos agregar esta parte del código en start(), introduciendo CLONE_NEWPID:

void start() {
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);
        _this->set_hostname();
        _this->set_rootdir();
        _this->set_procsys();
        _this->start_bash();
        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack,
                      CLONE_NEWUTS| // Espacio de nombres UTS
                      CLONE_NEWNS|  // Espacio de nombres de montaje
                      CLONE_NEWPID| // Espacio de nombres PID
                      SIGCHLD,      // Se envía una señal al proceso padre cuando el proceso hijo finaliza
                      this);
    waitpid(child_pid, nullptr, 0); // Esperar a que el proceso hijo finalice
}

Ahora, cuando compilamos y ejecutamos de nuevo, veremos que el contenedor tiene su propio espacio de procesos independiente:

En este punto, hemos utilizado la tecnología de espacios de nombres (Namespace) en Linux para aislar los recursos en los procesos hijos y dar a nuestro contenedor Docker su propio espacio de procesos y sistema de archivos.

Sin embargo, el contenedor todavía no puede acceder a la red, e incluso podemos acceder a los dispositivos de red de la máquina host usando ifconfig. Esto no es lo que queremos. A continuación, mejoraremos aún más el contenedor para que sea más como un contenedor completo, brindando soporte para el acceso a la red.

✨ Revisar Solución y Practicar

Principios de red de Docker

Anteriormente, tuvimos una comprensión preliminar de cómo Docker implementa un contenedor cerrado. Sin embargo, también descubrimos que el contenedor Docker que implementamos no admite el acceso a la red y que no es posible que los diferentes contenedores que ejecutamos tengan la capacidad de comunicarse entre sí.

Diagrama de puente de red de contenedores Docker

El principio de comunicación de red entre contenedores Docker se logra a través de un puente llamado 'docker0'. Los dos contenedores, 'container1' y 'container2', cada uno tiene su propio dispositivo de red, 'eth0'. Todas las solicitudes de red se reenviarán a través de 'eth0'. Dado que los contenedores se encuentran en procesos hijos, para habilitar la comunicación entre sus interfaces 'eth0', se deben crear un par de dispositivos de red, 'veth1' y 'veth2', y agregarlos al puente 'docker0'. Esto permite que el puente reenvíe y enrute incondicionalmente las solicitudes de red generadas por las interfaces 'eth0' dentro del contenedor, lo que habilita la comunicación entre los contenedores.

Por lo tanto, para que los contenedores que escribimos tengan capacidades de comunicación de red, primero necesitamos crear un puente que puedan utilizar. Por conveniencia, usaremos directamente el 'docker0' existente en el entorno.

Preparación para la creación de la red

Utilizar la API nativa de Linux para manipular la red es una tarea muy compleja, que también implica muchas operaciones en el lenguaje C. Para centrarnos más en el uso de C++ para codificar, aquí hay algunas "ruedas" que ya se han implementado para ti, lo que te permitirá manipular la red de manera más conveniente.

Ingresa al directorio /tmp y te hemos proporcionado cuatro archivos: network.h, nl.h, network.c y nl.c.

Copia estos cuatro archivos al directorio ~/project:

cp /tmp/network.h /tmp/nl.h /tmp/network.c /tmp/nl.c ~/project/

El código de los últimos tres archivos se toma del conjunto de herramientas LXC. Sin embargo, este código está escrito en el lenguaje C. Dado que C++ y C ya no son compatibles entre sí a partir de C++11, para que C++ pueda llamar a este código sin problemas, debemos tener algunos conocimientos sobre programación mixta de C/C++.

Primero, sabemos que transformar el código fuente en archivos ejecutables no se hace directamente, sino a través de varios pasos: preprocesamiento, compilación, ensamblaje y enlace. Por lo general, usamos el paso g++ main.cpp para completar todos los pasos anteriores de una vez.

Sin embargo, cuando el proyecto se vuelve más grande y aumenta el número de archivos fuente, no es rentable recompilar todo el proyecto solo por un cambio menor. En este punto, podemos compilar primero el código en archivos .o y luego realizar el trabajo de enlace. Esto también nos permite compilar un archivo enlazado compilado en el lenguaje C y el código fuente relacionado con C++ al mismo tiempo.

C++ y C tienen diferentes métodos de compilación y manejo, por lo que cuando queremos compilar un conjunto de código en el lenguaje C, necesitamos usar la macro __cplusplus y extern "C".

En network.h, se almacenan las declaraciones de interfaz relacionadas de network.c. Si comentamos las siguientes partes comentadas:

// #ifdef __cplusplus
// extern "C"
// {
// #endif
#include <sys/types.h>
int netdev_set_flag(const char *name, int flag);
……
void new_hwaddr(char *hwaddr);
// #ifdef __cplusplus
// }
// #endif

Usando gcc para compilarlo directamente en archivos .o:

gcc -c network.c nl.c

Y luego usando el siguiente código:

// test.cpp
#include "network.h"
int main() {
    new_hwaddr(nullptr);
    return 0;
}

Para compilarlo y probarlo:

g++ test.cpp network.o nl.o -std=c++11

Descubriremos que falla en la compilación y muestra un error undefined reference to 'new_hwaddr(char*)'.

/usr/bin/ld: /tmp/ccz4DEEy.o: in function `main':
test.cpp:(.text+0xe): undefined reference to `new_hwaddr(char*)'
collect2: error: ld returned 1 exit status

En otras palabras:

Cuando queremos compilar y enlazar bibliotecas de C en C++, necesitamos envolver la declaración relevante de la interfaz:

#ifdef __cplusplus
extern "C"
{
#endif
// C interface functions
#ifdef __cplusplus
}
#endif

En este momento, recompilamos network.c y nl.c en archivos .o nuevamente, y luego compilamos *.o con test.cpp para compilarlos con éxito.

Creación de la red del contenedor

Basándonos en la sección anterior sobre el principio de red de Docker, podemos resumir los siguientes pasos para habilitar que los contenedores que creamos admitan la red:

  1. Crear un par de dispositivos de red virtuales veth1/veth2;
  2. Establecer la dirección MAC de veth1;
  3. Agregar veth1 al puente labex0;
  4. Activar veth1;
  5. Crear un proceso hijo;
  6. Mover veth2 al espacio de nombres de red del proceso hijo y renombrarlo a eth0;
  7. Esperar a que el proceso hijo finalice;
  8. Eliminar los dispositivos de red veth1 y veth2;

Por lo tanto, debemos optimizar aún más la lógica de start().

Primero, debemos agregar configuración relacionada con la red a docker::container_config:

Incluir los archivos de encabezado:

#include <net/if.h>     // if_nametoindex
#include <arpa/inet.h>  // inet_pton
#include "network.h"

Agregar configuración docker::container_config:

// Configuración de inicio del contenedor Docker
typedef struct container_config {
    std::string host_name;      // Nombre del host
    std::string root_dir;       // Directorio raíz del contenedor
    std::string ip;             // IP del contenedor
    std::string bridge_name;    // Nombre del puente
    std::string bridge_ip;      // IP del puente
} container_config;

Luego, establecer la IP del contenedor, el nombre del puente a agregar docker0 y la IP del puente en main.cpp:

int main(int argc, char** argv) {
    std::cout << "...start container" << std::endl;
    docker::container_config config;
    config.host_name = "labex";
    config.root_dir  = "./labex";

    // Configurar parámetros de red
    config.ip        = "192.168.0.100"; // IP del contenedor
    config.bridge_name = "docker0";     // Puente del host
    config.bridge_ip   = "192.168.0.1"; // IP del puente del host

    docker::container container(config);
    container.start();
    std::cout << "stop container..." << std::endl;
    return 0;
}

Refactoricemos el método start() basado en la lógica de carga de dispositivos de red anterior:

private:
    // Guardar dispositivos de red del contenedor para su eliminación
    char *veth1;
    char *veth2;
public:
void start() {
    char veth1buf[IFNAMSIZ] = "labex0X";
    char veth2buf[IFNAMSIZ] = "labex0X";
    // Crear un par de dispositivos de red, uno para cargar en el host y el otro para mover al contenedor en el proceso hijo
    veth1 = lxc_mkifname(veth1buf); // La API lxc_mkifname requiere al menos una "X" en el nombre del dispositivo de red virtual para soportar la creación aleatoria de dispositivos de red virtuales
    veth2 = lxc_mkifname(veth2buf); // Esto es para garantizar la correcta creación de dispositivos de red. Ver la implementación de lxc_mkifname en network.c para más detalles
    lxc_veth_create(veth1, veth2);

    // Establecer la dirección MAC de veth1
    setup_private_host_hw_addr(veth1);

    // Agregar veth1 al puente
    lxc_bridge_attach(config.bridge_name.c_str(), veth1);

    // Activar veth1
    lxc_netdev_up(veth1);

    // Algunas tareas de configuración antes de la creación del contenedor
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);
        _this->set_hostname();
        _this->set_rootdir();
        _this->set_procsys();

        // Configurar la red dentro del contenedor
        //...

        _this->start_bash();
        return proc_wait;
    };

    // Crear el contenedor usando clone
    process_pid child_pid = clone(setup, child_stack,
                      CLONE_NEWUTS| // Espacio de nombres UTS
                      CLONE_NEWNS|  // Espacio de nombres de montaje
                      CLONE_NEWPID| // Espacio de nombres PID
                      CLONE_NEWNET| // Espacio de nombres de red
                      SIGCHLD,      // El proceso hijo enviará una señal al proceso padre cuando salga
                      this);

    // Mover veth2 al contenedor y renombrarlo como eth0
    lxc_netdev_move_by_name(veth2, child_pid, "eth0");

    waitpid(child_pid, nullptr, 0); // Esperar a que el proceso hijo salga
}
~container() {
    // Recuerda eliminar los dispositivos de red virtuales creados al salir
    lxc_netdev_delete_by_name(veth1);
    lxc_netdev_delete_by_name(veth2);
}

Nota: Agregar CLONE_NEWNET en clone.

De los pasos anteriores, podemos ver que después de crear los dispositivos de red y durante la creación del proceso hijo, necesitamos realizar configuraciones relacionadas dentro del contenedor en cooperación con los dispositivos de red externos:

  1. Activar el dispositivo lo dentro del contenedor;
  2. Configurar la dirección IP de eth0;
  3. Activar eth0;
  4. Establecer la puerta de enlace;
  5. Establecer la dirección MAC de eth0;
private:
void set_network() {

    int ifindex = if_nametoindex("eth0");
    struct in_addr ipv4;
    struct in_addr bcast;
    struct in_addr gateway;

    // Función de transformación de direcciones IP que convierte direcciones IP entre decimal punteado y binario
    inet_pton(AF_INET, this->config.ip.c_str(), &ipv4);
    inet_pton(AF_INET, "255.255.255.0", &bcast);
    inet_pton(AF_INET, this->config.bridge_ip.c_str(), &gateway);

    // Configurar la dirección IP de eth0
    lxc_ipv4_addr_add(ifindex, &ipv4, &bcast, 16);

    // Activar lo
    lxc_netdev_up("lo");

    // Activar eth0
    lxc_netdev_up("eth0");

    // Establecer la puerta de enlace
    lxc_ipv4_gateway_add(ifindex, &gateway);

    // Establecer la dirección MAC de eth0
    char mac[18];
    new_hwaddr(mac);
    setup_hw_addr(mac, "eth0");
}

Luego, llamar a este método en el setup del contenedor:

……
_this->set_procsys();
_this->set_network();   // Cooperación para la configuración de red dentro del contenedor
_this->start_bash();
return proc_wait;

En este punto, dado que hemos comenzado a usar los archivos enlazados compilados network.o y nl.o, escribamos un Makefile muy simple:

C = gcc
CXX = g++
C_LIB = network.c nl.c
C_LINK = network.o nl.o
MAIN = main.cpp
LD = -std=c++11
OUT = docker-run

all:
    make container
container:
    $(C) -c $(C_LIB)
    $(CXX) $(LD) -o $(OUT) $(MAIN) $(C_LINK)
clean:
    rm *.o $(OUT)

Nota: El comando en el Makefile debe comenzar con una Tabulación en lugar de espacios. Esto se debe a que el intérprete de Markdown convierte una Tabulación en cuatro espacios. Al escribir un Makefile, asegúrate de usar una Tabulación en lugar de cuatro espacios. De lo contrario, el Makefile mostrará un error "Makefile:10: *** missing separator. Stop."

Compilar y ejecutar de nuevo, y entrar al contenedor. Podemos usar ifconfig para verificar la red:

labex:project/ $ make
make container
make[1]: Entering directory '/home/labex/project'
gcc -c network.c nl.c
g++ -std=c++11 -o docker-run main.cpp network.o nl.o
make[1]: Leaving directory '/home/labex/project'
labex:project/ $ sudo./docker-run
...start container
root@labex:/## ifconfig
eth0      Link encap:Ethernet  HWaddr 00:16:3e:da:01:72
          inet6 addr: fe80::dc15:18ff:fe43:53b9/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:38 errors:0 dropped:0 overruns:0 frame:0
          TX packets:9 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:5744 (5.7 KB)  TX bytes:726 (726.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)
✨ Revisar Solución y Practicar

Resumen

A través de este proyecto, hemos logrado gradualmente lo siguiente: incorporar un sistema de archivos a un contenedor y habilitar el acceso a redes externas.

Hemos creado con éxito un contenedor Docker básico. Puedes optimizar aún más este contenedor para lograr una emulación más realista.