Ghost en Azure App Service Linux con Let's Encrypt

Microsoft vuelve a demostrar con hechos que GNU/Linux es ciudadano de primera en Azure, ya que se está convirtiendo en la base arquitectónica de multitud de servicios PaaS, siendo App Service uno de los más esperados.

28 min de lectura
Ghost en Azure App Service Linux con Let's Encrypt

Actualización 24/11/2017: Agregados pasos que faltaban en Actualizaciones.

Todos los elementos a los que hace referencia en este artículo se pueden encontrar en mi repositorio de Githib en: https://github.com/cmilanf/docker/tree/master/Linux/ghost-azurewebapplinux. Del mismo modo, la imagen Docker a la que se hace referencia se encuentra en: https://hub.docker.com/r/cmilanf/ghost-azurewebapplinux/

Algunos cambios recientes en mi vida personal y aparición del nuevo Azure App Service para Linux me han apartado un poquito más de lo habitual de las publicaciones de este blog; sin embargo, ¡aquí estoy de vuelta! Lo que voy a comentar en esta ocasión es un trabajo que se ve reflejado en este mismo blog y del cual ya se puede intuir alguna pista, como que el servidor HTTP ha pasado de ser IIS a nginx.

¿Ha dejado el blog en algún momento de estar en Azure? No ¿Y en App Service? No exactamente. Lo que ha ocurrido es que he migrado la plataforma de hospedaje de Azure App Service a Azure App Service Linux.

Como el artículo es un poquito extenso -el que más hasta la fecha-, voy a dejar un índice de contenidos a lo Wikipedia:

  1. Introducción: Ghost y Azure App Service, una relación complicada... ¡hasta ahora!
  2. Azure App Service sobre Linux.
  3. Ghost v1.x on Azure App Service Linux.
  4. Ghost v1.x on Azure App Service Linux + Let's Encrypt.
    1. Deberes previos.
    2. azuredeploy.json.
    3. Dockerfile.
    4. init-container.bash.
    5. init-letsencrypt.sh.
    6. update-azurewebapp-tls.bash.
    7. nginx-default.conf.
  5. Resumen de implementación
    1. Depurando la implementación
    2. Actualizaciones
  6. Networking entre Azure AppService Linux y SQL Database for MySQL
  7. Conclusiones

Introducción: Ghost y Azure App Service, una relación complicada... ¡hasta ahora!

No es ningún secreto que estoy particularmente enamorado de Ghost como plataforma de blogging, dado que es extremadamente sencilla y muy muy rápida; con un editor de Markdown potente. No en vano, el corazón de todo sistema de blogging es el editor de texto, donde potencialmente vamos a invertir el mayor porcentaje de tiempo.

Ghost está desarrollado en NodeJS y soporta MySQL y SQLite. El stack de ejecución de Ghost está totalmente orientado a GNU/Linux, como ya dejan ver en sus recomendaciones oficiales de hosting:

  • Ubuntu 16.04.
  • MySQL.
  • NGINX.
  • Systemd.
  • Node v6 via NodeSource.
  • Al menos 1 GB de RAM.
  • Un usuario distinto de root para la ejecución de los comandos ghost.

¿Significa esto que no pueda correr sobre entornos Windows? No, y de hecho hasta el pasado sábado 18 de noviembre de 2017 así ha sido con este blog. El siguiente stack tecnológico se puede utilizar para ejecutar Ghost:

  • Windows Server.
  • MySQL.
  • IIS + iisnode.
  • Node v6.
  • Al menos 1,5 GB de RAM.

Sin embargo para ser capaz de ejecutarse sobre un stack Microsoft, es necesario hacer algunas adaptaciones del código fuente de Ghost que de forma totalmente diligente está llevando a cabo Felix Resenberg en su respositorio de GitHub. Con esta adaptación ha estado ejecutándose el presente blog hasta ahora de forma impecable. Sin embargo, salvo algún PR aislado, Felix se ha tenido que enfrentar en solitario a seguir el ritmo de actualización y lanzamiento de nuevas versiones de Ghost; tarea que requiere un tiempo considerable.

Esta situación se ha agravado al pasar Ghost de la versión 0.11.x a las 1.x, donde los cambios mayores requieren aún más tiempo de apdaptación. Así otros desarrolladores como Chad Lee se han lanzado a hacer la suya propia, incluso orquestando una forma realmente ingeniosa de hospedarlo en los niveles de servicio gratuito de Azure... ¡incluso con TLS!

Por supuesto, todas estas complicaciones las encontramos cuando queremos hospedar Ghost en un entorno PaaS como Azure App Service -basado en Windows Server-, dado que sería realmente trivial implementarlo en una máquina virtual de Azure con GNU/Linux y no depender de adaptaciones.

¡Sin embargo Microsoft anunció el pasado 6 de septiembre de 2017 la disponibilidad general del nuevo Azure App Service on Linux! En esta nueva modalidad del servicio PaaS, la arquitectura subyacente deja de ser Windows Server en favor de GNU/Linux, siendo las webapps que creemos en el servicio contenedores Docker aislados entre sí. ¡De esta manera deberíamos poder ejecutar Ghost sin modificaciones en App Service!

Pero antes de adentrarnos en este jardín, deberíamos conocer los fundamentos arquitectónicos de la variante Linux de App Service. ¡Vamos a ello!

Azure App Service sobre Linux

Aunque Microsoft ha hecho un esfuerzo espectacular manteniendo la interfaz del portal de Azure, set de APIs e incluso el mismísimo Kudu, la realidad es que lo que tenemos por debajo es radicalmente distinto y conviene hacer un repaso por el nuevo servicio.

azurewebapplinux1

Como ya hemos comentado, una vez creamos un App Service Plan con arquitectura Linux, nuestras aplicaciones pasan a ser contenedores Docker no persistentes. La flexibilidad que nos da esto para hospedar nuestras propias aplicaciones es muy alta. Como se aprecia en la captura, la imagen Docker se puede retirar de Azure Container Registry, Docker Hub o bien de nuestro propio registro privado.

La arquitectura se puede entender con este diagrama:

azurewebapplinux2-1

Como se puede apreciar en el dibujo, se compone -a grandes rasgos- de tres elementos:

  • Front-end. Que es exactamente el mismo que tenemos en el App Service basado en Windows Server. De hecho, este componente sigue siendo Windows Server y hospeda un IIS ARR (Application Request Routing) que realiza la función de proxy inverso, balanceador de carga y terminación del TLS. Efectivamente, nuestra aplicación no necesita entender ni manejar TLS dado que esta pieza lo hace por nosotros.
  • Worker role. Compuesto por dos contenedores Docker:
    • App Container. Es el contenedor Docker que hospeda nuestra aplicación. No es persistente, lo cual quiere decir que cada vez la aplicación web se reinicia, escala o bien Microsoft por operativa interna la cambia de host, se vuelve a instanciar la imagen Docker como contenedor perdiendo cualquier contenido modificado. Como nota más que interesante quiero comentar que nuestros App Settings definidos para la aplicación web en el portal de Azure se trasladan como variables de entorno en el Linux de este contenedor.
    • Kudu container. Es el contenedor que hospeda la versión Linux de Kudu. La única forma posible de acceder por SSH al App Container es a través de Kudu, mediante un cliente SSH basado en web. Por tanto, el único flujo posible para conectarnos por SSH a nuestra aplicación es: Kudu -> cliente SSH -> nuestro contenedor.
  • Persistent storage. Puede que más de uno se haya alarmado en cuanto he mencionado que el App Container no es persistente y por tanto todos los archivos modificados o generados desde su despliegue están condenados a perderse. En este almacenamiento podemos conservar archivos que queramos que persistan a estas condiciones y que sean comunes a todas las instancias que tengamos desplegadas. Como se ve en el diagrama, se trata en realidad de un espacio de Azure Storage donde mapeamos el directorio /home a través de CIFS (SMB 3.0), por lo que es muy importante tener en cuenta que se trata de una unidad de red y que es insensible a los permisos de sistemas de archivos POSIX.

Por otro lado, los que estéis acostumbrados a utilizar App Service, seguramente os llame la atención de que muchas opciones -como el importantísimo Networking- aparecen sombreadas... Esto se debe a que esas características aún no se encuentran soportadas -de momento- cuando se opera en modo Linux.

azurewebapplinux3

¿Más dudas? Jim Cheshire escribió en septiembre un completísimo artículo con cosas que deberíamos saber a la hora de trabajar con Azure App Service. Dado los temas que se comentan en el, considero de vital importancia leerlo.

¿Pinta bien la nueva arquitectura? Es hora de volver al Ghost como caso práctico real de implementación.

Ghost v1.x on Azure App Service Linux

¿Fui el primero en lanzarme a implementar Ghost en Azure App Service Linux? No. Prashanth Madi ya llevó esta tarea a cabo y la explica en una estupenda publicación de su blog, de donde he cogido prestada la idea de diagrama de arquitectura que esbocé para este artículo.

Prashanth hace una aproximación muy interesante y válida al reto de la implementación de Ghost en App Service para Linux:

  • Ghost dispone de una imagen Docker oficial que cualquiera puede implementar.
  • Esta imagen no está totalmente lista para correr sobre Azure App Service Linux por tres motivos:
    • Se asume que el almacenamiento es persistente, bien porque lo es de facto o bien porque hemos montado un volumen externo o contenedor de datos.
    • No hay servidor SSH.
    • Utiliza SQLite, algo que es muy mala idea en Azure App Service Linux, al menos a día de escribir estas líneas.
  • Como solución plantea extender la imagen existente (FROM ghost:tag en su Dockerfile) y hacer que los números de versión de su extensión coincidan con los de la imagen base.

¿Y qué cambia en dicha extensión?

  • Se usa el almacenamiento persistente que hemos visto en la arquitectura.
  • Se instala el servidor de OpenSSH.
  • La base de datos pasa de ser SQLite a MySQL, utilizando Azure SQL Database for MySQL.

No voy a comentar los detalles de cómo lo hace porque para eso está su excelente artículo, y si bien esto habría sido suficiente para servidor, había un elemento clave que no tiene en cuenta y que echaba muy en falta: cifrado TLS.

Esta cuestión normalmente se hace tan trivial como ir al portal de Azure y subir nuestro certificado, pero... ¿Y si estamos utilizando Let's Encrypt? No parece muy práctico estar renovando certificados cada 3 meses y poniendo a punto la infraestructura de verificación para ello ¿no?

¡Decidido! ¡Había que ir más allá desde donde Prashanth lo había dejado y hacer la imagen friendly con Let's Encrypt para tener nuestro blog con TLS de forma gratuita!

Ghost v1.x on Azure App Service Linux + Let's Encrypt

En App Service basado en Windows teníamos la suerte de disponer de una estupenda extensión que automatizaba todo el proceso de generación de certificados y publicación en la aplicación web.

App Service Linux no soporta extensiones en la forma de su contrapartida de Windows pero... ya que nuestra aplicación es un contenedor Docker para Linux... ¡nada nos impide instalar en él todo lo que necesitemos!

Así pues, no debería ser nada complicado generar un flujo de petición y autorenovación de certificados en el mismo contenedor donde se hospeda nuestra aplicación web. Para ello, sólo necesitamos añadir tres utilidades básicas a lo que ya tenía la imagen de Prashanth:

  • certbot. El cliente oficial de Let's Encrypt para la gestión de sus certificados.
  • NGINX. Aunque Ghost se sirve directamente desde el puerto TCP 2368 sin necesidad de servidores HTTP adicionales, necesitamos uno aquí para poder servir los archivos de verificación que Let's Encrypt utiliza para comprobar la legitimidad de los dominios.
  • Azure CLI. El cliente de línea de comandos multiplataforma de Azure, que es el que nos permitirá subir y enlazar los certificados generados.

¿Parece sencillo? Técnicamente sí, pero hay más cosas a tener en cuenta de las que inicialmente puedas pensar. Tenerlo todo bien atado le llevó un tiempo a servidor, junto con el aprendizaje de la arquitectura y mucha prueba y error. ¡Pero leyendo este artículo os ahorraré el tiempo a vosotros!

¿Cómo voy a explicar el proceso? Vamos a dar una vuelta por los archivos más relevantes del repo y voy a explicar las peculiaridades de las acciones que se llevan a cabo en cada uno de ellos.

Deberes previos

Antes de poner a desplegar la plantilla JSON en Azure es necesario hacer previamente una serie de tareas:

  1. Elegir el nombre que va a tener nuestra aplicación web en Azure, así como el nombre de dominio personalizado que le vamos a asignar. En el caso del blog aquí presente fue fácil: calnus y calnus.com.
  2. Crear en nuestro proveedor DNS las entradas necesarias para que la aplicación web de Azure pueda agregar nuestro dominio y el subdominio www.. Así pues, servidor creó entradas para calnus.com y www.calnus.com.
  3. Crear un Azure AD Service Principal con permisos para modificar nuestra aplicación web. Utilizaremos estas credenciales para cargar y enlazar los certificados de Let's Encrypt.

¿Cómo llevamos a cabo el paso 3? Este sencillo script nos ayuda a ello:

@ECHO OFF
REM In order tu upload and bind TLS certificates to the Azure WebApp, the system needs access
REM to the Azure resource. We will add this permission through an Azure Service Principal.
REM This script uses Azure CLI for creating this permission. Parameter result should be
REM applied in the ARM JSON template when building the solution.
REM
REM You can use the same commands in a bash shell adapting variables references.
REM
REM Usage:  %1 - Resource Group Name
REM         %2 - Location (ex: westeurope)
REM         %3 - Azure Subscription Id
az group create --name %1 --location %2
az ad sp create-for-rbac -n %1 --role contributor --scopes /subscriptions/%3/resourceGroups/%1

El script es un archivo de procesamiento por lotes de DOS, pero como podéis ver es trivial pasarlo a bash.

azuredeploy.json

Realmente todo empieza aquí, por una implementación en Azure de los recursos necesarios para ejecutar el blog. Poner el código completo de la plantilla en este artículo es un poco overkill, así que permitidme que me centre en los aspectos fundamentales.

¿Qué instanciamos en esta plantilla? los siguientes elementos:

azurewebapplinux4

  • Microsoft.Web/serverfarms. El App Service Plan para Linux.
  • Microsoft.Web/sites. La aplicación web.
    • Microsoft.Web/sites/config. Cierta configuración de logging HTTP para poder ver qué pasa en nuestra imagen Docker mientras se instancia en contenedor.
    • Microsoft.Web/hostnameBindings (2). Los hostnames personalizados, en mi caso calnus.com y www.calnus.com.
  • Microsoft.DBforMySQL/servers. El servidor de base de datos MySQL en modo PaaS.
    • Microsoft.DBforMySQL/servers/databases. La base de datos MySQL.

Parámetros

Los siguientes parámetros deben ser proporcionados por el usuario en el momento de la implementación en Azure. Estos pasarán a ser App Settings, que a su vez estarán disponibles como variables del entorno en el momento de crear el contenedor de la imagen.

Hay que tener en cuenta que algunos son sensibles, pues contienen contraseñas de la base de datos, del Azure AD SP o del servicio SMTP. Recomiendo establecer contraseñas complejas y largas.

Aunque estos App Settings pasen a ser variables de entorno bash durante la ejecución del contenedor, conviene recalcar que NO estarán disponibles cuando entremos por SSH.

  • DOCKER_CUSTOM_IMAGE. La imagen Docker que vamos a desplegar, para el caso del blog Ghost, la del repositorio de servidor es la correcta: cmilanf/ghost-azurewebapplinux:1.17.3.
  • WEBAPP_CUSTOM_HOSTNAME. DNS personalizado de la applicación web. Ej: calnus.com
  • WEBAPP_NAME. Nombre de la aplicación web en Azuree. Ej: calnus
  • RESOURCE_GROUP. Nombre del grupo de recursos de Azure donde se va a desplegar la aplicación web.
  • GHOST_CONTENT. Directorio de instalación de Ghost, que debe encontrarse en el almacenamiento persistente. Por defecto: /home/site/wwwroot.
  • GHOST_URL. URL used for accesing the blog. A pesar de la terminación TLS se utilizará HTTPS. Ej: /
  • DB_TYPE. Tipo de base de datos, que podría ser 'mysql' o 'sqlite'. De momento 'sqlite' no está soportado en Azure AppService Linux.
  • DB_HOST. Nombre de la base de datos MySQL. Ej: calnus. Esto hará que la plantilla ARM cree automáticamente calnus.mysql.database.azure.com.
  • DB_NAME. Nombre de la base de datos MySQL. Esto será creado automáticamente por la plantilla ARM.
  • DB_USER. Usuario de la base de datos. Creado por la plantilla ARM.
  • DB_PASS. Contraseña para la atuenticación con la base de datos. ATENCIÓN: será visible desde el portal de Azure y almacenada en texto plano en el contenedor. Creado por la plantilla ARM.
  • SMTP_SERVICE. Servicio encargado del envío de correo electrónico.. Ej: SendGrid. Lista de servicios disponible en https://github.com/nodemailer/nodemailer/blob/0.7/lib/wellknown.js
  • SMTP_FROM. Remitente de los correos electrónicos del blog. Ej: blog@calnus.com
  • SMTP_USER. Nombre de usuario utilizado en la autenticación SMTP.
  • SMTP_PASSWORD. Contraseña utilizada en la autenticación SMTP. ATENCIÓN: será visible en el portal de Azure y almacenada en texto planto en el contenedor.
  • LETSENCRYPT_EMAIL. Dirección de correo electrónico utilizada para la generación de certificados TLS por parte de Let's Encrypt. Las notificaciones de expiración serán recibidas en esta dirección.
  • AZUREAD_SP_URL. URL del Service Princpal de Azure AD utilizado para cargar y enlazar certificados TLS en Azure. Creado por el script Create-AzureADSP. Ej: http://calnus
  • AZUREAD_SP_PASSWORD. Contraseña del Azure AD Service Principal.
  • AZUREAD_SP_TENANTID. Tenant ID del Azure AD Service Principal
  • HTTP_CUSTOM_ERRORS. Activar errores 404 y 500 personalizados en NGINX.

