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:
- Sistema de arquivos independente
- 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:
- Um segmento de código a ser executado
- Um espaço de pilha privado para o processo
- Um bloco de controle de processo (PCB)
- 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.

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
/tmpe fornecemos quatro arquivos:network.h,nl.h,network.cenl.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:
- Criar um par de dispositivos de rede virtual veth1/veth2;
- Definir o endereço MAC de veth1;
- Adicionar veth1 à ponte labex0;
- Ativar veth1;
- Criar um processo filho;
- Mover veth2 para o namespace de rede do processo filho e renomeá-lo para eth0;
- Aguardar a conclusão do processo filho;
- 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_NEWNETemclone.
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:
- Ativar o dispositivo
lodentro do contêiner; - Configurar o endereço IP de
eth0; - Ativar
eth0; - Definir o gateway;
- 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.



