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 <[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

Вже тут я борюся за мінімалізм, тож образ збирається однією великою командою.

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

Nginx

Для роздачі статики можна використовувати будь-який веб-сервер. Я звик працювати з 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 у мене вийшов. Коментарі російською – мої, англійською – з оригіналу.

Докер-файл

# Базовый образ снова 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;"]

Я відразу ж виправлю 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 "

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 "$@"

Щоб скрипт зробив свою справу, у js-файлах налаштування треба писати ось у такому вигляді: ${API_URL}.

Варто відзначити, що більшість сучасних SPA під час збирання додають до своїх файлів хеші. Це потрібно, щоб браузер міг сміливо закешувати файл на тривалий термін. Якщо файл все-таки зміниться, то зміниться і його хеш, що змусить браузер завантажити файл заново.

На жаль, у моєму методі, зміна конфігурації через змінні оточення не призводить до зміни хешу файлу, а значить інвалідувати кеш браузера треба якимось іншим чином. У мене цієї проблеми немає, тому що різні конфігурації розгортаються у різних середовищах.

Збираємо фінальний образ

Нарешті.

Докер-файл

# Первый базовый образ для сборки
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;"]

Тепер образ, що вийшов, можна зібрати і використовувати будь-де.

Джерело: habr.com