Especial cuidado: firewall y el cifrado TLS de la base de datos MySQL

Aunque se encuentre en preview, ya podemos hacer uso de la Azure SQL Database for MySQL, y he de decir que el nivel básico de 50 DTUs funciona estupendamente bien con Ghost en un modesto sitio como el de servidor. Sin embargo, hay que tener especial cuidado, ya que para que no haya problemas en la implementación la plantilla deja inicialmente el firewall totalmente abierto, tal como se puede apreciar en la siguiente declaración:

{
    "type": "firewallrules",
    "condition": "[equals(parameters('dbType'),'mysql')]",
    "apiVersion": "2016-02-01-privatepreview",
    "dependsOn": [
        "[concat('Microsoft.DBforMySQL/servers/', parameters('dbHost'))]"
    ],
    "location": "[resourceGroup().location]",
    "comments": "It is not the best idea to have the database firewall wide open. Consider to change it late to the outbound IP addresses of the Azure WebApp.",
    "name": "AllAllowed",
    "properties": {
        "startIpAddress": "0.0.0.0",
        "endIpAddress": "255.255.255.255"
    }
}

Volveremos a este tema al final del artículo cuando el servicio esté implementado y funcionando.

Por otro lado, también llamará la atención que desactivamos el requerimiento de conexiones TLS a la base de datos. No he encontrado ningún parámetro de configuración en Ghost que permita manejar estas conexiones cifradas, algo que sería muy deseable y razón por la que se vuelve aún más crítico tener el firewall de la base de datos correctamente acotado. En cuanto haya novedades en este tema actualizaré el artículo.

Dockerfile

Examinemos los distintos bloques del Dockerfile. Me voy a permitir la licencia de eliminar los LABEL en este artículo para ahorrar espacio.

FROM ghost:{{TAG}}

RUN apt-get -y update \
  && apt-get install -y --no-install-recommends lsof at openssl openssh-server supervisor cron git nano jq less linuxlogo unzip \
  && echo "root:Docker!" | chpasswd \
  && echo "30 * * * * /usr/bin/linuxlogo -L 11 -u > /etc/motd" > /etc/cron.d/linuxlogo \
  && chmod 755 /etc/cron.d/linuxlogo \
  && /usr/bin/linuxlogo -L 11 -u > /etc/motd

RUN set -ex \
  && echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ wheezy main" | \
  tee /etc/apt/sources.list.d/azure-cli.list \
  && echo "deb http://ftp.debian.org/debian jessie-backports main" | \
  tee /etc/apt/sources.list.d/jessie-backports.list \
  && apt-key adv --keyserver packages.microsoft.com --recv-keys 417A0893 \
  && apt-get -y --no-install-recommends install apt-transport-https net-tools \
  && apt-get -y update \
  && apt-get -y upgrade \
  && apt-get -y --no-install-recommends install azure-cli \
  && apt-get -y --no-install-recommends install certbot nginx -t jessie-backports \
  && apt-get -y autoremove \
  && apt-get -y autoclean \
  && set +ex

RUN cd current \
  && npm install mysqljs/mysql \
  && cd /var/lib/ghost

COPY sshd_config /etc/ssh/
COPY init-container.bash /usr/local/bin/
COPY migrate-util.bash /usr/local/bin/
COPY supervisord-container.conf /etc/supervisor/conf.d/
COPY convertLetsEncryptToPfx.bash /usr/local/bin/
COPY update-azurewebapp-tls.bash /usr/local/bin/
COPY init-letsencrypt.sh /usr/local/bin/
COPY var-check.bash /usr/local/bin/
COPY nginx-default.conf /etc/nginx/sites-available/default
COPY .bashrc /root/
COPY http_custom_errors.zip /root/

EXPOSE 2222 80

CMD ["/usr/local/bin/init-container.bash"]

Vayamos bloque por bloque:

  • Línea 1. Vamos a extender la imagen oficial de Ghost, por lo que nos beneficiaremos de todo el trabajo de actualización y puesta al día que ellos hagan. Servidor añadirá a esta imagen los elementos necesarios para que funcione perfectamente en Azure App Service Linux. De momento está basada en Debian 8.9 Jessie (oldstable). En algún momento deberán actualizar para basarla en Stretch.
  • Líneas 3-8. Instalamos software fundamental y asignamos a root la contraseña Docker!. Como el contenedor no va ser accesible por SSH bajo ningún caso desde el exterior y sólo se podrá hacer mediante Kudu, esto no supone problema. Como es evidente debemos evitar instalar ningún software expuesto al exterior que use la base de datos de usuarios locales de Linux y admita root como usuario. Más sobre el SSH en App Service Linux aquí. En cuanto a linuxlogo, es que es una monada y no lo puedo evitar :)
  • Líneas 10-23. Necesitamos agregar un par de repositorios APT adicionales: el de Jessie Backports para poder instalar certbot y una versión razonablemente actualizada de nginx y el del Azure CLI de Microsoft. Microsoft sirve sus repositorios APT mediante HTTPS, por lo que el paquete apt-transport-https también es necesario.
  • Líneas 25-27. Prashanth comenta en su artículo que debido a una peculiaridad de la Azure SQL Database for MySQL es necesario instalar esta librería mediante NPM. Debería ser algo temporal no obstante.
  • Líneas 29-39. Todos los archivos de configuración que vamos a copiar al contenedor. Concretamente: servidor SSH, supervisord y nginx.
  • Línea 41. Los puertos TCP que vamos a exponer: 2222 (SSH) y el 80 (HTTP). En el primer caso no hay opción: debe ser ese para el SSH y en el segundo, aunque sea el puerto 80 para HTTP nuestro blog se servirá única y exclusivamente sobre HTTPS; como expliqué anteriormente, hay terminación TLS en el front-end. Cabe destacar que Azure App Service Linux sólo soporta exponer un único puerto TCP más allá del de SSH. ¿Tu aplicación necesita forzosamente exponer más de uno? Entonces tendrás que mirar otras soluciones como Azure Container Service.
  • Línea 43. El parámetro que vamos a pasar al ENTRYPOINT declarado en la imagen base, en este caso nuestro script para iniciar la puesta a punto del contenedor.

