A Single-page Application (SPA) is a collection of static JavaScript and HTML files, as well as images and other resources. Because they don't change dynamically, publishing them to the web is very easy. To do this, there are a large number of cheap and even free services, starting with a simple GitHub Pages (and for some even with narod.ru) and ending with a CDN like Amazon S3. However, I needed something else.
I needed a Docker image with SPA so that it could be easily run both in production as part of a Kubernetes cluster and on a back-end developer's machine who has no idea what SPA is.
I have determined the following requirements for the image:
- ease of use (but not assembly);
- minimum size both in terms of disk and RAM;
- configuration through environment variables so that the image can be used in different environments;
- the most efficient distribution of files.
Today I will show you how:
- gut nginx;
- build brotli from source;
- teach static files to understand environment variables;
- and of course how to assemble a Docker image from all this.
The purpose of this article is to share my experience and provoke experienced community members to constructive criticism.
Compiling an Image to Build
To make the final Docker image small in size, you need to follow two rules: a minimum of layers and a minimalistic base image. One of the smallest base images is the Alpine Linux image, so that's what I'll choose. Someone may argue that Alpine is not suitable for production and may be right. But personally, I never had any problems with him and there are no arguments against him.
To have fewer layers, I will assemble the image in 2 stages. The first one is draft, it will contain all auxiliary utilities and temporary files. And in the final version, I will write down only the final version of the application.
Let's start with a helper image.
In order to compile a SPA application, you usually need node.js. I will take the official image bundled with the npm and yarn package managers. From myself, I will add node-gyp, which is needed to build some npm packages, and the Brotli compressor from Google, which will be useful to us later.
Dockerfile with comments.
# ΠΠ°Π·ΠΎΠ²ΡΠΉ ΠΎΠ±ΡΠ°Π·
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
Already here I am fighting for minimalism, so the image is assembled by one big team.
The finished image can be found here:
Any web server can be used to distribute statics. I am used to working with nginx, so I will use it now.
nginx has an official Docker image, but there are too many modules in it for simple distribution of statics. Which ones are included in the distribution can be viewed with a special command or in the official 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
I will take the Dockerfile as a basis, but leave in it only what is needed for distributing static. My version will not be able to work over HTTPS, will not support authorization, and much more. But my version will be able to distribute files compressed with the Brotli algorithm, which is slightly more efficient than gzip. We will compress the files once, there is no need to do it on the fly.
Here is the Dockerfile I got. Comments in Russian are mine, in English are from the 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;"]
I'll fix nginx.conf right away so that gzip and brotli are enabled by default. I will also turn on caching headers, because we will have never-changing static. And the final touch will be redirecting all 404 requests to index.html, this is necessary for navigation in the 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";
}
}
}
}
You can download the finished image here:
Teaching statics to understand environment variables
Why might the SPA need settings? For example, in order to specify which RESTful API to use. Typically, the settings for the desired environment are passed to the SPA during the build phase. If you need to change something, you will have to rebuild the application. I do not want it. I want the application to be built once at the CI stage, and configured as much as necessary at the CD stage using environment variables.
Of course, static files themselves don't understand any environment variables. Therefore, you have to go to the trick. In the final image, I will not run nginx, but a special shell script that will read environment variables, write them to static files, compress them, and only after that transfer control to nginx.
For this, the ENTRYPOINT parameter is provided in the Dockerfile. Let's pass the following script to it (using Angular as an example):
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 "$@"
For the script to do its job, you need to write the settings in the js-files like this:${API_URL}
.It is worth noting that most modern SPAs add hashes to their files during assembly. This is necessary so that the browser can safely cache the file for a long time. If the file does change, then its hash will also change, which in turn will force the browser to download the file again.
Unfortunately, in my method, changing the configuration through environment variables does not change the hash of the file, which means that the browser cache must be invalidated in some other way. I don't have this problem because different configurations are deployed in different environments.
Assembling the final image
At last.
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;"]
Now the resulting image can be assembled and used anywhere.
Source: habr.com