В этой главе мы реализуем в приложении работу со статическими файлами и покажем, как правильно отдавать их клиенту.

Приложение в этой главе не предназначено для использования в production без доработки. Готовое к работе в production приложение мы получим в конце руководства.

Подготовка окружения

Подготовьте рабочее окружение согласно c инструкциями главы “Подготовка окружения”, если это ещё не сделано.

Рабочее окружение работало, но перестало? Инструкции из этой главы не работают? Может помочь:

Работает ли Docker?

Запустим приложение Docker Desktop. Приложению понадобится некоторое время для того, чтобы запустить Docker. Если никаких ошибок в процессе запуска не возникло, то проверим, что Docker запущен и корректно настроен:

docker run hello-world

Результат успешного выполнения команды:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:9f6ad537c5132bcce57f7a0a20e317228d382c3cd61edae14650eec68b2b345c
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

При возникновении проблем обратитесь к документации Docker для их устранения.

Запустим приложение Docker Desktop. Приложению понадобится некоторое время для того, чтобы запустить Docker. Если никаких ошибок в процессе запуска не возникло, то проверим, что Docker запущен и корректно настроен:

docker run hello-world

Результат успешного выполнения команды:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:9f6ad537c5132bcce57f7a0a20e317228d382c3cd61edae14650eec68b2b345c
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

При возникновении проблем обратитесь к документации Docker для их устранения.

Запустим Docker:

sudo systemctl restart docker

Убедимся, что Docker запустился:

sudo systemctl status docker

Результат выполнения команды, если Docker успешно запущен:

● docker.service - Docker Application Container Engine
     Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
     Active: active (running) since Thu 2021-06-24 13:05:17 MSK; 13s ago
TriggeredBy: ● docker.socket
       Docs: https://docs.docker.com
   Main PID: 2013888 (dockerd)
      Tasks: 36
     Memory: 100.3M
     CGroup: /system.slice/docker.service
             └─2013888 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

dockerd[2013888]: time="2021-06-24T13:05:16.936197880+03:00" level=warning msg="Your kernel does not support CPU realtime scheduler"
dockerd[2013888]: time="2021-06-24T13:05:16.936219851+03:00" level=warning msg="Your kernel does not support cgroup blkio weight"
dockerd[2013888]: time="2021-06-24T13:05:16.936224976+03:00" level=warning msg="Your kernel does not support cgroup blkio weight_device"
dockerd[2013888]: time="2021-06-24T13:05:16.936311001+03:00" level=info msg="Loading containers: start."
dockerd[2013888]: time="2021-06-24T13:05:17.119938367+03:00" level=info msg="Loading containers: done."
dockerd[2013888]: time="2021-06-24T13:05:17.134054120+03:00" level=info msg="Daemon has completed initialization"
systemd[1]: Started Docker Application Container Engine.
dockerd[2013888]: time="2021-06-24T13:05:17.148493957+03:00" level=info msg="API listen on /run/docker.sock"

Теперь проверим, что Docker доступен и корректно настроен:

docker run hello-world

Результат успешного выполнения команды:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:9f6ad537c5132bcce57f7a0a20e317228d382c3cd61edae14650eec68b2b345c
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

При возникновении проблем обратитесь к документации Docker для их устранения.

Перезагружали компьютер после подготовки окружения?

Запустим кластер minikube, уже настроенный в начале главы “Подготовка окружения”:

minikube start

Выставим Namespace по умолчанию, чтобы не указывать его при каждом вызове kubectl:

kubectl config set-context minikube --namespace=werf-guide-app

Результат успешного выполнения команды:

😄  minikube v1.20.0 on Ubuntu 20.04
✨  Using the docker driver based on existing profile
👍  Starting control plane node minikube in cluster minikube
🚜  Pulling base image ...
🎉  minikube 1.21.0 is available! Download it: https://github.com/kubernetes/minikube/releases/tag/v1.21.0
💡  To disable this notice, run: 'minikube config set WantUpdateNotification false'

