Docker-Image für die Verteilung von Single Page Application

Bei einer Single-Page-Anwendung (SPA) handelt es sich um eine Reihe statischer JavaScript- und HTML-Dateien sowie Bilder und anderer Ressourcen. Da sie sich nicht dynamisch ändern, ist die Online-Veröffentlichung sehr einfach. Dafür gibt es eine Vielzahl günstiger und sogar kostenloser Dienste, angefangen bei einfachen GitHub Pages (und für manche sogar bei narod.ru) bis hin zu einem CDN wie Amazon S3. Allerdings brauchte ich etwas anderes.

Ich brauchte ein Docker-Image mit SPA, damit es problemlos sowohl in der Produktion als Teil eines Kubernetes-Clusters als auch auf dem Rechner eines Back-End-Entwicklers gestartet werden konnte, der keine Ahnung hat, was SPA ist.

Folgende Bildanforderungen habe ich für mich ermittelt:

  • Benutzerfreundlichkeit (aber nicht Montage);
  • Mindestgröße sowohl in Bezug auf Festplatte als auch RAM;
  • Konfiguration durch Umgebungsvariablen, sodass das Bild in verschiedenen Umgebungen verwendet werden kann;
  • die effizienteste Verteilung von Dateien.

Heute erzähle ich Ihnen, wie:

  • Darm-Nginx;
  • Brotli aus Quellen bauen;
  • Bringen Sie statischen Dateien bei, Umgebungsvariablen zu verstehen.
  • und natürlich, wie man daraus ein Docker-Image zusammenstellt.

Der Zweck dieses Artikels besteht darin, meine Erfahrungen zu teilen und erfahrene Community-Mitglieder zu konstruktiver Kritik anzuregen.

Erstellen eines Images für die Montage

Um das endgültige Docker-Image klein zu machen, müssen Sie zwei Regeln einhalten: ein Minimum an Ebenen und ein minimalistisches Basisimage. Eines der kleinsten Basis-Images ist das Alpine-Linux-Image, daher werde ich dieses auswählen. Manche mögen argumentieren, dass der Alpine nicht für die Produktion geeignet sei, und sie könnten Recht haben. Aber ich persönlich hatte nie Probleme mit ihm und es gibt keine Argumente gegen ihn.

Um weniger Ebenen zu haben, werde ich das Bild in zwei Schritten zusammensetzen. Beim ersten handelt es sich um einen Entwurf; alle Hilfsprogramme und temporären Dateien bleiben darin erhalten. Und im letzten Schritt werde ich nur noch die endgültige Fassung des Antrags niederschreiben.

Beginnen wir mit dem Hilfsbild.

Um eine SPA-Anwendung zu kompilieren, benötigen Sie normalerweise node.js. Ich nehme das offizielle Image, das auch mit den Paketmanagern npm und Yarn geliefert wird. In meinem eigenen Namen werde ich node-gyp hinzufügen, das zum Erstellen einiger npm-Pakete benötigt wird, und den Brotli-Kompressor von Google, der uns später nützlich sein wird.

Dockerfile mit Kommentaren.

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

Schon hier kämpfe ich für Minimalismus, daher wird das Bild von einem großen Team zusammengestellt.

Das fertige Bild finden Sie hier: https://hub.docker.com/r/alexxxnf/spa-builder. Allerdings empfehle ich, sich nicht auf die Bilder anderer zu verlassen und eigene zu sammeln.

Sie können jeden Webserver zum Verteilen statischer Inhalte verwenden. Ich bin es gewohnt, mit Nginx zu arbeiten, also werde ich es jetzt verwenden.

Nginx hat ein offizielles Docker-Image, aber es enthält zu viele Module für eine einfache statische Verteilung. Welche im Lieferumfang enthalten sind, kann ein spezielles Team oder das offizielle Dockerfile einsehen.

$ 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

Ich werde die Docker-Datei als Grundlage verwenden, aber nur das darin belassen, was zur Verteilung statischer Inhalte benötigt wird. Meine Version kann nicht über HTTPS funktionieren, unterstützt keine Autorisierung und vieles mehr. Aber meine Version wird in der Lage sein, mit dem Brotli-Algorithmus komprimierte Dateien zu verteilen, der etwas effizienter als gzip ist. Wir werden die Dateien nur einmal komprimieren; es ist nicht nötig, dies spontan zu tun.

Dies ist die Docker-Datei, die ich erhalten habe. Kommentare auf Russisch stammen von mir, auf Englisch - vom 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;"]

Ich werde nginx.conf sofort reparieren, sodass gzip und brotli standardmäßig aktiviert sind. Ich werde auch Caching-Header einbeziehen, da sich die statische Aufladung nie ändern wird. Und der letzte Schliff besteht darin, alle 404-Anfragen an index.html umzuleiten, dies ist für die Navigation im SPA notwendig.

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

Das fertige Bild können Sie hier herunterladen: https://hub.docker.com/r/alexxxnf/nginx-spa. Es benötigt 10,5 MB. Der ursprüngliche Nginx belegte 19,7 MB. Mein sportliches Interesse ist befriedigt.

Statik lehren, um Umgebungsvariablen zu verstehen

Warum sind möglicherweise Einstellungen in SPA erforderlich? Beispielsweise um festzulegen, welche RESTful API verwendet werden soll. Typischerweise werden die Einstellungen für die gewünschte Umgebung in der Build-Phase an SPA übertragen. Wenn Sie etwas ändern müssen, müssen Sie die Anwendung neu erstellen. Ich will es nicht. Ich möchte, dass die Anwendung einmal in der CI-Phase erstellt und in der CD-Phase mithilfe von Umgebungsvariablen so weit wie nötig konfiguriert wird.

Natürlich verstehen statische Dateien selbst keine Umgebungsvariablen. Daher müssen Sie einen Trick anwenden. Im endgültigen Image werde ich nicht Nginx starten, sondern ein spezielles Shell-Skript, das Umgebungsvariablen liest, sie in statische Dateien schreibt, sie komprimiert und erst dann die Kontrolle an Nginx überträgt.

Zu diesem Zweck stellt das Dockerfile den Parameter ENTRYPOINT zur Verfügung. Geben wir ihm das folgende Skript (am Beispiel von 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")" > "$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 "$@"

Damit das Skript seine Aufgabe erfüllen kann, müssen die Einstellungen in dieser Form in die js-Dateien geschrieben werden: ${API_URL}.

Es ist erwähnenswert, dass die meisten modernen SPAs beim Erstellen Hashes zu ihren Dateien hinzufügen. Dies ist notwendig, damit der Browser die Datei für längere Zeit sicher zwischenspeichern kann. Wenn sich die Datei tatsächlich ändert, ändert sich auch ihr Hash, was wiederum dazu führt, dass der Browser die Datei erneut herunterlädt.

Leider führt bei meiner Methode die Änderung der Konfiguration über Umgebungsvariablen nicht zu einer Änderung des Datei-Hashs, was bedeutet, dass der Browser-Cache auf andere Weise ungültig gemacht werden muss. Ich habe dieses Problem nicht, da unterschiedliche Konfigurationen in unterschiedlichen Umgebungen bereitgestellt werden.

Zusammenstellen des endgültigen Bildes

Endlich

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

Jetzt kann das resultierende Bild zusammengestellt und überall verwendet werden.

Source: habr.com