Docker-образ для раздачи Single Page Application

от автора

Single-page Application (SPA) – это набор статических JavaScript и HTML файлов, а так же картинок и других ресурсов. Поскольку они не изменяются динамически, опубликовать их в интернете очень просто. Для этого существует большое количество дешёвых и даже бесплатных сервисов, начиная с простого GitHub Pages (а для кого-то даже с narod.ru) и заканчивая CDN вроде Amazon S3. Однако мне нужно было другое.

Мне нужен был Docker-образ с SPA, чтобы его легко можно было запустить как в продакшене в составе Kubernetes-кластера, так и на машине back-end разработчика, который понятия не имеет, что такое SPA.

Я для себя определил следующие требования к образу:

  • простота в использовании (но не в сборке);
  • минимальный размер как с точки зрения диска, так и с точки зрения RAM;
  • настройка через переменные окружения, чтобы образ можно было использовать в разных средах;
  • максимально эффективная раздача файлов.

Сегодня я расскажу как:

  • выпотрошить nginx;
  • собрать brotli из исходников;
  • научить статические файлы понимать переменные окружения;
  • ну и конечно как собрать из всего этого Docker-образ.

Цель этой статьи поделиться моим опытом и спровоцировать опытных участников сообщества на конструктивную критику.

Сборка образа для сборки

Чтобы финальный Docker-образ получился маленьким по размеру, нужно придерживаться двух правил: минимум слоёв и минималистичный базовый образ. Одним из самых маленьких базовых образов является образ Alpine Linux, поэтому именно его я и выберу. Кто-то может возразить, что Alpine не подходит для продакшена и, возможно, окажется прав. Но лично у меня с ним никогда не возникало проблем и никаких аргументов против него нет.

Чтобы было поменьше слоёв, я буду собирать образ в 2 этапа. Первый – черновой, в нём останутся все вспомогательные утилиты и временные файлы. А в чистовой я запишу только финальную версию приложения.

Начнём со вспомогательного образа.

Для того, чтобы скомпилировать SPA-приложение, обычно, нужен node.js. Я возьму официальный образ в комплекте с которым так же есть пакетные менеджеры npm и yarn. От себя я добавлю node-gyp, который нужен для сборки некоторых npm-пакетов, и компрессор Brotli от Google, который пригодится нам позже.

Dockerfile с комментариями.

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

Уже здесь я борюсь за минимализм, поэтому образ собирается одной большой командой.

Готовый образ можно найти тут: https://hub.docker.com/r/alexxxnf/spa-builder. Хотя я рекомендую не полагаться на чужие образы и собрать свой.

nginx

Для раздачи статики можно использовать любой web-сервер. Я привык работать с nginx, поэтому и сейчас буду использовать его.

У nginx есть официальный Docker-образ, однако для простой раздачи статики в нём слишком много модулей. Какие именно включены в поставку можно посмотреть специальной командой или же в официальном 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

Я возьму за основу Dockerfile, но оставлю в нём только то, что нужно для раздачи статики. Мой вариант не сможет работать по HTTPS, не будет поддерживать авторизацию и многое другое. Зато моя версия сможет раздавай файлы, сжатые алгоритмом Brotli, который немного эффективнее, чем gzip. Сжимать файлы будем один раз, делать это на лету нет необходимости.

Вот какой Dockerfile у меня получился. Комментарии на русском – мои, на английском – из оригинала.

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

Я сразу же поправлю nginx.conf, чтобы gzip и brotli были включены по умолчанию. Так же включу кэширующие заголовки, ведь у нас будет раздаваться никогда не меняющаяся статика. И последним штрихом будет переадресация всех 404 запросов на index.html, это необходимо для навигации в 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";             }         }     } }

Скачать готовый образ можно здесь: https://hub.docker.com/r/alexxxnf/nginx-spa. Он занимает 10,5 МБ. Оригинальный nginx занимал 19,7 МБ. Мой спортивный интерес удовлетворён.

Учим статику понимать переменные окружения

Для чего в SPA могут понадобится настройки? Например, для того, чтобы указать какой RESTful API использовать. Обычно настройки для нужного окружения передаются в SPA на этапе сборки. Если нужно что-то поменять, то придётся пересобрать приложение. Я этого не хочу. Я хочу чтобы приложение собиралось один раз на стадии CI, а конфигурировалось столько, сколько нужно, на стадии CD с помощью переменных окружения.

Разумеется, статические файлы сами по себе не понимают никаких переменных окружения. Поэтому, придётся пойти на хитрость. В финальном образе я буду запускать не nginx, а специальный shell-скрипт, который прочитает пременные окружения, запишит их в статические файлы, сожмёт их и только после этого передаст управление nginx.

Для этого в Dockerfile предусмотрен параметр ENTRYPOINT. Передадим ему вот такой скрипт (на примере 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 "$"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$"); 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 "$@"

Чтобы скрипт сделал своё дело, в js-файлах настройки надо писать вот в таком виде: ${API_URL}.

Стоит отметить, что большинство современных SPA при сборке добавляют к своим файлам хэши. Это нужно, чтобы браузер мог смело закэшировать файл на длительный срок. Если файл всё-таки изменится, то изменится и его хэш, что в свою очередь заставит браузер скачать файл заново.

К сожалению, в моём методе, изменение конфигурации через переменные окружения не приводит к изменению хэша файла, а значит инвалидировать кэш браузера надо каким-то другим образом. У меня этой проблемы нет, потому что разные конфигурации разворачиваются в разных средах.

Собираем финальный образ

Наконец-то.

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

Теперь получившийся образ можно собрать и использовать где-угодно.

ссылка на оригинал статьи https://habr.com/ru/post/498472/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *