用于分发单页应用程序的 Docker 映像

单页应用程序 (SPA) 是一组静态 JavaScript 和 HTML 文件,以及图像和其他资源。 因为它们不会动态变化,所以在线发布它们非常容易。 为此有大量廉价甚至免费的服务,从简单的 GitHub Pages(有些甚至是 narod.ru)到像 Amazon S3 这样的 CDN。 然而,我还需要别的东西。

我需要一个带有 SPA 的 Docker 镜像,以便它可以作为 Kubernetes 集群的一部分在生产中轻松启动,也可以在不知道 SPA 是什么的后端开发人员的机器上启动。

我为自己确定了以下形象要求:

  • 易于使用(但不易于组装);
  • 磁盘和 RAM 的最小大小;
  • 通过环境变量进行配置,使得镜像可以在不同的环境下使用;
  • 最有效的文件分发。

今天我会告诉你如何:

  • 肠道 nginx;
  • 从源代码构建 brotli;
  • 教静态文件理解环境变量;
  • 当然,还有如何从这一切中组装一个 Docker 镜像。

本文的目的是分享我的经验并激发经验丰富的社区成员提出建设性的批评。

构建装配图像

为了使最终的 Docker 镜像体积更小,您需要遵守两个规则:最少的层数和简约的基础镜像。 最小的基础镜像之一是 Alpine Linux 镜像,所以我会选择它。 有些人可能会认为 Alpine 不适合生产,他们可能是对的。 但就我个人而言,我和他之间从来没有任何问题,也没有人反对他。

为了减少层数,我将分两个阶段组装图像。 第一个是草稿;所有辅助实用程序和临时文件都将保留在其中。 在最后阶段我只会写下应用程序的最终版本。

让我们从辅助图像开始。

为了编译 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的

您可以使用任何 Web 服务器来分发静态内容。 我习惯使用 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 MB。 最初的 nginx 占用了 19,7 MB。 我的运动兴趣得到了满足。

教授静态知识以了解环境变量

为什么 SPA 中需要设置? 例如,为了指定使用哪个 RESTful API。 通常,所需环境的设置会在构建阶段传输到 SPA。 如果您需要更改某些内容,则必须重建应用程序。 我不想要这个。 我希望应用程序在 CI 阶段构建一次,并在 CD 阶段使用环境变量进行尽可能多的配置。

当然,静态文件本身不理解任何环境变量。 因此,你必须使用一个技巧。 在最终图像中,我不会启动 nginx,而是启动一个特殊的 shell 脚本,该脚本将读取环境变量,将它们写入静态文件,压缩它们,然后将控制权转移给 nginx。

为此,Dockerfile 提供了 ENTRYPOINT 参数。 让我们给他以下脚本(以 Angular 为例):

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

为了让脚本完成其工作,设置必须以这种形式写入 js 文件中: ${API_URL}.

值得注意的是,大多数现代 SPA 在构建时都会向其文件添加哈希值。 这是必要的,以便浏览器可以安全地长时间缓存文件。 如果文件确实发生变化,那么它的哈希值也会发生变化,这反过来又会迫使浏览器再次下载该文件。

不幸的是,在我的方法中,通过环境变量更改配置不会导致文件哈希发生更改,这意味着必须以其他方式使浏览器缓存失效。 我没有这个问题,因为不同的环境部署了不同的配置。

将最终图像放在一起

最后。

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

现在,生成的图像可以在任何地方组装和使用。

来源: habr.com