Imatge Docker per a la distribució de l'aplicació de pàgina única

L'aplicació d'una sola pàgina (SPA) és un conjunt de fitxers JavaScript i HTML estàtics, així com imatges i altres recursos. Com que no canvien dinàmicament, publicar-los en línia és molt fàcil. Hi ha un gran nombre de serveis barats i fins i tot gratuïts per a això, començant per unes simples pàgines de GitHub (i per a alguns fins i tot amb narod.ru) i acabant amb un CDN com Amazon S3. Tanmateix, necessitava una altra cosa.

Necessitava una imatge de Docker amb SPA perquè es pogués llançar fàcilment tant en producció com a part d'un clúster de Kubernetes com a la màquina d'un desenvolupador de fons que no té ni idea de què és SPA.

He determinat els requisits d'imatge següents per a mi mateix:

  • facilitat d'ús (però no de muntatge);
  • mida mínima tant pel que fa al disc com a la memòria RAM;
  • configuració mitjançant variables d'entorn perquè la imatge es pugui utilitzar en diferents entorns;
  • la distribució més eficient dels fitxers.

Avui us explicaré com:

  • gut nginx;
  • construir brotli a partir de fonts;
  • ensenyar fitxers estàtics a comprendre les variables d'entorn;
  • i per descomptat com muntar una imatge de Docker a partir de tot això.

L'objectiu d'aquest article és compartir la meva experiència i provocar crítiques constructives als membres experimentats de la comunitat.

Construcció d'una imatge per al muntatge

Per fer que la imatge final de Docker sigui petita, heu de complir dues regles: un mínim de capes i una imatge base minimalista. Una de les imatges base més petites és la imatge Alpine Linux, així que això és el que triaré. Alguns podrien argumentar que l'Alpine no és apte per a la producció, i podrien tenir raó. Però personalment, no he tingut mai cap problema amb ell i no hi ha arguments en contra.

Per tenir menys capes, muntaré la imatge en 2 etapes. El primer és un esborrany; hi quedaran totes les utilitats auxiliars i fitxers temporals. I en l'etapa final només anotaré la versió final de l'aplicació.

Comencem per la imatge auxiliar.

Per compilar una aplicació SPA, normalment necessiteu node.js. Prendré la imatge oficial, que també ve amb els gestors de paquets npm i yarn. En nom meu, afegiré node-gyp, que és necessari per construir alguns paquets npm, i el compressor Brotli de Google, que ens serà útil més endavant.

Dockerfile amb comentaris.

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

Ja aquí estic lluitant pel minimalisme, de manera que la imatge la fa un gran equip.

La imatge acabada la podeu trobar aquí: https://hub.docker.com/r/alexxxnf/spa-builder. Encara que us recomano no confiar en les imatges d'altres persones i col·leccionar les vostres.

nginx

Podeu utilitzar qualsevol servidor web per distribuir contingut estàtic. Estic acostumat a treballar amb nginx, així que ara el faré servir.

Nginx té una imatge oficial de Docker, però té massa mòduls per a una distribució estàtica senzilla. Quins s'inclouen a l'entrega es poden veure per un equip especial o al Dockerfile oficial.

$ 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

Faré servir el Dockerfile com a base, però hi deixaré només el que es necessita per distribuir contingut estàtic. La meva versió no podrà funcionar amb HTTPS, no admetrà l'autorització i molt més. Però la meva versió serà capaç de distribuir fitxers comprimits amb l'algoritme Brotli, que és una mica més eficient que gzip. Comprimirem els fitxers una vegada; no cal fer-ho sobre la marxa.

Aquest és el Dockerfile amb el qual vaig acabar. Els comentaris en rus són meus, en anglès - de l'original.

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

Arreglaré immediatament nginx.conf perquè gzip i brotli estiguin habilitats per defecte. També inclouré les capçaleres de la memòria cau, perquè mai no canviarem l'estàtica. I el toc final serà redirigir les 404 peticions a index.html, això és necessari per a la navegació a l'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";
            }
        }
    }
}

Podeu descarregar-vos la imatge acabada aquí: https://hub.docker.com/r/alexxxnf/nginx-spa. Ocupa 10,5 MB. El nginx original ocupava 19,7 MB. El meu interès esportiu està satisfet.

Ensenyament de l'estàtica per comprendre les variables d'entorn

Per què es poden necessitar configuracions a SPA? Per exemple, per especificar quina API RESTful cal utilitzar. Normalment, la configuració de l'entorn desitjat es transfereix a SPA en l'etapa de creació. Si necessiteu canviar alguna cosa, haureu de reconstruir l'aplicació. No ho vull. Vull que l'aplicació es creï una vegada a l'etapa CI i es configure tant com sigui necessari a l'etapa del CD mitjançant variables d'entorn.

Per descomptat, els fitxers estàtics no entenen cap variable d'entorn. Per tant, haureu d'utilitzar un truc. A la imatge final, no llançaré nginx, sinó un script d'intèrpret d'ordres especial que llegirà variables d'entorn, les escriurà en fitxers estàtics, els comprimirà i només després transferirà el control a nginx.

Amb aquest propòsit, el Dockerfile proporciona el paràmetre ENTRYPOINT. Donem-li el següent script (utilitzant Angular com a exemple):

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

Perquè l'script faci la seva feina, la configuració s'ha d'escriure als fitxers js d'aquesta forma: ${API_URL}.

Val la pena assenyalar que la majoria dels SPA moderns afegeixen hash als seus fitxers quan es construeixen. Això és necessari perquè el navegador pugui emmagatzemar el fitxer a la memòria cau durant molt de temps. Si el fitxer canvia, el seu hash canviarà, cosa que al seu torn obligarà el navegador a descarregar-lo de nou.

Malauradament, en el meu mètode, canviar la configuració mitjançant variables d'entorn no comporta un canvi en el hash del fitxer, la qual cosa significa que la memòria cau del navegador s'ha d'invalidar d'una altra manera. No tinc aquest problema perquè es despleguen diferents configuracions en diferents entorns.

Ajuntant la imatge final

Finalment.

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

Ara la imatge resultant es pot muntar i utilitzar a qualsevol lloc.

Font: www.habr.com