Criando um Contêiner Docker Simples em C++

C++Beginner
Pratique Agora

Introdução

A essência do Docker é usar o LXC para alcançar uma funcionalidade semelhante à de uma máquina virtual, economizando assim recursos de hardware e fornecendo aos usuários mais recursos computacionais. Este projeto combina C++ com as tecnologias Namespace e Control Group do Linux para implementar um contêiner Docker simples.

Finalmente, alcançaremos as seguintes funcionalidades para o contêiner:

  1. Sistema de arquivos independente
  2. Suporte para acesso à rede

👀 Visualização

$ 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)

🎯 Tarefas

Neste projeto, você aprenderá:

  • Como criar um contêiner Docker simples usando C++ e a tecnologia Namespace do Linux
  • Como implementar um sistema de arquivos independente para o contêiner
  • Como habilitar o acesso à rede para o contêiner

🏆 Conquistas

Após concluir este projeto, você será capaz de:

  • Criar um contêiner Docker simples usando C++ e a tecnologia Namespace do Linux
  • Implementar um sistema de arquivos independente para o contêiner
  • Habilitar o acesso à rede para o contêiner

Tecnologia Linux Namespace

Em C++, estamos familiarizados com a palavra-chave namespace. Em C++, cada namespace isola os mesmos nomes em diferentes códigos, de modo que, desde que os nomes dos namespaces sejam diferentes, os nomes do código nos namespaces podem ser os mesmos, resolvendo assim o problema de conflitos de nomes no código.

O Linux Namespace, por outro lado, é uma tecnologia fornecida pelo kernel do Linux que oferece uma solução para isolamento de recursos para aplicações, semelhante ao conceito de namespace em C++. Sabemos que recursos como PID, IPC e rede devem ser gerenciados pelo próprio sistema operacional, mas o Linux Namespace pode tornar esses recursos não mais globais e atribuí-los a namespaces específicos.

No mundo da tecnologia Docker, frequentemente ouvimos termos como LXC e virtualização em nível de sistema operacional, e o LXC utiliza a tecnologia Namespace para alcançar o isolamento de recursos entre diferentes contêineres. Ao utilizar a tecnologia Namespace, os processos dentro de diferentes contêineres pertencem a namespaces diferentes e não interferem uns com os outros. Em resumo, a tecnologia Namespace fornece uma forma leve de virtualização que nos permite operar propriedades em todo o sistema a partir de diferentes perspectivas.

No Linux, a chamada de sistema mais importante relacionada ao Namespace é clone(). O propósito de clone() é restringir threads a um namespace específico ao criar processos.

Encapsulamento de Chamadas de Sistema

Como as chamadas de sistema do Linux são escritas em C, precisamos escrever código C++ para o nosso projeto. Para manter um estilo de codificação consistente que seja puramente em C++, primeiro encapsularemos essas APIs necessárias em uma forma C++, o que também nos permitirá ter uma compreensão mais profunda de como essas APIs são usadas.

Usaremos as seguintes APIs:

clone()

Tanto as chamadas de sistema clone quanto fork são usadas para criar processos no Linux. No entanto, fork é apenas uma pequena parte de clone. A diferença entre elas reside no fato de que fork apenas cria um processo filho que é uma cópia exata do processo pai, enquanto clone é mais poderoso, pois permite a cópia seletiva dos recursos do processo pai para o processo filho. Os recursos que não são copiados são compartilhados entre os processos por meio da cópia de ponteiros (arg). Os recursos específicos a serem copiados podem ser especificados usando flags, e a função retorna o PID do processo filho.

Sabemos que um processo consiste em quatro elementos principais:

  1. Um segmento de código a ser executado
  2. Um espaço de pilha privado para o processo
  3. Um bloco de controle de processo (PCB)
  4. Namespaces específicos do processo

Os dois primeiros elementos correspondem aos parâmetros fn e child_stack em clone. O bloco de controle de processo é controlado pelo kernel e não precisamos nos preocupar com ele. Portanto, os namespaces estão associados ao parâmetro flags. Para atingir nosso objetivo de criar um contêiner Docker, os principais parâmetros de que precisamos são os seguintes:

