Conexiones remotas al Docker Engine de Windows Server 2019

¿Tu estación de trabajo va justa de potencia para trabajar con Windows Server Containers? ¿Quizás usas Linux o Mac? ¡Veamos cómo configurar Docker en Windows Server 2019 para aceptar conexiones remotas!

9 min de lectura
Conexiones remotas al Docker Engine de Windows Server 2019

ACTUALIZACIÓN 2019/09/24: No estaban publicadas las últimas versiones de los scripts de generación de certificados y por tanto no funcionaban adecuadamente. El articulo ha sido actualizado con las correctas.

Imagina la siguiente situación: estás trabajando en un proyecto con Windows Server Containers y tu objetivo es poner a funcionar tu aplicación en Azure Kubernetes Service. Te puedes encontrar tres problemas que pueden afectar muy negativamente a tu productividad si trabajas con Docker Desktop:

  • A fecha de escribir estas líneas, Windows 10 sólo puede trabajar con Docker en modo Hyper-V Isolation, lo que significa que cuando haces el proceso de docker build y cuando instancias un contenedor, en realidad estás creando máquinas virtuales. Si tu portátil va justo de recursos, esto puede lastrarle considerablemente.
  • En contraste con el punto anterior, la implementación de nodos Windows Server en Kubernetes y por consiguiente la preview de Windows Server Containers de Azure Kubernetes Service sólo soportan Process Isolation, lo cual quiere decir que tu imagen base de Docker debe coincidir con la que se va a ejecutar en el nodo. Actualmente AKS está utilizando Windows Server 2019 build 10.0.17763.557, 11 de junio de 2019 para sus nodos Windows.
  • También puede ocurrir que nuestro sistema operativo principal no es Windows, pero necesitamos trabajar con Windows Server Containers y no queremos desplegar una máquina virtual en nuestra estación de trabajo.

¿Cómo podemos solucionar esta situación y ser ágiles construyendo contenedores Windows Server listos para AKS/EKS/GKE/...? Creando nuestra propia máquina de build que acepte conexiones remotas.

¿Qué vamos a hacer? ¡Docker Engine remoting!

Docker está diseñado con una arquitectura cliente-servidor desacoplada. Por defecto, cuando instalamos Docker Desktop o Docker CE en nuestra estación de trabajo, se nos instala también la parte servidor e interactuamos con ella localmente mediante socket UNIX (sólo Linux), TCP, Named Pipe (sólo Windows) o incluso HTTP.

Si has leído bien te habrás percatado que el protocolo TCP está en la lista, así que vamos a hacer lo siguiente:

  1. Nos creamos una máquina Windows Server 2019 con la build exacta que necesitemos. No sólo puede ser una instalación Server Core, sino que además lo recomiendo. Tampoco importa si lo hacemos en on-premises, Azure, AWS, GCP, etc... siempre y cuando tengamos conectidiad de red hasta ella. La conectividad puede ser pública -sin necesidad de túneles VPN- porque vamos a cifrarlo todo con TLS.
  2. Windows Server viene de serie con licencia de Docker Engine Enterprise. No está mal, ¿verdad? Procedemos a instalarlo siguiendo estas instrucciones.
  3. Generaremos unos certificados X.509 para cifrar y autenticar la conexiónes a este servidor.
  4. Realizamos la configuración del Docker Engine en la parte servidor con los certificados generados.
  5. Configuramos nuestro cliente para usar el servidor Docker remoto.

¡Vamos a ello!

Instalando Docker Engine en Windows Server

Voy a obviar toda la parte de instalación de Windows Server, que entiendo que no entraña ningún misterio para nadie. Voy a enfocar los pasos a trabajar siempre desde consola, de forma que es válido tanto para Server Core como para instalaciones con interfaz gráfica.

Gracias a OneGet esto se ha vuelto tan sencillo como instalar paquetes en nuestra distribución de Linux favorita... Sólo tenemos que abrir una PowerShell en modo administrador y ejecutar:

(Cuidado, esto puede reiniciar la máquina al acabar)

Install-Module DockerMsftProvider -Force
Install-Package Docker -ProviderName DockerMsftProvider -Force
if ((Install-WindowsFeature Containers).RestartNeeded) { Restart-Computer }

¡Ya tenemos Docker Engine Enterprise instalado en nuestro Windows Server! Si todo está OK deberíamos poder ejecutar docker version y ver lo siguiente por pantalla:

docker_remote1

Generando certificados X.509 para Docker Engine

