Docker-Image zur Bereitstellung einer Single Page Application

Eine Single-Page-Anwendung (SPA) ist eine Sammlung von statischen JavaScript- und HTML-Dateien sowie Bildern und anderen Ressourcen. Da diese nicht dynamisch verändert werden, ist es sehr einfach, sie im Internet zu veröffentlichen. Es gibt viele günstige und sogar kostenlose Services, von einfachen GitHub Pages (für manche vielleicht sogar von narod.ru) bis hin zu CDNs wie Amazon S3. Ich benötigte jedoch etwas anderes.

Ich brauchte ein Docker-Image mit einer SPA, um es sowohl in der Produktion im Rahmen eines Kubernetes-Clusters als auch auf dem Rechner eines Back-End-Entwicklers, der keine Ahnung von SPAs hat, einfach starten zu können.

Ich habe mir folgende Anforderungen an das Image definiert:

  • Einfachheit in der Nutzung (aber nicht im Aufbau);
  • Minimale Größe sowohl hinsichtlich der Festplatte als auch des RAM;
  • Konfiguration über Umgebungsvariablen, damit das Image in verschiedenen Umgebungen genutzt werden kann;
  • Maximal effiziente Verteilung von Dateien.

Heute werde ich zeigen, wie man:

  • nginx ausräumt;
  • brotli aus den Quellen zusammenstellt;
  • statische Dateien darauf trainiert, Umgebungsvariablen zu verstehen;
  • und natürlich, wie man daraus ein Docker-Image erstellt.

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

Image-Build für die Erstellung

Um ein finales Docker-Image klein zu halten, ist es wichtig, zwei Regeln zu beachten: Minimierung der Schichten und Verwendung eines minimalistischen Basis-Images. Eines der kleinsten Basis-Images ist das Alpine Linux-Image, daher werde ich genau dieses wählen. Einige mögen argumentieren, dass Alpine für die Produktion nicht geeignet ist, und könnten recht haben. Aber persönlich hatte ich nie Probleme damit, und ich habe keine Gegenargumente dazu.

Um die Anzahl der Schichten zu reduzieren, werde ich das Image in zwei Schritten erstellen. Der erste Schritt ist ein Entwurf, in dem alle Hilfsprogramme und temporären Dateien verbleiben werden. In der endgültigen Version werde ich nur die fertige Anwendung festhalten.

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 mitbringt. Von meiner Seite füge ich node-gyp hinzu, das für die Erstellung bestimmter npm-Pakete benötigt wird, sowie den Brotli-Kompressor von Google, der uns später nützlich sein wird.

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 setze ich auf Minimalismus, weshalb das Image von einem großen Team erstellt wird.

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 Bereitstellung von statischen Dateien kann jeder Webserver verwendet werden. Ich habe mich an nginx gewöhnt, daher werde ich auch jetzt damit arbeiten.

nginx hat ein offizielles Docker-Image, aber für die einfache Bereitstellung von statischen Dateien sind darin zu viele Module enthalten. Welche genau in die Lieferung aufgenommen wurden, kann mit einem speziellen Befehl oder im offiziellen Dockerfile angezeigt werden.

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

nginx-Version: nginx/1.17.9
gebaut mit gcc 8.3.0 (Alpine 8.3.0) 
gebaut mit OpenSSL 1.1.1d  10. Sep 2019
TLS SNI Unterstützung aktiviert
Konfigurationsparameter: --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 auf einem Dockerfile basieren, aber nur das für die Bereitstellung von statischen Dateien Notwendige darin belassen. Meine Version wird nicht über HTTPS funktionieren, keine Authentifizierung unterstützen und vieles mehr. Dafür wird meine Version jedoch Dateien liefern, die mit dem Brotli-Algorithmus komprimiert sind, der etwas effizienter ist als gzip. Wir werden die Dateien einmal komprimieren; es ist nicht notwendig, dies im laufenden Betrieb zu tun.

Das ist das Dockerfile, das ich erstellt habe. Die Kommentare auf Russisch sind von mir, 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 sofort die nginx.conf anpassen, damit gzip und Brotli standardmäßig aktiviert sind. Ich werde auch die Cache-Header aktivieren, da wir statische Dateien bereitstellen werden, die sich niemals ändern. Als letztes werde ich eine Weiterleitung aller 404-Anfragen auf index.html implementieren, was für die Navigation in einer SPA notwendig 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 Abbild herunterladen: https://hub.docker.com/r/alexxxnf/nginx-spa. Es belegt 10,5 MB. Die Originalversion von nginx hatte 19,7 MB. Mein sportliches Interesse ist gestillt.

Wir lehren die Statik, Umgebungsvariablen zu 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 gewünschte 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 in der CI-Phase gebaut wird und so oft wie nötig in der CD-Phase konfiguriert wird, mithilfe von Umgebungsvariablen.

Natürlich verstehen statische Dateien von sich aus keine Umgebungsvariablen. Daher muss ein Trick angewendet werden. Im finalen Image werde ich nicht nginx ausführen, sondern ein spezielles Shell-Skript, das die Umgebungsvariablen liest, sie in die statischen Dateien schreibt, sie komprimiert und erst dann die Kontrolle an nginx übergibt.

Dafür ist im Dockerfile der Parameter ENTRYPOINT vorgesehen. Übergeben wir ihm ein solches Skript (am Beispiel 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 "

'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

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


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

Es ist erwähnenswert, dass die meisten modernen SPAs beim Build Hashes zu ihren Dateien hinzufügen. Dies ist notwendig, damit der Browser die Datei sicher für eine lange Zeit cachen kann. Ändert sich die Datei, ändert sich auch ihr Hash, was wiederum den Browser zwingt, die Datei erneut herunterzuladen.

Leider führt in meiner Methode die Änderung der Konfiguration über Umgebungsvariablen nicht zu einer Änderung des Datei-Hashes, was bedeutet, dass der Browser-Cache auf eine andere Weise ungültig gemacht werden muss. Dieses Problem habe ich nicht, da verschiedene Konfigurationen in verschiedenen Umgebungen bereitgestellt werden.

Das endgültige Image erstellen

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 entstandene Image zusammengestellt und überall verwendet werden.

Quelle: habr.com

Zuverlässiges Webhosting mit DDoS-Schutz, VPS- und VDS-Server kaufen 🔥 Zuverlässiges Webhosting mit DDoS-Schutz, VPS- und VDS-Server kaufen | ProHoster