🔄  Restarting existing docker container for "minikube" ...
🐳  Preparing Kubernetes v1.20.2 on Docker 20.10.6 ...
🔎  Verifying Kubernetes components...
    ▪ Using image gcr.io/google_containers/kube-registry-proxy:0.4
    ▪ Using image k8s.gcr.io/ingress-nginx/controller:v0.44.0
    ▪ Using image registry:2.7.1
    ▪ Using image docker.io/jettech/kube-webhook-certgen:v1.5.1
    ▪ Using image docker.io/jettech/kube-webhook-certgen:v1.5.1
    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🔎  Verifying registry addon...
🔎  Verifying ingress addon...
🌟  Enabled addons: storage-provisioner, registry, default-storageclass, ingress
🏄  Done! kubectl is now configured to use "minikube" cluster and "werf-guide-app" namespace by default

Убедитесь, что вывод команды содержит строку:

Restarting existing docker container for "minikube"

Если её нет, значит был создан новый кластер minikube вместо использования старого. В таком случае повторите установку рабочего окружения с minikube с самого начала.

Теперь запустите команду в фоновом PowerShell-терминале и не закрывайте его:

minikube tunnel --cleanup=true

Запустим кластер minikube, уже настроенный в начале главы “Подготовка окружения”:

minikube start --namespace werf-guide-app

Выставим Namespace по умолчанию, чтобы не указывать его при каждом вызове kubectl:

kubectl config set-context minikube --namespace=werf-guide-app

Результат успешного выполнения команды:

😄  minikube v1.20.0 on Ubuntu 20.04
✨  Using the docker driver based on existing profile
👍  Starting control plane node minikube in cluster minikube
🚜  Pulling base image ...
🎉  minikube 1.21.0 is available! Download it: https://github.com/kubernetes/minikube/releases/tag/v1.21.0
💡  To disable this notice, run: 'minikube config set WantUpdateNotification false'

🔄  Restarting existing docker container for "minikube" ...
🐳  Preparing Kubernetes v1.20.2 on Docker 20.10.6 ...
🔎  Verifying Kubernetes components...
    ▪ Using image gcr.io/google_containers/kube-registry-proxy:0.4
    ▪ Using image k8s.gcr.io/ingress-nginx/controller:v0.44.0
    ▪ Using image registry:2.7.1
    ▪ Using image docker.io/jettech/kube-webhook-certgen:v1.5.1
    ▪ Using image docker.io/jettech/kube-webhook-certgen:v1.5.1
    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🔎  Verifying registry addon...
🔎  Verifying ingress addon...
🌟  Enabled addons: storage-provisioner, registry, default-storageclass, ingress
🏄  Done! kubectl is now configured to use "minikube" cluster and "werf-guide-app" namespace by default

Убедитесь, что вывод команды содержит строку:

Restarting existing docker container for "minikube"

Если её нет, значит был создан новый кластер minikube вместо использования старого. В таком случае повторите установку рабочего окружения с minikube с самого начала.

Случайно удаляли Namespace приложения?

Если вы непреднамеренно удалили Namespace приложения, то необходимо выполнить следующие команды, чтобы продолжить прохождение руководства:

kubectl create namespace werf-guide-app
kubectl create secret docker-registry registrysecret \
  --docker-server='https://index.docker.io/v1/' \
  --docker-username='<ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>' \
  --docker-password='<ПАРОЛЬ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>'

Результат успешного выполнения команды:

namespace/werf-guide-app created
secret/registrysecret created
Ничего не помогло, окружение или инструкции по-прежнему не работают?

Если ничего не помогло, то пройдите главу “Подготовка окружения” с начала, подготовив новое окружение с нуля. Если и это не помогло, тогда, пожалуйста, расскажите о своей проблеме в нашем Telegram или оставьте Issue на GitHub, и мы обязательно вам поможем.

Подготовка репозитория

Обновим существующий репозиторий с приложением:

Выполним следующий набор команд в PowerShell:

cd ~/werf-guide/app

