Obraz Docker do dystrybucji Single Page Application

Aplikacja jednostronicowa (SPA) to zestaw statycznych plików JavaScript i HTML, a także obrazów i innych zasobów. Ponieważ nie zmieniają się one dynamicznie, bardzo łatwo można je opublikować w internecie. Istnieje wiele tanich, a nawet darmowych serwisów, od prostego GitHub Pages (a dla niektórych nawet z narod.ru) po CDN, takie jak Amazon S3. Jednak potrzebowałem czegoś innego.

Potrzebowałem obrazu Dockera z SPA, aby mógł być łatwo uruchamiany zarówno w produkcji w ramach klastra Kubernetes, jak i na maszynie backendowego programisty, który nie ma pojęcia, czym jest SPA.

Określiłem dla siebie następujące wymagania dotyczące obrazu:

  • łatwość użytkowania (ale nie w budowie);
  • minimalny rozmiar zarówno pod względem dysku, jak i RAM-u;
  • konfiguracja przez zmienne środowiskowe, aby obraz można było używać w różnych środowiskach;
  • maksymalnie efektywne udostępnianie plików.

Dziś opowiem, jak:

  • wyczyścić nginx;
  • zbudować brotli z źródeł;
  • nauczyć statyczne pliki rozumieć zmienne środowiskowe;
  • i oczywiście, jak stworzyć z tego wszystko obraz Dockera.

Celem tego artykułu jest podzielenie się moim doświadczeniem i zachęcenie do konstruktywnej krytyki doświadczonych uczestników społeczności.

Budowanie obrazu do budowy

Aby końcowy obraz Dockera był mały, należy przestrzegać dwóch zasad: minimalna liczba warstw i minimalistyczny obraz bazowy. Jednym z najmniejszych obrazów bazowych jest obraz Alpine Linux, dlatego go wybiorę. Niektórzy mogą zaprotestować, że Alpine nie nadaje się do produkcji, i być może mają rację. Ale osobiście nigdy nie miałem z nim problemów, a nie ma żadnych argumentów przeciwko niemu.

Aby mieć mniej warstw, będę budować obraz w dwóch etapach. Pierwszy – roboczy, w którym pozostaną wszystkie pomocnicze narzędzia i pliki tymczasowe. A w czystym zapiszę tylko końcową wersję aplikacji.

Zacznijmy od obrazu pomocniczego.

Aby skompilować aplikację SPA, zwykle potrzebny jest node.js. Wezmę oficjalny obraz, w zestawie z którym są również menedżery pakietów npm i yarn. Od siebie dodam node-gyp, który jest potrzebny do budowy niektórych pakietów npm, oraz kompresor Brotli od Google, który przyda nam się później.

Dockerfile z komentarzami.

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

Już tutaj walczę o minimalizm, dlatego obraz powstaje jednym dużym poleceniem.

Gotowy obraz można znaleźć tutaj: https://hub.docker.com/r/alexxxnf/spa-builder. Jednak zalecam nie polegać na cudzych obrazach i stworzyć swój własny.

nginx

Do serwowania statyki można użyć dowolnego serwera webowego. Przyzwyczaiłem się pracować z nginx, więc tym razem również go użyję.

Nginx ma oficjalny obraz Dockera, jednak do prostej dystrybucji statyki zawiera zbyt wiele modułów. Jakie dokładnie są w zestawie, można sprawdzić specjalną komendą lub w oficjalnym 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

Wezmę za podstawę Dockerfile, ale zostawię w nim tylko to, co potrzebne do dystrybucji statyki. Moja wersja nie będzie działać po HTTPS, nie będzie obsługiwać autoryzacji i wiele więcej. Ale moja wersja będzie mogła serwować pliki skompresowane algorytmem Brotli, który jest trochę bardziej wydajny niż gzip. Skompresujemy pliki raz, nie ma potrzeby robić tego na bieżąco.

Oto, jaki Dockerfile mi wyszedł. Komentarze po rosyjsku – moje, po angielsku – z oryginału.

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

Od razu poprawię nginx.conf, aby gzip i brotli były włączone domyślnie. Również dodam nagłówki cache'ujące, ponieważ będziemy serwować statykę, która nigdy się nie zmienia. Ostatnim szlifem będzie przekierowanie wszystkich zapytań 404 na index.html, co jest konieczne do nawigacji w 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";
            }
        }
    }
}

Możesz pobrać gotowy obraz tutaj: https://hub.docker.com/r/alexxxnf/nginx-spa. Zajmuje 10,5 MB. Oryginalny nginx zajmował 19,7 MB. Moje sportowe zainteresowanie zostało zaspokojone.

Uczymy statykę, aby zrozumieć zmienne środowiskowe

Po co w SPA mogą być potrzebne ustawienia? Na przykład, aby określić, które RESTful API używać. Zwykle ustawienia dla odpowiedniego środowiska są przekazywane do SPA na etapie budowy. Jeśli potrzebujesz coś zmienić, będziesz musiał ponownie zbudować aplikację. Nie chcę tego. Chcę, aby aplikacja była budowana raz na etapie CI, a konfigurowana tyle, ile potrzeba, na etapie CD za pomocą zmiennych środowiskowych.

Oczywiście, statyczne pliki same w sobie nie rozumieją żadnych zmiennych środowiskowych. Dlatego trzeba będzie pójść na sprytność. W finalnym obrazie nie uruchomię nginx, a specjalny skrypt powłoki, który odczyta zmienne środowiskowe, zapisze je w plikach statycznych, skompresuje je, a dopiero potem przekaże kontrolę nginx.

W tym celu w Dockerfile przewidziano parametr ENTRYPOINT. Przekażemy mu taki skrypt (na przykład 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"
zrobione
}
compress () {
for i in $(find "$TARGET_DIR" | grep -E ".css$|.html$|.js$|.svg$|.txt$|.ttf

quot;); do
# Используем максимальную степень сжатия
gzip -9kf "$i" && brotli -fZ "$i"
zrobione
}
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 skrypt wykonał swoją robotę, w plikach js ustawienia muszą być zapisane w takiej formie: ${API_URL}.

Warto zauważyć, że większość nowoczesnych SPA podczas budowy dodaje do swoich plików hasze. To potrzebne, aby przeglądarka mogła spokojnie cachować plik na długi czas. Jeśli plik ulegnie zmianie, zmieni się również jego hash, co z kolei spowoduje, że przeglądarka pobierze plik ponownie.

Niestety, w mojej metodzie zmiana konfiguracji przez zmienne środowiskowe nie prowadzi do zmiany hasha pliku, więc muszę w inny sposób unieważnić pamięć podręczną przeglądarki. Nie mam tego problemu, ponieważ różne konfiguracje są wdrażane w różnych środowiskach.

Tworzymy końcowy obraz

Wreszcie.

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 otrzymany obraz można zbudować i używać wszędzie.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster