Imagine Docker pentru distribuirea aplicației cu o singură pagină

Aplicația pentru o singură pagină (SPA) este un set de fișiere JavaScript și HTML statice, precum și imagini și alte resurse. Pentru că nu se schimbă dinamic, publicarea lor online este foarte ușoară. Există un număr mare de servicii ieftine și chiar gratuite pentru asta, începând cu un simplu GitHub Pages (și pentru unii chiar cu narod.ru) și terminând cu un CDN precum Amazon S3. Totuși, aveam nevoie de altceva.

Aveam nevoie de o imagine Docker cu SPA, astfel încât să poată fi lansată cu ușurință atât în ​​producție ca parte a unui cluster Kubernetes, cât și pe mașina unui dezvoltator back-end care habar nu are ce este SPA.

Am stabilit următoarele cerințe de imagine pentru mine:

  • ușurință în utilizare (dar nu și asamblare);
  • dimensiune minimă atât în ​​ceea ce privește discul cât și memoria RAM;
  • configurare prin variabile de mediu astfel încât imaginea să poată fi utilizată în diferite medii;
  • cea mai eficientă distribuție a fișierelor.

Astăzi vă voi spune cum:

  • intestin nginx;
  • construiți brotli din surse;
  • învață fișierele statice să înțeleagă variabilele de mediu;
  • și, desigur, cum să asamblați o imagine Docker din toate acestea.

Scopul acestui articol este de a împărtăși experiența mea și de a provoca membrii experimentați ai comunității la critici constructive.

Construirea unei imagini pentru asamblare

Pentru a face imaginea finală Docker mică, trebuie să respectați două reguli: un minim de straturi și o imagine de bază minimalistă. Una dintre cele mai mici imagini de bază este imaginea Alpine Linux, așa că asta voi alege. Unii ar putea argumenta că Alpine nu este potrivit pentru producție și ar putea avea dreptate. Dar personal, nu am avut niciodată probleme cu el și nu există argumente împotriva lui.

Pentru a avea mai puține straturi, voi asambla imaginea în 2 etape. Primul este o schiță; toate utilitățile auxiliare și fișierele temporare vor rămâne în el. Și în etapa finală voi scrie doar versiunea finală a aplicației.

Să începem cu imaginea auxiliară.

Pentru a compila o aplicație SPA, de obicei aveți nevoie de node.js. Voi lua imaginea oficială, care vine și cu managerii de pachete npm și yarn. În numele meu, voi adăuga node-gyp, care este necesar pentru construirea unor pachete npm, și compresorul Brotli de la Google, care ne va fi de folos mai târziu.

Dockerfile cu comentarii.

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

Deja aici lupt pentru minimalism, așa că imaginea este realizată de o echipă mare.

Imaginea finalizată poate fi găsită aici: https://hub.docker.com/r/alexxxnf/spa-builder. Deși recomand să nu te bazezi pe imaginile altora și să le colecționezi pe ale tale.

Nginx

Puteți utiliza orice server web pentru a distribui conținut static. Sunt obișnuit să lucrez cu nginx, așa că îl voi folosi acum.

Nginx are o imagine oficială Docker, dar are prea multe module pentru o distribuție statică simplă. Care sunt incluse în livrare pot fi văzute de o echipă specială sau în 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

Voi folosi Dockerfile ca bază, dar voi lăsa în el doar ceea ce este necesar pentru a distribui conținut static. Versiunea mea nu va putea funcționa prin HTTPS, nu va accepta autorizarea și multe altele. Dar versiunea mea va putea distribui fișiere comprimate cu algoritmul Brotli, care este puțin mai eficient decât gzip. Vom comprima fișierele o dată; nu este nevoie să facem acest lucru din mers.

Acesta este Dockerfile cu care am ajuns. Comentariile în rusă sunt ale mele, în engleză - din 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;"]

Voi repara imediat nginx.conf, astfel încât gzip și brotli să fie activate implicit. Voi include, de asemenea, anteturile de stocare în cache, deoarece nu vom avea niciodată schimbare statică. Iar atingerea finală va fi redirecționarea tuturor celor 404 solicitări către index.html, acest lucru este necesar pentru navigarea în 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";
            }
        }
    }
}

Poți descărca imaginea finalizată de aici: https://hub.docker.com/r/alexxxnf/nginx-spa. Ocupă 10,5 MB. Nginx original a ocupat 19,7 MB. Interesul meu sportiv este satisfăcut.

Predarea staticii pentru a înțelege variabilele de mediu

De ce ar putea fi necesare setări în SPA? De exemplu, pentru a specifica ce API RESTful să utilizați. De obicei, setările pentru mediul dorit sunt transferate în SPA în etapa de construire. Dacă trebuie să schimbați ceva, va trebui să reconstruiți aplicația. Eu nu-l vreau. Vreau ca aplicația să fie construită o dată în stadiul CI și configurată atât cât este necesar în stadiul CD folosind variabile de mediu.

Desigur, fișierele statice în sine nu înțeleg nicio variabilă de mediu. Prin urmare, va trebui să folosiți un truc. În imaginea finală, nu voi lansa nginx, ci un script shell special care va citi variabilele de mediu, le va scrie în fișiere statice, le va comprima și abia apoi va transfera controlul către nginx.

În acest scop, Dockerfile furnizează parametrul ENTRYPOINT. Să-i dăm următorul script (folosind Angular ca exemplu):

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

Pentru ca scriptul să-și facă treaba, setările trebuie scrise în fișierele js sub această formă: ${API_URL}.

Este de remarcat faptul că majoritatea SPA-urilor moderne adaugă hash-uri la fișierele lor atunci când construiesc. Acest lucru este necesar pentru ca browserul să poată stoca fișierul în cache pentru o lungă perioadă de timp. Dacă fișierul se schimbă, atunci hash-ul său se va schimba, ceea ce la rândul său va forța browserul să descarce fișierul din nou.

Din păcate, în metoda mea, schimbarea configurației prin variabilele de mediu nu duce la o modificare a hash-ului fișierului, ceea ce înseamnă că memoria cache a browserului trebuie să fie invalidată într-un alt mod. Nu am această problemă, deoarece configurații diferite sunt implementate în medii diferite.

Adunarea imaginii finale

In cele din urma.

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

Acum, imaginea rezultată poate fi asamblată și utilizată oriunde.

Sursa: www.habr.com