Dado que nos vamos a conectar remotamente a la máquina que acabamos de instalar, necesitamos dos elementos esenciales: establecer un mecanismo de autenticación y cifrar la comunicación.

Docker Engine hace esto mediante certificados X.509 y se describe con detalle en la documentación oficial de Docker. Sin embargo, esta documentación está muy pensada para entornos UNIX y nos puede dar algún dolor de cabeza si queremos hacer los pasos desde Windows, sobre todo porque necesitamos openssl y no es algo que de momento venga de serie con Windows. Para facilitar la vida un poco y hacer todo lo necesario de forma automática he preparado el siguiente script de PowerShell:

param (
    [Parameter(Mandatory=$true)]
    [string]$DnsName,
    [string]$OpenSslDownloadUri="https://slproweb.com/download/Win64OpenSSL_Light-1_1_1d.exe"
)
$ErrorActionPreference = 'Stop'
$ip=(Resolve-DnsName -Name $DnsName -Type A).IPAddress
Invoke-WebRequest -Uri $OpenSslDownloadUri -UseBasicParsing -OutFile "$($env:temp)\openssl_setup.exe"
Start-Process -FilePath "$($env:temp)\openssl_setup.exe" -ArgumentList '/VERYSILENT','/NORESTART','/LOG' -NoNewWindow -Wait
[Environment]::SetEnvironmentVariable('PATH', $env:Path + ';C:\Program Files\OpenSSL-Win64\bin\', [EnvironmentVariableTarget]::User)
[Environment]::SetEnvironmentVariable('PATH', $env:Path + ';C:\Program Files\OpenSSL-Win64\bin\', [EnvironmentVariableTarget]::Process)
openssl.exe genrsa -aes256 -out ca-key.pem 4096
openssl.exe req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem
openssl.exe genrsa -out server-key.pem 4096
openssl.exe req -subj "/CN=$DnsName" -sha256 -new -key server-key.pem -out server.csr
"subjectAltName = DNS:$DnsName,IP:$ip,IP:10.10.10.20,IP:127.0.0.1`nextendedKeyUsage = serverAuth`n" `
    | Out-File -FilePath 'extfile.cnf' -Encoding ASCII -NoNewline
openssl.exe x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem `
    -CAcreateserial -out server-cert.pem -extfile extfile.cnf
openssl.exe genrsa -out key.pem 4096
openssl.exe req -subj '/CN=client' -new -key key.pem -out client.csr
"extendedKeyUsage = clientAuth`n" | Out-File -FilePath 'extfile-client.cnf' -Encoding ASCII -NoNewline
openssl.exe x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem `
    -CAcreateserial -out cert.pem -extfile extfile-client.cnf
Remove-Item -Path client.csr
Remove-Item -Path server.csr
Remove-Item -Path extfile.cnf
Remove-Item -Path extfile-client.cnf
Remove-Item "$($env:temp)\openssl_setup.exe"

Como se puede ver, lo primero que hacemos es instalar openssl y agregarlo al PATH. A partir de ahí generamos una CA, y emitimos con ella un certificado separando archivos para clave pública y privada. Como he comentado antes, insto a examinar el proceso al detalle en la documentación de Docker.

Tras ejecutarlo, deberíamos haber obtenido tres archivos: ca.pem, server-cert.pem y server-key.pem. Los ponemos a buen recaudo en una carpeta accesible por Docker, servidor los ha puesto en C:\ProgramData\docker\config\.

Un script similar para bash, suponiendo que tenemos dig y openssl instalados sería el siguiente:

#!/bin/sh
set -e
while [ $# -gt 0 ]; do
    case "$1" in
        --dns-name )
            HOST="$2"; shift 2
            ;;
        * )
            echo "Argument $1 is not known, please use --dns-name"
            exit 1
            ;;
    esac
done
if [ -z $HOST ]; then
    echo "Use --dns-name parameter to specify the Docker build machine hostname"
    exit 1
fi
IP=$(dig +short $HOST A)
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=$HOST" -sha256 -new -key server-key.pem -out server.csr
echo "subjectAltName = DNS:${HOST},IP:${IP},IP:10.10.10.20,IP:127.0.0.1" >> extfile.cnf
echo "extendedKeyUsage = serverAuth" >> extfile.cnf
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem \
    -CAcreateserial -out server-cert.pem -extfile extfile.cnf
openssl genrsa -out key.pem 4096
openssl req -subj '/CN=client' -new -key key.pem -out client.csr
echo "extendedKeyUsage = clientAuth" > extfile-client.cnf
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem \
    -CAcreateserial -out cert.pem -extfile extfile-client.cnf
rm -v client.csr server.csr extfile.cnf extfile-client.cnf

Configurando Docker para aceptar conexiones remotas autenticadas con TLS

Con nuestros certificados generados, ahora tenemos que configurar el Docker Engine. Con cierta sensación de dejà vu de este artículo, recordaréis que por defecto, en Windows, Docker almacena su configuración en C:\ProgramData\docker\config\daemon.json.

Vamos a agregar la siguiente configuración:

{
    "tls": true,
    "tlsverify": true,
    "tlscacert": "C:\\ProgramData\\docker\\config\\ca.pem",
    "tlscert": "C:\\ProgramData\\docker\\config\\server-cert.pem",
    "tlskey": "C:\\ProgramData\\docker\\config\\server-key.pem",
    "hosts": ["tcp://0.0.0.0:2376", "npipe://"]
}

Tras ello, reiniciamos el Docker Engine mediante Restart-Service docker y si tras ejecutar docker version volvemos a obtener el mismo resultado, ¡la cosa pinta muy bien! Si no fuera el caso, como os podréis imaginar toca revisar el Visor de Eventos de Windows.

Como nota importante deciros que Docker Engine usa de forma estandarizada el puerto tcp/2375 para conexiones no cifradas y el tcp/2376 para las cifradas, de ahí que en esta ocasión usemos este último. Por otro lado, nada nos impide utilizar el puerto que queramos siempre que pongamos cliente, servidor, cortafuegos y nats de acuerdo.

Ya sólo nos queda permitir conexiones a través del puerto 2376 en nuestros firewalls o routers. Por ejemplo, en el caso de un Network Security Group de Azure quedaría así:

docker_remote2

Configurando el cliente Docker para usar la máquina remota

Cualquier máquina cliente con Docker puede conectarse al demonio que acabamos de configurar. Esto nos permite configuraciones tan flexibles como trabajar con Windows Server Containers desde estaciones de trabajo GNU/Linux o desde macOS.

El cliente de línea de comandos de Docker ha utilizado históricamente variables de entorno para configurar con qué servidor debe establecer la comunicación, concretamente la conocida DOCKER_HOST. Sin embargo versiones recientes también pueden utilizar docker context de una forma muy parecida a como opera kubectl para cambiar entre clusters de Kubernetes.

¿Qué tenemos que hacer?

  • Instalar los certificados que generamos en el servidor, de forma que nos podamos autenticar.
  • Configurar el cliente Docker mediante variables de entorno o contextos (sólo soportados en versiones recientes). Para el caso de variables de entorno, tenemos:
    • DOCKER_HOST. DNS o dirección IP de la máquina remota. Nos conectaremos por TCP.
    • DOCKER_PORT. Puerto para la conexión TCP.
    • DOCKER_TLS_VERIFY. Usar TLS y validar la cadena de certificación.

Script de configuración

He preparado un script para configurar nuestro cliente de un modo u otro, suponiendo que tenemos los certificados en el mismo directorio donde se ejecuta. Si utilizamos el parámetro -Legacy configurará Docker mediante variables de entorno, en caso contrario mediante contextos.

Para PowerShell -y máquinas Windows- el script sería:

param(
    [string]$DockerHostname='mydockerbuildmachine.westeurope.cloudapp.azure.com',
    [string]$DockerPort='2376',
    [string]$DockerContextName='remotedocker',
    [string]$DockerContextDescription='Remote Docker build machine',
    [switch]$Uninstall,
    [switch]$Legacy
)
if($Uninstall)
{
    if($Legacy) {
        [Environment]::SetEnvironmentVariable('DOCKER_HOST', $null, [EnvironmentVariableTarget]::User)
        [Environment]::SetEnvironmentVariable('DOCKER_TLS_VERIFY', $null, [EnvironmentVariableTarget]::User)
    } else {
        docker context rm ${DockerContextName}
    }
} else {
    $DOCKER_HOME="${env:USERPROFILE}\.docker"
    if(-Not (Test-Path -Path $DOCKER_HOME)) { New-Item -Path $DOCKER_HOME -ItemType Directory }
    $files = @("ca.pem","cert.pem","key.pem")
    foreach($f in $files) {
        Copy-Item $f -Destination $DOCKER_HOME
    }
    if($Legacy) {
        [Environment]::SetEnvironmentVariable('DOCKER_HOST', "tcp://${DockerHostname}:${DockerPort}", [EnvironmentVariableTarget]::User)
        [Environment]::SetEnvironmentVariable('DOCKER_TLS_VERIFY', '1', [EnvironmentVariableTarget]::User)
    } else {
        docker context create ${DockerContextName} --description ${DockerContextDescription} --docker "host=tcp://${DockerHostname}:${DockerPort},ca=${DOCKER_HOME}\$($files[0]),cert=${DOCKER_HOME}\$($files[1]),key=${DOCKER_HOME}\$($files[2])"
    }
}

Mientras que la versión bash sería:

#!/bin/bash
DOCKER_HOSTNAME='mydockerbuildmachine.westeurope.cloudapp.azure.com'
DOCKER_PORT='2376'
DOCKER_CONTEXTNAME='remotedocker'
DOCKER_CONTEXTDESC='Remote Docker build machine'
UNINSTALL=''

while [ $# -gt 0 ]; do
    case "$1" in
        --docker-hostname )
            DOCKER_HOSTNAME="$2"; shift 2
            ;;
        --docker-port )
            DOCKER_PORT="$2"; shift 2
            ;;
        --docker-contextname )
            DOCKER_CONTEXTNAME="$2"; shift 2
            ;;
        --docker-contextdescription )
            DOCKER_CONTEXTDESC="$2"; shift 2
            ;;
        --uninstall )
            UNINSTALL='yes'; shift
            ;;
        --legacy )
            LEGACY='yes'; shift
            ;;
    esac
done

if [ $uninstall ]; then
    if [ $legacy ]; then
        sed -i 's/^export DOCKER_HOST/ d' ~/.bashrc
        sed -i 's/^export DOCKER_TLS_VERIFY/ d' ~/.bashrc
        unset DOCKER_HOST
        unset DOCKER_TLS_VERIFY
    else
        docker context rm ${DOCKER_CONTEXTNAME}
    fi
else
    DOCKER_HOME=~/.docker
    if [ -d "${DOCKER_HOME}" ]; then
        FILES=( 'ca.pem' 'cert.pem' 'key.pem' )
        for f in "${FILES[@]}"
        do
            cp -f ${f} ${DOCKER_HOME}
        done
        if [ $legacy ]; then
            echo "export DOCKER_HOST='tcp://${DOCKER_HOSTNAME}:${DOCKER_PORT}'" >> ~/.bashrc
            echo "export DOCKER_TLS_VERIFY=1" >> ~/.bashrc
            export DOCKER_HOST='tcp://${DOCKER_HOSTNAME}:${DOCKER_PORT}'
            export DOCKER_TLS_VERIFY=1
        else
            docker context create ${DOCKER_CONTEXTNAME} --description "${DOCKER_CONTEXTDESC}" --docker "host=tcp://${DOCKER_HOSTNAME}:${DOCKER_PORT},ca=${DOCKER_HOME}/${FILES[0]},cert=${DOCKER_HOME}/${FILES[1]},key=${DOCKER_HOME}/${FILES[2]}"
        fi
    else
        echo "The directory ${DOCKER_HOME} has not been found"
    fi
fi

Si has optado por configurar tu cliente Docker por variables de entorno no tienes que hacer nada más, pero si lo has hecho por contexto deberás lanzar la orden docker context use para especificar con qué Engine te quieres comunicar. En mi caso he lanzado docker context use wedocker0.

Tras ello, desde mi consola bash he lanzado docker version y he obtenido lo siguiente:

docker_remote3

Atención al detalle de las diferencias:

  • El Server es Docker Engine - Enterprise, mientras que mi cliente es la edición Community.
  • Las versiones difieren, el Server es la 18.09.7 mientras que el cliente es la 19.03.1.
  • La arquitectura del cliente es linux/amd64 mientras que la del servidor es windows/amd64.

Hecho esto, ya podemos ejecutar comandos de Docker en el servidor remoto de forma totalmente transparente a como lo haríamos en local:

$ docker run -it -p 80:80 --rm --name iis --entrypoint powershell.exe mcr.microsoft.com/windows/servercore/iis

En el caso de docker build, el demonio se encargará de enviar todo el contexto de contrucción (que por defecto son todos los archivos de la carpeta del Dockerfile) a la máquina remota, situación en la que el .dockerignore adquiere mayor importancia.

¡Espero que esto mejore vuestra productividad con Docker!
Happy dockering!