Docker: Criando um servidor de imagens privadas

Saudações. Instruções de como instalar o Docker e criar um servidor de hospedagem de imagens prontas para uso privado e autenticado.

Pré-requisitos (consta em outros artigos aqui do blog):

  • Instalação do Linux (Debian, Alpine) e programas básicos;
  • Agente do hypervisor (Q-Emu/KVM ou VMware);
  • Data/hora via NTP;
  • Ajuste fino no kernel Linux;
  • Nome de DNS (FQDN) configurado para uso no LetsEncrypt;
  • Instalar o Docker ou Podman;

1 – Motivos para criar um repositório Docker privado

Algumas vantagens de usar seu próprio repositório de imagens OCI (containers, Docker, Podman, Kubernets) é:

  • Segurança – Imagens públicas podem ser atualizadas para corrigir falhas, mas tambem podem sofrer adição de bugs e problemas mais graves (exploit, vírus, spyware, backdoor, …);
  • Isolamento – Algumas empresas tem políticas ultra-rígidas quanto ao uso de imagens de containers, restringindo a apenas imagens construídas localmente;
  • Agilidade – ao trabalhar com imagens muito grandes (1G+), manter cópias dentro da empresa ajuda no deploy e testes de alta velocidade;
  • Privacidade e propriedade intelectual – Ter imagens privadas com conteúdo sensível restrito aos operadores da empresa, impedindo que os funcionários e operadores da nuvem pública possam analisar, vazar e explorar essas imagens;

Por último, você pode criar seu repositório privado e usá-lo como proxy para repositórios externos. Essa é uma forma de impedir sua empresa de software de usar diretamente repositórios externos e auditar quem e quando usou imagens públicas externas.

2 – Preparando ambiente

Esse bloco de código abaixo (ShellScript) instala o ambiente mínimo. Caso você já tenha instalado tudo até o Docker, ignore-a. Preparativos no Debian, instalando programas e ajustes fundamentais:

Bash
# Atualizar:
apt -y update; apt -y upgrade; apt -y dist-upgrade; apt -y autoremove;

# Agente do hypervisor:
hostnamectl | grep -qi vmware && A=open-vm-tools;
hostnamectl | grep -qi kvm && A=qemu-guest-agent;
apt-get -y install $A; systemctl enable $A; systemctl start $A;

# Ferramentas recomendadas:
apt-get -y install mc uuid uuid-runtime;
apt-get -y install iproute2 bridge-utils iputils-ping fping;
apt-get -y install tcpdump strace htop psmisc iotop;
apt-get -y install tar zstd xz-utils zip;
apt-get -y install gnupg2 openssl curl wget ca-certificates jq;
apt-get -y install openssh-client openssh-server rsync;
apt-get -y install nftables conntrack;
apt-get -y install apache2-utils;

# Data/hora sincronizada no NTP
timedatectl set-timezone America/Sao_Paulo;
apt-get -y install systemd-timesyncd;
(   echo '[Time]';
    echo 'NTP=200.160.0.8 200.189.40.8 2001:12ff::8 2001:12f8:9:1::8'
    echo 'FallbackNTP=200.20.186.75 200.20.186.94 200.20.224.100 200.20.224.101';
    echo 'RootDistanceMaxSec=5'; echo 'PollIntervalMinSec=32';
    echo 'PollIntervalMaxSec=2048'; echo 'ConnectionRetrySec=30';
    echo 'SaveIntervalSec=60';
) >  /etc/systemd/timesyncd.conf;
systemctl restart systemd-timesyncd;

# Prompt de shell personalizado para diferenciar servidor!
export PS1='\[\033[0;99m\][\[\033[0;96m\]\u\[\033[0;99m\]@\[\033[0;93m\]\h\[\033[0;99m\]] \[\033[1;38m\]\w\[\033[0;99m\] \$\[\033[0m\] ';
echo "export PS1='$PS1';" > /etc/profile.d/ps1.sh;

Fazer tuning do Kernel e instalando Docker:

