Docker image pro distribuci jednostránkové aplikace

Jednostránková aplikace (SPA) je sada statických souborů JavaScript a HTML, stejně jako obrázků a dalších zdrojů. Protože se dynamicky nemění, je jejich publikování online velmi snadné. K tomu existuje velké množství levných a dokonce bezplatných služeb, počínaje jednoduchými stránkami GitHub (a pro některé dokonce narod.ru) a konče CDN, jako je Amazon S3. Potřeboval jsem však něco jiného.

Potřeboval jsem image Docker s SPA, aby mohl být snadno spuštěn jak v produkci jako součást clusteru Kubernetes, tak na stroji back-endového vývojáře, který nemá ponětí, co je SPA.

Stanovil jsem si pro sebe následující požadavky na obrázek:

  • snadné použití (ale ne montáž);
  • minimální velikost jak z hlediska disku, tak RAM;
  • konfigurace pomocí proměnných prostředí, aby bylo možné obraz použít v různých prostředích;
  • nejefektivnější distribuce souborů.

Dnes vám řeknu jak:

  • střevní nginx;
  • stavět brotli ze zdrojů;
  • naučit statické soubory porozumět proměnným prostředí;
  • a samozřejmě jak z toho všeho sestavit obraz Dockeru.

Účelem tohoto článku je podělit se o své zkušenosti a vyprovokovat zkušené členy komunity ke konstruktivní kritice.

Vytvoření obrazu pro sestavení

Aby byl výsledný obrázek Docker malý, musíte dodržet dvě pravidla: minimum vrstev a minimalistický základní obrázek. Jeden z nejmenších základních obrazů je obraz Alpine Linux, takže to si vyberu. Někdo by mohl namítnout, že Alpine není vhodný pro výrobu, a mohl by mít pravdu. Osobně jsem s ním ale nikdy žádné problémy neměl a nejsou proti němu žádné argumenty.

Abych měl méně vrstev, sestavím obrázek ve 2 fázích. První je koncept, všechny pomocné nástroje a dočasné soubory v něm zůstanou. A ve finální fázi si už napíšu jen finální verzi aplikace.

Začněme pomocným obrázkem.

Abyste mohli sestavit aplikaci SPA, obvykle potřebujete node.js. Vezmu oficiální obrázek, který je také dodáván se správci balíčků npm a příze. Za sebe přidám node-gyp, který je potřeba k sestavení některých npm balíčků, a kompresor Brotli od Googlu, který se nám bude hodit později.

Dockerfile s komentáři.

# Базовый образ
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ž tady bojuji za minimalismus, takže image dává dohromady jeden velký tým.

Hotový obrázek najdete zde: https://hub.docker.com/r/alexxxnf/spa-builder. I když doporučuji nespoléhat se na cizí snímky a sbírat si vlastní.

Nginx

K distribuci statického obsahu můžete použít jakýkoli webový server. Jsem zvyklý pracovat s nginx, takže ho teď využiji.

Nginx má oficiální obraz Dockeru, ale má příliš mnoho modulů pro jednoduchou statickou distribuci. Které z nich jsou součástí dodávky, lze vidět ve speciálním týmu nebo v oficiálním souboru 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

Jako základ použiji Dockerfile, ale nechám v něm jen to, co je potřeba k distribuci statického obsahu. Moje verze nebude moci fungovat přes HTTPS, nebude podporovat autorizaci a mnoho dalšího. Ale moje verze bude schopna distribuovat soubory komprimované pomocí algoritmu Brotli, který je o něco efektivnější než gzip. Soubory zkomprimujeme jednou, není třeba to dělat za běhu.

Toto je Dockerfile, se kterým jsem skončil. Komentáře v ruštině jsou moje, v angličtině - 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žitě opravím nginx.conf tak, aby gzip a brotli byly ve výchozím nastavení povoleny. Zahrnu také cachovací hlavičky, protože budeme mít nikdy se neměnící statické. A posledním krokem bude přesměrování všech 404 požadavků na index.html, což je nezbytné pro navigaci 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ázek si můžete stáhnout zde: https://hub.docker.com/r/alexxxnf/nginx-spa. Zabírá 10,5 MB. Původní nginx zabíral 19,7 MB. Můj sportovní zájem je uspokojen.

Výuka statiky porozumět proměnným prostředí

Proč může být ve SPA potřeba nastavení? Chcete-li například určit, které RESTful API se má použít. Obvykle se nastavení požadovaného prostředí přenesou do SPA ve fázi sestavení. Pokud potřebujete něco změnit, budete muset aplikaci přestavět. Já to nechci. Chci, aby byla aplikace jednou sestavena ve fázi CI a nakonfigurována podle potřeby ve fázi CD pomocí proměnných prostředí.

Samotné statické soubory samozřejmě nerozumí žádným proměnným prostředí. Proto budete muset použít trik. Na finálním obrázku nespustím nginx, ale speciální shell skript, který bude číst proměnné prostředí, zapisovat je do statických souborů, komprimovat je a teprve poté přenést řízení na nginx.

Pro tento účel poskytuje Dockerfile parametr ENTRYPOINT. Dejme mu následující skript (jako pří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 mohl dělat svou práci, musí být nastavení zapsáno do souborů js v tomto tvaru: ${API_URL}.

Stojí za zmínku, že většina moderních SPA při vytváření přidává do svých souborů hash. To je nezbytné, aby prohlížeč mohl bezpečně uložit soubor do mezipaměti po dlouhou dobu. Pokud se soubor změní, změní se jeho hash, což zase přinutí prohlížeč soubor stáhnout znovu.

Bohužel v mé metodě změna konfigurace pomocí proměnných prostředí nevede ke změně hashe souboru, což znamená, že cache prohlížeče musí být zneplatněna jiným způsobem. Tento problém nemám, protože v různých prostředích jsou nasazeny různé konfigurace.

Skládání finálního obrázku

Konečně.

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

Nyní lze výsledný obrázek sestavit a použít kdekoli.

Zdroj: www.habr.com