Llamará la atención que no estoy realizando ninguna acción sobre /home en mi Dockerfile. El motivo es que -evidentemente- esta carpeta compartida por SMB no está disponible en el momento de la construcción de la imagen; por lo que relegaremos estas tareas al script bash.

init-container.bash

Este script inicia la configuración de nuestro contenedor y es desde el punto de vista de servidor, bastante sencillo de entender. A recalcar:

mkdir -v -p /var/run/sshd
mkdir -v -p /home/LogFiles/letsencrypt
mkdir -v -p /home/LogFiles/supervisor
mkdir -v -p /home/letsencrypt/workdir
mkdir -v -p "$GHOST_CONTENT"

Como mencioné anteriormente, llevo a cabo creación de directorios en el script porque la carpeta persistente /home, montada mediante SMB en Azure Storage como parte de la arquitectura de App Service Linux, no existe en el momento de construcción de la imagen del contenedor. Por ello, todo lo que dejemos en /home en nuestro Dockerfile se perderá.

Por supuesto, podemos prescindir de la carpeta persistente, pero eso no es lo que queremos ¿verdad?

MySQL vs SQLite

Más adelante en el script tenemos una bifurcación en función del sistema de base de datos elegido. Hasta ahora, el presente blog en App Service Windows utilizada SQLite de forma muy eficaz, ya que para el volumen de tráfico del sitio era más que suficiente y simplificaba muchísimo su mantenimiento; eso es, una vez superado un pequeño problema con la copia de seguridad.

Pensé que aquí podía hacer lo idem, pero craso error. Si desde dentro de nuestro contenedor de App Service Linux listamos los sistemas de archivos montados descubriremos...

# mount -l
//10.0.xx.xx/volume-xx-default/115f13ea4e57daef513/da98a08e4467f8ac749c88e459899 on /home type cifs (rw,relatime,vers=3.0,sec=ntlmssp,cache=strict,username=dummyadmin,domain=RD000XXXXXXXXX,uid=0,noforceuid,gid=0,noforcegid,addr=10.0.xx.xx,file_mode=0777,dir_mode=0777,nounix,serverino,mapposix,mfsymlinks,noperm,rsize=1048576,wsize=1048576,echo_interval=60,actimeo=1)

De aquí aprendemos:

  • Que, sin duda, /home se monta como unidad de red mediante SMB 3.0.
  • Que nuestras carpetas y archivos no van a tener propietarios.
  • Que nuestras carpetas y archivos tendrán permiso 0777 invariablemente.
  • mfsymlinks nos parece sugerir que podremos crear enlaces simbólicos, pero no entre distintos sistemas de archivos.

Pero lo más importante: por tratarse de un sistema de archivos de red, no soporta los mecanismos de bloqueo que una base de datos necesita. La documentación oficial de SQLite dice sobre este aspecto:

SQLite depends on the underlying filesystem to do locking as the documentation says it will. But some filesystems contain bugs in their locking logic such that the locks do not always behave as advertised. This is especially true of network filesystems and NFS in particular. If SQLite is used on a filesystem where the locking primitives contain bugs, and if two or more threads or processes try to access the same database at the same time, then database corruption might result.

Así que definitvamente hospedar una base de datos SQLite en una unidad de red no es la mejor idea que se nos puede ocurrir. Parece claro que nos vamos a MySQL ¿no?

supervisord, el gobernador de nuestro contenedor Docker

Por filosofía, un contenedor Docker no debería ejecutar demonios, sino que todos los procesos deberían ser de primer plano. De hecho, un contenedor finaliza automáticamente su ejecución una vez el proceso con PID 1 finaliza; por lo que si lo que este proceso no hace más que ejecutar otro en segundo plano... el contenedor finalizará su ejecución igualmente.

Del mismo modo, Docker también recomienda que un proceso == un contenedor. Así pues si tenemos que ejecutar múltiples procesos nuestra aplicación debería constar de varios contenedores.

En ninguno de los dos casos vamos a seguir estas indicaciones. Como siempre que rompemos la filosofía de algo, debemos saber muy bien lo que estamos haciendo y por qué. Este caso la respuesta es sencilla: App Service Linux no soporta aplicaciones web que requieran más de un contenedor, excepto que ese otro contenedor sea otra aplicación web. En este caso tenemos Ghost, certbot, Azure CLI, NGINX... Estamos ya muy fuera de eso así que no tenemos más remedio que agruparlo todo en un contenedor... ¡pero hagámoslo bien!

supervisord es un sistema de control de procesos para UNIX que nos permite lanzar distintos aplicativos en primer plano, monitorizar su ejecución y llevar acciones a cabo en caso de que caigan. Como es extremadamente sencillo, ligero y eficaz es ideal para contenedores Docker; evitando meternos en jardines extremadamente complejos como la ejecución de systemd dentro de un contenedor (a día de hoy lo considero muy mala idea).

El script init-container.bash finaliza con estas tres líneas:

/usr/local/bin/migrate-util.bash
at -f /usr/local/bin/init-letsencrypt.sh now + 3 minutes
/usr/bin/supervisord -c /etc/supervisor/supervisord.conf

La primera es un script para migrar la base de datos que creó la imagen base de Ghost a nuestro MySQL en Azure; mientras que la última ejecuta supervisor y a partir de ahí nuestro contenedor queda desplegado y no se ejecutan más acciones de scripting. Sobre la segunda línea, iremos a ello más adelante.

