Imagen de Docker para distribución de aplicaciones de una sola página

La aplicación de página única (SPA) es un conjunto de archivos estáticos JavaScript y HTML, así como imágenes y otros recursos. Como no cambian dinámicamente, publicarlos en línea es muy fácil. Hay una gran cantidad de servicios económicos e incluso gratuitos para esto, comenzando con una simple página de GitHub (y para algunos incluso con narod.ru) y terminando con una CDN como Amazon S3. Sin embargo, necesitaba algo más.

Necesitaba una imagen de Docker con SPA para poder lanzarla fácilmente tanto en producción como parte de un clúster de Kubernetes como en la máquina de un desarrollador back-end que no tiene idea de qué es SPA.

He determinado los siguientes requisitos de imagen para mí:

  • facilidad de uso (pero no de montaje);
  • tamaño mínimo tanto en términos de disco como de RAM;
  • configuración a través de variables de entorno para que la imagen pueda ser utilizada en diferentes entornos;
  • la distribución más eficiente de archivos.

Hoy te cuento cómo:

  • intestino nginx;
  • construir brotli a partir de fuentes;
  • enseñar archivos estáticos a comprender las variables de entorno;
  • y por supuesto cómo montar una imagen de Docker a partir de todo esto.

El propósito de este artículo es compartir mi experiencia y provocar críticas constructivas en miembros experimentados de la comunidad.

Construyendo una imagen para el montaje.

Para que la imagen final de Docker sea pequeña, debe cumplir con dos reglas: un mínimo de capas y una imagen base minimalista. Una de las imágenes base más pequeñas es la imagen de Alpine Linux, así que esa es la que elegiré. Algunos podrían argumentar que el Alpine no es apto para la producción, y tal vez tengan razón. Pero personalmente nunca he tenido ningún problema con él y no hay argumentos en su contra.

Para tener menos capas, ensamblaré la imagen en 2 etapas. El primero es un borrador, en él permanecerán todas las utilidades auxiliares y archivos temporales. Y en la etapa final solo escribiré la versión final de la aplicación.

Empecemos por la imagen auxiliar.

Para compilar una aplicación SPA, normalmente necesita node.js. Tomaré la imagen oficial, que también viene con los administradores de paquetes npm y Yarn. Por mi parte, agregaré node-gyp, que es necesario para crear algunos paquetes npm, y el compresor Brotli de Google, que nos será útil más adelante.

Dockerfile con comentarios.

# Базовый образ
FROM node:12-alpine
LABEL maintainer="Aleksey Maydokin <[email protected]>"
ENV BROTLI_VERSION 1.0.7
# Пакеты, которые нужны, чтобы собрать из исходников Brotli
RUN apk add --no-cache --virtual .build-deps 
        bash 
        gcc 
        libc-dev 
        make 
        linux-headers 
        cmake 
        curl 
    && mkdir -p /usr/src 
    # Исходники Brotli скачиваем из официального репозитория
    && curl -LSs https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz | tar xzf - -C /usr/src 
    && cd /usr/src/brotli-$BROTLI_VERSION 
    # Компилируем Brotli
    && ./configure-cmake --disable-debug && make -j$(getconf _NPROCESSORS_ONLN) && make install 
    # Добавляем node-gyp
    && yarn global add node-gyp 
    # Убираем за собой мусор
    && apk del .build-deps && yarn cache clean && rm -rf /usr/src

Ya aquí estoy luchando por el minimalismo, por eso la imagen la crea un gran equipo.

La imagen terminada se puede encontrar aquí: https://hub.docker.com/r/alexxxnf/spa-builder. Aunque recomiendo no basarte en imágenes ajenas y coleccionar las tuyas propias.

nginx

Puede utilizar cualquier servidor web para distribuir contenido estático. Estoy acostumbrado a trabajar con nginx, así que lo usaré ahora.

Nginx tiene una imagen oficial de Docker, pero tiene demasiados módulos para una distribución estática simple. Los que están incluidos en la entrega los puede ver un equipo especial o en el Dockerfile oficial.

$ ejecución de ventana acoplable --rm nginx:1-alpine nginx -V

nginx version: nginx/1.17.9
built by gcc 8.3.0 (Alpine 8.3.0) 
built with OpenSSL 1.1.1d  10 Sep 2019
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --with-perl_modules_path=/usr/lib/perl5/vendor_perl --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-Os -fomit-frame-pointer' --with-ld-opt=-Wl,--as-needed

Usaré el Dockerfile como base, pero dejaré en él solo lo necesario para distribuir contenido estático. Mi versión no podrá funcionar a través de HTTPS, no admitirá autorización y mucho más. Pero mi versión podrá distribuir archivos comprimidos con el algoritmo Brotli, que es un poco más eficiente que gzip. Comprimiremos los archivos una vez; no es necesario hacerlo sobre la marcha.

Este es el Dockerfile con el que terminé. Los comentarios en ruso son míos, en inglés, del original.

Dockerfile

