Imagem Docker para distribuição de Single Page Application.

Uma Aplicação de Página Única (SPA) é um conjunto de arquivos estáticos JavaScript e HTML, além de imagens e outros recursos. Como eles não mudam dinamicamente, publicá-los na internet é muito simples. Existem muitos serviços baratos e até gratuitos para isso, começando com o simples GitHub Pages (e para alguns, até narod.ru) e terminando com CDNs como o Amazon S3. No entanto, eu precisava de outra coisa.

Eu precisava de uma imagem Docker com SPA, para que pudesse ser facilmente executada 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 é uma SPA.

Eu defini os seguintes requisitos para a imagem:

  • facilidade de uso (mas não na construção);
  • tamanho mínimo tanto em termos de disco quanto de RAM;
  • configuração através de variáveis de ambiente, para que a imagem pudesse ser usada em diferentes ambientes;
  • distribuição de arquivos o mais eficiente possível.

Hoje, vou mostrar como:

  • desmontar o nginx;
  • compilar o brotli a partir do código-fonte;
  • ensinar os arquivos estáticos a entenderem variáveis de ambiente;
  • e, claro, como montar tudo isso em uma imagem Docker.

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

Construção da imagem para construção

Para que a imagem final do Docker seja pequena, preciso seguir duas regras: minimizar o número de camadas e usar uma imagem base minimalista. Uma das imagens base mais pequenas é a imagem Alpine Linux, por isso escolherei essa. Alguém pode argumentar que o Alpine não é adequado para produção e, talvez, tenha razão. Mas pessoalmente, nunca tive problemas com ele e não há argumentos contra.

Para ter menos camadas, vou construir a imagem em 2 etapas. A primeira – a de esboço, que conterá todas as utilidades auxiliares e arquivos temporários. E a versão final, em que só vou registrar a versão final do aplicativo.

Começamos pela imagem auxiliar.

Para compilar uma aplicação SPA, geralmente é necessário o node.js. Eu vou pegar a imagem oficial que vem com os gerenciadores de pacotes npm e yarn. Adicionarei o node-gyp, que é necessário para compilar alguns pacotes npm, e o compressor Brotli do Google, que será útil mais tarde.

Dockerfile com comentários.

# Базовый образ
FROM node:12-alpine
LABEL maintainer="Aleksey Maydokin <amaydokin@gmail.com>"
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, por isso a imagem é construída com um único grande comando.

A imagem pronta pode ser encontrada aqui: https://hub.docker.com/r/alexxxnf/spa-builder. Embora eu recomende não depender de imagens de outros e criar a sua própria.

nginx

Para servir arquivos estáticos, você pode usar qualquer servidor web. Eu costumo trabalhar com o nginx, então vou usá-lo agora.

O nginx tem uma imagem oficial do Docker, mas para simplesmente servir arquivos estáticos, vem com muitos módulos. Quais estão incluídos na distribuição podem ser verificados com um comando especial ou no Dockerfile oficial.

$ docker run —rm nginx:1-alpine nginx -V

versão do nginx: nginx/1.17.9
construído pelo gcc 8.3.0 (Alpine 8.3.0) 
construído com OpenSSL 1.1.1d  10 de setembro de 2019
suporte a SNI TLS ativado
argumentos de configuração: --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

Vou usar um Dockerfile, mas deixarei nele apenas o que é necessário para servir arquivos estáticos. Minha versão não irá funcionar por HTTPS, não suportará autenticação e muito mais. Mas minha versão poderá servir arquivos compactados com o algoritmo Brotli, que é um pouco mais eficiente do que gzip. Vamos comprimir os arquivos uma vez, não há necessidade de fazer isso em tempo real.

Aqui está o Dockerfile que eu obtive. Comentários em russo são meus, em inglês são do original.

Dockerfile

# Базовый образ снова Alpine
FROM alpine:3.9
LABEL maintainer="Aleksey Maydokin <amaydokin@gmail.com>"
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;"]

Vou corrigir imediatamente o nginx.conf para que gzip e brotli estejam habilitados por padrão. Também adicionarei cabeçalhos de cache, pois vamos servir arquivos estáticos que nunca mudam. E o último toque será redirecionar todas as requisições 404 para o index.html, o que é necessário para a navegação em uma 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 pronta aqui: https://hub.docker.com/r/alexxxnf/nginx-spa. Ele ocupa 10,5 MB. O nginx original ocupava 19,7 MB. Meu interesse esportivo está satisfeito.

Ensinamos a estática a compreender variáveis de ambiente

Para que as configurações podem ser necessárias em uma SPA? Por exemplo, para indicar qual API RESTful usar. Normalmente, as configurações para o ambiente necessário são passadas à SPA na fase de construção. Se algo precisar ser alterado, o aplicativo terá que ser reconstruído. Eu não quero isso. Quero que o aplicativo seja construído uma única vez na fase de CI e configurado quantas vezes forem necessárias na fase de CD com o uso de variáveis de ambiente.

Obviamente, os arquivos estáticos em si não compreendem nenhuma variável de ambiente. Portanto, vou precisar recorrer a uma artimanha. Na imagem final, não estarei executando o nginx, mas um script shell especial que lerá as variáveis de ambiente, as gravará nos arquivos estáticos, os compactará e só depois transferirá o controle para o nginx.

Para isso, no Dockerfile, existe o parâmetro ENTRYPOINT. Vamos passar este script (exemplo com 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"
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, nas configurações dos arquivos js, deve-se escrever da seguinte forma: ${API_URL}.

Vale ressaltar que a maioria das SPAs modernas, durante a construção, adiciona hashes aos seus arquivos. Isso é necessário para que o navegador possa armazenar o arquivo em cache por um longo período. Se o arquivo mudar, seu hash também mudará, o que fará com que o navegador baixe o arquivo novamente.

Infelizmente, no meu método, a alteração da configuração através de variáveis de ambiente não altera o hash do arquivo, portanto, é necessário invalidar o cache do navegador de outra forma. Eu não tenho esse problema, pois diferentes configurações são implantadas em diferentes ambientes.

Estamos reunindo 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 criada e usada em qualquer lugar.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster