Obraz platformy Docker do dystrybucji aplikacji jednostronicowej

Aplikacja jednostronicowa (SPA) to zestaw statycznych plików JavaScript i HTML, a także obrazów i innych zasobów. Ponieważ nie zmieniają się dynamicznie, opublikowanie ich w Internecie jest bardzo łatwe. Istnieje wiele tanich, a nawet bezpłatnych usług, zaczynając od prostych stron GitHub (a dla niektórych nawet narod.ru), a kończąc na CDN, takim jak Amazon S3. Jednakże potrzebowałem czegoś innego.

Potrzebowałem obrazu Dockera ze SPA, aby można go było łatwo uruchomić zarówno na produkcji w ramach klastra Kubernetes, jak i na maszynie back-end developera, który nie ma pojęcia, czym jest SPA.

Ustaliłem dla siebie następujące wymagania obrazowe:

  • łatwość użycia (ale nie montażu);
  • minimalny rozmiar zarówno pod względem dysku, jak i pamięci RAM;
  • konfiguracja poprzez zmienne środowiskowe, dzięki czemu obraz może być używany w różnych środowiskach;
  • najbardziej efektywną dystrybucję plików.

Dziś opowiem Wam jak:

  • jelitowy nginx;
  • buduj brotli ze źródeł;
  • uczyć pliki statyczne rozumienia zmiennych środowiskowych;
  • i oczywiście, jak z tego wszystkiego złożyć obraz Dockera.

Celem tego artykułu jest podzielenie się moimi doświadczeniami i sprowokowanie doświadczonych członków społeczności do konstruktywnej krytyki.

Budowanie obrazu do montażu

Aby końcowy obraz Dockera był niewielki, należy przestrzegać dwóch zasad: minimum warstw i minimalistycznego obrazu bazowego. Jednym z najmniejszych obrazów bazowych jest obraz Alpine Linux, więc to właśnie wybiorę. Niektórzy mogą twierdzić, że Alpine nie nadaje się do produkcji i mogą mieć rację. Ale osobiście nigdy nie miałem z nim żadnych problemów i nie mam przeciwko niemu żadnych argumentów.

Aby mieć mniej warstw, złożę obraz w 2 etapach. Pierwszy to wersja robocza, w której pozostaną wszystkie narzędzia pomocnicze i pliki tymczasowe. A w końcowym etapie napiszę jedynie ostateczną wersję aplikacji.

Zacznijmy od obrazu pomocniczego.

Do skompilowania aplikacji SPA zazwyczaj potrzebny jest plik node.js. Wezmę oficjalny obraz, który jest również dołączony do menedżerów pakietów npm i przędzy. W swoim imieniu dodam node-gyp, który jest potrzebny do zbudowania niektórych pakietów npm, oraz kompresor Brotli od Google, który przyda się nam później.

Plik Docker z komentarzami.

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

Już tutaj walczę o minimalizm, więc za wizerunek odpowiada jeden wielki zespół.

Gotowy obraz można zobaczyć tutaj: https://hub.docker.com/r/alexxxnf/spa-builder. Chociaż nie polecam polegać na obrazach innych osób i kolekcjonować własne.

nginx

Możesz użyć dowolnego serwera WWW do dystrybucji treści statycznych. Przyzwyczaiłem się do pracy z Nginxem, więc użyję go teraz.

Nginx ma oficjalny obraz Dockera, ale ma zbyt wiele modułów do prostej dystrybucji statycznej. Które z nich są zawarte w dostawie, może sprawdzić specjalny zespół lub w oficjalnym pliku Dockerfile.

$ uruchomienie dokera --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 podstawę wykorzystam plik Dockerfile, ale pozostawię w nim tylko to, co jest potrzebne do dystrybucji treści statycznych. Moja wersja nie będzie działać przez HTTPS, nie będzie obsługiwać autoryzacji i wiele więcej. Ale moja wersja będzie mogła dystrybuować pliki skompresowane algorytmem Brotli, który jest nieco wydajniejszy niż gzip. Pliki skompresujemy raz, nie ma potrzeby robić tego na bieżąco.

To jest plik Dockerfile, z którym skończyłem. Komentarze w języku rosyjskim są moje, w języku angielskim - z oryginału.

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

Natychmiast naprawię plik nginx.conf, aby domyślnie włączone były gzip i brotli. Dołączę także nagłówki buforowania, ponieważ nigdy nie będziemy mieć zmieniających się danych statycznych. Ostatnim akcentem będzie przekierowanie wszystkich żądań 404 do pliku Index.html, jest to niezbędne 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";
            }
        }
    }
}

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

Nauczanie statyki zrozumienia zmiennych środowiskowych

Dlaczego ustawienia w SPA mogą być potrzebne? Na przykład, aby określić, którego interfejsu API RESTful użyć. Zazwyczaj ustawienia żądanego środowiska są przenoszone do SPA na etapie kompilacji. Jeśli chcesz coś zmienić, będziesz musiał przebudować aplikację. Nie chcę tego. Zależy mi na tym, aby aplikacja była zbudowana jednorazowo na etapie CI i skonfigurowana w miarę potrzeb na etapie CD przy użyciu zmiennych środowiskowych.

Oczywiście same pliki statyczne nie rozumieją żadnych zmiennych środowiskowych. Dlatego będziesz musiał zastosować sztuczkę. Na ostatecznym obrazie nie uruchomię nginx, ale specjalny skrypt powłoki, który odczyta zmienne środowiskowe, zapisze je do plików statycznych, skompresuje i dopiero wtedy przekaże kontrolę do nginx.

W tym celu plik Dockerfile udostępnia parametr ENTRYPOINT. Dajmy mu następujący skrypt (na przykładzie Angulara):

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 skrypt spełnił swoje zadanie należy zapisać ustawienia w plikach js w postaci: ${API_URL}.

Warto zauważyć, że większość nowoczesnych SPA dodaje skróty do swoich plików podczas budowania. Jest to konieczne, aby przeglądarka mogła bezpiecznie buforować plik przez długi czas. Jeśli plik ulegnie zmianie, zmieni się jego skrót, co z kolei zmusi przeglądarkę do ponownego pobrania pliku.

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

Składanie ostatecznego obrazu

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 powstały obraz można złożyć i wykorzystać w dowolnym miejscu.

Źródło: www.habr.com