# Чтобы увидеть, какие изменения мы собрались вносить далее в этой главе, заменим все файлы приложения
# в репозитории новыми, уже измененными файлами приложения, которые содержат описанные далее изменения.
git rm -r .
cp -Recurse -Force ~/werf-guide/guides/examples/nodejs/030_assets/* .
git add .
git commit -m WIP
Посмотреть, какие именно изменения мы произведём
# Показать, какие файлы мы собираемся изменить
git show --stat
# Показать изменения
git show

Выполним следующий набор команд в Bash:

cd ~/werf-guide/app

# Чтобы увидеть, какие изменения мы собрались вносить далее в этой главе, заменим все файлы приложения
# в репозитории новыми, уже измененными файлами приложения, которые содержат описанные далее изменения.
git rm -r .
cp -rf ~/werf-guide/guides/examples/nodejs/030_assets/. .
git add .
git commit -m WIP
Посмотреть, какие именно изменения мы произведём
# Показать, какие файлы мы собираемся изменить
git show --stat
# Показать изменения
git show

Не работает? Попробуйте инструкции на вкладке “Начинаю проходить руководство с этой главы” выше.

Подготовим новый репозиторий с приложением:

Выполним следующий набор команд в PowerShell:

# Склонируем репозиторий с примерами в ~/werf-guide/guides, если он ещё не был склонирован
if (-not (Test-Path ~/werf-guide/guides)) {
  git clone https://github.com/werf/werf-guides $env:HOMEPATH/werf-guide/guides
}

# Скопируем файлы приложения (пока без изменений) в ~/werf-guide/app
rm -Recurse -Force ~/werf-guide/app
cp -Recurse -Force ~/werf-guide/guides/examples/nodejs/020_logging ~/werf-guide/app

# Сделаем из директории ~/werf-guide/app git-репозиторий
cd ~/werf-guide/app
git init
git add .
git commit -m initial

# Чтобы увидеть, какие изменения мы собрались вносить далее в этой главе, заменим все файлы приложения
# в репозитории новыми, уже измененными файлами приложения, которые содержат описанные далее изменения.
git rm -r .
cp -Recurse -Force ~/werf-guide/guides/examples/nodejs/030_assets/* .
git add .
git commit -m WIP
Посмотреть, какие именно изменения мы произведём
# Показать, какие файлы мы собираемся изменить
git show --stat
# Показать изменения
git show

Выполним следующий набор команд в Bash:

# Склонируем репозиторий с примерами в ~/werf-guide/guides, если он ещё не был склонирован
test -e ~/werf-guide/guides || git clone https://github.com/werf/werf-guides ~/werf-guide/guides

# Скопируем файлы приложения (пока без изменений) в ~/werf-guide/app
rm -rf ~/werf-guide/app
cp -rf ~/werf-guide/guides/examples/nodejs/020_logging ~/werf-guide/app

# Сделаем из директории ~/werf-guide/app git-репозиторий
cd ~/werf-guide/app
git init
git add .
git commit -m initial

# Чтобы увидеть, какие изменения мы собрались вносить далее в этой главе, заменим все файлы приложения
# в репозитории новыми, уже измененными файлами приложения, которые содержат описанные далее изменения.
git rm -r .
cp -rf ~/werf-guide/guides/examples/nodejs/030_assets/. .
git add .
git commit -m WIP
Посмотреть, какие именно изменения мы произведём
# Показать, какие файлы мы собираемся изменить
git show --stat
# Показать изменения
git show

Добавление страницы /image в приложение

Добавим нашему приложению новый endpoint /image, который будет отдавать страницу, использующую набор статических файлов. Для сборки JS-, CSS- и медиафайлов будем использовать webpack.

Собирать статику будем в директории dist. Там же будет главная страница и страница с изображением:

...
/* GET home page. */
router.get('/', function (req, res, next) {
  res.sendFile(path.resolve(process.cwd(), 'dist', 'index.html'));
});
... /* GET home page. */ router.get('/', function (req, res, next) { res.sendFile(path.resolve(process.cwd(), 'dist', 'index.html')); });
const express = require('express');
const router = express.Router();

/* Image page. */
router.get('/', express.static('dist/image.html'));

module.exports = router;
const express = require('express'); const router = express.Router(); /* Image page. */ router.get('/', express.static('dist/image.html')); module.exports = router;

Приложение обновлено: теперь, в дополнение к уже знакомому по прошлым главам /ping, у приложения есть новый endpoint /image. На последнем отображается страница, использующая для работы разные типы статических файлов.

В начале главы описаны команды, с помощью которых можно увидеть полный, исчерпывающий список изменений, сделанных с приложением в этой главе.

Организация раздачи статических файлов

Приложение на Node.js может обслуживать статические файлы, в частности в express можно сделать контроллер с помощью express.static. Однако, как советуют авторы express, более эффективным решением для статики будет сервер наподобие NGINX. Рекомендуется это потому, что производительность и эффективность reverse proxy для раздачи статических файлов на порядки выше, чем у Node.js.

На практике обходиться без reverse proxy перед приложением допускается только во время разработки. Кроме более эффективной отдачи статических файлов reverse proxy хорошо дополняет сервер приложения, давая много дополнительных возможностей, которые обычно не реализуют на уровне последнего, а также предоставляя быструю и гибкую маршрутизацию запросов.

Есть несколько способов, как закрыть сервер приложения за reverse proxy в Kubernetes. Мы будем использовать распространенный и простой способ, который, к тому же, хорошо масштабируется. В нем перед каждым контейнером с Node.js поднимается NGINX-контейнер (в том же Pod), через который в приложение проксируются все запросы, кроме запросов на статические файлы. Статические файлы отдаёт сам NGINX-контейнер, доставая их из своей файловой системы.

Приступим к непосредственной реализации.

Обновление сборки и деплоя

Начнём с реорганизации сборки приложения.

Организация файлов

Для сборки статики возьмем webpack. Сейчас в приложении есть одна главная страница, доступная на корневом маршруте /. Эта страница появилась со старта проекта. Ее содержимое лежит в файле /public/index.html. В этом разделе мы добавим еще одну страницу, которая будет доступна по адресу /image. Также мы реорганизуем структуру каталога /public, чтобы явно разделить его на две части: страницы (pages) и подключаемые к страницам файлы (assets).

$ tree public
public/
├── assets
│   ├── images
│   │   └── werf-logo.svg
│   ├── javascripts
│   │   ├── image.js
│   │   └── index.js
│   └── stylesheets
│       ├── image.css
│       └── style.css
└── pages
    ├── image.html
    └── index.html

HTML-файлы будут служить нам шаблонами страниц. Страницы не будет кэшироваться, потому что они всегда остаются доступными на постоянном маршруте (/, /image), и нам нужно их время от времени менять и оперативно доносить изменения до пользователей. Однако подключаемые файлы будут собираться так, что их имена будут менятьcя в зависимости от их содержимого. Так можно будет достичь двух целей: надолго кэшировать эти файлы в браузере и заодно свободно менять их. После сборки статики, в каталоге dist будет такой результат:

$ tree dist/
├── css
│   ├── image.40428375b4e566574c8f.css
│   └── index.1e6f9f5ee05a92734053.css
├── image.html
├── index.html
├── js
│   ├── image.e67ef581c6705e6bd9a0.js
│   ├── index.e5a57023092221b727e0.js
│   └── runtime.f9a303951d184e8c1ce3.js
└── media
    └── 2c6aa8e8ef0b96213f30.svg

Далее рассмотрим, как его получить.

Сборка статики

Чтобы собирать статику, добавим следующие модули:

$ npm install --save-dev \
  webpack webpack-cli webpack-dev-middleware \
  html-webpack-plugin \
  css-loader mini-css-extract-plugin css-minimizer-webpack-plugin
  • webpack, webpack-cli — собирать статику;
  • webpack-dev-middleware — отдавать статику приложением во время разработки;
  • html-webpack-plugin — генерировать HTML-страницы с динамическим подключением ассетов;
  • css-loader — задействовать CSS в сборке;
  • mini-css-extract-plugin — выделить CSS в отдельные файлы, чтобы кэшировать отдельно от JS;
  • css-minimizer-webpack-plugin — минифицировать CSS (работает с параметром mode: "production").

Добавим команду для сборки в package.json: "build": "webpack" и обработчик запросов к статике, если приложение запущено не в режиме production:

...
const app = express();

if (process.env.NODE_ENV != 'production') {
  const webpack = require('webpack');
  const webpackDevMiddleware = require('webpack-dev-middleware');
  const config = require('./webpack.config.js');

  const compiler = webpack(config);

  app.use(
    webpackDevMiddleware(compiler, {
      publicPath: config.output.publicPath,
    })
  );
}
... const app = express(); if (process.env.NODE_ENV != 'production') { const webpack = require('webpack'); const webpackDevMiddleware = require('webpack-dev-middleware'); const config = require('./webpack.config.js'); const compiler = webpack(config); app.use( webpackDevMiddleware(compiler, { publicPath: config.output.publicPath, }) ); }

Убедимся, что локально сборка и сервер работают. Для этого соберем статику, запустим сервер и откроем в браузере страницу с изображением http://localhost:3000/image:

$ npm run build && npm start
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  mode: 'development',
  devServer: { static: '/dist' },
  devtool: 'inline-source-map',

  // Две точки входа, для которых мы собираем бандлы. Таким образом мы разделяем стили и
  // скрипты для двух страниц.
  entry: {
    index: path.resolve(__dirname, 'public/assets/javascripts/index.js'),
    image: path.resolve(__dirname, 'public/assets/javascripts/image.js'),
  },

  // Определяем, как называются переработанные скрипты и с каким префиксом ожидать
  // статические файлы.
  output: {
    filename: 'js/[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
    publicPath: '/static/',
  },

  plugins: [
    // Подключаем плагин для выделения CSS в отдельные файлы
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash].css',
    }),
    // Генерируем главную страницу из ее шаблона и указываем, что подключить нужно только
    // точку входа index, включая CSS и JS. Так мы избежим подключения скриптов
    // и стилей от страницы с изображением.
    new HtmlWebpackPlugin({
      title: 'Werf Guide App',
      template: 'public/pages/index.html',
      chunks: ['index'],
    }),
    // То же самое для страницы c изображением, но еще явно указываем конечное имя HTML-файла.
    new HtmlWebpackPlugin({
      title: 'Werf Logo',
      filename: 'image.html',
      template: 'public/pages/image.html',
      chunks: ['image'],
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
      // Изображения перенесем в каталог /static/media, причем часть "static" мы определили
      // в настройках "output".
      {
        test: /\.(png|svg|jpg|jpeg|gif)$/i,
        type: 'asset/resource',
        generator: {
          filename: 'media/[contenthash][ext]',
        },
      },
    ],
  },
  // С помощью оптимизаций мы выделили скрипт с райнтаймом, чтобы уменьшить общий размер статики,
  // который нужно будет загрузить для разных страниц. Также добавили минимизацию CSS для production.
  optimization: {
    moduleIds: 'deterministic',
    runtimeChunk: 'single',
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
    minimizer: [new CssMinimizerPlugin()],
  },
};
const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); module.exports = { mode: 'development', devServer: { static: '/dist' }, devtool: 'inline-source-map', // Две точки входа, для которых мы собираем бандлы. Таким образом мы разделяем стили и // скрипты для двух страниц. entry: { index: path.resolve(__dirname, 'public/assets/javascripts/index.js'), image: path.resolve(__dirname, 'public/assets/javascripts/image.js'), }, // Определяем, как называются переработанные скрипты и с каким префиксом ожидать // статические файлы. output: { filename: 'js/[name].[contenthash].js', path: path.resolve(__dirname, 'dist'), clean: true, publicPath: '/static/', }, plugins: [ // Подключаем плагин для выделения CSS в отдельные файлы new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash].css', }), // Генерируем главную страницу из ее шаблона и указываем, что подключить нужно только // точку входа index, включая CSS и JS. Так мы избежим подключения скриптов // и стилей от страницы с изображением. new HtmlWebpackPlugin({ title: 'Werf Guide App', template: 'public/pages/index.html', chunks: ['index'], }), // То же самое для страницы c изображением, но еще явно указываем конечное имя HTML-файла. new HtmlWebpackPlugin({ title: 'Werf Logo', filename: 'image.html', template: 'public/pages/image.html', chunks: ['image'], }), ], module: { rules: [ { test: /\.css$/i, use: [MiniCssExtractPlugin.loader, 'css-loader'], }, // Изображения перенесем в каталог /static/media, причем часть "static" мы определили // в настройках "output". { test: /\.(png|svg|jpg|jpeg|gif)$/i, type: 'asset/resource', generator: { filename: 'media/[contenthash][ext]', }, }, ], }, // С помощью оптимизаций мы выделили скрипт с райнтаймом, чтобы уменьшить общий размер статики, // который нужно будет загрузить для разных страниц. Также добавили минимизацию CSS для production. optimization: { moduleIds: 'deterministic', runtimeChunk: 'single', splitChunks: { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', }, }, }, minimizer: [new CssMinimizerPlugin()], }, };

Деплой статики и приложения

Теперь, когда у нас есть статика для NGINX и страницы для сервера приложений, нужно разделить статику по контейнерам: HTML — в контейнере с приложением, остальное — с NGINX.

FROM node:12-alpine as builder
WORKDIR /app

# Копируем в образ файлы, нужные для установки зависимостей приложения.
COPY package.json package-lock.json ./

# Устанавливаем зависимости приложения.
RUN npm ci

# Копируем в образ все остальные файлы приложения.
COPY . .

# Собираем статические файлы.
RUN npm run build

#############################################################################

FROM node:12-alpine as backend
WORKDIR /app

# Копируем в образ файлы, нужные для установки зависимостей приложения.
COPY package.json package-lock.json ./

# Устанавливаем зависимости приложения.
RUN npm ci --production

# Копируем файлы приложения.
COPY app.js ./
RUN mkdir dist
COPY --from=builder /app/dist/*.html ./dist/
COPY --from=builder /app/bin         ./bin/
COPY --from=builder /app/routes      ./routes/


#############################################################################

# NGINX-образ с собранными ранее ассетами.
FROM nginx:stable-alpine as frontend
WORKDIR /www

# Копируем собранные ассеты из предыдушего сборочного образа.
COPY --from=builder /app/dist /www/static

# Копируем конфигурацию NGINX.
COPY .werf/nginx.conf /etc/nginx/nginx.conf
FROM node:12-alpine as builder WORKDIR /app # Копируем в образ файлы, нужные для установки зависимостей приложения. COPY package.json package-lock.json ./ # Устанавливаем зависимости приложения. RUN npm ci # Копируем в образ все остальные файлы приложения. COPY . . # Собираем статические файлы. RUN npm run build ############################################################################# FROM node:12-alpine as backend WORKDIR /app # Копируем в образ файлы, нужные для установки зависимостей приложения. COPY package.json package-lock.json ./ # Устанавливаем зависимости приложения. RUN npm ci --production # Копируем файлы приложения. COPY app.js ./ RUN mkdir dist COPY --from=builder /app/dist/*.html ./dist/ COPY --from=builder /app/bin ./bin/ COPY --from=builder /app/routes ./routes/ ############################################################################# # NGINX-образ с собранными ранее ассетами. FROM nginx:stable-alpine as frontend WORKDIR /www # Копируем собранные ассеты из предыдушего сборочного образа. COPY --from=builder /app/dist /www/static # Копируем конфигурацию NGINX. COPY .werf/nginx.conf /etc/nginx/nginx.conf

Во время сборки в образ с NGINX будет добавлен его конфигурационный файл:

user nginx;
worker_processes 1;
pid /run/nginx.pid;

events {
  worker_connections 1024;
}

http {
  include /etc/nginx/mime.types;
  default_type application/octet-stream;

  upstream backend {
    server 127.0.0.1:3000 fail_timeout=0;
  }

  server {
    listen 80;
    server_name _;

    root /www;

    client_max_body_size 100M;
    keepalive_timeout 10s;

    # По пути /static отдадим ассеты напрямую из файловой системы NGINX-контейнера.
    location /static {
      # В силу особенностей механизма сборки ассетов с Webpack клиент может хранить кэш ассетов сколь
      # угодно долго, не беспокоясь об их инвалидации.
      expires 1y;
      add_header Cache-Control public;
      add_header Last-Modified "";
      add_header ETag "";

      # Когда есть возможность, отдаём заранее сжатые файлы (вместо сжатия на лету).
      gzip_static on;

      access_log off;

      try_files $uri =404;
    }

    # Ассеты медиафайлов (картинки и т.п.) также отдадим из файловой системы NGINX-контейнера, но
    # отключим для них сжатие gzip.
    location /static/media {
      expires 1y;
      add_header Cache-Control public;
      add_header Last-Modified "";
      add_header ETag "";

      access_log off;

      try_files $uri =404;
    }

    # Все запросы, кроме запросов на получение ассетов, отправляются на Node.js-бэкенд.
    location / {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header Host $http_host;
      proxy_redirect off;

      proxy_pass http://backend;
    }
  }
}
user nginx; worker_processes 1; pid /run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; upstream backend { server 127.0.0.1:3000 fail_timeout=0; } server { listen 80; server_name _; root /www; client_max_body_size 100M; keepalive_timeout 10s; # По пути /static отдадим ассеты напрямую из файловой системы NGINX-контейнера. location /static { # В силу особенностей механизма сборки ассетов с Webpack клиент может хранить кэш ассетов сколь # угодно долго, не беспокоясь об их инвалидации. expires 1y; add_header Cache-Control public; add_header Last-Modified ""; add_header ETag ""; # Когда есть возможность, отдаём заранее сжатые файлы (вместо сжатия на лету). gzip_static on; access_log off; try_files $uri =404; } # Ассеты медиафайлов (картинки и т.п.) также отдадим из файловой системы NGINX-контейнера, но # отключим для них сжатие gzip. location /static/media { expires 1y; add_header Cache-Control public; add_header Last-Modified ""; add_header ETag ""; access_log off; try_files $uri =404; } # Все запросы, кроме запросов на получение ассетов, отправляются на Node.js-бэкенд. location / { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_redirect off; proxy_pass http://backend; } } }

Обновим конфигурацию werf.yaml, чтобы werf собрал и сохранил два образа (backend и frontend) вместо одного:

project: werf-guide-app
configVersion: 1

---
image: backend
dockerfile: Dockerfile
target: backend

---
image: frontend
dockerfile: Dockerfile
target: frontend
project: werf-guide-app configVersion: 1 --- image: backend dockerfile: Dockerfile target: backend --- image: frontend dockerfile: Dockerfile target: frontend

Добавим новый NGINX-контейнер в Deployment приложения:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: werf-guide-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: werf-guide-app
  template:
    metadata:
      labels:
        app: werf-guide-app
    spec:
      imagePullSecrets:
      - name: registrysecret
      containers:
      - name: backend
        image: {{ .Values.werf.image.backend }}
        command: ["node", "./bin/www"]
        ports:
        - containerPort: 3000
        env:
        - name: NODE_ENV
          value: production
      - name: frontend
        image: {{ .Values.werf.image.frontend }}
        ports:
        - containerPort: 80
apiVersion: apps/v1 kind: Deployment metadata: name: werf-guide-app spec: replicas: 1 selector: matchLabels: app: werf-guide-app template: metadata: labels: app: werf-guide-app spec: imagePullSecrets: - name: registrysecret containers: - name: backend image: {{ .Values.werf.image.backend }} command: ["node", "./bin/www"] ports: - containerPort: 3000 env: - name: NODE_ENV value: production - name: frontend image: {{ .Values.werf.image.frontend }} ports: - containerPort: 80

Теперь Service и Ingress должны обращаться к NGINX на 80-й порт, а не 3000-й. Так NGINX будет раздавать статику и проксировать запросы к приложению:

apiVersion: v1
kind: Service
metadata:
  name: werf-guide-app
spec:
  selector:
    app: werf-guide-app
  ports:
  - name: http
    port: 80
apiVersion: v1 kind: Service metadata: name: werf-guide-app spec: selector: app: werf-guide-app ports: - name: http port: 80
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
  name: werf-guide-app
spec:
  rules:
  - host: werf-guide-app.test
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: werf-guide-app
            port:
              number: 80
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: kubernetes.io/ingress.class: nginx name: werf-guide-app spec: rules: - host: werf-guide-app.test http: paths: - path: / pathType: Prefix backend: service: name: werf-guide-app port: number: 80

Проверка

Теперь попробуем переразвернуть приложение:

werf converge --repo <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app

Ожидаемый результат:

┌ Concurrent builds plan (no more than 5 images at the same time)
│ Set #0:
│ - ⛵ image backend
│ - ⛵ image frontend
└ Concurrent builds plan (no more than 5 images at the same time)

┌ ⛵ image backend
│ ┌ Building stage backend/dockerfile
│ │ backend/dockerfile  Sending build context to Docker daemon  250.4kB
│ │ backend/dockerfile  Step 1/23 : FROM node:12-alpine as builder
    ...
│ │ backend/dockerfile  Successfully built e946be9a8efb
│ │ backend/dockerfile  Successfully tagged d81cab88-2050-4c42-96c1-206cd16f4e73:latest
│ │ ┌ Store stage into <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app
│ │ └ Store stage into <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app (18.04 seconds)
│ ├ Info
│ │      name: <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app:3b2118a74c218dc86950cfc5c392fd1cf77e99f451e200eb4db8be67-1636714673154
│ │        id: e946be9a8efb
│ │   created: 2021-11-12 13:57:53 +0300 MSK
│ │      size: 30.3 MiB
│ └ Building stage backend/dockerfile (27.87 seconds)
└ ⛵ image backend (35.62 seconds)

┌ ⛵ image frontend
│ ┌ Building stage frontend/dockerfile
│ │ frontend/dockerfile  Sending build context to Docker daemon  250.4kB
│ │ frontend/dockerfile  Step 1/27 : FROM node:12-alpine as builder
    ...
│ │ frontend/dockerfile  Successfully built aca38d642814
│ │ frontend/dockerfile  Successfully tagged 433fb7c7-d9bb-499d-a9fb-7557ad133b67:latest
│ │ ┌ Store stage into <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app
│ │ └ Store stage into <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app (18.17 seconds)
│ ├ Info
│ │      name: <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app:1bdbc7a2dd2038a9e81882ba4e2c4f795543eae30562925e4e4cb4bb-1636714673008
│ │        id: aca38d642814
│ │   created: 2021-11-12 13:57:52 +0300 MSK
│ │      size: 9.4 MiB
│ └ Building stage frontend/dockerfile (27.85 seconds)
└ ⛵ image frontend (35.74 seconds)

┌ Waiting for release resources to become ready
│ ┌ Status progress
│ │ DEPLOYMENT                                                                    REPLICAS      AVAILABLE       UP-TO-DATE
│ │ werf-guide-app                                                                2/1           1               1
│ │ │   POD                          READY      RESTARTS      STATUS              ---
│ │ ├── guide-app-57ff8c54d6-nppmx   0/2        0             ContainerCreating   Waiting for: replicas 2->1                          ↵
│ │
│ │ └── guide-app-d47fd67f9-qln6t    1/1        0             Running
│ └ Status progress
│
│ ┌ Status progress
│ │ DEPLOYMENT                                                                    REPLICAS      AVAILABLE       UP-TO-DATE
│ │ werf-guide-app                                                                2/1           1               1
│ │ │   POD                          READY      RESTARTS      STATUS              ---
│ │ ├── guide-app-57ff8c54d6-nppmx   0/2        0             ContainerCreating   Waiting for: replicas 2->1                          ↵
│ │
│ │ └── guide-app-d47fd67f9-qln6t    1/1        0             Running
│ └ Status progress
│
│ ┌ Status progress
│ │ DEPLOYMENT                                                                    REPLICAS      AVAILABLE       UP-TO-DATE
│ │ werf-guide-app                                                                2->1/1        1               1
│ │ │   POD                          READY      RESTARTS      STATUS
│ │ ├── guide-app-57ff8c54d6-nppmx   2/2        0             ContainerCreating
│ │ │                                                         -> Running
│ │ └── guide-app-d47fd67f9-qln6t    1/1        0             Running ->
│ │                                                           Terminating
│ └ Status progress
└ Waiting for release resources to become ready (10.41 seconds)

Release "werf-guide-app" has been upgraded. Happy Helming!
NAME: werf-guide-app
LAST DEPLOYED: Fri Nov 12 13:58:16 2021
NAMESPACE: werf-guide-app
STATUS: deployed
REVISION: 6
TEST SUITE: None
Running time 51.80 seconds

Откроем в браузере http://werf-guide-app.test/image и нажмём на кнопку Get image. Ожидаемый результат:

Также обратим внимание на то, какие ресурсы были запрошены и по каким ссылкам (последний ресурс здесь получен через Ajax-запрос):

Теперь наше приложение является не просто API, но веб-приложением, которое имеет средства для эффективного менеджмента статических файлов и JavaScript.

Также наше приложение готово выдерживать приличные нагрузки при большом количестве запросов к статическим файлам, и эти запросы не будут сказываться на работе приложения в целом. Масштабирование же Node.js (отвечает за динамический контент) и NGINX (статический контент) происходит простым увеличением количества реплик (replicas) в Deployment’е приложения.