Bash
# Tuning de Sysctl
wget https://tmsoft.com.br/temp/sysctl-tuning.sh -O /tmp/sysctl.sh;
sh /tmp/sysctl.sh;

# Baixar script instalador oficial:
curl -fsSL get.docker.com -o /tmp/get-docker.sh;
sh /tmp/get-docker.sh;

Ambiente Docker mínimo para container com acesso HTTPs (LetsEncrypt+Traefik):

Bash
# Configure seu email para que o LetsEncrypt aceite
# gerar seus certificados:
EMAIL=seu-email-aqui@dominio-aqui.com.br;

# Criar rede de containers
    # Rede de containers somente ipv4
    docker network create -d bridge \
        -o "com.docker.network.bridge.name"="br-net-public" \
        --subnet 10.249.0.0/16 --gateway 10.249.255.254 \
        network_public;

# Traefik como proxy-reverso automatizado:
    # Diretorio de dados persistentes
    mkdir -p /storage/traefik-app/letsencrypt;
    mkdir -p /storage/traefik-app/logs;
    mkdir -p /storage/traefik-app/config;

    # Renovar execucao (remove, atualiza, reinstala, nao perde dados)
    docker rm -f traefik-app 2>/dev/null;
    docker pull traefik:latest;
    docker run -d --restart=unless-stopped \
      --name traefik-app -h traefik-app.intranet.br \
      --memory=1g --memory-swap=1g -p 80:80 -p 443:443 -p 8080:8080 \
      --network network_public --ip=10.249.255.253 \
      \
      -v /var/run/docker.sock:/var/run/docker.sock:ro \
      -v /storage/traefik-app/letsencrypt:/etc/letsencrypt \
      -v /storage/traefik-app/config:/etc/traefik \
      -v /storage/traefik-app/logs:/logs \
      \
      traefik:latest \
      --global.checkNewVersion=false \
      --global.sendAnonymousUsage=false \
      --api.insecure=true \
      --log.level=INFO \
      --log.filePath=/logs/error.log \
      --accessLog.filePath=/logs/access.log \
      --entrypoints.web.address=:80 \
      --entrypoints.web.http.redirections.entryPoint.to=websecure \
      --entrypoints.web.http.redirections.entryPoint.scheme=https \
      --entrypoints.web.http.redirections.entryPoint.permanent=true \
      --entrypoints.websecure.address=:443 \
      --providers.docker=true \
      --providers.file.directory=/etc/traefik \
      --certificatesresolvers.letsencrypt.acme.email=$EMAIL \
      --certificatesresolvers.letsencrypt.acme.storage=/etc/letsencrypt/acme.json \
      --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web;

Agora temos um ambiente Docker para rodar o container de registro (register:2) para armazenar e fornecer nossas imagens de containers.

3 – Criando container de gestão de imagens

O “register” (nome escolhido para uso local) ou “registry” (nome oficial da imagem) é um container que gerencia suas imagens e provê um servidor HTTP com a API de gestão dessas imagens.

Diretório para armazenar nosso registro de imagens no HOST:

Bash
# Diretorios no HOST:
mkdir -p /storage/register/auth;
mkdir -p /storage/register/certs;
mkdir -p /storage/register/config;
mkdir -p /storage/register/data;

Criando usuários autorizados a usar nosso registro usando controle do Traefik:

Bash
# Instalar apache2-utils para geração de logins HTTP-AUTH:
apt-get -y install apache2-utils;

# Garantir a existencia do arquivo com base de usuários:
touch /storage/traefik-app/config/register.users;

# Criar usuario "admin" com poderes completos (push/pull/delete):
# - login: admin
# - senha: tulipa
htpasswd -Bb /storage/traefik-app/config/register.users  admin      tulipa;

# Criar o usuário "anonymous" em nosso registro para permitir acesso
# de leitura (pull only) às nossas imagens:
# - login: anonymous
# - senha: anonymous
htpasswd -Bb /storage/traefik-app/config/register.users anonymous  anonymous;

