Imagem Docker para distribuição de aplicativo de página única

Aplicativo de página única (SPA) é um conjunto de arquivos JavaScript e HTML estáticos, bem como imagens e outros recursos. Como eles não mudam dinamicamente, publicá-los online é muito fácil. Há um grande número de serviços baratos e até gratuitos para isso, começando com um simples GitHub Pages (e para alguns até com narod.ru) e terminando com um CDN como o Amazon S3. No entanto, eu precisava de outra coisa.

Eu precisava de uma imagem Docker com SPA para que ela pudesse ser facilmente lançada tanto em produção como parte de um cluster Kubernetes quanto na máquina de um desenvolvedor back-end que não tem ideia do que é SPA.

Eu determinei os seguintes requisitos de imagem para mim:

  • facilidade de uso (mas não de montagem);
  • tamanho mínimo tanto em termos de disco quanto de RAM;
  • configuração através de variáveis ​​de ambiente para que a imagem possa ser utilizada em diferentes ambientes;
  • a distribuição mais eficiente de arquivos.

Hoje vou te contar como:

  • intestino nginx;
  • construir brotli a partir de fontes;
  • ensinar arquivos estáticos a entender variáveis ​​de ambiente;
  • e claro como montar uma imagem Docker a partir de tudo isso.

O objetivo deste artigo é compartilhar minha experiência e provocar críticas construtivas em membros experientes da comunidade.

Construindo uma imagem para montagem

Para tornar a imagem final do Docker pequena, você precisa seguir duas regras: um mínimo de camadas e uma imagem de base minimalista. Uma das menores imagens base é a imagem Alpine Linux, então é isso que vou escolher. Alguns podem argumentar que o Alpine não é adequado para produção e podem estar certos. Mas, pessoalmente, nunca tive problemas com ele e não há argumentos contra ele.

Para ter menos camadas, montarei a imagem em 2 etapas. O primeiro é um rascunho, nele permanecerão todos os utilitários auxiliares e arquivos temporários. E na etapa final irei apenas anotar a versão final do aplicativo.

Vamos começar com a imagem auxiliar.

Para compilar um aplicativo SPA, geralmente você precisa do node.js. Vou pegar a imagem oficial, que também vem com os gerenciadores de pacotes npm e yarn. Em meu próprio nome, adicionarei o node-gyp, necessário para construir alguns pacotes npm, e o compressor Brotli do Google, que será útil para nós mais tarde.

Dockerfile com comentários.

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

Já aqui estou lutando pelo minimalismo, então a imagem é montada por uma grande equipe.

A imagem finalizada pode ser encontrada aqui: https://hub.docker.com/r/alexxxnf/spa-builder. Embora eu recomende não confiar nas imagens de outras pessoas e coletar as suas próprias.

nginx

Você pode usar qualquer servidor web para distribuir conteúdo estático. Estou acostumado a trabalhar com nginx, então vou usá-lo agora.

O Nginx possui uma imagem oficial do Docker, mas possui muitos módulos para uma distribuição estática simples. Quais estão incluídos na entrega podem ser consultados por uma equipe especial ou no 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

Usarei o Dockerfile como base, mas deixarei nele apenas o necessário para distribuir conteúdo estático. Minha versão não funcionará em HTTPS, não suportará autorização e muito mais. Mas minha versão será capaz de distribuir arquivos compactados com o algoritmo Brotli, que é um pouco mais eficiente que o gzip. Compactaremos os arquivos uma vez; não há necessidade de fazer isso imediatamente.

Este é o Dockerfile que acabei usando. Os comentários em russo são meus, em inglês - do 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;"]

Corrigirei imediatamente o nginx.conf para que o gzip e o brotli sejam habilitados por padrão. Também incluirei cabeçalhos de cache, porque nunca mudaremos a estática. E o toque final será redirecionar todas as solicitações 404 para index.html, isso é necessário para navegação no 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";
            }
        }
    }
}

Você pode baixar a imagem finalizada aqui: https://hub.docker.com/r/alexxxnf/nginx-spa. Ocupa 10,5 MB. O nginx original ocupava 19,7 MB. Meu interesse esportivo está satisfeito.

Ensinando estática para entender variáveis ​​de ambiente

Por que as configurações podem ser necessárias no SPA? Por exemplo, para especificar qual API RESTful usar. Normalmente, as configurações do ambiente desejado são transferidas para o SPA na fase de construção. Se precisar alterar alguma coisa, você terá que reconstruir o aplicativo. Eu não quero isso. Quero que o aplicativo seja construído uma vez no estágio de CI e configurado tanto quanto necessário no estágio de CD usando variáveis ​​de ambiente.

É claro que os próprios arquivos estáticos não compreendem nenhuma variável de ambiente. Portanto, você terá que usar um truque. Na imagem final, não iniciarei o nginx, mas um script shell especial que irá ler as variáveis ​​​​de ambiente, gravá-las em arquivos estáticos, compactá-las e só então transferir o controle para o nginx.

Para isso, o Dockerfile fornece o parâmetro ENTRYPOINT. Vamos dar a ele o seguinte script (usando Angular como exemplo):

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

Para que o script faça seu trabalho, as configurações devem ser escritas nos arquivos js neste formato: ${API_URL}.

É importante notar que a maioria dos SPAs modernos adiciona hashes aos seus arquivos durante a construção. Isso é necessário para que o navegador possa armazenar o arquivo em cache com segurança por um longo tempo. Se o arquivo for alterado, seu hash será alterado, o que forçará o navegador a baixar o arquivo novamente.

Infelizmente, no meu método, alterar a configuração por meio de variáveis ​​de ambiente não leva a uma alteração no hash do arquivo, o que significa que o cache do navegador deve ser invalidado de alguma outra forma. Não tenho esse problema porque configurações diferentes são implantadas em ambientes diferentes.

Montando a imagem final

Finalmente.

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

Agora a imagem resultante pode ser montada e usada em qualquer lugar.

Fonte: habr.com