Игра Яндекс Практикума
Игра Яндекс Практикума
Игра Яндекс Практикума

Собираем и деплоим приложение на Node.js с помощью werf. Работа с базой данных 

Отредактировано

Как в MySQL добавить Node.js приложение, развернуть базу данных в кластере, как правильно инициализировать её и выполнить миграции.

4К открытий4К показов

В прошлый раз мы рассмотрели базовое Node.js-приложение и его деплой в Kubernetes. Теперь мы дополним его, подключив к нему базу данных. В качестве последней будем использовать MySQL, добавив в приложение необходимые для работы с ней компоненты, развернем БД в кластере, а также рассмотрим, как правильно инициализировать базу и выполнить миграции.

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

Перед работой убедитесь, что у вас установлена актуальная версия werf из стабильной ветки. В случае, если вы еще не устанавливали werf, сделайте это по официальной инструкции.

Инструкции из статьи полностью совместимы и тестировались на операционной системе Linux Ubuntu 20.04.03. Для ОС других семейств, таких как macOS или Windows, могут быть отличия. Подробнее с ними можно ознакомиться в официальной документации к werf.

Также убедитесь, что у на вашем компьютере запущен кластер minikube, в котором подготовлено пространство имён werf-first-app. Если вы уже выполняли инструкции из первой статьи, то достаточно только запустить кластер следующей командой:

			minikube start --namespace werf-first-app
		

А затем установить пространство имён по умолчанию:

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

Если же вы впервые устанавливаете werf и проходите инструкции, то полное руководство по настройке minikube можно найти в главе «Предварительная настройка окружения» предыдущей статьи.

Подключение базы данных

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

На текущий момент у нас есть stateless-приложение, то есть оно не использует БД и не хранит в ней никаких данных. В реальности такое случается нечасто, поэтому давайте доработаем его до stateful, добавив поддержку MySQL.

Основные изменения, которые мы произведем далее:

  1. Добавим зависимости, необходимые для взаимодействия с MySQL.
  2. Настроим конфигурацию для соединения с БД.
  3. Создадим миграцию и модель схемы БД.
  4. Создадим маршруты в приложении, по которым будет происходить сохранение и чтение данных.

Установим зависимости, требуемые для работы с БД:

			npm i sequelize mysql2 express-async-handler sequelize-cli
		

Здесь мы добавили следующие пакеты:

  • sequelize — ORM, обеспечивающий работы с БД;
  • mysql2 — драйвер MySQL;
  • express-async-handler — расширение контроллеров запросов, позволяющее работать с async/await;
  • sequelize-cli — утилита, позволяющая генерировать миграции и модели.

Чтобы sequelize мог работать, мы создали для него конфигурационный файл .sequelizerc, в котором задали параметры подключения к БД, а также структуру файлов самого sequelize. Все директории, имеющие отношение в БД, мы определили в каталог db, а конигурацию в файл config/database.json.

			const path = require('path');

module.exports = {
  config: path.resolve('config', 'database.json'),
  'models-path': path.resolve('db', 'models'),
  'seeders-path': path.resolve('db', 'seeders'),
  'migrations-path': path.resolve('db', 'migrations'),
};
		

Добавление endpoint’ов

Чтобы убедиться, что приложение и правда умеет обращаться к БД, добавим ему пару новых endpoint’ов — /remember и /say — первый будет записывать данные в БД, а другой извлекать и показывать. В каталоге routes добавим файл talkers.js:

			//@ts-check
const express = require('express');
const router = express.Router();
const asyncHandler = require('express-async-handler');

module.exports = (db) => {
  router.get(
    '/say',
    asyncHandler(async (req, res) => {
      try {
        const talker = await db.sequelize.transaction(async (t) => {
          return await db.Talker.findOne({ where: { id: 1 }, transaction: t });
        });

        if (!talker) {
          res.send(`I have nothing to say.\n`);
          return;
        }

        res.send(`${talker.answer}, ${talker.name}!\n`);
      } catch (e) {
        res.status(500).send(`Something went wrong: ${e.message}\n`);
      }
    })
  );

  router.get(
    '/remember',
    asyncHandler(async (req, res) => {
      const { answer, name } = req.query;
      if (!answer) {
        res.status(422).send('You forgot the answer :(\n');
        return;
      }

      if (!name) {
        res.status(422).send('You forgot the name :(\n');
        return;
      }

      try {
        await db.sequelize.transaction(async (t) => {
          const [talker] = await db.Talker.findOrCreate({
            where: { id: 1 },
            transaction: t,
          });
          talker.set({ answer, name });
          await talker.save({ transaction: t });
        });

        res.send(`Got it.\n`);
      } catch (e) {
        res.status(500).send(`Something went wrong: ${e.message}\n`);
      }
    })
  );

  return router;
};
		

