Docker-Image zur Bereitstellung einer Single Page Application

Eine Single-Page-Anwendung (SPA) ist eine Sammlung statischer JavaScript- und HTML-Dateien sowie Bilder und anderer Ressourcen. Da sie sich nicht dynamisch ändern, ist es sehr einfach, sie im Internet zu veröffentlichen. Dafür gibt es eine Vielzahl von günstigen bis kostenlosen Diensten, von einfachen GitHub Pages (für einige sogar von narod.ru) bis zu CDN-Anbietern wie Amazon S3. Allerdings benötigte ich etwas anderes.

Ich benötigte ein Docker-Image mit einer SPA, damit es sowohl in der Produktion innerhalb eines Kubernetes-Clusters als auch auf dem Rechner eines Back-End-Entwicklers, der keine Ahnung von SPAs hat, einfach gestartet werden kann.

Ich habe mir folgende Anforderungen an das Image gesetzt:

  • Benutzerfreundlichkeit (aber nicht in der Zusammenstellung);
  • Minimaler Speicherbedarf sowohl in Bezug auf Disk als auch RAM;
  • Konfiguration über Umgebungsvariablen, damit das Image in verschiedenen Umgebungen verwendet werden kann;
  • Maximale Effizienz bei der Bereitstellung von Dateien.

Heute werde ich erläutern, wie man:

  • nginx entrümpelt;
  • Brotli aus den Quellen kompiliert;
  • statische Dateien so anpasst, dass sie Umgebungsvariablen verstehen;
  • und natürlich, wie man aus all dem ein Docker-Image erstellt.

Ziel dieses Artikels ist es, meine Erfahrungen zu teilen und erfahrene Mitglieder der Community zu konstruktiver Kritik zu bewegen.

Image für den Build-Prozess

Damit das finale Docker-Image klein ausfällt, sollten zwei Regeln beachtet werden: minimale Schichten und ein minimalistisches Basis-Image. Eines der kleinsten Basis-Images ist das Alpine Linux-Image, und genau das werde ich wählen. Einige könnten einwenden, dass Alpine nicht für die Produktion geeignet ist, und vielleicht hätten sie recht. Aber persönlich hatte ich nie Probleme damit, und es gibt keine Argumente gegen es.

Um die Anzahl der Schichten zu minimieren, werde ich das Image in zwei Phasen erstellen. Die erste – die Rohversion, die alle Hilfswerkzeuge und temporären Dateien enthält. In der sauberen Version werde ich nur die endgültige Version der Anwendung speichern.

Lassen Sie uns mit dem Hilfs-Image beginnen.

Um eine SPA-Anwendung zu kompilieren, wird normalerweise node.js benötigt. Ich werde das offizielle Image verwenden, das auch die Paketmanager npm und yarn beinhaltet. Von mir füge ich node-gyp hinzu, das für den Build bestimmter npm-Pakete benötigt wird, und den Brotli-Compiler von Google, den wir später benötigen werden.

Dockerfile mit Kommentaren.

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

Bereits hier kämpfe ich für Minimalismus, daher wird das Image mit einem großen Befehl erstellt.

Das fertige Image finden Sie hier: https://hub.docker.com/r/alexxxnf/spa-builder. Obwohl ich empfehle, sich nicht auf fremde Images zu verlassen und Ihr eigenes zu erstellen.

nginx

Für die Auslieferung von Statik kann jeder Webserver verwendet werden. Ich arbeite gerne mit nginx, deshalb werde ich ihn auch jetzt verwenden.

nginx hat ein offizielles Docker-Image, aber für die einfache Auslieferung von Statik sind darin zu viele Module enthalten. Welche genau im Lieferumfang enthalten sind, kann man mit einem speziellen Befehl oder im offiziellen Dockerfile einsehen.

$ docker run —rm nginx:1-alpine nginx -V

nginx version: nginx/1.17.9
gebaut von gcc 8.3.0 (Alpine 8.3.0) 
gebaut mit OpenSSL 1.1.1d  10. Sep 2019
TLS SNI Unterstützung aktiviert
Konfigurationsargumente: --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 das Dockerfile als Grundlage verwenden, aber nur das beibehalten, was für die Auslieferung von Statik notwendig ist. Meine Version wird nicht über HTTPS arbeiten, keine Authentifizierung unterstützen und vieles mehr. Stattdessen wird meine Version in der Lage sein, Dateien auszuliefern, die mit dem Brotli-Algorithmus komprimiert sind, der etwas effizienter ist als gzip. Wir werden die Dateien einmal komprimieren, es ist nicht notwendig, dies zur Laufzeit zu tun.

