Docker image pre distribúciu jednostránkovej aplikácie

Jednostránková aplikácia (SPA) je sada statických súborov JavaScript a HTML, ako aj obrázkov a iných zdrojov. Keďže sa dynamicky nemenia, ich publikovanie online je veľmi jednoduché. Na to existuje veľké množstvo lacných a dokonca aj bezplatných služieb, počnúc jednoduchými stránkami GitHub (a pre niektorých dokonca aj narod.ru) a končiac CDN, ako je Amazon S3. Potreboval som však niečo iné.

Potreboval som obraz Docker s SPA, aby ho bolo možné ľahko spustiť vo výrobe ako súčasť klastra Kubernetes, ako aj na stroji back-end vývojára, ktorý netuší, čo je SPA.

Pre seba som si určil nasledujúce požiadavky na obrázok:

  • jednoduchosť použitia (ale nie montáž);
  • minimálna veľkosť disku aj pamäte RAM;
  • konfigurácia prostredníctvom premenných prostredia, aby sa obraz dal použiť v rôznych prostrediach;
  • najefektívnejšia distribúcia súborov.

Dnes vám poviem ako:

  • črevný nginx;
  • stavať brotli zo zdrojov;
  • naučiť statické súbory porozumieť premenným prostredia;
  • a samozrejme ako z toho všetkého zostaviť obraz Docker.

Účelom tohto článku je podeliť sa o svoje skúsenosti a vyprovokovať skúsených členov komunity ku konštruktívnej kritike.

Vytvorenie obrazu na zostavenie

Aby bol výsledný obrázok Docker malý, musíte dodržiavať dve pravidlá: minimum vrstiev a minimalistický základný obrázok. Jeden z najmenších základných obrazov je obraz Alpine Linux, takže si vyberiem. Niekto by mohol namietať, že Alpine nie je vhodný na výrobu a možno majú pravdu. Ale osobne som s ním nikdy nemal žiadne problémy a nie sú proti nemu žiadne argumenty.

Aby som mal menej vrstiev, zostavím obrázok v 2 etapách. Prvý je koncept, všetky pomocné nástroje a dočasné súbory v ňom zostanú. A v záverečnej fáze už len zapíšem finálnu verziu aplikácie.

Začnime pomocným obrázkom.

Na zostavenie aplikácie SPA zvyčajne potrebujete node.js. Vezmem oficiálny obrázok, ktorý sa dodáva aj so správcami balíkov npm a priadze. Vo svojom mene pridám node-gyp, ktorý je potrebný na zostavenie niektorých npm balíčkov a kompresor Brotli od Google, ktorý sa nám bude hodiť neskôr.

Dockerfile s komentármi.

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

Už tu bojujem za minimalizmus, takže imidž dáva dokopy jeden veľký tím.

Hotový obrázok nájdete tu: https://hub.docker.com/r/alexxxnf/spa-builder. Aj keď odporúčam nespoliehať sa na zábery iných ľudí a zozbierať si vlastné.

nginx

Na distribúciu statického obsahu môžete použiť ľubovoľný webový server. Som zvyknutý pracovať s nginx, takže ho teraz použijem.

Nginx má oficiálny obraz Docker, ale má príliš veľa modulov na jednoduchú statickú distribúciu. Ktoré z nich sú súčasťou dodávky, môže vidieť špeciálny tím alebo v oficiálnom súbore Dockerfile.

$ 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

Ako základ použijem Dockerfile, no nechám v ňom len to, čo je potrebné na distribúciu statického obsahu. Moja verzia nebude môcť pracovať cez HTTPS, nebude podporovať autorizáciu a oveľa viac. Moja verzia však bude schopná distribuovať súbory komprimované pomocou algoritmu Brotli, ktorý je o niečo efektívnejší ako gzip. Súbory skomprimujeme raz, nie je potrebné to robiť za behu.

Toto je Dockerfile, s ktorým som skončil. Komentáre v ruštine sú moje, v angličtine - z originálu.

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

Okamžite opravím nginx.conf tak, aby gzip a brotli boli štandardne povolené. Zahrniem aj hlavičky ukladania do vyrovnávacej pamäte, pretože budeme mať nikdy nemenné statické. A posledným krokom bude presmerovanie všetkých 404 požiadaviek na index.html, čo je potrebné pre navigáciu v 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";
            }
        }
    }
}

Hotový obrázok si môžete stiahnuť tu: https://hub.docker.com/r/alexxxnf/nginx-spa. Zaberá 10,5 MB. Pôvodný nginx zaberal 19,7 MB. Môj športový záujem je spokojný.

Výučba statiky porozumieť premenným prostredia

Prečo môžu byť potrebné nastavenia v SPA? Napríklad, aby ste určili, ktoré RESTful API sa má použiť. Nastavenia pre požadované prostredie sa zvyčajne prenesú do SPA vo fáze zostavovania. Ak potrebujete niečo zmeniť, budete musieť aplikáciu prebudovať. Nechcem to. Chcem, aby bola aplikácia vytvorená raz vo fáze CI a nakonfigurovaná podľa potreby vo fáze CD pomocou premenných prostredia.

Samozrejme, statické súbory samotné nerozumejú žiadnym premenným prostredia. Preto budete musieť použiť trik. Na konečnom obrázku nespustím nginx, ale špeciálny shell skript, ktorý bude čítať premenné prostredia, zapisovať ich do statických súborov, komprimovať ich a až potom preniesť riadenie na nginx.

Na tento účel poskytuje súbor Dockerfile parameter ENTRYPOINT. Dajme mu nasledujúci skript (ako príklad použijeme 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 "$@"

Aby skript vykonal svoju prácu, musia byť nastavenia zapísané v súboroch js v tomto tvare: ${API_URL}.

Stojí za zmienku, že väčšina moderných SPA pridáva do svojich súborov pri vytváraní hash. Je to potrebné, aby prehliadač mohol bezpečne uložiť súbor do vyrovnávacej pamäte na dlhú dobu. Ak sa súbor zmení, zmení sa jeho hash, čo zase prinúti prehliadač stiahnuť súbor znova.

Bohužiaľ v mojej metóde zmena konfigurácie prostredníctvom premenných prostredia nevedie k zmene hash súboru, čo znamená, že vyrovnávacia pamäť prehliadača musí byť zneplatnená iným spôsobom. Nemám tento problém, pretože v rôznych prostrediach sú nasadené rôzne konfigurácie.

Skladanie finálneho obrazu

Konečne.

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

Teraz je možné výsledný obrázok zostaviť a použiť kdekoľvek.

Zdroj: hab.com