Classificação do Namespace Parâmetro da Chamada de Sistema


    UTS         CLONE_NEWUTS
   Mount        CLONE_NEWNS
    PID         CLONE_NEWPID
  Network       CLONE_NEWNET

Pelos nomes, pode-se ver que CLONE_NEWNS fornece montagem relacionada ao sistema de arquivos para cópia e recursos relacionados ao sistema de arquivos, CLONE_NEWUTS fornece a capacidade de definir o nome do host, CLONE_NEWPID fornece suporte a espaço de processo independente e CLONE_NEWNET fornece suporte relacionado à rede.

execv()

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

execv executa o arquivo executável especificado por path. Essa chamada de sistema permite que nosso processo filho execute /bin/bash para manter o contêiner em execução.

sethostname()

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

Como o nome sugere, essa chamada de sistema é usada para definir o nome do host. Vale a pena mencionar que, como as strings no estilo C usam ponteiros e o comprimento da string não pode ser determinado diretamente de dentro, o parâmetro len é usado para obter o comprimento da string.

chdir()

int chdir(const char *path);

Sabemos que qualquer programa é executado em um diretório específico. Quando precisamos acessar recursos, podemos usar caminhos relativos em vez de caminhos absolutos para acessar os recursos relevantes. chdir nos fornece a conveniência de alterar o diretório de trabalho do nosso programa, que pode ser usado para certos propósitos não divulgados.

chroot()

Essa chamada de sistema é usada para alterar o diretório raiz:

int chroot(const char *path);

mount()

Essa chamada de sistema é usada para montar sistemas de arquivos, semelhante ao comando mount.

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

Criando Subprocessos de Container

Entre no diretório ~/project e crie um arquivo chamado docker.hpp. Neste arquivo, primeiro criaremos um namespace chamado docker que pode ser chamado pelo nosso código externo.

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

// Arquivos de cabeçalho para chamadas de 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 Padrão C
#include <cstring>

// Biblioteca Padrão C++
#include <string>       // std::string

#define STACK_SIZE (512 * 512) // Define o tamanho do espaço do processo filho

namespace docker {
    // .. onde a mágica do docker começa
}

Vamos começar definindo algumas variáveis para melhorar a legibilidade:

// Definido dentro do namespace `docker`
typedef int proc_status;
proc_status proc_err  = -1;
proc_status proc_exit = 0;
proc_status proc_wait = 1;

Antes de definir a classe container, vamos analisar os parâmetros necessários para criar um contêiner. Não consideraremos a configuração relacionada à rede por enquanto. Para criar um contêiner Docker a partir de uma imagem, só precisamos especificar o nome do host e a localização da imagem. Portanto:

// Configuração de inicialização do contêiner Docker
typedef struct container_config {
    std::string host_name;      // Nome do host
    std::string root_dir;       // Diretório raiz do contêiner
} container_config;

Agora, vamos definir a classe container e fazer com que ela execute a configuração necessária para o contêiner no construtor:

class container {
private:
    // Melhora a legibilidade
    typedef int process_pid;

    // Pilha do processo filho
    char child_stack[STACK_SIZE];

    // Configuração do contêiner
    container_config config;
public:
    container(container_config &config) {
        this->config = config;
    }
};

Antes de pensar nos métodos específicos na classe container, vamos primeiro pensar em como usaríamos essa classe container. Para isso, vamos criar um arquivo main.cpp na pasta ~/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 o contêiner
    // ...

    docker::container container(config);// Constrói o contêiner com base na configuração
    container.start();                  // Inicia o contêiner
    std::cout << "stop container..." << std::endl;
    return 0;
}

Em main.cpp, para tornar a inicialização do contêiner concisa e fácil de entender, vamos supor que o contêiner é iniciado usando um método start(). Isso fornece uma base para escrever o arquivo docker.hpp posteriormente.

Agora, vamos voltar para docker.hpp e implementar o método start():

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

        // Executar as configurações relevantes para o contêiner
        // ...

        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack+STACK_SIZE, // Move para o final da pilha
                        SIGCHLD,      // Envia um sinal para o processo pai quando o processo filho sai
                        this);
    waitpid(child_pid, nullptr, 0); // Aguarda o processo filho sair
}

O método docker::container::start() usa a chamada de sistema clone() no Linux. Para passar a instância do objeto docker::container para a função de callback setup, podemos passá-la usando o quarto argumento de clone(). Aqui, passamos o ponteiro this.

Quanto à função setup, criamos uma expressão lambda para ela. Em C++, uma expressão lambda com uma lista de captura vazia pode ser passada como um ponteiro de função. Portanto, setup se torna a função de callback passada para clone().

Você também pode usar uma função membro estática definida na classe em vez de uma expressão lambda, mas isso tornaria o código menos elegante.

No construtor desta classe container, definimos uma função de tratamento de processo filho para ser chamada pela chamada de sistema clone(). Usamos typedef para alterar o tipo de retorno desta função para proc_status. Quando esta função retorna proc_wait, o processo filho clonado por clone() esperará para sair.

No entanto, isso não é suficiente porque não executamos nenhuma configuração dentro do processo. Como resultado, nosso programa sairá imediatamente, pois não há mais nada a fazer assim que o processo for iniciado. Como sabemos, no Docker, para manter um contêiner em execução, podemos usar:

docker run -it ubuntu:14.04 /bin/bash

Isso vincula STDIN ao /bin/bash do contêiner. Então, vamos adicionar um método start_bash() à classe docker::container:

private:
void start_bash() {
    // Converte com segurança std::string C++ para string no estilo C char *
    // A partir do C++14, esta atribuição direta é proibida: `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);           // Executa /bin/bash no processo filho
    delete []c_bash;
}

E chame-o dentro de setup:

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

Agora, podemos ver as seguintes ações:

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

Nas etapas acima, primeiro verificamos o hostname atual, compilamos o código que escrevemos até agora, executamos e entramos em nosso contêiner. Podemos ver que, depois de entrar no contêiner, o prompt do bash muda, que é o que esperávamos.

No entanto, é fácil notar que este não é o resultado que queremos, pois é exatamente o mesmo que nosso sistema host. Quaisquer operações realizadas dentro deste "contêiner" afetarão diretamente o sistema host.

É aqui que introduzimos os namespaces necessários na API clone.

Habilitando o Container a Ter Seu Próprio Hostname

Como mencionado anteriormente na seção sobre chamadas de sistema, é bastante simples definir o nome do host de um processo filho usando uma chamada de sistema. Portanto, criamos um método privado para a classe docker::container:

private:
// Define o nome do host do contêiner
void set_hostname() {
    sethostname(this->config.host_name.c_str(), this->config.host_name.length());
}

Também fazemos alterações no método start() da seguinte forma:

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

        // Configura o contêiner
        _this->set_hostname();
        _this->start_bash();

        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack+STACK_SIZE,
                        CLONE_NEWUTS| // Adiciona o namespace UTS
                        SIGCHLD,      // Envia sinal para o pai quando o processo filho sai
                        this);
    waitpid(child_pid, nullptr, 0); // Aguarda o processo filho sair
}

No arquivo main.cpp, configuramos o nome do host:

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

Agora, vamos recompilar o código:

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

Observa-se que nosso contêiner sai imediatamente. Isso ocorre porque, uma vez que introduzimos o namespace, nosso programa requer privilégios de superusuário. Portanto, precisamos executar o programa com 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

No entanto, isso ainda não atinge o efeito desejado do contêiner porque, como podemos ver pelo comando ls, ainda podemos acessar o diretório da máquina host.

Habilitando o Container com Seu Próprio Sistema de Arquivos

Na tecnologia Docker, os contêineres são criados com base em imagens. Como queremos implementar um contêiner, é natural que precisemos criá-lo com base em uma imagem. Felizmente, preparamos uma imagem Docker para você. Você pode obtê-la baixando-a de:

cd ~/project
wget --header="User-Agent: Mozilla/5.0" https://file.labex.io/lab/171925/docker-image.tar

Em seguida, extraia-a para a pasta ~/project/labex:

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

Aqui, você pode encontrar alguns erros de extração. Isso ocorre porque, no ambiente, alguns arquivos estão proibidos de serem criados externamente. Isso não afeta nossa implementação de nosso próprio contêiner, então apenas ignore-o.

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

Após a conclusão da extração, poderemos ver um diretório Linux quase completo em labex:

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

Agora, queremos que docker::container entre neste diretório e o use como o diretório raiz, mascarando o acesso externo do subprocesso ao iniciar:

private:
// Define o diretório raiz
void set_rootdir() {

    // Chamada de sistema chdir, muda para um determinado diretório
    chdir(this->config.root_dir.c_str());

    // Chamada de sistema chroot, define o diretório raiz, como já
    // mudamos para o diretório atual anteriormente
    // podemos simplesmente usar o diretório atual como o diretório raiz
    chroot(".");
}

Em seguida, preencha a configuração relevante em 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";
    ……

E habilite CLONE_NEWNS na chamada clone() para ativar o 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| // Namespace UTS
                      CLONE_NEWNS|  // Namespace Mount
                      SIGCHLD,      // Sinal é enviado para o processo pai quando o processo filho sai
                      this);
    waitpid(child_pid, nullptr, 0); // Aguarda o processo filho sair
}

Agora, vamos recompilar:

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

Ao executar ls, podemos ver que o processo filho agora está vivendo em um diretório linux completo.

Habilitando o Container a Ter Seu Próprio Sistema de Processos

No entanto, ainda há um problema. Se usarmos comandos como ps ou top, ainda podemos observar todos os processos no processo pai. Este não é o efeito desejado. Por exemplo, podemos ver a.out na saída de ps, e o valor do ID do processo também é muito grande.

Para resolver este problema, precisamos introduzir o PID Namespace para isolar o espaço PID dos processos filhos do processo pai.

private:
// Configura um namespace de processo independente
void set_procsys() {
    // Monta o sistema de arquivos proc
    mount("none", "/proc", "proc", 0, nullptr);
    mount("none", "/sys", "sysfs", 0, nullptr);
}

Da mesma forma, ainda precisamos adicionar esta parte do código em start(), introduzindo 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| // Namespace UTS
                      CLONE_NEWNS|  // Namespace Mount
                      CLONE_NEWPID| // Namespace PID
                      SIGCHLD,      // Sinal é enviado para o processo pai quando o processo filho sai
                      this);
    waitpid(child_pid, nullptr, 0); // Aguarda o processo filho sair
}

Agora, quando compilamos e executamos novamente, veremos que o contêiner tem seu próprio espaço de processo independente:

Neste ponto, usamos a tecnologia Namespace no Linux para isolar os recursos nos processos filhos e dar ao nosso contêiner Docker seu próprio espaço de processo e sistema de arquivos.

No entanto, o contêiner ainda não pode acessar a rede, e podemos até acessar os dispositivos de rede da máquina host usando ifconfig. Isso não é o que queremos. Em seguida, aprimoraremos ainda mais o contêiner para torná-lo mais parecido com um contêiner completo, fornecendo suporte para acesso à rede.

Princípios de Rede Docker

Anteriormente, tivemos uma compreensão preliminar de como o Docker implementa um contêiner fechado. No entanto, também descobrimos que o contêiner Docker que implementamos não suporta acesso à rede e não é possível que os diferentes contêineres que executamos tenham a capacidade de se comunicar entre si.

Diagrama da ponte de rede do contêiner Docker

O princípio da comunicação de rede entre contêineres Docker é alcançado através de uma ponte chamada 'docker0'. Os dois contêineres, 'container1' e 'container2', cada um tem seu próprio dispositivo de rede, 'eth0'. Todas as solicitações de rede serão encaminhadas através de 'eth0'. Como os contêineres vivem em processos filhos, para habilitar a comunicação entre suas interfaces 'eth0', um par de dispositivos de rede, 'veth1' e 'veth2', precisa ser criado e adicionado à ponte 'docker0'. Isso permite que a ponte encaminhe e roteie incondicionalmente as solicitações de rede geradas pelas interfaces 'eth0' dentro do contêiner, permitindo a comunicação entre os contêineres.

Portanto, para que os contêineres que escrevemos tenham capacidades de comunicação de rede, primeiro precisamos criar uma ponte que eles possam usar. Para conveniência, usaremos diretamente o 'docker0' existente no ambiente.

Preparação para a Criação da Rede

Usar a API nativa do Linux para manipular a rede é uma tarefa muito complexa, que também envolve muitas operações em linguagem C. Para focar mais no uso de C++ para codificação, aqui estão algumas "rodas" que já foram implementadas para você, o que tornará mais conveniente para você manipular a rede.

Entre no diretório /tmp e fornecemos quatro arquivos: network.h, nl.h, network.c e nl.c.

Copie esses quatro arquivos para o diretório ~/project:

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

O código dos três últimos arquivos é retirado do conjunto de ferramentas LXC. No entanto, este código é escrito em linguagem C. Como C++ e C não são mais compatíveis entre si a partir de C++11, para que C++ possa chamar este código sem problemas, devemos ter algum conhecimento sobre programação mista C/C++.

Primeiro, sabemos que transformar o código-fonte em arquivos executáveis não é feito diretamente, mas por meio de várias etapas: pré-processamento, compilação, montagem e vinculação. Normalmente, usamos a etapa g++ main.cpp para concluir todas as etapas acima de uma vez.

No entanto, quando o projeto se torna maior e o número de arquivos-fonte aumenta, não é econômico recompilar todo o projeto apenas por uma pequena alteração. Neste ponto, podemos primeiro compilar o código em arquivos .o e, em seguida, prosseguir com o trabalho de vinculação. Isso também torna possível compilar um arquivo vinculado compilado em linguagem C e código-fonte relacionado ao C++ ao mesmo tempo.

C++ e C têm diferentes métodos de compilação e tratamento, então, quando queremos compilar um conjunto de código em linguagem C, precisamos usar a macro __cplusplus e extern "C".

Em network.h, as declarações de interface relacionadas de network.c são armazenadas. Se comentarmos as partes comentadas a seguir:

// #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 compilá-lo diretamente em arquivos .o:

gcc -c network.c nl.c

E então usando o seguinte código:

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

Para compilar e testá-lo:

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

Descobriremos que ele falha na compilação e solicita um erro 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

Em outras palavras:

Quando queremos compilar e vincular bibliotecas C em C++, precisamos encapsular a declaração relevante da interface:

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

Neste momento, recompilamos network.c e nl.c em arquivos .o novamente e, em seguida, compilamos *.o com test.cpp para compilá-los com sucesso.

Criando uma Rede de Contêineres

Com base na seção anterior sobre o princípio de rede do Docker, podemos resumir as seguintes etapas para permitir que os contêineres que criamos suportem a rede:

  1. Criar um par de dispositivos de rede virtual veth1/veth2;
  2. Definir o endereço MAC de veth1;
  3. Adicionar veth1 à ponte labex0;
  4. Ativar veth1;
  5. Criar um processo filho;
  6. Mover veth2 para o namespace de rede do processo filho e renomeá-lo para eth0;
  7. Aguardar a conclusão do processo filho;
  8. Excluir os dispositivos de rede veth1 e veth2;

Então, devemos otimizar ainda mais a lógica start().

Primeiro, devemos adicionar a configuração relacionada à rede a docker::container_config:

Inclua os arquivos de cabeçalho:

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

Adicione a configuração docker::container_config:

// Configuração de inicialização do contêiner Docker
typedef struct container_config {
    std::string host_name;      // Nome do host
    std::string root_dir;       // Diretório raiz do contêiner
    std::string ip;             // IP do contêiner
    std::string bridge_name;    // Nome da ponte
    std::string bridge_ip;      // IP da ponte
} container_config;

Em seguida, defina o IP do contêiner, o nome da ponte a ser adicionada docker0 e o IP da ponte em 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 rede
    config.ip        = "192.168.0.100"; // IP do contêiner
    config.bridge_name = "docker0";     // Ponte do host
    config.bridge_ip   = "192.168.0.1"; // IP da ponte do host

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

Vamos refatorar o método start() com base na lógica de carregamento dos dispositivos de rede acima:

private:
    // Salvar dispositivos de rede do contêiner para exclusão
    char *veth1;
    char *veth2;
