صورة عامل ميناء لتوزيع تطبيق صفحة واحدة

تطبيق الصفحة الواحدة (SPA) عبارة عن مجموعة من ملفات JavaScript وHTML الثابتة، بالإضافة إلى الصور والموارد الأخرى. ونظرًا لأنها لا تتغير ديناميكيًا، فإن نشرها عبر الإنترنت يعد أمرًا سهلاً للغاية. هناك عدد كبير من الخدمات الرخيصة وحتى المجانية لهذا الغرض، بدءًا من صفحات GitHub البسيطة (وبالنسبة للبعض حتى مع narod.ru) وتنتهي بـ CDN مثل Amazon S3. ومع ذلك، كنت بحاجة إلى شيء آخر.

كنت بحاجة إلى صورة Docker مع SPA حتى يمكن إطلاقها بسهولة في الإنتاج كجزء من مجموعة Kubernetes وعلى جهاز مطور الواجهة الخلفية الذي ليس لديه أي فكرة عن SPA.

لقد حددت بنفسي متطلبات الصورة التالية:

  • سهولة الاستخدام (ولكن ليس التجميع)؛
  • الحد الأدنى للحجم سواء من حيث القرص أو ذاكرة الوصول العشوائي؛
  • التكوين من خلال متغيرات البيئة بحيث يمكن استخدام الصورة في بيئات مختلفة؛
  • التوزيع الأكثر كفاءة للملفات.

اليوم سأخبرك كيف:

  • القناة الهضمية nginx؛
  • بناء بروتلي من المصادر؛
  • تعليم الملفات الثابتة لفهم متغيرات البيئة؛
  • وبالطبع كيفية تجميع صورة Docker من كل هذا.

الغرض من هذه المقالة هو مشاركة تجربتي وحث أفراد المجتمع ذوي الخبرة على النقد البناء.

بناء صورة للتجميع

لجعل صورة Docker النهائية صغيرة الحجم، عليك الالتزام بقاعدتين: الحد الأدنى من الطبقات وصورة أساسية بسيطة. إحدى أصغر الصور الأساسية هي صورة Alpine Linux، وهذا ما سأختاره. قد يجادل البعض بأن جبال الألب غير مناسبة للإنتاج، وقد يكونون على حق. لكن شخصيا لم أواجه أي مشاكل معه ولا توجد أي حجج ضده.

للحصول على طبقات أقل، سأقوم بتجميع الصورة على مرحلتين. الأول عبارة عن مسودة، وستبقى فيه جميع الأدوات المساعدة والملفات المؤقتة. وفي المرحلة النهائية سأكتب فقط النسخة النهائية من التطبيق.

لنبدأ بالصورة المساعدة.

لتجميع تطبيق SPA، تحتاج عادةً إلى Node.js. سألتقط الصورة الرسمية، والتي تأتي أيضًا مع مديري الحزم npm وغزل. ونيابة عن نفسي، سأقوم بإضافة 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 الذي انتهى بي الأمر به. التعليقات باللغة الروسية هي تعليقاتي، باللغة الإنجليزية - من النص الأصلي.

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، وتكوينه بقدر الضرورة في مرحلة القرص المضغوط باستخدام متغيرات البيئة.

وبطبيعة الحال، الملفات الثابتة نفسها لا تفهم أي متغيرات البيئة. لذلك، سيكون عليك استخدام الخدعة. في الصورة النهائية، لن أقوم بتشغيل nginx، بل سأقوم بتشغيل برنامج نصي خاص لـ Shell يقرأ متغيرات البيئة، ويكتبها في ملفات ثابتة، ويضغطها، وبعد ذلك فقط ينقل التحكم إلى nginx.

لهذا الغرض، يوفر Dockerfile معلمة ENTRYPOINT. لنقدم له النص التالي (باستخدام Angular كمثال):

عامل ميناء-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}.

تجدر الإشارة إلى أن معظم المنتجعات الصحية الحديثة تضيف تجزئة إلى ملفاتها عند الإنشاء. يعد ذلك ضروريًا حتى يتمكن المتصفح من تخزين الملف مؤقتًا بأمان لفترة طويلة. إذا تغير الملف، فستتغير التجزئة الخاصة به، مما سيجبر المتصفح على تنزيل الملف مرة أخرى.

لسوء الحظ، في طريقتي، لا يؤدي تغيير التكوين من خلال متغيرات البيئة إلى تغيير في تجزئة الملف، مما يعني أنه يجب إبطال ذاكرة التخزين المؤقت للمتصفح بطريقة أخرى. لا أواجه هذه المشكلة لأنه يتم نشر تكوينات مختلفة في بيئات مختلفة.

تجميع الصورة النهائية

وأخيرا.

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

الآن يمكن تجميع الصورة الناتجة واستخدامها في أي مكان.

المصدر: www.habr.com