Nota:

  • O traefik foi mapeado no HOST assim:
    • /storage/traefik-app/config > /etc/traefik
  • Dentro do container traefik o arquivo é:
    • /etc/traefik/register.users
  • No HOST o arquivo é:
    • /storage/traefik-app/config/register.users
  • Toda configuração em LABELs dos containers deve considerar o caminho dentro do traefik.
  • Você irá manipular no HOST os usuários e senhas no caminho:
    • /storage/traefik-app/config/register.users

Agora vamos criar o arquivo de configuração do registry para que ele próprio autentique usuários. Crie o arquivo /storage/register/config/config.yml no HOST com o conteúdo abaixo, respeitando a quantidade de espaços (sintaxe YAML):

/storage/register/config/config.yml
version: 0.1

log:
  fields:
    service: registry

storage:
  cache:
    blobdescriptor: inmemory
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: true

http:
  addr: :5000
  headers:
    X-Content-Type-Options: [nosniff]
    Access-Control-Allow-Origin: ['*']
    Access-Control-Allow-Methods: ['HEAD', 'GET', 'OPTIONS', 'DELETE', 'PUT', 'POST']
    Access-Control-Allow-Headers: ['Authorization', 'Accept', 'Cache-Control', 'Content-Type']

auth:
  htpasswd:
    realm: basic-realm
    path: /etc/register.users

Mapeamento:

  • Arquivo no HOST:
    • /storage/register/config/config.yml
  • Arquivo no container “register“:
    • /etc/docker/registry/config.yml

Criar container do register:

Bash
# Nome de DNS publico do container, edite para o nome
# que voce configurou
    FQDN="register.ajustefino.net";

# Diretorio de dados persistentes do container:
    mkdir -p /storage/register/data;

# Arquivo de usuarios e senhas dentro do container Traefik:
    PWFILE=/etc/traefik/register.users;

# Arquivo de usuarios para conferencia do container register:
    PWHOSTF=/storage/traefik-app/config/register.users;
    PWLOCAL=/etc/register.users;

# Mapeamento do arquivo de config
    HOSTF_AUTHFILE=/storage/register/config/config.yml;
    LOCAL_AUTHFILE=/etc/docker/registry/config.yml;
    
# Atualizar e rodar imagem do register:
    docker rm -f register 2>/dev/null;
    docker pull "docker.io/registry:2";
    docker run -d --restart=always \
        --name register -h register.intranet.br \
        --network network_public --ip=10.249.255.252 \
        --memory=1g --memory-swap=1g \
        \
          -p "127.0.0.1:5000:5000" \
          -p "[::1]:5000:5000" \
          \
          -v /storage/register/data:/var/lib/registry \
          -v $HOSTF_AUTHFILE:$LOCAL_AUTHFILE:ro \
          -v $PWHOSTF:$PWLOCAL:ro \
          \
          --label "traefik.enable=true" \
          --label "traefik.http.routers.rtry.rule=Host(\`$FQDN\`)" \
          --label "traefik.http.routers.rtry.entrypoints=websecure" \
          --label "traefik.http.routers.rtry.tls=true" \
          --label "traefik.http.routers.rtry.tls.certresolver=letsencrypt" \
          --label "traefik.http.routers.rtry.middlewares=rtry-auth" \
          --label "traefik.http.middlewares.rtry-auth.basicauth.usersfile=$PWFILE" \
          --label "traefik.http.services.rtry.loadbalancer.server.port=5000" \
          \
          "docker.io/registry:2";

# Acesso:
    echo;
    echo "Acesso:";
    echo "Web......: https://$FQDN";
    echo;

Vale destacar que o container não terá autenticação quando acessado de dentro do HOST e dos containers vizinhos, para evitar problemas de segurança a porta 5000 do registry será disponibilizado somente no LOCALHOST (127.0.0.1 ou ::1). O acesso externo via Internet depende 100% do Traefik.

4 – Testando funcionamento básico

