Hình ảnh Docker để phân phối Ứng dụng Trang Đơn

Ứng dụng một trang (SPA) là một tập hợp các tệp JavaScript và HTML tĩnh, cũng như hình ảnh và các tài nguyên khác. Vì chúng không thay đổi linh hoạt nên việc xuất bản chúng lên mạng rất dễ dàng. Có một số lượng lớn các dịch vụ giá rẻ và thậm chí miễn phí cho việc này, bắt đầu bằng Trang GitHub đơn giản (và đối với một số thậm chí có cả narod.ru) và kết thúc bằng CDN như Amazon S3. Tuy nhiên, tôi cần một cái gì đó khác.

Tôi cần một hình ảnh Docker với SPA để có thể dễ dàng khởi chạy nó cả trong quá trình sản xuất như một phần của cụm Kubernetes và trên máy của một nhà phát triển phụ trợ không biết SPA là gì.

Tôi đã xác định các yêu cầu về hình ảnh sau đây cho bản thân:

  • dễ sử dụng (nhưng không lắp ráp);
  • kích thước tối thiểu cả về đĩa và RAM;
  • cấu hình thông qua các biến môi trường để hình ảnh có thể được sử dụng trong các môi trường khác nhau;
  • phân phối tập tin hiệu quả nhất.

Hôm nay tôi sẽ cho bạn biết làm thế nào:

  • ruột nginx;
  • xây dựng brotli từ các nguồn;
  • dạy các tệp tĩnh để hiểu các biến môi trường;
  • và tất nhiên là cách tập hợp hình ảnh Docker từ tất cả những điều này.

Mục đích của bài viết này là chia sẻ kinh nghiệm của tôi và khuyến khích các thành viên cộng đồng có kinh nghiệm đưa ra những lời phê bình mang tính xây dựng.

Xây dựng hình ảnh để lắp ráp

Để làm cho hình ảnh Docker cuối cùng có kích thước nhỏ, bạn cần tuân thủ hai quy tắc: tối thiểu các lớp và hình ảnh cơ sở tối giản. Một trong những image cơ sở nhỏ nhất là image Alpine Linux, vì vậy đó là những gì tôi sẽ chọn. Một số người có thể lập luận rằng Alpine không phù hợp để sản xuất và họ có thể đúng. Nhưng về mặt cá nhân, tôi chưa bao giờ có bất kỳ vấn đề gì với anh ấy và không có tranh cãi nào chống lại anh ấy.

Để có ít lớp hơn, tôi sẽ ghép hình ảnh thành 2 giai đoạn. Đầu tiên là bản nháp, tất cả các tiện ích phụ trợ và tệp tạm thời sẽ vẫn ở trong đó. Và ở giai đoạn cuối cùng, tôi sẽ chỉ viết ra phiên bản cuối cùng của ứng dụng.

Hãy bắt đầu với hình ảnh phụ trợ.

Để biên dịch một ứng dụng SPA, bạn thường cần node.js. Tôi sẽ lấy hình ảnh chính thức, cũng đi kèm với trình quản lý gói sợi và npm. Thay mặt tôi, tôi sẽ thêm node-gyp, cần thiết để xây dựng một số gói npm và trình nén Brotli của Google, điều này sẽ hữu ích cho chúng ta sau này.

Dockerfile với ý kiến.

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

Ở đây, tôi đang đấu tranh cho chủ nghĩa tối giản, vì vậy hình ảnh được một nhóm lớn tập hợp lại.

Hình ảnh hoàn thiện có thể được tìm thấy ở đây: https://hub.docker.com/r/alexxxnf/spa-builder. Mặc dù tôi khuyên bạn không nên dựa vào hình ảnh của người khác và sưu tầm hình ảnh của riêng mình.

nginx

Bạn có thể sử dụng bất kỳ máy chủ web nào để phân phối nội dung tĩnh. Tôi đã quen làm việc với nginx nên bây giờ tôi sẽ sử dụng nó.