La configuración de supervisord es la siguiente:

[supervisord]
nodaemon=true
childlogdir=/home/LogFiles/supervisor/
logfile=/home/LogFiles/supervisord.log
logifle_maxbytes=50MB
loglevel=info

[program:atd]
command=/usr/sbin/atd -f
autorestart=true
startsecs=3

[program:sshd]
command=/usr/sbin/sshd -D -e
autorestart=true
startsecs=5

[program:cron]
command=/usr/sbin/cron -f
autorestart=true
startsecs=5

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autorestart=true
startsecs=5

[program:ghostapp]
command=gosu node node current/index.js
autorestart=true
startsecs=5

Cosas a destacar:

  • Los logs los guardo en /home ya que quiero que persistan para poder examinarlos.
  • Fijáos como todos los procesos los lanzo en modo foreground y supervisor se encarga de gestionarlos. Es una de sus capacidades que me encantan.
  • Si alguno de los procesos cae, el mismo se encarga de intentar levantarlo.

Lanzar en un contenedor Docker el servicio de SSH mediante un 'service ssh start' me parece una muy mala práctica a pesar de lo que el artículo ofical de Microsoft pueda decir, ya que lo ejecuta en segundo plano y tenemos poco control sobre si se realizó con éxito o sobre si el servicio cae más adelante.

Tanto en la penúltima línea del init-container.bash como en la configuración de supervisor vemos un programa que he pasado por alto hasta ahora: atd. ¿Qué es y por qué es necesario?

atd

atd es un programador de tareas para sistemas UNIX que a diferencia de cron ejecuta los programas una única vez en algún momento específico del futuro. ¿Por qué lo necesitamos? Volvamos a las tres últimas líneas de init-container.bash:

/usr/local/bin/migrate-util.bash
at -f /usr/local/bin/init-letsencrypt.sh now + 3 minutes
/usr/bin/supervisord -c /etc/supervisor/supervisord.conf

Si no pudiéramos usar atd tendríamos un pequeño problema para ejecutar la generación de certificados de Let's Encrypt:

  • Let's Encrypt necesita un servidor web para verificar la legitimidad de los dominios para los que solicitamos los certificados.
  • En nuestro contenedor, este servidor web es nginx y supervisor es el encargado de ponerlo en ejecución.
  • supervisor va a ser siempre lo último que ejecutemos en la inicialización de nuestro contenedor, así que no es posible lanzar init-letsencrypt.sh después de su ejecución... a menos que lo programemos.

Por eso utilizo atd para dar la siguiente orden: dentro de 3 minutos lanzarás el script /usr/local/bin/init-letsencrypt.sh. at graba la orden en la cola y una vez atd se ejecute a través de supervisor -que lo hará siguiente en el script- recogerá la orden y la llevará a cabo.

Si pasados 3 minutos no tenemos nuestro servidor nginx funcionando y atendiendo peticiones es que algo ha ido muy mal. Tened en cuenta que estos 3 minutos de inicialización pueden parecer exagerados, pero sólo son necesarios la primera vez que creamos nuestra webapp, ya que en reinicios posteriores nuestros certificados TLS están configurados y funcionando.

init-letsencrypt.sh

Esta fue una de mis grandes motivaciones para hacer esta extensión de la imagen Docker de Ghost, agregar soporte para Let's Encrypt... ¡y es justo lo que vamos a hacer ahora! Veamos el script:

#!/bin/sh
if ! grep -m 1 -q -- "--renew-hook" /etc/cron.d/certbot; then
    sed -i -e 's#certbot -q renew#certbot --config-dir /home/letsencrypt --work-dir /home/letsencrypt/workdir --logs-dir /home/LogFiles/letsencrypt --renew-hook /usr/local/bin/update-azurewebapp-tls.bash -q renew#g' /etc/cron.d/certbot
fi

if [ ! -f /home/letsencrypt/live/$WEBAPP_CUSTOM_HOSTNAME/fullchain.pem ]; then
    certbot certonly --config-dir /home/letsencrypt --work-dir /home/letsencrypt/workdir \
        --logs-dir /home/LogFiles/letsencrypt --webroot --email $LETSENCRYPT_EMAIL --agree-tos \
        -w /home/site/wwwroot -d $WEBAPP_CUSTOM_HOSTNAME -d www.$WEBAPP_CUSTOM_HOSTNAME

    if [ $? -eq 0 ]; then
        /usr/local/bin/update-azurewebapp-tls.bash
    else
        echo "init-letsencrypt.sh: There was a problem with TLS certificate generation"
    fi
fi

Básicamente tenemos dos sentencias condicionales que controlan que no ejecutemos las mismas acciones una y otra vez en caso de reinstanciar el contenedor o ejecutar el script manualmente por tareas de mantenimiento.

El primer if comprueba el job de cron de certbot que se encarga de hacer dos comprobaciones al día de si los certificados de Let's Encrypt están próximos a expirar y en caso afirmativo, pasa a renovarlos automáticamente. Esto es estupendo, pero no nos sirve de mucho si al renovarlos no los cargamos y enlazamos en Azure. Para este tipo de situaciones, certbot tiene un parámetro muy potente llamado --renew-hook que nos permite ejecutar un script o programa en caso de que la renovación del certificado se lleve a cabo. Por tanto, modifico este job para que incluya dicho parámetro y de paso informe a certbot que debe usar el almacenamiento persistente.

El segundo if comprueba si Let's Encrypt ya generó en alguna instancia anterior los certificados y en caso negativo pasa a hacerlo. Para que la generación de certificados funcione correctamente es indispensable que:

  1. nginx este en ejecución.
  2. Nuestro proveedor de DNS apunte correctamente el hostname a Azure.
  3. Azure tenga admitidos los Custom hostname.

Si te ha fallado cualquiera de estos tres puntos el contenedor se inicializará correctamente, pero el cifrado TLS no estará configurado. Ante esto tienes dos opciones: reinicia el contenedor (recomendado) o bien ejecuta desde SSH el script init-letsencrypt.sh suministrando todas las variables que va a necesitar.

