단일 페이지 애플리케이션 배포용 Docker 이미지

SPA(단일 페이지 애플리케이션)는 정적 JavaScript 및 HTML 파일, 이미지 및 기타 리소스의 집합입니다. 동적으로 변경되지 않기 때문에 온라인에 게시하는 것이 매우 쉽습니다. 간단한 GitHub 페이지(일부는 narod.ru 포함)로 시작하여 Amazon S3와 같은 CDN으로 끝나는 저렴하고 무료 서비스도 많이 있습니다. 그러나 다른 것이 필요했습니다.

Kubernetes 클러스터의 일부로 프로덕션 환경과 SPA가 무엇인지 모르는 백엔드 개발자의 컴퓨터에서 쉽게 시작할 수 있도록 SPA가 포함된 Docker 이미지가 필요했습니다.

나는 다음과 같은 이미지 요구 사항을 결정했습니다.

  • 사용 용이성(조립은 아님)
  • 디스크와 RAM 측면에서 최소 크기;
  • 이미지를 다양한 환경에서 사용할 수 있도록 환경 변수를 통해 구성합니다.
  • 가장 효율적인 파일 배포.

오늘은 그 방법을 알려드리겠습니다.

  • 내장 nginx;
  • 소스에서 brotli를 빌드합니다.
  • 환경 변수를 이해하도록 정적 파일을 가르칩니다.
  • 물론 이 모든 것에서 Docker 이미지를 조립하는 방법도 있습니다.

이 글의 목적은 내 경험을 공유하고 경험이 풍부한 커뮤니티 회원들이 건설적인 비판을 하도록 유도하는 것입니다.

어셈블리용 이미지 구축

최종 Docker 이미지의 크기를 작게 만들려면 최소한의 레이어와 최소한의 기본 이미지라는 두 가지 규칙을 준수해야 합니다. 가장 작은 기본 이미지 중 하나는 Alpine Linux 이미지이므로 이를 선택하겠습니다. 어떤 사람들은 알파인이 생산에 적합하지 않다고 주장할 수도 있는데, 그들의 말이 옳을 수도 있습니다. 그러나 개인적으로 나는 그와 어떤 문제도 겪은 적이 없으며 그에 대한 논쟁도 없습니다.

레이어 수를 줄이기 위해 이미지를 2단계로 조립하겠습니다. 첫 번째는 초안이며 모든 보조 유틸리티와 임시 파일은 그대로 유지됩니다. 그리고 마지막 단계에서는 지원서의 최종 버전만 적겠습니다.

보조 이미지부터 시작해 보겠습니다.

SPA 애플리케이션을 컴파일하려면 일반적으로 node.js가 필요합니다. npm 및 Yarn 패키지 관리자와 함께 제공되는 공식 이미지를 사용하겠습니다. 나 자신을 대신하여 일부 npm 패키지를 빌드하는 데 필요한 node-gyp와 나중에 유용할 Google의 Brotli 압축기를 추가하겠습니다.

주석이 포함된 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를 사용하겠습니다.

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를 통해 작동할 수 없으며 인증 등을 지원하지 않습니다. 하지만 내 버전은 gzip보다 약간 더 효율적인 Brotli 알고리즘으로 압축된 파일을 배포할 수 있습니다. 파일을 한 번만 압축하면 즉시 압축할 필요가 없습니다.

이것이 내가 완성한 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;"]

gzip과 brotli가 기본적으로 활성화되도록 nginx.conf를 즉시 수정하겠습니다. 우리는 정적을 결코 변경하지 않을 것이기 때문에 캐싱 헤더도 포함할 것입니다. 마지막 작업은 모든 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,5MB를 차지합니다. 원래 nginx는 19,7MB를 차지했습니다. 나의 스포츠 관심은 만족스럽습니다.

환경 변수를 이해하기 위한 정적 교육

SPA에서 설정이 필요한 이유는 무엇입니까? 예를 들어 사용할 RESTful API를 지정하기 위해. 일반적으로 구축 단계에서 원하는 환경에 대한 설정이 SPA로 전송됩니다. 무언가를 변경해야 한다면 애플리케이션을 다시 빌드해야 합니다. 나는 그것을 원하지 않는다. CI 단계에서 애플리케이션을 한 번 구축하고 CD 단계에서 환경 변수를 사용하여 필요한 만큼 구성하고 싶습니다.

물론 정적 파일 자체는 환경 변수를 이해하지 못합니다. 따라서 트릭을 사용해야합니다. 최종 이미지에서는 nginx를 실행하지 않고 환경 변수를 읽고 이를 정적 파일에 쓰고 압축한 다음 제어권을 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}.

대부분의 최신 SPA는 빌드할 때 파일에 해시를 추가한다는 점은 주목할 가치가 있습니다. 이는 브라우저가 오랫동안 파일을 안전하게 캐시할 수 있도록 하기 위해 필요합니다. 파일이 변경되면 해당 해시가 변경되어 브라우저가 파일을 다시 다운로드하게 됩니다.

불행하게도 내 방법에서는 환경 변수를 통해 구성을 변경해도 파일 해시가 변경되지 않으므로 다른 방법으로 브라우저 캐시를 무효화해야 합니다. 서로 다른 구성이 서로 다른 환경에 배포되기 때문에 이 문제가 발생하지 않습니다.

최종 이미지 합치기

마침내

도커 파일

# Первый базовый образ для сборки
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;"]

이제 결과 이미지를 조합하여 어디에서나 사용할 수 있습니다.

출처 : habr.com