Jednostránková aplikace (SPA) je soubor statických JavaScript a HTML souborů, jakož i obrázků a dalších zdrojů. Protože se dynamicky nemění, je velmi snadné je publikovat na internetu. K tomu existuje spousta levných a dokonce i bezplatných služeb, od jednoduchého GitHub Pages (a pro někoho dokonce z narod.ru) až po CDN jako Amazon S3. Ale já potřeboval něco jiného.
Potřeboval jsem Docker image se SPA, aby jej bylo možné snadno spustit jak v produkci v rámci Kubernetes clusteru, tak na stroji vývojáře back-endu, který nemá ponětí, co SPA znamená.
Stanovil jsem si následující požadavky na image:
- jednoduchost použití (ale ne ve sběru);
- minimální velikost jak z hlediska disku, tak z hlediska RAM;
- nastavení prostřednictvím proměnných prostředí, abych mohl image používat v různých prostředích;
- co nejefektivnější distribuce souborů.
Dnes vám ukážu jak:
- vyprázdnit nginx;
- sestavit brotli ze zdrojového kódu;
- naučit statické soubory chápat proměnné prostředí;
- a samozřejmě jak sestavit z toho všeho Docker image.
Cílem tohoto článku je podělit se o svoje zkušenosti a provokovat zkušené členy komunity ke konstruktivní kritice.
Sestavení image pro sestavení
Aby finální Docker image měl malou velikost, je třeba řídit se dvěma pravidly: minimum vrstev a minimalistická základní image. Jednou z nejmenších základních images je image Alpine Linux, proto ji zvolím. Někdo může oponovat, že Alpine není vhodný pro produkci a možná má pravdu. Ale osobně jsem s ním nikdy neměl žádné problémy a nemám žádné argumenty proti němu.
Abych měl méně vrstev, budu image sestavovat ve dvou krocích. První - pracovní, ve kterém zůstanou všechny pomocné nástroje a dočasné soubory. A ve finálním zapíši pouze finální verzi aplikace.
Začněme s pomocným image.
Abyste mohli zkompilovat SPA aplikaci, obvykle potřebujete node.js. Vezmu oficiální image, s nimiž jsou také balíčkové manažery npm a yarn. Přidám si node-gyp, který je potřebný pro sestavení některých npm balíčků, a kompresor Brotli od Googlu, který nám později přijde vhod.
Dockerfile s komentáři.
# Базовый образ
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/srcJiže zde se snažím o minimalismus, proto se image sestavuje jedním velkým příkazem.
Hotový obraz můžete najít zde: . Ačkoliv doporučuji nespoléhat se na cizí obrazy a sestavit si vlastní.
nginx
Pro distribuci statického obsahu můžete použít jakýkoliv webový server. Jsem zvyklý pracovat s nginx, takže ho použiju i tentokrát.
nginx má oficiální Docker obraz, avšak pro jednoduchou distribuci statiky obsahuje příliš mnoho modulů. Jaké moduly jsou zahrnuty, můžete zjistit speciálním příkazem nebo v oficiálním 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-neededVezmu jako základ Dockerfile, ale ponechám v něm pouze to, co je potřebné pro distribuci statiky. Moje verze nebude fungovat přes HTTPS, nebude podporovat autorizaci a mnoho dalšího. Zato moje verze dokáže distribuovat soubory komprimované algoritmem Brotli, který je o něco efektivnější než gzip. Soubory budeme komprimovat jednou, dělat to za chodu není potřeba.
Toto je můj Dockerfile. Komentáře v ruštině – jsou moje, v angličtině – z originálu.
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;"]Hned opravím nginx.conf, aby gzip a brotli byly povoleny jako výchozí. Také povolím kešovací hlavičky, protože budeme distribuovat statiku, která se nikdy nemění. A posledním dotykem bude přesměrování všech požadavků 404 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ý obraz si můžete stáhnout zde: . Zabírá 10,5 MB. Původní nginx zabíral 19,7 MB. Můj sportovní zájem je uspokojen.
Učíme statiku chápat proměnné prostředí
Proč jsou v SPA potřeba nastavení? Například pro určení, jaké RESTful API použít. Obvykle se nastavení pro konkrétní prostředí předávají do SPA během fáze sestavení. Pokud je potřeba něco změnit, je nutné znovu sestavit aplikaci. To nechci. Chci, aby se aplikace sestavila jednou ve fázi CI a konfigurovala podle potřeby ve fázi CD pomocí proměnných prostředí.
Samozřejmě, statické soubory samy o sobě nerozumějí žádným proměnným prostředí. Proto bude potřeba se uchýlit k triku. V konečném obrazu nebudu spouštět nginx, ale speciální shellový skript, který přečte proměnné prostředí, zapíše je do statických souborů, zkomprimuje je a teprve poté předá řízení nginx.
K tomu je v Dockerfile předpokládán parametr ENTRYPOINT. Př předáme následující skript (na příkladu 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"
done
}
compress () {
for i in $(find "$TARGET_DIR" | grep -E ".css$|.html$|.js$|.svg$|.txt$|.ttfquot;); 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 splnil svůj úkol, v js-souborech je třeba nastavovat takto:${API_URL}.Je třeba poznamenat, že většina moderních SPA při sestavení přidává ke svým souborům hashing. To je potřeba, aby si prohlížeč mohl bez problému kešovat soubor na delší dobu. Pokud se soubor skutečně změní, změní se i jeho hash, což zase způsobí, že si prohlížeč stáhne soubor znovu.
Bohužel, v mé metodě změna konfigurace prostřednictvím proměnných prostředí nevedla ke změně hash souboru, a proto je třeba nějak jinak invalidovat mezipaměť prohlížeče. Tento problém nemám, protože různé konfigurace se nasazují v různých prostředích.
Sestavujeme finální obraz
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í je možné vytvořený obraz sestavit a použít kdekoli.
Zdroj: habr.com