public:
void start() {
    char veth1buf[IFNAMSIZ] = "labex0X";
    char veth2buf[IFNAMSIZ] = "labex0X";
    // Criar um par de dispositivos de rede, um para ser carregado no host e o outro para ser movido para o contêiner no processo filho
    veth1 = lxc_mkifname(veth1buf); // A API lxc_mkifname requer pelo menos um "X" para ser adicionado ao nome do dispositivo de rede virtual para suportar a criação aleatória de dispositivos de rede virtual
    veth2 = lxc_mkifname(veth2buf); // Isso é para garantir a criação correta dos dispositivos de rede. Consulte a implementação de lxc_mkifname em network.c para obter detalhes
    lxc_veth_create(veth1, veth2);

    // Definir o endereço MAC de veth1
    setup_private_host_hw_addr(veth1);

    // Adicionar veth1 à ponte
    lxc_bridge_attach(config.bridge_name.c_str(), veth1);

    // Ativar veth1
    lxc_netdev_up(veth1);

    // Algum trabalho de configuração antes da criação do contêiner
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);
        _this->set_hostname();
        _this->set_rootdir();
        _this->set_procsys();

        // Configurar a rede dentro do contêiner
        // ...

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

    // Criar o contêiner usando clone
    process_pid child_pid = clone(setup, child_stack,
                      CLONE_NEWUTS| // UTS   namespace
                      CLONE_NEWNS|  // Mount namespace
                      CLONE_NEWPID| // PID   namespace
                      CLONE_NEWNET| // Net   namespace
                      SIGCHLD,      // O processo filho enviará um sinal para o processo pai quando sair
                      this);

    // Mover veth2 para o contêiner e renomeá-lo como eth0
    lxc_netdev_move_by_name(veth2, child_pid, "eth0");

    waitpid(child_pid, nullptr, 0); // Aguardar a saída do processo filho
}
~container() {
    // Lembre-se de excluir os dispositivos de rede virtual criados ao sair
    lxc_netdev_delete_by_name(veth1);
    lxc_netdev_delete_by_name(veth2);
}

Nota: Adicione CLONE_NEWNET em clone.

Das etapas acima, podemos ver que, após criar os dispositivos de rede e durante a criação do processo filho, precisamos realizar configurações relacionadas dentro do contêiner em cooperação com os dispositivos de rede externos:

  1. Ativar o dispositivo lo dentro do contêiner;
  2. Configurar o endereço IP de eth0;
  3. Ativar eth0;
  4. Definir o gateway;
  5. Definir o endereço MAC de eth0;
private:
void set_network() {

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

    // Função de transformação de endereço IP que converte endereços IP entre decimal pontilhado e binário
    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 o endereço IP de eth0
    lxc_ipv4_addr_add(ifindex, &ipv4, &bcast, 16);

    // Ativar lo
    lxc_netdev_up("lo");

    // Ativar eth0
    lxc_netdev_up("eth0");

    // Definir o gateway
    lxc_ipv4_gateway_add(ifindex, &gateway);

    // Definir o endereço MAC de eth0
    char mac[18];
    new_hwaddr(mac);
    setup_hw_addr(mac, "eth0");
}

Em seguida, chame este método no setup do contêiner:

……
_this->set_procsys();
_this->set_network();   // Cooperação para configuração de rede dentro do contêiner
_this->start_bash();
return proc_wait;

Neste ponto, como começamos a usar os arquivos de vinculação compilados network.o e nl.o, vamos escrever um Makefile muito simples:

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: O comando no Makefile deve começar com uma Tab em vez de espaços. Isso é causado pelo fato de que o interpretador Markdown converte uma Tab em quatro espaços. Ao escrever um Makefile, certifique-se de usar uma Tab em vez de quatro espaços. Caso contrário, o Makefile solicitará um erro "Makefile:10: *** missing separator. Stop."

Compile e execute-o novamente e entre no contêiner. Podemos usar ifconfig para verificar a rede:

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)

Resumo

Através deste projeto, alcançamos gradualmente o seguinte: incorporar um sistema de arquivos em um contêiner e habilitar o acesso a redes externas.

Criamos com sucesso um contêiner Docker básico. Você pode otimizar ainda mais este contêiner para obter uma emulação mais realista.

✨ Verificar Solução e Praticar✨ Verificar Solução e Praticar✨ Verificar Solução e Praticar✨ Verificar Solução e Praticar✨ Verificar Solução e Praticar✨ Verificar Solução e Praticar✨ Verificar Solução e Praticar✨ Verificar Solução e Praticar✨ Verificar Solução e Praticar