Сгенерируем модель и миграцию БД с помощью команды:

			npx sequelize-cli model:generate --name Talker --attributes answer:string,name:string
		

Модель можно посмотреть в файле db/models/talker.js:

			'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class Talker extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // Define association here.
    }
  }
  Talker.init(
    {
      answer: DataTypes.STRING,
      name: DataTypes.STRING,
    },
    {
      sequelize,
      modelName: 'Talker',
    }
  );
  return Talker;
};
		

Также автоматически была сгенерирована миграция для новой модели, расположенная в файле db/migrations/20211101064002-create-talker.js:

			'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('Talkers', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER,
      },
      answer: {
        type: Sequelize.STRING,
      },
      name: {
        type: Sequelize.STRING,
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('Talkers');
  },
};
		

Осталось создать новые маршруты в файле app.js:

			...
const talkersRouter = require('./routes/talkers');
...
app.use('/', talkersRouter(db));
		

Приложение подготовлено и готово к запуску!

Разворачиваем MySQL

В реальных условиях БД можно поднять как внутри кластера Kubernetes, так и за его пределами. При использовании внешней базы доступны различные варианты: от собственной базы, развернутой на своей инстраструктуре, до managed-решений наподобие Amazon RDS.

В качестве примера с нашим приложением мы развернем базу данных MySQL внутри кластера, воспользовавшись типом ресурсов StatefulSet. Для этого создадим файл .helm/templates/database.yaml со следующими параметрами:

			apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  serviceName: mysql
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
      - name: mysql
        image: mysql:5.7
        ports:
        - containerPort: 3306
        env:
        - name: MYSQL_ROOT_PASSWORD
          value: password
        volumeMounts:
        - name: mysql-data
          mountPath: /var/lib/mysql
  volumeClaimTemplates:
  - metadata:
      name: mysql-data
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 100Mi

---
apiVersion: v1
kind: Service
metadata:
  name: mysql
spec:
  selector:
    app: mysql
  ports:
  - port: 3306
		

Теперь настроим наше приложение на подключение к созданной БД. Стоит отметить, что Sequelize ожидает определения подключения для всех окружений в единственном файле — config/database.json. Пропишем в нем нужные параметры:

			{
  "development": {
    "username": "root",
    "password": "null",
    "database": "werf-first-app",
    "host": "mysql",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "werf-first-app",
    "host": "mysql",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": "password",
    "database": "werf-first-app",
    "host": "mysql",
    "dialect": "mysql"
  }
}
		

Инициализация БД

Развернуть MySQL — это еще половина дела. Далее следует ее правильно инициализировать, проведя нужные миграции и дождавшись ее готовности к работе.

В Kubernetes есть несколько проверенных способов для развертывания и инициализации базы данных. Один из них — использование отдельной Job’ы одновременно с разввертыванием основного приложения. В ней будут выполнять все необходимые для инициализации действия.

Очень важно соблюсти правильный порядок развертывания ресурсов, поэтому мы будем следовать двум правилам:

  • Job’а должна убедиться, что база доступна, и только затем выполнить миграции;
  • приложение ожидает перед запуском следующего, что:
  1. БД доступна;,
  2. БД подготовлена и готова к работе;,
  3. миграции выполнены.

При соблюдении этих правил все компоненты создаются одновременно, но функционировать начнут в заданном порядке:

  1. Поднимается БД;
  2. Выполнятся миграции и инициализация с помощью Job’ы.
  3. Стартанут приложения..

Приступим к реализации. Для начала добавим Job (.helm/templates/job-db-setup-and-migrate.yaml), которая будет заниматься инициализацией и миграциями БД:

			apiVersion: batch/v1
kind: Job
metadata:
  # Версия Helm-релиза в имени Job заставит Job каждый раз пересоздаваться.
  # Так мы сможем обойти то, что Job неизменяема.
  name: "setup-and-migrate-db-rev{{ .Release.Revision }}"
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      imagePullSecrets:
      - name: registrysecret
      containers:
      - name: setup-and-migrate-db
        image: {{ .Values.werf.image.backend }}
        command:
        - sh
        - -euc
        - |
          is_mysql_available() {
            tries=$1
            i=0
            while [ $i -lt $tries ]; do
              mysqladmin -h mysql -P 3306 -u root -p=password ping || return 1
              i=$((i+1))
              sleep 1
            done
          }

          # Дождёмся, когда `mysqladmin ping` отработает 10 раз подряд.
          until is_mysql_available 10; do
            sleep 1
          done

          # Выполним первоначальную настройку базы, если она не выполнена, а иначе выполним миграции.
          ./node_modules/.bin/sequelize-cli db:create
          ./node_modules/.bin/sequelize-cli db:migrate
        env:
        - name: NODE_ENV
          value: production
		

Следует пояснить, почему мы ожидаем целых десять проверок БД. Если этого не сделать, то mysqladmin ping выполнится всего один раз перед тем, как StatefusSet с MySQL начнет перезапускаться при деплое. При этом база данные окажется недоступна.

Дополнительно следует обратить внимание, что при первом запуске основной процесс MySQL может перезапуститься несколько раз без перезапуска самого контейнера. Это может привести к ситуации, когда БД ушла в перезапуск, а Job’а уже начала выполнять миграции. Последние при этом само собой будут неудачными, и весь процесс разворачивания закончится ошибкой.

Число 10 здесь выбрано просто как пример, его можно изменять в зависимости от ситуации.

В Deployment’е также необходимо внести изменения, добавив проверку доступности БД перед тем, как стартовать приложение:

			apiVersion: apps/v1
kind: Deployment
metadata:
  name: werf-first-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: werf-first-app
  template:
    metadata:
      labels:
        app: werf-first-app
    spec:
      imagePullSecrets:
      - name: registrysecret
      initContainers:
      - name: wait-db-readiness
        image: {{ .Values.werf.image.backend }}
        command:
        - sh
        - -euc
        - |
          # Дожидаемся доступности БД и выполнения миграций.
          until ./node_modules/.bin/sequelize-cli db:migrate:status; do
            sleep 1
          done
        env:
        - name: NODE_ENV
          value: production
      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
		

Все готово, можно запускать.

Проверка работоспособности

Выполним команду ниже, чтобы запустить процесс деплоя:

			werf converge --repo <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-first-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
│ Use cache image for backend/dockerfile
│      name: <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-first-app:e37f25c6618bd6e16325671eb0260f19408f26eb0420091b96b7ae12-1645523149535
│        id: 1da0820c6995
│   created: 2022-02-22 12:45:49 +0300 MSK
│      size: 47.9 MiB
└ ⛵ image backend (1.33 seconds)

┌ ⛵ image frontend
│ Use cache image for frontend/dockerfile
│      name: <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-first-app:45a9a4a848d8821f7db9c0547000414fff924ae5ca8911d62a18d1ea-1645523148164
│        id: 4da6bc98bb74
│   created: 2022-02-22 12:45:47 +0300 MSK
│      size: 9.6 MiB
└ ⛵ image frontend (1.70 seconds)

