Immagine Docker per la distribuzione dell'applicazione a pagina singola

L'applicazione a pagina singola (SPA) è un insieme di file JavaScript e HTML statici, nonché immagini e altre risorse. Poiché non cambiano dinamicamente, pubblicarli online è molto semplice. A questo scopo esistono numerosi servizi economici e persino gratuiti, a partire da un semplice GitHub Pages (e per alcuni anche con narod.ru) e terminando con un CDN come Amazon S3. Tuttavia, avevo bisogno di qualcos'altro.

Avevo bisogno di un'immagine Docker con SPA in modo che potesse essere facilmente avviata sia in produzione come parte di un cluster Kubernetes, sia sulla macchina di uno sviluppatore back-end che non ha idea di cosa sia SPA.

Ho determinato personalmente i seguenti requisiti di immagine:

  • facilità d'uso (ma non di montaggio);
  • dimensione minima sia in termini di disco che di RAM;
  • configurazione tramite variabili d'ambiente in modo che l'immagine possa essere utilizzata in diversi ambienti;
  • la distribuzione più efficiente dei file.

Oggi ti dirò come:

  • gengiva intestinale;
  • costruire brotli dalle fonti;
  • insegnare ai file statici a comprendere le variabili di ambiente;
  • e ovviamente come assemblare un'immagine Docker da tutto questo.

Lo scopo di questo articolo è condividere la mia esperienza e provocare membri esperti della comunità a critiche costruttive.

Costruire un'immagine per l'assemblaggio

Per ridurre le dimensioni dell'immagine Docker finale, è necessario rispettare due regole: un minimo di livelli e un'immagine di base minimalista. Una delle immagini di base più piccole è l'immagine Alpine Linux, quindi è quella che sceglierò. Alcuni potrebbero obiettare che l’Alpine non è adatto alla produzione, e potrebbero avere ragione. Ma personalmente non ho mai avuto problemi con lui e non ci sono argomenti contro di lui.

Per avere meno strati, assemblerò l'immagine in 2 fasi. La prima è una bozza: tutte le utilità ausiliarie e i file temporanei rimarranno al suo interno. E nella fase finale scriverò solo la versione finale dell'applicazione.

Cominciamo con l'immagine ausiliaria.

Per compilare un'applicazione SPA, di solito è necessario node.js. Prenderò l'immagine ufficiale, che viene fornita anche con i gestori di pacchetti npm e filato. Per conto mio aggiungerò node-gyp, necessario per creare alcuni pacchetti npm, e il compressore Brotli di Google, che ci sarà utile in seguito.

Dockerfile con commenti.

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

Già qui sto lottando per il minimalismo, quindi l'immagine è messa insieme da un grande team.

L'immagine finita può essere trovata qui: https://hub.docker.com/r/alexxxnf/spa-builder. Anche se consiglio di non fare affidamento sulle immagini di altre persone e di collezionare le proprie.

nginx

È possibile utilizzare qualsiasi server Web per distribuire contenuto statico. Sono abituato a lavorare con nginx, quindi lo userò ora.

Nginx ha un'immagine Docker ufficiale, ma ha troppi moduli per una semplice distribuzione statica. Quelli inclusi nella consegna possono essere visualizzati da un team speciale o nel Dockerfile ufficiale.

$ docker esegui --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

Utilizzerò il Dockerfile come base, ma lascerò al suo interno solo ciò che è necessario per distribuire il contenuto statico. La mia versione non potrà funzionare su HTTPS, non supporterà l'autorizzazione e molto altro. Ma la mia versione sarà in grado di distribuire file compressi con l'algoritmo Brotli, che è leggermente più efficiente di gzip. Comprimeremo i file una volta; non è necessario farlo al volo.

Questo è il Dockerfile con cui mi sono ritrovato. I commenti in russo sono miei, in inglese sono dall'originale.

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

Correggerò immediatamente nginx.conf in modo che gzip e brotli siano abilitati per impostazione predefinita. Includerò anche le intestazioni di memorizzazione nella cache, perché non avremo mai modifiche statiche. E il tocco finale sarà reindirizzare tutte le 404 richieste su index.html, necessario per la navigazione nella 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";
            }
        }
    }
}

Puoi scaricare l'immagine finita qui: https://hub.docker.com/r/alexxxnf/nginx-spa. Occupa 10,5 MB. Il nginx originale occupava 19,7 MB. Il mio interesse sportivo è soddisfatto.

Insegnare la statica per comprendere le variabili ambientali

Perché potrebbero essere necessarie le impostazioni in SPA? Ad esempio, per specificare quale API RESTful utilizzare. In genere, le impostazioni per l'ambiente desiderato vengono trasferite a SPA in fase di creazione. Se hai bisogno di cambiare qualcosa, dovrai ricostruire l'applicazione. Io non lo voglio. Voglio che l'applicazione venga creata una volta nella fase CI e configurata quanto necessario nella fase CD utilizzando le variabili di ambiente.

Naturalmente, i file statici stessi non comprendono alcuna variabile di ambiente. Pertanto, dovrai usare un trucco. Nell'immagine finale, non lancerò nginx, ma uno speciale script di shell che leggerà le variabili di ambiente, le scriverà in file statici, le comprimerà e solo successivamente trasferirà il controllo a nginx.

A questo scopo il Dockerfile mette a disposizione il parametro ENTRYPOINT. Diamogli il seguente script (usando Angular come esempio):

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

Affinché lo script faccia il suo lavoro, le impostazioni devono essere scritte nei file js in questo formato: ${API_URL}.

Vale la pena notare che la maggior parte delle SPA moderne aggiunge hash ai propri file durante la creazione. Ciò è necessario affinché il browser possa memorizzare nella cache il file in modo sicuro per un lungo periodo. Se il file cambia, cambierà anche il suo hash, il che a sua volta costringerà il browser a scaricare nuovamente il file.

Sfortunatamente, nel mio metodo, la modifica della configurazione tramite variabili d'ambiente non porta a una modifica dell'hash del file, il che significa che la cache del browser deve essere invalidata in qualche altro modo. Non ho questo problema perché diverse configurazioni vengono distribuite in ambienti diversi.

Mettere insieme l'immagine finale

Infine.

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

Ora l'immagine risultante può essere assemblata e utilizzata ovunque.

Fonte: habr.com