Une Application à Une Page (SPA) est un ensemble de fichiers JavaScript et HTML statiques, ainsi que d'images et d'autres ressources. Comme ils ne changent pas dynamiquement, il est très facile de les publier sur internet. Il existe de nombreux services bon marché, voire gratuits, allant de GitHub Pages (et pour certains, même de narod.ru) à des CDN comme Amazon S3. Cependant, j'avais besoin de quelque chose de différent.
J'avais besoin d'une image Docker avec SPA, pour la déployer facilement aussi bien en production dans un cluster Kubernetes que sur la machine du développeur back-end, qui n'a pas la moindre idée de ce qu'est une SPA.
J'ai défini les exigences suivantes pour l'image :
- facilité d'utilisation (mais pas de construction difficile);
- taille minimale tant en termes d'espace disque qu'en RAM;
- configuration par variables d'environnement, afin que l'image puisse être utilisée dans différents environnements;
- distribution de fichiers maximisée en efficacité.
Aujourd'hui, je vais expliquer comment :
- vider nginx;
- compiler brotli à partir des sources;
- faire en sorte que les fichiers statiques comprennent les variables d'environnement;
- et bien sûr, comment construire le tout en une image Docker.
L'objectif de cet article est de partager mon expérience et de provoquer des critiques constructives de la part des membres expérimentés de la communauté.
Construction de l'image pour le build.
Pour que l'image Docker finale soit petite, il faut respecter deux règles : un minimum de couches et une base minimaliste. L'une des plus petites images de base est celle d'Alpine Linux, c'est donc celle que je choisirai. Certaines personnes pourraient objecter qu'Alpine n'est pas adapté à la production et pourraient avoir raison. Mais personnellement, je n'ai jamais eu de problèmes avec, et je n'ai aucun argument contre cela.
Pour réduire le nombre de couches, je vais construire l'image en 2 étapes. La première étape sera une ébauche, contenant tous les utilitaires auxiliaires et les fichiers temporaires. Dans l'étape finale, je n'enregistrerai que la version définitive de l'application.
Commencez par l'image auxiliaire.
Pour compiler une application SPA, on a généralement besoin de node.js. Je vais prendre l'image officielle qui comprend également les gestionnaires de paquets npm et yarn. J'ajouterai node-gyp, nécessaire à la compilation de certains paquets npm, et le compresseur Brotli de Google, qui nous sera utile plus tard.
Dockerfile avec commentaires.
# Базовый образ
FROM node:12-alpine
LABEL maintainer="Aleksey Maydokin <amaydokin@gmail.com>"
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/srcDéjà ici, je vise le minimalisme, donc l'image est construite d'une seule grande commande.
Vous pouvez trouver l'image prête ici : . Bien que je recommande de ne pas se fier aux images des autres et de créer la vôtre.
nginx
Pour servir des fichiers statiques, n'importe quel serveur web peut être utilisé. Je suis habitué à travailler avec nginx, donc je vais l'utiliser encore cette fois.
Il existe une image Docker officielle pour nginx, mais pour une simple distribution de fichiers statiques, il y a trop de modules inclus. Vous pouvez voir lesquels sont inclus dans la livraison en utilisant une commande spéciale ou dans le Dockerfile officiel.
$ docker run —rm nginx:1-alpine nginx -V
version de nginx : nginx/1.17.9
construit par gcc 8.3.0 (Alpine 8.3.0)
construit avec OpenSSL 1.1.1d 10 Sep 2019
Support SNI TLS activé
arguments de configuration : --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-neededJe vais prendre comme base le Dockerfile, mais je ne laisserai que ce qui est nécessaire pour servir des fichiers statiques. Ma version ne pourra pas fonctionner en HTTPS, ne prendra pas en charge l'authentification et bien d'autres choses. En revanche, ma version pourra servir des fichiers compressés avec l'algorithme Brotli, qui est un peu plus efficace que gzip. Les fichiers seront compressés une seule fois, il n'est pas nécessaire de le faire à la volée.
Voici le Dockerfile que j'ai obtenu. Les commentaires en russe sont les miens, ceux en anglais sont de l'original.
Dockerfile
# Базовый образ снова Alpine
FROM alpine:3.9
LABEL maintainer="Aleksey Maydokin <amaydokin@gmail.com>"
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 pour que gzip et brotli soient activés par défaut. J'activerai également les en-têtes de cache, car nous servirons des fichiers statiques qui ne changent jamais. Et la dernière touche sera de rediriger toutes les requêtes 404 vers index.html, ce qui est nécessaire pour la navigation dans une 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 prête ici : . Il prend 10,5 Mo. L'original nginx prenait 19,7 Mo. Mon intérêt sportif est satisfait.
Nous apprenons à comprendre les variables d'environnement du statique
Pourquoi des configurations peuvent-elles être nécessaires dans un SPA ? Par exemple, pour indiquer quelle API RESTful utiliser. En général, les configurations pour l'environnement requis sont transmises au SPA lors du processus de construction. Si quelque chose doit être modifié, l'application devra être reconstruite. Je ne veux pas cela. Je veux que l'application soit construite une seule fois lors de l'étape CI, et configurée autant que nécessaire lors de l'étape CD à l'aide des variables d'environnement.
Évidemment, les fichiers statiques eux-mêmes ne comprennent pas de variables d'environnement. Donc, je devrai utiliser une petite astuce. Dans l'image finale, je ne vais pas exécuter nginx, mais un script shell spécial qui lira les variables d'environnement, écrira dans les fichiers statiques, les compressera et seulement après cela passera le contrôle à nginx.
Pour cela, le paramètre ENTRYPOINT est prévu dans le Dockerfile. Nous allons passer ce script (par exemple pour Angular) :
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"
done
}
compress () {
for i in $(find "$TARGET_DIR" | grep -E ".css$|.html$|.js$|.svg$|.txt$|.ttfquot;); 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 configurations dans les fichiers js doivent être écrites de cette manière :${API_URL}.Il convient de noter que la plupart des SPA modernes ajoutent des hachages à leurs fichiers lors de la construction. Cela permet au navigateur de mettre en cache le fichier pendant une longue période. Si le fichier change, son hachage changera également, ce qui poussera le navigateur à télécharger à nouveau le fichier.
Malheureusement, dans ma méthode, changer la configuration via des variables d'environnement ne modifie pas le hachage du fichier, il faut donc invalider le cache du navigateur d'une autre manière. Je n'ai pas ce problème, car différentes configurations sont déployées dans différents environnements.
Nous rassemblons 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;"]Maintenant, l'image obtenue peut être assemblée et utilisée partout.
Source : habr.com