Testando acesso HTTP (somente via LOCALHOST do HOST, sem senha):

Bash
# Testando acesso localhost:
    # - IPv4:
    curl -v "http://127.0.0.1:5000";      # http 200, vazio
    curl -v "http://localhost:5000/v2/";  # http 200, JSON vazio
    # - IPv6:
    curl -v "http://[::1]:5000";          # http 200, vazio
    curl -v "http://[::1]:5000/v2/";      # http 200, JSON vazio

Testando acesso HTTPs para acesso externo (Internet):

Bash
# Obs: troque pelo nome do seu servidor:
FQDN="register.ajustefino.net";

# Testando:
    curl "https://$FQDN";
        # 401 Unauthorized

# O retorno "401 Unauthorized" é correto, significa que o Traefik só permite
# acesso com autenticacao ao container do registry.

# Testando acesso com usuário anonymous senha anonymous:
    curl -s -u "anonymous:anonymous" "https://$FQDN";
    # Retorno esperado:  HTTP/2 200, sem conteudo.

    curl -s -u "anonymous:anonymous" "https://$FQDN/v2/";
    # Retorno esperado:  HTTP/2 200, JSON vazio.
    
    curl -s -u "anonymous:anonymous" "https://$FQDN/v2/_catalog";
    # Retorno esperado:  HTTP/2 200, JSON vazio (se nao houver imagens registradas)

5 – Criar uma imagem, hospedar e distribuir

Vamos criar uma imagem básica de exemplo no nosso Docker e em seguida hospedá-la no nosso registry para que os usuários externos possam usá-la em seus servidores.

A princípio, vamos apenas criar uma imagem local (não relacionada com o registry):

Bash
# Criar um projeto de imagem docker para teste:
mkdir -p /tmp/hello-world-test;
cd /tmp/hello-world-test;
(
    echo 'FROM debian:bookworm';
    echo 'RUN (apt -y update; apt -y upgrade; apt -y dist-upgrade; )';
    echo 'RUN (apt -y install supervisor; )';
    echo 'WORKDIR /root';
    echo -n 'CMD [';
    echo -n '"/usr/bin/supervisord",';
    echo -n '"--nodaemon",';
    echo -n '"-c","/etc/supervisor/supervisord.conf"';
    echo -n ']';
    echo;
) > /tmp/hello-world-test/Dockerfile;

# Construir imagem chamada 'hello-world-test', tag: 'hello-world-test:latest':
cd /tmp/hello-world-test;
docker build . -t hello-world-test;

# Colocar TAG de versão especifica '1.2.3':
# - obs: vc pode colocar mais tags se desejar (1.2, 1.2.3beta, 1.2.3alpha, ...):
docker tag hello-world-test  hello-world-test:1.2.3;

# Conferindo imagem local:
docker image ls;
docker image ls hello-world-test;
docker image ls hello-world-test:latest;
docker image ls hello-world-test:1.2.3;

# Conferindo detalhes do historico de construcao imagem local:
docker image history hello-world-test;
docker image history hello-world-test:latest;
docker image history hello-world-test:1.2.3;

# Conferindo todos os metadados da imagem local:
docker image inspect hello-world-test;
docker image inspect hello-world-test:latest;
docker image inspect hello-world-test:1.2.3;

Conectar Docker local no servidor registry – fazendo login no repositório:

Bash
# Login como admin (push/pull)
docker login localhost:5000
# Username: admin
# Password: [senha do admin], padrao:tulipa
  # WARNING! Your credentials are stored unencrypted in '/root/.docker/config.json'.
  # Configure a credential helper to remove this warning. See
  # https://docs.docker.com/go/credential-store/

  # Login Succeeded

# Visualizar servidores de imagens conectados no Docker local:
cat ~/.docker/config.json;
    # {
    # 	"auths": {
    # 		"localhost:5000": {
    # 			"auth": "YWRtaW46dHVsaXBh"
    # 		}
    # 	}
    # }

# Caso deseje para de usar o registry local, execute:
#- docker logout localhost:5000;