update-azurewebapp-tls.bash

Con nuestros certificados ya generados nos queda solo subirlos y enlazarlos en Azure, cosa que también debemos automatizar si no queremos estar preocupados de ello cada 3 meses. Veamos el script que se encarga de ello:

#!/bin/bash
echo "update-azurewebapp-tls.bash: Preparing certificate for Azure Webapp import..."
if [ -f /home/letsencrypt/live/$WEBAPP_CUSTOM_HOSTNAME/fullchain.pem ]; then
    # If the certifcate exists, then let's get his thumbprint
    echo "update-azurewebapp-tls.bash: Certificate file found"
    X509_THUMBPRINT=$(openssl x509 -in /home/letsencrypt/live/$WEBAPP_CUSTOM_HOSTNAME/fullchain.pem -noout -fingerprint | sed -e 's/://g' | sed -e 's/SHA1 Fingerprint=//')
    echo "Thumbprint: $X509_THUMBPRINT"

    # PFX conversion, the supported format in Azure
    /usr/local/bin/convertLetsEncryptToPfx.bash

    # Azure Webapp certificate upload and binding through Azure AD Service Principal
    if [ -f /home/letsencrypt/live/$WEBAPP_CUSTOM_HOSTNAME/fullchain.pfx ] && [ -f /home/letsencrypt/live/passfile.txt ]; then
        echo "Azure SP LOGIN..."
        az login --service-principal -u $AZUREAD_SP_URL -p $AZUREAD_SP_PASSWORD --tenant $AZUREAD_SP_TENANTID
        echo "Azure certificate UPLOAD..."
        az webapp config ssl upload \
            --certificate-file /home/letsencrypt/live/$WEBAPP_CUSTOM_HOSTNAME/fullchain.pfx \
            --certificate-password $(cat /home/letsencrypt/live/passfile.txt) \
            --name $WEBAPP_NAME \
            --resource-group $RESOURCE_GROUP
        echo "Azure certificate BINDING..."
        az webapp config ssl bind \
            --certificate-thumbprint $X509_THUMBPRINT \
            --name $WEBAPP_NAME \
            --resource-group $RESOURCE_GROUP \
            --ssl-type SNI
    else
        echo "update-azurewebapp-tls.bash: Required file is missing, passfile.txt or fullchain.pfx"
    fi
else
    echo "update-azurewebapp-tls.bash: Certificate file not found, skipping..."
fi

Las tareas que llevamos a cabo aquí son sencillas:

  1. Comprobamos si el certificado que vamos a subir existe, ya que en caso contrario no hay nada que hacer.
  2. Extraemos su thumbprint mediante openssl, la navaja suiza del TLS y el SSL.
  3. Convertimos los certificados de formato PEM a PFX, que es el formato que espera Azure.
  4. Comenzamos a utilizar Azure CLI. Primero iniciamos la sesión mediante el Service Principal de Azure AD que creamos en los deberes previos.
  5. Cargamos el certificado en nuestra aplicación web.
  6. Enlazamos el certificado en nuestra aplicación web.

Si todo ha ido bien, pasados 5 minutos aproximadamente nuestra aplicación debe empezar a verse correctamente mediante TLS.

azurewebapplinux5

nginx-default.conf

El último archivo que servidor piensa que merece la pena ver al detalle es la configuración de NGINX, que considero de vital importancia. Como comenté al incio del artículo, los desarrolladores de Ghost recomiendan poner NGINX como frontal HTTP y proxy inverso. Este tipo de configuraciones se ven con mucha frecuencia en implementaciones con Apache Tomcat donde lo habitual es que no esté expuesto directamente al usuario final.

La configuración relevante de NGINX es la siguiente:

server {
    server_name www.{{WEBAPP_CUSTOM_HOSTNAME}};
    location ~ ^/.well-known {
        root /home/site/wwwroot;
    }
    location / {
        return 301 $scheme://{{WEBAPP_CUSTOM_HOSTNAME}}$request_uri;
    }
}

server {
    listen 80 default_server;
    client_max_body_size 50m;

    location ~ ^/.well-known {
        root /home/site/wwwroot;
    }

    location / {
        if ($http_x_arr_ssl = "") {
            return 301 https://$host$request_uri;
        }

        proxy_pass http://localhost:2368;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_buffering off;
    }
}

Como se puede ver, esta llevando a cabo 4 tareas, todas ellas muy importantes:

  1. Realiza redirección de www.calnus.com a calnus.com, sin que ello pueda afectar a la verificación que lleva a cabo Let's Encrypt.
  2. Valga la redundancia, siempre que .well-known esté presente, NGINX no redirigirá la petición a Ghost y la procesará el mismo. De esta forma, Let's Encrypt valida nuestro dominio.
  3. En caso de que no haya ningún .well-known detectamos si venimos de HTTPS comprobado la variable de cabecera HTTP-X-ARR-SSL. Si la variable está vacía la petición viene como HTTP y por tanto hacemos 301 a HTTPS, en caso contrario ya viene como HTTPS. Esta forma de verificación es específica de peticiones que provienen del IIS ARR, como es el caso de Azure App Service.
  4. Realizamos la función de proxy inverso hasta nuestra aplicación web. En el caso de Ghost para que el TLS funcione correctamente es de vital importancia el header X-FORWARDED-PROTO, que debemos poner de forma estática a https (es frecuente ver esta configuración como $scheme). De lo contrario Ghost pensará que están accediendo al blog por HTTP (que en el fondo a nivel interno es cierto) y no construirá correctamente las URLs. El síntoma más común de una configuración incorrecta es el bucle de redirecciones HTTP.

Dicho esto, hemos pasado por todos los elementos relevantes de la implementación. Como el camino ha sido un poquito largo, es un buen momento para recapitular cómo implementar nuestro blog de Ghost en Azure App Service Linux.

Resumen de implementación