┌ Waiting for release resources to become ready
│ ┌ job/setup-and-migrate-db-rev14 po/setup-and-migrate-db-rev14--1-jl2h8 container/setup-and-migrate-db logs
│ │ mysqladmin: connect to server at 'mysql' failed
│ │ error: 'Access denied for user 'root'@'172.17.0.1' (using password: YES)'
│ │ mysqladmin: connect to server at 'mysql' failed
│ │ error: 'Access denied for user 'root'@'172.17.0.1' (using password: YES)'
│ │ mysqladmin: connect to server at 'mysql' failed
│ │ error: 'Access denied for user 'root'@'172.17.0.1' (using password: YES)'
│ │ mysqladmin: connect to server at 'mysql' failed
│ │ error: 'Access denied for user 'root'@'172.17.0.1' (using password: YES)'
│ │ mysqladmin: connect to server at 'mysql' failed
│ │ error: 'Access denied for user 'root'@'172.17.0.1' (using password: YES)'
│ └ job/setup-and-migrate-db-rev14 po/setup-and-migrate-db-rev14--1-jl2h8 container/setup-and-migrate-db logs
│
│ ┌ Status progress
│ │ DEPLOYMENT                                                                                                  REPLICAS            AVAILABLE             UP-TO-DATE
│ │ werf-first-app                                                                                              1/1                 1                     1
│ │ STATEFULSET                                                                                                 REPLICAS            READY                 UP-TO-DATE
│ │ mysql                                                                                                       1/1                 1                     1
│ │ JOB                                                                                                         ACTIVE              DURATION              SUCCEEDED/FAILED
│ │ setup-and-migrate-db-rev14                                                                                  1                   2s                    0/0
│ │ │   POD                                      READY          RESTARTS            STATUS                      ---
│ │ └── and-migrate-db-rev14--1-jl2h8            1/1            0                   Running                     Waiting for: pods should be terminated, succeeded 0->1
│ └ Status progress
│
| ...
│
│ ┌ job/setup-and-migrate-db-rev14 po/setup-and-migrate-db-rev14--1-jl2h8 container/setup-and-migrate-db logs
│ │
│ │ Sequelize CLI [Node: 12.22.10, CLI: 6.3.0, ORM: 6.9.0]
│ │
│ │ Loaded configuration file "config/database.json".
│ │ Using environment "production".
│ │ Database werf-first-app created.
│ │
│ │ Sequelize CLI [Node: 12.22.10, CLI: 6.3.0, ORM: 6.9.0]
│ │
│ │ Loaded configuration file "config/database.json".
│ │ Using environment "production".
│ │ No migrations were executed, database schema was already up to date.
│ └ job/setup-and-migrate-db-rev14 po/setup-and-migrate-db-rev14--1-jl2h8 container/setup-and-migrate-db logs
│
│ ┌ Status progress
│ │ DEPLOYMENT                                                                                                  REPLICAS            AVAILABLE             UP-TO-DATE
│ │ werf-first-app                                                                                              1/1                 1                     1
│ │ STATEFULSET                                                                                                 REPLICAS            READY                 UP-TO-DATE
│ │ mysql                                                                                                       1/1                 1                     1
│ │ JOB                                                                                                         ACTIVE              DURATION              SUCCEEDED/FAILED
│ │ setup-and-migrate-db-rev14                                                                                  0                   13s                   0->1/0
│ │ │   POD                                      READY          RESTARTS            STATUS
│ │ └── and-migrate-db-rev14--1-jl2h8            0/1            0                   Running -> Completed
│ └ Status progress
└ Waiting for release resources to become ready (13.31 seconds)

Release "werf-first-app" has been upgraded. Happy Helming!
NAME: werf-first-app
LAST DEPLOYED: Tue Feb 22 12:58:17 2022
NAMESPACE: werf-first-app
STATUS: deployed
REVISION: 14
TEST SUITE: None
Running time 17.17 seconds
		

Обратите внимание, что не стоит пугаться сообщений об ошибках, если они появятся. Это говорит лишь о том, что Sequelize не смог подключиться к базе данных, потому что они еще не создана (не прошли миграции). Нужно просто подождать, и все заработает.

Проверим, что все работает как нужно. Попробуем обратиться на /say:

			curl http://werf-first-app.test/say
		

Так как в базе данных пока ничего нет, мы увидим ответ:

			I have nothing to say.
		

Добавим туда данные, воспользовавшись /remember:

			curl "http://werf-first-app.test/remember?answer=Love+you&name=sweetie"
		

Если все прошло успешно, получим ответ:

			Got it.
		

Еще раз прочитаем данные из БД:

			curl http://werf-first-app.test/say
		

Ожидаемый результат в случае успеха:

			Love you, sweetie!
		

Убедимся, что текст, который мы увидели, взят прямо из базы данных. Посмотрим содержимое таблицы talkers напрямую:

			kubectl exec -it statefulset/mysql -- mysql -ppassword -e "SELECT * from Talkers" werf-first-app
		

Мы должны увидеть табличку с нашим текстом:

			+----+----------+---------+---------------------+---------------------+
| id | answer   | name    | createdAt           | updatedAt           |
+----+----------+---------+---------------------+---------------------+
|  1 | Love you | sweetie | 2022-02-22 10:06:32 | 2022-02-22 10:06:32 |
+----+----------+---------+---------------------+---------------------+
		

Все работает! Наше приложение стало stateful. Подобный подход к взаимодействию с базами данных аналогичен с любыми другими БД, не только с MySQL.

Заключение

Мы посмотрели на пример развертывания в кластере MySQL, ее инициализации, выполнение миграций, а также настроили наше приложение на подключение и использование базы данных.

В основе этой статьи лежит наш онлайн-самоучитель. В ней рассказано не все, более подробные инструкции или теорию можно найти в полной версии самоучителя, в разделе «Реалистичные приложения».

Надеемся, что эта статья окажется полезной для вас в части приобретения нового опыта в области деплоя приложений в Kubernetes.

Ждем вас в комментариях к статье, а также в Telegram-каналe werf_ru. Мы всегда рады оказать вам любое содействие на этом трудном пути. Также не забывайте про GitHub-репозиторий werf, там всегда будут рады любым issues (и звездам).

Следите за новыми постами
Следите за новыми постами по любимым темам
4К открытий4К показов