Docker-bild för distribution av Single Page Application

Single-page Application (SPA) är en uppsättning statiska JavaScript- och HTML-filer, samt bilder och andra resurser. Eftersom de inte förändras dynamiskt är det väldigt enkelt att publicera dem online. Det finns ett stort antal billiga och till och med gratis tjänster för detta, som börjar med en enkel GitHub-sidor (och för vissa även med narod.ru) och slutar med ett CDN som Amazon S3. Men jag behövde något annat.

Jag behövde en Docker-bild med SPA så att den enkelt kunde lanseras både i produktion som en del av ett Kubernetes-kluster och på maskinen hos en back-end-utvecklare som inte har en aning om vad SPA är.

Jag har bestämt följande bildkrav för mig själv:

  • användarvänlighet (men inte montering);
  • minsta storlek både vad gäller disk och RAM;
  • konfigurering genom miljövariabler så att bilden kan användas i olika miljöer;
  • den mest effektiva distributionen av filer.

Idag ska jag berätta hur:

  • tarm nginx;
  • bygga brotli från källor;
  • lära statiska filer att förstå miljövariabler;
  • och naturligtvis hur man sätter ihop en Docker-bild från allt detta.

Syftet med den här artikeln är att dela med mig av mina erfarenheter och provocera erfarna samhällsmedlemmar till konstruktiv kritik.

Bygga en bild för montering

För att göra den slutliga Docker-bilden liten i storlek måste du följa två regler: ett minimum av lager och en minimalistisk basbild. En av de minsta basbilderna är Alpine Linux-bilden, så det är vad jag väljer. Vissa kanske hävdar att Alpine inte är lämplig för produktion, och de kanske har rätt. Men personligen har jag aldrig haft några problem med honom och det finns inga argument mot honom.

För att ha färre lager kommer jag att montera bilden i 2 steg. Den första är ett utkast; alla hjälpprogram och temporära filer kommer att finnas kvar i det. Och i slutskedet kommer jag bara att skriva ner den slutliga versionen av applikationen.

Låt oss börja med hjälpbilden.

För att kompilera en SPA-applikation behöver du vanligtvis node.js. Jag tar den officiella bilden, som också följer med npm och garnpakethanterare. För min egen räkning kommer jag att lägga till node-gyp, som behövs för att bygga några npm-paket, och Brotli-kompressorn från Google, som kommer att vara användbar för oss senare.

Dockerfil med kommentarer.

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

Redan här kämpar jag för minimalism, så bilden sätts ihop av ett stort team.

Den färdiga bilden hittar du här: https://hub.docker.com/r/alexxxnf/spa-builder. Även om jag rekommenderar att du inte litar på andras bilder och samlar dina egna.

nginx

Du kan använda vilken webbserver som helst för att distribuera statiskt innehåll. Jag är van vid att arbeta med nginx, så jag ska använda det nu.

Nginx har en officiell Docker-bild, men den har för många moduler för enkel statisk distribution. Vilka som ingår i leveransen kan ses av ett speciellt team eller i den officiella Dockerfilen.

$ docker run --rm nginx:1-alpin 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

Jag kommer att använda Dockerfilen som grund, men jag lämnar bara det som behövs för att distribuera statiskt innehåll i den. Min version kommer inte att kunna fungera över HTTPS, kommer inte att stödja auktorisering och mycket mer. Men min version kommer att kunna distribuera filer komprimerade med Brotli-algoritmen, som är något effektivare än gzip. Vi kommer att komprimera filer en gång, det finns ingen anledning att göra detta i farten.

Det här är Dockerfilen jag slutade med. Kommentarer på ryska är mina, på engelska - från originalet.

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

Jag fixar omedelbart nginx.conf så att gzip och brotli är aktiverade som standard. Jag kommer också att inkludera cachningsrubriker, eftersom vi aldrig kommer att ändra statisk. Och sista handen blir att omdirigera alla 404 förfrågningar till index.html, detta är nödvändigt för navigering i 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";
            }
        }
    }
}

Du kan ladda ner den färdiga bilden här: https://hub.docker.com/r/alexxxnf/nginx-spa. Den tar upp 10,5 MB. Den ursprungliga nginxen tog upp 19,7 MB. Mitt idrottsintresse är tillfredsställt.

Lära ut statik för att förstå miljövariabler

Varför kan inställningar behövas i SPA? Till exempel för att ange vilket RESTful API som ska användas. Vanligtvis överförs inställningar för den önskade miljön till SPA i byggskedet. Om du behöver ändra något måste du bygga om programmet. Jag vill inte ha den. Jag vill att applikationen ska byggas en gång på CI-stadiet och konfigureras så mycket som behövs på CD-stadiet med hjälp av miljövariabler.

Naturligtvis förstår inte statiska filer själva några miljövariabler. Därför måste du använda ett trick. I den slutliga bilden kommer jag inte att starta nginx, utan ett speciellt skalskript som kommer att läsa miljövariabler, skriva dem till statiska filer, komprimera dem och först sedan överföra kontrollen till nginx.

För detta ändamål tillhandahåller Dockerfilen parametern ENTRYPOINT. Låt oss ge honom följande skript (med Angular som exempel):

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

För att skriptet ska göra sitt jobb måste inställningarna skrivas i js-filerna i det här formuläret: ${API_URL}.

Det är värt att notera att de flesta moderna SPA lägger till hash i sina filer när de bygger. Detta är nödvändigt så att webbläsaren säkert kan cache filen under lång tid. Om filen ändras kommer dess hash att ändras, vilket i sin tur tvingar webbläsaren att ladda ner filen igen.

Tyvärr, i min metod, leder ändring av konfigurationen genom miljövariabler inte till en ändring av filens hash, vilket innebär att webbläsarens cache måste ogiltigförklaras på något annat sätt. Jag har inte det här problemet eftersom olika konfigurationer distribueras i olika miljöer.

Att sätta ihop den slutliga bilden

Till sist.

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

Nu kan den resulterande bilden sättas ihop och användas var som helst.

Källa: will.com