Imaxe Docker para a distribución da aplicación de páxina única

A aplicación de páxina única (SPA) é un conxunto de ficheiros JavaScript e HTML estáticos, así como imaxes e outros recursos. Como non cambian de forma dinámica, publicalos en liña é moi sinxelo. Hai un gran número de servizos baratos e mesmo gratuítos para iso, comezando por unha simple páxinas de GitHub (e para algúns mesmo con narod.ru) e rematando cun CDN como Amazon S3. Non obstante, necesitaba outra cousa.

Necesitaba unha imaxe de Docker con SPA para que puidese lanzarse facilmente tanto en produción como parte dun clúster de Kubernetes, como na máquina dun programador back-end que non ten nin idea do que é SPA.

Determinei os seguintes requisitos de imaxe para min:

  • facilidade de uso (pero non montaxe);
  • tamaño mínimo tanto en disco como en memoria RAM;
  • configuración a través de variables de ambiente para que a imaxe poida ser usada en diferentes ambientes;
  • a distribución máis eficiente de ficheiros.

Hoxe vouvos contar como:

  • intestino nginx;
  • construír brotli a partir de fontes;
  • ensinar ficheiros estáticos a comprender as variables de ambiente;
  • e por suposto como montar unha imaxe de Docker a partir de todo isto.

O propósito deste artigo é compartir a miña experiencia e provocar a crítica construtiva dos membros experimentados da comunidade.

Construír unha imaxe para a súa montaxe

Para que a imaxe de Docker final sexa pequena, debes cumprir dúas regras: un mínimo de capas e unha imaxe base minimalista. Unha das imaxes de base máis pequenas é a imaxe de Alpine Linux, así que é o que escollerei. Algúns poderían argumentar que o Alpine non é axeitado para a produción, e poden ter razón. Pero persoalmente, nunca tiven ningún problema con el e non hai argumentos contra el.

Para ter menos capas, montarei a imaxe en 2 etapas. O primeiro é un borrador; nel permanecerán todas as utilidades auxiliares e ficheiros temporais. E na fase final só escribirei a versión final da aplicación.

Imos comezar coa imaxe auxiliar.

Para compilar unha aplicación SPA, normalmente necesitas node.js. Tomarei a imaxe oficial, que tamén vén cos xestores de paquetes npm e yarn. No meu propio nome, engadirei node-gyp, que é necesario para construír algúns paquetes npm, e o compresor Brotli de Google, que nos será útil máis adiante.

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

Xa aquí estou loitando polo minimalismo, polo que a imaxe é xuntada por un gran equipo.

A imaxe rematada pódese atopar aquí: https://hub.docker.com/r/alexxxnf/spa-builder. Aínda que recomendo non confiar nas imaxes alleas e recoller as túas.

Nginx

Podes usar calquera servidor web para distribuír contido estático. Estou afeito a traballar con nginx, así que o vou usar agora.

Nginx ten unha imaxe oficial de Docker, pero ten demasiados módulos para unha distribución estática sinxela. Cales están incluídos na entrega poden ser vistos por un equipo especial ou no Dockerfile oficial.

$ docker run --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

Utilizarei o Dockerfile como base, pero deixarei nel só o necesario para distribuír contido estático. A miña versión non poderá funcionar con HTTPS, non admitirá autorización e moito máis. Pero a miña versión poderá distribuír ficheiros comprimidos co algoritmo Brotli, que é un pouco máis eficiente que gzip. Comprimiremos os ficheiros unha vez; non hai necesidade de facelo sobre a marcha.

Este é o Dockerfile co que acabei. Os comentarios en ruso son meus, en inglés - do orixinal.

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;"]

Inmediatamente repararei nginx.conf para que gzip e brotli estean activados por defecto. Tamén incluirei cabeceiras de caché, porque nunca teremos cambios estáticos. E o toque final será redirixir as 404 solicitudes a index.html, isto é necesario para a navegación no 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";
            }
        }
    }
}

Podes descargar a imaxe rematada aquí: https://hub.docker.com/r/alexxxnf/nginx-spa. Ocupa 10,5 MB. O nginx orixinal ocupaba 19,7 MB. O meu interese deportivo está satisfeito.

Ensinar a estática para comprender as variables ambientais

Por que pode ser necesaria a configuración no SPA? Por exemplo, para especificar que API RESTful usar. Normalmente, a configuración do ambiente desexado transfírese a SPA na fase de construción. Se necesitas cambiar algo, terás que reconstruír a aplicación. Non o quero. Quero que a aplicación se constrúe unha vez na fase de CI e se configure tanto como sexa necesario na fase de CD usando variables de ambiente.

Por suposto, os ficheiros estáticos non entenden ningunha variable de ambiente. Polo tanto, terás que usar un truco. Na imaxe final, non lanzarei nginx, senón un script de shell especial que lerá variables de ambiente, escribilos en ficheiros estáticos, comprimilos e só despois transferirá o control a nginx.

Para este fin, o Dockerfile proporciona o parámetro ENTRYPOINT. Imos darlle o seguinte script (usando Angular como exemplo):

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 o script faga o seu traballo, a configuración debe escribirse nos ficheiros js deste formulario: ${API_URL}.

Paga a pena notar que a maioría dos SPA modernos engaden hash aos seus ficheiros ao construír. Isto é necesario para que o navegador poida almacenar o ficheiro na caché durante moito tempo. Se o ficheiro cambia, o seu hash cambiará, o que á súa vez obrigará ao navegador a descargar o ficheiro de novo.

Desafortunadamente, no meu método, cambiar a configuración a través de variables de ambiente non leva a un cambio no hash do ficheiro, o que significa que a caché do navegador debe ser invalidada doutro xeito. Non teño este problema porque se implementan diferentes configuracións en diferentes ambientes.

Xuntando a imaxe final

Finalmente.

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;"]

Agora a imaxe resultante pódese montar e usar en calquera lugar.

Fonte: www.habr.com