# Базовый образ снова Alpine
FROM alpine:3.9
LABEL maintainer="Aleksey Maydokin <[email protected]>"
ENV NGINX_VERSION 1.16.0
ENV NGX_BROTLI_VERSION 0.1.2
ENV BROTLI_VERSION 1.0.7
RUN set -x 
    && addgroup -S nginx 
    && adduser -D -S -h /var/cache/nginx -s /sbin/nologin -G nginx nginx 
# Устанавливаем пакеты, которые нужны чтобы собрать nginx и модуль ngx_brotli к нему
    && apk add --no-cache --virtual .build-deps 
            gcc 
            libc-dev 
            make 
            linux-headers 
            curl 
    && mkdir -p /usr/src 
# Скачиваем исходники
    && curl -LSs https://nginx.org/download/nginx-$NGINX_VERSION.tar.gz | tar xzf - -C /usr/src 
    && curl -LSs https://github.com/eustas/ngx_brotli/archive/v$NGX_BROTLI_VERSION.tar.gz | tar xzf - -C /usr/src 
    && curl -LSs https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz | tar xzf - -C /usr/src 
    && rm -rf /usr/src/ngx_brotli-$NGX_BROTLI_VERSION/deps/brotli/ 
    && ln -s /usr/src/brotli-$BROTLI_VERSION /usr/src/ngx_brotli-$NGX_BROTLI_VERSION/deps/brotli 
    && cd /usr/src/nginx-$NGINX_VERSION 
    && CNF="
            --prefix=/etc/nginx 
            --sbin-path=/usr/sbin/nginx 
            --modules-path=/usr/lib/nginx/modules 
            --conf-path=/etc/nginx/nginx.conf 
            --error-log-path=/var/log/nginx/error.log 
            --http-log-path=/var/log/nginx/access.log 
            --pid-path=/var/run/nginx.pid 
            --lock-path=/var/run/nginx.lock 
            --http-client-body-temp-path=/var/cache/nginx/client_temp 
            --http-proxy-temp-path=/var/cache/nginx/proxy_temp 
            --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp 
            --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp 
            --http-scgi-temp-path=/var/cache/nginx/scgi_temp 
            --user=nginx 
            --group=nginx 
            --without-http_ssi_module 
            --without-http_userid_module 
            --without-http_access_module 
            --without-http_auth_basic_module 
            --without-http_mirror_module 
            --without-http_autoindex_module 
            --without-http_geo_module 
            --without-http_split_clients_module 
            --without-http_referer_module 
            --without-http_rewrite_module 
            --without-http_proxy_module 
            --without-http_fastcgi_module 
            --without-http_uwsgi_module 
            --without-http_scgi_module 
            --without-http_grpc_module 
            --without-http_memcached_module 
            --without-http_limit_conn_module 
            --without-http_limit_req_module 
            --without-http_empty_gif_module 
            --without-http_browser_module 
            --without-http_upstream_hash_module 
            --without-http_upstream_ip_hash_module 
            --without-http_upstream_least_conn_module 
            --without-http_upstream_keepalive_module 
            --without-http_upstream_zone_module 
            --without-http_gzip_module 
            --with-http_gzip_static_module 
            --with-threads 
            --with-compat 
            --with-file-aio 
            --add-dynamic-module=/usr/src/ngx_brotli-$NGX_BROTLI_VERSION 
    " 
# Собираем
    && ./configure $CNF 
    && make -j$(getconf _NPROCESSORS_ONLN) 
    && make install 
    && rm -rf /usr/src/ 
# Удаляем динамический brotli модуль, оставляя только статический
    && rm /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so 
    && sed -i '$ d' /etc/apk/repositories 