Agora vamos enviar nossa imagem para o registro central:

Bash
# Taggear uma imagem local para marcar informacao de tag vinculada no
# registry (via acesso HOST > container) - Acao local
docker tag hello-world-test:latest localhost:5000/hello-world-test:latest

# Fazer push (upload para o registry - requer usuario com poder de admin)
docker push localhost:5000/hello-world-test:latest
    # The push refers to repository [localhost:5000/hello-world-test]
    # 5f70bf18a086: Pushed 
    # 5848ef4a0019: Pushing [======================>    ] 24.84MB/55.41MB
    # 03bbca755e3f: Pushed 
    # 175a19836175: Pushing [==========>                ]      # 24.41MB/116.5MB

Conferindo se a imagem enviada (push) consta no servidor registry :

Bash
# Instalar JQ para visualizar JSON no shell:
apt-get -y install jq;

# Consultar inventário do registry:
curl -s "http://localhost:5000/v2/_catalog";

# Consultar inventário do registry, visualizar melhor interpretando o JSON:
curl -s "http://localhost:5000/v2/_catalog" | jq;
    # {
    #   "repositories": [
    #     "hello-world-test"
    #   ]
    # }

6 – Importar imagens públicas para seu registry

Você pode importar as imagens públicas e adicionar a TAG para subir ela no seu registry local, ou pode renomear a imagem para garantir uma referência que só exista localmente, observe:

Bash
# Baixar imagem do Debian do Docker.io
docker  pull  docker.io/debian:trixie

# Listar imagens locais:
docker image ls --filter "reference=debian*"

# Adicionar TAG local:
docker  tag  docker.io/debian:trixie  localhost:5000/debian:trixie;
docker  tag  docker.io/debian:trixie  localhost:5000/dockerio_debian:trixie;

# Upar imagem com TAG local para o registry privado:
docker push localhost:5000/debian:trixie;
docker push localhost:5000/dockerio_debian:trixie;

# Consultar inventário do registry, visualizar melhor interpretando o JSON:
curl -s "http://localhost:5000/v2/_catalog" | jq;
    # {
    #   "repositories": [
    #     "debian",
    #     "dockerio_debian",
    #     "hello-world-test"
    #   ]
    # }

# Consultar tags da imagem debian no registry:
curl -s -X GET "http://localhost:5000/v2/debian/tags/list";
    # {
    #   "name": "debian",
    #   "tags": [
    #     "trixie"
    #   ]
    # }

7 – Usando nosso repositório nos clientes

Nos servidores e clientes da Intranet ou Internet, usaremos a URL oficial do nosso repositório (nome DNS global = FQDN).

Bash
# Nome DNS oficial do repositorio:
FQDN=register.ajustefino.net;

# Login:
docker login $FQDN;

# Username: admin
# Password: [senha do admin], padrao:tulipa
  # WARNING! Your credentials are stored unencrypted in '/root/.docker/config.json'.
  # Configure a credential helper to remove this warning. See
  # https://docs.docker.com/go/credential-store/

  # Login Succeeded

# Visualizar servidores de imagens conectados no Docker local:
cat ~/.docker/config.json;
    # {
    # 	"auths": {
    # 		"register.ajustefino.net": {
    # 			"auth": "YWRtaW46dHVsaXBh"
    # 		}
    # 	}
    # }

# Caso deseje para de usar o registry local, execute:
docker logout $FQDN;

8 – Definir nosso registry como padrão

Esse procedimento é opcional.

Vamos definir a preferência de nosso servidor Docker local para usar nosso próprio repositório. Edite o arquivo /etc/docker/daemon.json adicionando:

/etc/docker/daemon.json
{
  "registry-mirrors": [
    "https://register.ajustefino.net"
  ]
}

Observação: vai ser necessário fazer login. Você pode gerar o JSON em ~/.docker/config.json para deixar tudo pronto via script.

Terminamos por hoje!

Patrick Brandão, patrickbrandao@gmail.com