Los pasos son:

  1. Lleva a cabo los deberes previos comentados en este artículo.
  2. Despliega la plantilla JSON en tu suscripción de Azure. Como atajo puedes hacerlo presionando en el siguiente botón: Azure Deploy
  3. Una vez finalizada la implementación de recursos de Azure, es necesario ser paciente para que se descargue la imagen Docker y se ejecuten los scripts que has visto en este artículo.
  4. Comprueba si necesitas ajustar tu DNS y los Custom Hostname de Azure. En caso afirmativo tendrás que reiniciar el contenedor tras realizar la tarea.
  5. Comprueba las Outbound IP Addresses de tu aplicación web y actualiza el firewall de la base de datos MySQL acorde a ellas.

Depurando la implementación

Si las cosas van mal, la imagen está preparada para dejarte ver toda la información que necesitas para ver donde está el problema.

Kudu

En primer lugar, puedes ver como ha ido el proceso de descarga y ejecución de la imagen Docker desde Kudu. En el enlace Current Docker logs podrás ver cómo ha ido el docker pull y el docker run.

azurewebapplinux6

SSH

En el almacenamiento persistente, concretamente en /home/LogFiles las aplicaciones y los scripts guardan sus bitácoras, salvo el propio Ghost que lo hace en /home/site/wwwroot/logs.

azurewebapplinux7

Actualizaciones

Ghost tiene ahora un ciclo de lanzamientos más ágil, donde publican actualizaciones más pequeñas de forma muy frecuente. Para facilitar el mantenimiento, han incorporado en el Ghost CLI la capacidad de actualizar automáticamente la plataforma con un único comando.

# ghost update

azurewebapplinux10

Este comando realiza las siguientes tareas:

  • Comprueba si hay nuevas versiones de Ghost disponibles.
  • Comprueba si estas nuevas versiones requieren actualización de la base de datos.
  • Descarga la nueva versión y la aplica en nuestro sistema de archivos.
  • Ejecuta las actualizaciones de la base de datos.
  • Reinicia Ghost.

Así pues hay dos elementos fundamentales en las actualizaciones: archivos y base de datos.

Como comenté anteriormente, el contenedor de nuestra aplicación no es persistente, por lo que la actualización de archivos que realizará esta utilidad se perderá en cuanto se den alguna de las condiciones que comentaba en la descripción de Azure App Service sobre Linux. Sin embargo, sí que estamos interesados en la actualización de la base de datos. Por tanto, vamos a proceder de la siguiente manera:

  1. Hacemos copia de seguridad del sistema de archivos y la base de datos. Dentro de la Azure Web App la funcionalidad de Backup funciona perfectamente con esta implementación. No sigáis adelante sin haber hecho esto.
  2. Accedemos a esta URL y comprobamos si el número de la nueva versión existe. Si aun no está notifícamelo o bien espera a que aparezca.
  3. Entramos por SSH en nuestra app, nos posicionamos en /var/lib/ghost y ejecutamos ghost update. Como el proceso de Ghost está siendo gestionado por supervisord, el comando nos acabará dando error cuando intente reiniciarlo. Lo importante es que la tarea Running database migrations complete correctamente; es el único cambio persistente por parte de este comando. La siguiente captura muestra un resultado esperado de ghost update correcto para nuestros propósitos.azurewebapplinux13
  4. Ahora toca hacer persistentes los cambios en el sistema de archivos. En nuestra aplicación web desde Azure, accedemos a Docker Container y cambiamos el número de tag por el correspondiente.
  5. Esperamos entre 5-10 minutos a que la actualización se lleve a cabo. Tendremos un downtime de aproximadamente 2 minutos.

azurewebapplinux11

¿Algo ha ido mal? Puedes probar a poner de vuelta la versión de contenedor que originalmente tenías. Si esto no es suficiente, restaura la copia de seguridad.

Networking entre Azure AppService Linux y SQL Database for MySQL

Como ya he ido adelantando, este es uno de los puntos más oscuros de la implementación, y es que ninguno de los dos servicios soporta a día de escribir estas líneas integración con VNET, por lo que deben hablarse a través del direccionamiento público.

Para evitar tener el firewall de la base de datos MySQL abierto totalmente al exterior, podemos determinar las Outbound IP Addresses de nuestra aplicación web en Azure, que son las direcciones IP desde las que puede iniciar conexiones salientes. Es importante no confundirla con la Virtual IP Address que es por donde reciben las entrantes.

Para consultar estas direcciones IP podemos ir a las Properties de nuestra aplicación web en el portal de Azure.

azurewebapplinux12

Microsoft NO garantiza que estas IPs se mantengan estáticas. Aunque son razonablemente estables y no cambian con facilidad, tenemos que asumir que podrían hacerlo en cualquier momento. Estos valores se pueden consultar desde la API REST de Azure, PowerShell o Azure CLI.

Una vez tenemos estas direcciones, accedemos a Connection Security de nuestra base de datos MySQL. Eliminamos el rango totalmente abierto e introducimos estas IPs.

azurewebapplinux9

Con esto agregamos una capa de seguridad más razonable a nuestros recursos hasta que podamos integrarlos en una VNET, que será la solución ideal.

Conclusiones

Microsoft vuelve a demostrar con hechos que GNU/Linux es ciudadano de primera en Azure, no sólo porque podamos desplegar máquinas virtuales con dicho sistema operativo, sino porque se está convirtiendo en la base arquitectónica de multitud de servicios PaaS, siendo App Service uno de los más esperados.

Ghost es un mero ejemplo de aplicación que entrañaba ciertas dificultadas su implementación en modelo PaaS por realizar las conversiones pertinentes de arquitectura Linux a Windows, pero afortunadamente eso ya no es necesario.

Por otro lado, también queda patente como la contenerización de la mano de Docker está jugando un papel cada vez más relevante en la estrategia cloud de modernización de cualquier infraestructura... ¡y razones para ello no faltan!

¿Qué te parece este cambio? ¿Deseando probar el servicio? ¡Deja tus impresiones u opiniones como comentarios!

Happy ghosting!