# Bring in gettext so we can get `envsubst`, then throw
# the rest away. To do this, we need to install `gettext`
# then move `envsubst` out of the way so `gettext` can
# be deleted completely, then move `envsubst` back.
    && apk add --no-cache --virtual .gettext gettext 
    && mv /usr/bin/envsubst /tmp/ 
    && runDeps="$( 
        scanelf --needed --nobanner /usr/sbin/nginx /usr/lib/nginx/modules/*.so /tmp/envsubst 
            | awk '{ gsub(/,/, "nso:", $2); print "so:" $2 }' 
            | sort -u 
            | xargs -r apk info --installed 
            | sort -u 
    )" 
    && apk add --no-cache $runDeps 
    && apk del .build-deps 
    && apk del .gettext 
    && mv /tmp/envsubst /usr/local/bin/ 
# Bring in tzdata so users could set the timezones through the environment
# variables
    && apk add --no-cache tzdata 
# forward request and error logs to docker log collector
    && ln -sf /dev/stdout /var/log/nginx/access.log 
    && ln -sf /dev/stderr /var/log/nginx/error.log
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
STOPSIGNAL SIGTERM
CMD ["nginx", "-g", "daemon off;"]

Arreglaré inmediatamente nginx.conf para que gzip y brotli estén habilitados de forma predeterminada. También incluiré encabezados de almacenamiento en caché, porque nunca cambiaremos la estática. Y el toque final será redirigir todas las solicitudes 404 a index.html, esto es necesario para la navegación en el SPA.

nginx.conf

user nginx;
worker_processes  1;
error_log /var/log/nginx/error.log warn;
pid       /var/run/nginx.pid;
load_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;
events {
    worker_connections 1024;
}
http {
    include      mime.types;
    default_type application/octet-stream;
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';
    access_log /var/log/nginx/access.log main;
    sendfile on;
    keepalive_timeout 65;
    gzip_static   on;
    brotli_static on;
    server {
        listen      80;
        server_name localhost;
        charset utf-8;
        location / {
            root html;
            try_files $uri /index.html;
            etag on;
            expires max;
            add_header Cache-Control public;
            location = /index.html {
                expires 0;
                add_header Cache-Control "no-cache, public, must-revalidate, proxy-revalidate";
            }
        }
    }
}

Puedes descargar la imagen terminada aquí: https://hub.docker.com/r/alexxxnf/nginx-spa. Ocupa 10,5 MB. El nginx original ocupaba 19,7 MB. Mi interés deportivo está satisfecho.

Enseñar estática para comprender las variables ambientales.

¿Por qué podrían ser necesarias configuraciones en SPA? Por ejemplo, para especificar qué API RESTful utilizar. Normalmente, la configuración del entorno deseado se transfiere al SPA en la etapa de construcción. Si necesita cambiar algo, deberá reconstruir la aplicación. No lo quiero. Quiero que la aplicación se cree una vez en la etapa de CI y se configure tanto como sea necesario en la etapa de CD utilizando variables de entorno.

Por supuesto, los archivos estáticos en sí no comprenden ninguna variable de entorno. Por tanto, tendrás que utilizar un truco. En la imagen final, no iniciaré nginx, sino un script de shell especial que leerá las variables de entorno, las escribirá en archivos estáticos, las comprimirá y solo entonces transferirá el control a nginx.

Para ello, el Dockerfile proporciona el parámetro ENTRYPOINT. Démosle el siguiente script (usando Angular como ejemplo):

docker-entrypoint.sh

#!/bin/sh
set -e
FLAG_FILE="/configured"
TARGET_DIR="/etc/nginx/html"
replace_vars () {
  ENV_VARS='$(awk 'BEGIN{for(v in ENVIRON) print "

quot;v}')'
# В Angular ищем плейсхолдеры в main-файлах
for f in "$TARGET_DIR"/main*.js; do
# envsubst заменяет в файлах плейсхолдеры на значения из переменных окружения
echo "$(envsubst "$ENV_VARS" < "$f")" > "$f"
done
}
compress () {
for i in $(find "$TARGET_DIR" | grep -E ".css$|.html$|.js$|.svg$|.txt$|.ttf


quot;); do
# Используем максимальную степень сжатия
gzip -9kf "$i" && brotli -fZ "$i"
done
}
if [ "$1" = 'nginx' ]; then
# Флаг нужен, чтобы выполнить скрипт только при самом первом запуске
if [ ! -e "$FLAG_FILE" ]; then
echo "Running init script"
echo "Replacing env vars"
replace_vars
echo "Compressing files"
compress
touch $FLAG_FILE
echo "Done"
fi
fi
exec "$@"

Para que el script haga su trabajo, la configuración debe escribirse en los archivos js de esta forma: ${API_URL}.

Vale la pena señalar que la mayoría de los SPA modernos agregan hashes a sus archivos durante la compilación. Esto es necesario para que el navegador pueda almacenar en caché el archivo de forma segura durante mucho tiempo. Si el archivo cambia, su hash cambiará, lo que a su vez obligará al navegador a descargar el archivo nuevamente.

Desafortunadamente, en mi método, cambiar la configuración a través de variables de entorno no conduce a un cambio en el hash del archivo, lo que significa que el caché del navegador debe invalidarse de alguna otra manera. No tengo este problema porque se implementan diferentes configuraciones en diferentes entornos.

Armando la imagen final

Por último.

Dockerfile

# Первый базовый образ для сборки
FROM alexxxnf/spa-builder as builder
# Чтобы эффктивнее использовать кэш Docker-а, сначала устанавливаем только зависимости
COPY ./package.json ./package-lock.json /app/
RUN cd /app && npm ci --no-audit
# Потом собираем само приложение
COPY . /app
RUN cd /app && npm run build -- --prod --configuration=docker

# Второй базовый образ для раздачи
FROM alexxxnf/nginx-spa
# Забираем из первого образа сначала компрессор
COPY --from=builder /usr/local/bin/brotli /usr/local/bin
# Потом добавляем чудо-скрипт
COPY ./docker/docker-entrypoint.sh /docker-entrypoint.sh
# И в конце забираем само приложение
COPY --from=builder /app/dist/app /etc/nginx/html/
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]

Ahora la imagen resultante se puede ensamblar y utilizar en cualquier lugar.

Fuente: habr.com