Una Aplicación de Página Única (SPA) es un conjunto de archivos estáticos de JavaScript y HTML, así como imágenes y otros recursos. Dado que no cambian dinámicamente, es muy fácil publicarlos en Internet. Para ello, hay una gran cantidad de servicios baratos e incluso gratuitos, desde GitHub Pages (y para algunos, incluso desde narod.ru) hasta CDNs como Amazon S3. Sin embargo, yo necesitaba algo diferente.
Necesitaba una imagen de Docker con SPA, para que pudiera ejecutarse fácilmente tanto en producción como en una máquina de un desarrollador back-end que no tiene idea de lo que es una SPA.
Definí los siguientes requisitos para la imagen:
- facilidad de uso (pero no en la construcción);
- tamaño mínimo tanto en términos de disco como de RAM;
- configuración a través de variables de entorno, para que la imagen pudiera usarse en diferentes entornos;
- distribución de archivos lo más eficiente posible.
Hoy les contaré cómo:
- desmontar nginx;
- compilar brotli desde el código fuente;
- hacer que los archivos estáticos entiendan las variables de entorno;
- y, por supuesto, cómo construir una imagen de Docker a partir de todo esto.
El objetivo de este artículo es compartir mi experiencia y provocar críticas constructivas de los miembros experimentados de la comunidad.
Construcción de la imagen para el ensamblaje
Para que la imagen final de Docker sea pequeña, hay que seguir dos reglas: un mínimo de capas y una imagen base minimalista. Una de las imágenes base más pequeñas es la de Alpine Linux, por lo que elegiré esta. Algunos pueden argumentar que Alpine no es adecuado para producción y, posiblemente, tengan razón. Pero personalmente nunca he tenido problemas con él, y no hay argumentos en su contra.
Para reducir el número de capas, construiré la imagen en 2 etapas. Primero, la etapa provisional, donde quedarán todas las utilidades auxiliares y archivos temporales. Y en la etapa final, solo grabaré la versión final de la aplicación.
Comencemos con la imagen auxiliar.
Para compilar una aplicación SPA, normalmente se necesita node.js. Tomaré la imagen oficial que también incluye gestores de paquetes npm y yarn. Yo añadiré node-gyp, que se necesita para compilar algunos paquetes npm, y el compresor Brotli de Google, que nos será útil más adelante.
Dockerfile con comentarios.
# Базовый образ
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/srcAquí ya estoy luchando por el minimalismo, así que la imagen se compone de un solo gran comando.
Puedes encontrar la imagen lista aquí: . Aunque recomiendo no depender de imágenes ajenas y crear la tuya propia.
nginx
Para servir archivos estáticos puedes usar cualquier servidor web. Estoy acostumbrado a trabajar con nginx, por eso lo usaré ahora.
Hay una imagen oficial de Docker para nginx, pero para servir archivos estáticos hay demasiados módulos. Puedes ver cuáles están incluidos en la distribución con un comando especial o en el 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-neededTomaré como base el Dockerfile, pero dejaré solo lo necesario para servir archivos estáticos. Mi versión no podrá funcionar por HTTPS, no soportará la autorización y mucho más. Sin embargo, mi versión podrá servir archivos comprimidos con el algoritmo Brotli, que es un poco más eficiente que gzip. Comprimiremos los archivos una vez, no es necesario hacerlo sobre la marcha.
Así quedó mi Dockerfile. Los comentarios en ruso son míos, los en inglés son del 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;"]Inmediatamente modificaré nginx.conf para que gzip y brotli estén habilitados por defecto. También activaré los encabezados de caché, ya que vamos a servir estáticos que nunca cambian. Y el último toque será redirigir todas las solicitudes 404 a index.html, lo cual es necesario para la navegación en 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";
}
}
}
}Puedes descargar la imagen lista aquí: . Ocupa 10,5 MB. La versión original de nginx ocupaba 19,7 MB. Mi curiosidad ha sido satisfecha.
Enseñamos a la estática a entender las variables de entorno
¿Para qué podrían necesitarse configuraciones en un SPA? Por ejemplo, para indicar qué API RESTful utilizar. Normalmente, las configuraciones necesarias para el entorno se pasan al SPA en la fase de construcción. Si algo necesita cambiar, se tendrá que reconstruir la aplicación. No quiero eso. Quiero que la aplicación se construya una vez en la fase de CI, y se configure todo lo necesario en la fase de CD usando variables de entorno.
Por supuesto, los archivos estáticos no entienden por sí mismos ninguna variable de entorno. Por lo tanto, tendré que hacer un truco. En la imagen final, no ejecutaré nginx, sino un script de shell especial que leerá las variables de entorno, las escribirá en los archivos estáticos, los comprimirá y solo después de eso pasará el control a nginx.
Para esto, en el Dockerfile hay un parámetro ENTRYPOINT. Le pasaremos este script (usando Angular como ejemplo):
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$|.ttfquot;); do
# Используем максимальную степень сжатия
gzip -9kf "$i" && brotli -fZ "$i"
done
}
if [ "$1" = 'nginx' ]; then
# Флаг нужен, чтобы выполнить скрипт только при самом первом запуске
if [ ! -e "$FLAG_FILE" ]; then
echo "Ejecutando el script de inicialización"
echo "Reemplazando las variables de entorno"
replace_vars
echo "Comprimiendo archivos"
comprimir
touch $FLAG_FILE
echo "Listo"
fi
fi
exec "$@"
Para que el script haga su trabajo, en los archivos js, la configuración debe escribirse de esta manera:${API_URL}.Cabe destacar que la mayoría de los SPA modernos, al ser construidos, agregan hashes a sus archivos. Esto es necesario para que el navegador pueda almacenar el archivo en caché durante un período prolongado. Si el archivo cambia, también cambiará su hash, lo que hará que el navegador vuelva a descargar el archivo.
Desafortunadamente, en mi método, modificar la configuración a través de variables de entorno no provoca un cambio en el hash del archivo, por lo que hay que invalidar la caché del navegador de alguna otra manera. No tengo ese problema, porque diferentes configuraciones se implementan en diferentes entornos.
Estamos creando la imagen 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;"]Ahora se puede construir y utilizar la imagen resultante donde sea.
Fuente: habr.com