Das ist das Dockerfile, das ich erstellt habe. Die Kommentare auf Russisch sind meine, die auf Englisch stammen aus dem 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;"]

Ich werde die nginx.conf sofort anpassen, damit gzip und Brotli standardmäßig aktiviert sind. Außerdem werde ich Cache-Header aktivieren, da wir statische Inhalte ausliefern werden, die sich nie ändern. Und als letzten Schliff leite ich alle 404-Anfragen auf index.html um, was für die Navigation in einer SPA erforderlich ist.

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

Hier können Sie das fertige Image herunterladen: https://hub.docker.com/r/alexxxnf/nginx-spa. Es hat eine Größe von 10,5 MB. Das ursprüngliche nginx hatte 19,7 MB. Mein sportliches Interesse ist befriedigt.

Lernen, wie statische Dateien Umgebungsvariablen verstehen

Warum könnten in einer SPA Einstellungen benötigt werden? Zum Beispiel, um anzugeben, welche RESTful API verwendet werden soll. Normalerweise werden die Einstellungen für die jeweilige Umgebung während des Build-Prozesses an die SPA übergeben. Wenn etwas geändert werden muss, muss die Anwendung neu gebaut werden. Das möchte ich nicht. Ich möchte, dass die Anwendung einmal während der CI-Phase gebaut wird und dann so oft wie nötig in der CD-Phase mit Hilfe von Umgebungsvariablen konfiguriert wird.

Natürlich verstehen statische Dateien von sich aus keine Umgebungsvariablen. Daher muss ich einen Trick anwenden. Im endgültigen Image werde ich nicht nginx starten, sondern ein spezielles Shell-Skript, das die Umgebungsvariablen liest, sie in statische Dateien schreibt, sie komprimiert und erst danach die Kontrolle an nginx übergibt.

Dafür ist der Parameter ENTRYPOINT im Dockerfile vorgesehen. Wir werden ihm folgendes Skript übergeben (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-файлах
für f in "$TARGET_DIR"/main*.js; do
# envsubst заменяет в файлах плейсхолдеры на значения из переменных окружения
echo "$(envsubst "$ENV_VARS" "$f"
fertig
}
komprimieren () {
for i in $(find "$TARGET_DIR" | grep -E ".css$|.html$|.js$|.svg$|.txt$|.ttf

quot;); do
# Используем максимальную степень сжатия
gzip -9kf "$i" && brotli -fZ "$i"
fertig
}
if [ "$1" = 'nginx' ]; then
# Флаг нужен, чтобы выполнить скрипт только при самом первом запуске
if [ ! -e "$FLAG_FILE" ]; then
echo "Initialisierungsskript wird ausgeführt"
echo "Umgebungsvariablen werden ersetzt"
variablen_ersetzen
echo "Dateien werden komprimiert"
compress
touch $FLAG_FILE
echo "Fertig"
fi
fi
exec "$@"


Damit das Skript seinen Job macht, müssen die Einstellungen in den js-Dateien in folgender Form geschrieben werden: ${API_URL}.

Es sei darauf hingewiesen, dass die meisten modernen SPAs beim Build- Prozess ihren Dateien Hashes hinzufügen. Dies ist nötig, damit der Browser die Datei ohne Bedenken über einen längeren Zeitraum cachen kann. Wenn sich die Datei ändert, ändert sich auch ihr Hash, was den Browser dazu bringt, die Datei erneut herunterzuladen.

Leider führt in meiner Methode eine Änderung der Konfiguration über Umgebungsvariablen nicht zu einer Änderung des Dateihashes, was bedeutet, dass der Browser-Cache auf andere Weise invalidiert werden muss. Ich habe dieses Problem nicht, da verschiedene Konfigurationen in unterschiedlichen Umgebungen bereitgestellt werden.

Wir erstellen das endgültige Image

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 fertige Image gebaut und überall verwendet werden.

Quelle: habr.com

60GB SSD 8Gb DDR4