Nginx có hình ảnh Docker chính thức, nhưng nó có quá nhiều mô-đun để phân phối tĩnh đơn giản. Những cái nào được bao gồm trong quá trình phân phối có thể được xem bởi một nhóm đặc biệt hoặc trong Dockerfile chính thức.

$ 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

Tôi sẽ sử dụng Dockerfile làm cơ sở, nhưng tôi sẽ chỉ để lại trong đó những gì cần thiết để phân phối nội dung tĩnh. Phiên bản của tôi sẽ không thể hoạt động qua HTTPS, không hỗ trợ ủy quyền và hơn thế nữa. Nhưng phiên bản của tôi sẽ có thể phân phối các tệp được nén bằng thuật toán Brotli, hiệu quả hơn một chút so với gzip. Chúng tôi sẽ nén các tập tin một lần; không cần phải thực hiện việc này một cách nhanh chóng.

Đây là Dockerfile mà tôi đã kết thúc. Bình luận bằng tiếng Nga là của tôi, bằng tiếng Anh - từ bản gốc.

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

Tôi sẽ sửa ngay nginx.conf để gzip và brotli được bật theo mặc định. Tôi cũng sẽ bao gồm các tiêu đề bộ nhớ đệm vì chúng tôi sẽ không bao giờ thay đổi tĩnh. Và bước cuối cùng sẽ là chuyển hướng tất cả các yêu cầu 404 đến index.html, điều này cần thiết cho việc điều hướng trong 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";
            }
        }
    }
}

Bạn có thể tải hình ảnh đã hoàn thành tại đây: https://hub.docker.com/r/alexxxnf/nginx-spa. Nó chiếm 10,5 MB. Nginx ban đầu chiếm 19,7 MB. Sở thích thể thao của tôi được thỏa mãn.

Dạy tĩnh học để hiểu các biến môi trường

Tại sao có thể cần cài đặt trong SPA? Ví dụ: để chỉ định API RESTful nào sẽ sử dụng. Thông thường, cài đặt cho môi trường mong muốn sẽ được chuyển sang SPA ở giai đoạn xây dựng. Nếu cần thay đổi điều gì đó, bạn sẽ phải xây dựng lại ứng dụng. Tôi không muốn nó. Tôi muốn ứng dụng được xây dựng một lần ở giai đoạn CI và được định cấu hình nhiều nhất có thể ở giai đoạn CD bằng cách sử dụng các biến môi trường.

Tất nhiên, bản thân các tệp tĩnh không hiểu bất kỳ biến môi trường nào. Vì vậy, bạn sẽ phải sử dụng một thủ thuật. Trong hình ảnh cuối cùng, tôi sẽ không khởi chạy nginx mà là một tập lệnh shell đặc biệt sẽ đọc các biến môi trường, ghi chúng vào tệp tĩnh, nén chúng và chỉ sau đó chuyển quyền điều khiển sang nginx.

Với mục đích này, Dockerfile cung cấp tham số ENTRYPOINT. Hãy đưa cho anh ấy đoạn script sau (dùng Angular làm ví dụ):

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

Để tập lệnh thực hiện công việc của mình, các cài đặt phải được ghi trong tệp js ở dạng này: ${API_URL}.

Điều đáng chú ý là hầu hết các SPA hiện đại đều thêm hàm băm vào tệp của họ khi xây dựng. Điều này là cần thiết để trình duyệt có thể lưu trữ tệp một cách an toàn trong thời gian dài. Nếu tệp thay đổi thì hàm băm của nó sẽ thay đổi, điều này sẽ buộc trình duyệt tải xuống lại tệp.

Thật không may, theo phương pháp của tôi, việc thay đổi cấu hình thông qua các biến môi trường không dẫn đến thay đổi hàm băm của tệp, điều đó có nghĩa là bộ đệm của trình duyệt phải bị vô hiệu hóa theo một cách nào đó. Tôi không gặp vấn đề này vì các cấu hình khác nhau được triển khai trong các môi trường khác nhau.

Ghép lại hình ảnh cuối cùng

Cuối cùng.

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

Bây giờ hình ảnh thu được có thể được lắp ráp và sử dụng ở bất cứ đâu.

Nguồn: www.habr.com