Image Docker pour la distribution de l'application à page unique

Une application monopage (SPA) est un ensemble de fichiers JavaScript et HTML statiques, ainsi que d'images et d'autres ressources. Parce qu’ils ne changent pas de manière dynamique, les publier en ligne est très simple. Il existe un grand nombre de services bon marché et même gratuits pour cela, en commençant par de simples pages GitHub (et pour certains même avec narod.ru) et en terminant par un CDN comme Amazon S3. Cependant, j'avais besoin d'autre chose.

J'avais besoin d'une image Docker avec SPA pour qu'elle puisse être facilement lancée à la fois en production dans le cadre d'un cluster Kubernetes et sur la machine d'un développeur back-end qui n'a aucune idée de ce qu'est SPA.

J'ai déterminé moi-même les exigences d'image suivantes :

  • facilité d'utilisation (mais pas de montage) ;
  • taille minimale tant en termes de disque que de RAM ;
  • configuration via des variables d'environnement afin que l'image puisse être utilisée dans différents environnements ;
  • la distribution la plus efficace des fichiers.

Aujourd'hui, je vais vous expliquer comment :

  • intestin nginx;
  • construire du brotli à partir de sources ;
  • apprendre aux fichiers statiques à comprendre les variables d'environnement ;
  • et bien sûr comment assembler une image Docker à partir de tout ça.

Le but de cet article est de partager mon expérience et de provoquer des critiques constructives des membres expérimentés de la communauté.

Construire une image pour l'assemblage

Pour que l'image Docker finale soit de petite taille, vous devez respecter deux règles : un minimum de calques et une image de base minimaliste. L'une des plus petites images de base est l'image Alpine Linux, c'est donc celle que je vais choisir. Certains diront peut-être que l’Alpine n’est pas adaptée à la production, et ils pourraient avoir raison. Mais personnellement, je n’ai jamais eu de problèmes avec lui et il n’y a aucun argument contre lui.

Pour avoir moins de calques, je vais assembler l'image en 2 étapes. Le premier est un brouillon : tous les utilitaires auxiliaires et fichiers temporaires y resteront. Et dans la phase finale, je n'écrirai que la version finale de l'application.

Commençons par l'image auxiliaire.

Afin de compiler une application SPA, vous avez généralement besoin de node.js. Je vais prendre l'image officielle, qui est également fournie avec les gestionnaires de packages npm et fil. En mon nom personnel, j'ajouterai node-gyp, qui est nécessaire pour construire certains packages npm, et le compresseur Brotli de Google, qui nous sera utile plus tard.

Fichier Docker avec commentaires.

# Базовый образ
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

Déjà ici, je me bats pour le minimalisme, donc l'image est composée par une grande équipe.

L'image terminée peut être trouvée ici : https://hub.docker.com/r/alexxxnf/spa-builder. Bien que je recommande de ne pas vous fier aux images des autres et de collecter les vôtres.

nginx

Vous pouvez utiliser n'importe quel serveur Web pour distribuer du contenu statique. J'ai l'habitude de travailler avec nginx, je vais donc l'utiliser maintenant.

Nginx a une image Docker officielle, mais elle contient trop de modules pour une distribution statique simple. Ceux qui sont inclus dans la livraison peuvent être vus par une équipe spéciale ou dans le Dockerfile officiel.

$ 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

J'utiliserai le Dockerfile comme base, mais je n'y laisserai que ce qui est nécessaire pour distribuer du contenu statique. Ma version ne pourra pas fonctionner via HTTPS, ne prendra pas en charge l'autorisation et bien plus encore. Mais ma version pourra distribuer des fichiers compressés avec l'algorithme Brotli, légèrement plus efficace que gzip. Nous compresserons les fichiers une seule fois ; il n’est pas nécessaire de le faire à la volée.

C'est le Dockerfile avec lequel je me suis retrouvé. Les commentaires en russe sont les miens, en anglais - de l'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;"]

Je vais immédiatement corriger nginx.conf afin que gzip et brotli soient activés par défaut. J'inclurai également les en-têtes de mise en cache, car nous n'aurons jamais de changement statique. Et la touche finale sera de rediriger toutes les requêtes 404 vers index.html, cela est nécessaire à la navigation dans le 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";
            }
        }
    }
}

Vous pouvez télécharger l'image terminée ici : https://hub.docker.com/r/alexxxnf/nginx-spa. Cela prend 10,5 Mo. Le nginx d'origine occupait 19,7 Mo. Mon intérêt sportif est satisfait.

Enseigner la statique pour comprendre les variables d'environnement

Pourquoi des paramètres peuvent-ils être nécessaires dans SPA ? Par exemple, afin de spécifier quelle API RESTful utiliser. En règle générale, les paramètres de l'environnement souhaité sont transférés vers SPA au stade de la construction. Si vous devez modifier quelque chose, vous devrez reconstruire l'application. Je ne le veux pas. Je souhaite que l'application soit construite une fois au stade CI et configurée autant que nécessaire au stade CD à l'aide de variables d'environnement.

Bien entendu, les fichiers statiques eux-mêmes ne comprennent aucune variable d’environnement. Par conséquent, vous devrez utiliser une astuce. Dans l'image finale, je ne lancerai pas nginx, mais un script shell spécial qui lira les variables d'environnement, les écrira dans des fichiers statiques, les compressera et transférera ensuite le contrôle à nginx.

A cet effet, le Dockerfile fournit le paramètre ENTRYPOINT. Donnons-lui le script suivant (en utilisant Angular comme exemple) :

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 "$@"

Pour que le script fasse son travail, les paramètres doivent être écrits dans les fichiers js sous cette forme : ${API_URL}.

Il convient de noter que la plupart des SPA modernes ajoutent des hachages à leurs fichiers lors de la construction. Cela est nécessaire pour que le navigateur puisse mettre le fichier en cache en toute sécurité pendant une longue période. Si le fichier change, son hachage changera, ce qui obligera le navigateur à télécharger à nouveau le fichier.

Malheureusement, dans ma méthode, la modification de la configuration via des variables d'environnement n'entraîne pas de modification du hachage du fichier, ce qui signifie que le cache du navigateur doit être invalidé d'une autre manière. Je n'ai pas ce problème car différentes configurations sont déployées dans différents environnements.

Assembler l'image finale

Enfin.

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

L’image résultante peut désormais être assemblée et utilisée n’importe où.

Source: habr.com