Вносить изменения в код приложения и тут же автоматически получать задеплоенные изменения, чтобы быстро тестировать его, — мечта разработчика. В этой статье мы посмотрим, как реализовать такой подход для небольшого приложения с фронтендом и бэкендом: организуем два варианта локального стенда на базе minikube или Docker с автоматическим развертыванием всех изменений или только закоммиченых в Git. Бэкенд приложения напишем на Go, а фронтенд — на Vue.js. Все это позволит быстро запускать проект для тестирования прямо во время разработки, что, несомненно, повысит удобство работы с приложением.

На старте вам понадобится установленная утилита werf (инструкция по установке есть на официальном сайте) — с ее помощью мы и будем максимально удобно организовывать автоматический деплой всех изменений. Всё остальное поставим в процессе. 

В предыдущей статье мы рассмотрели, как собрать и развернуть приложение в кластере под управлением платформы Deckhouse с помощью утилиты werf. Сегодня в качестве следующего шага мы организуем локальный стенд для разработки, позволяющий с помощью werf настроить автоматическую сборку приложения на машине разработчика. Вам необязательно читать и выполнять инструкции из предыдущей статьи — мы всё построили так, чтобы эта статья была самодостаточной.

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

У нас уже есть шаблон небольшого приложения — это простой веб-сервис, в котором можно написать сообщение, сохранить его в базу данных и отобразить сохраненные ранее сообщения. Пока оно представляет из себя монолитный бэкенд на Go с подготовленными для деплоя в кластер Kubernetes Helm-чартами. В приложении реализована как логика работы с БД для сохранения и получения собщений, так и шаблонизация HTML-страниц и их отображение пользователю по соответствующим эндпоинтам — то есть нет жесткого разделения на бэкенд и фронтенд.

Далее мы немного доработаем приложение, разбив его на отдельные сервисы фронтенда и бэкенда: вынесем весь интерфейс во фронтенд, а в бэкенде оставим только возможность отвечать на REST-запросы. Кроме того, мы добавим чарты для новых сервисов и настроим запуск приложения в Docker Compose на локальной машине разработчика. Это позволит сделать наше приложение более интерактивным с точки зрения пользователя и более «сложным» для развертывания — ведь фронтенд и бэкенд теперь будут развертываться как отдельные сервисы, работающие параллельно и отвечающие за запросы по одному доменному имени. В итоге наша задача по развертыванию получится максимально приближенной к реальной жизни. 

Реорганизация проекта

Создадим новый каталог, в котором будем разрабатывать приложение. Для начала скопируем содержимое оригинального проекта в подкаталог backend нового проекта. В нем будет храниться только то, что отвечает за работу с БД и организацию REST API. 

Фронтенд приложения расположится в подкаталоге frontend корневого каталога, но создавать его пока не нужно: мы сделаем это на стадии подготовки нового приложения на Vue.js. 

В итоге у приложения должна получиться следующая файловая структура:

$ tree -a .
.
└── backend
   ├── .helm
   │   └── templates
   │       ├── database.yaml
   │       ├── deployment.yaml
   │       ├── ingress.yaml
   │       ├── job-db-setup-and-migrate.yaml
   │       └── service.yaml
   ├── Dockerfile
   ├── cmd
   │   └── main.go
   ├── db
   │   └── migrations
   │       ├── 000001_create_talkers_table.down.sql
   │       └── 000001_create_talkers_table.up.sql
   ├── go.mod
   ├── go.sum
   ├── internal
   │   ├── app
   │   │   └── app.go
   │   ├── common
   │   │   └── json_logger_filter.go
   │   ├── controllers
   │   │   └── db_controllers.go
   │   └── services
   │       └── db_service.go
   ├── templates
   │   ├── index.html
   │   ├── remember.html
   │   └── say.html
   └── werf.yaml

Разработка бэкенда приложения

В нашем исходном приложении реализованы генерация страниц и передача их браузеру пользователя. В новой версии за веб-представление приложения будет отвечать отдельный сервис на Vue.js, поэтому давайте уберем из бэкенда все, что связано с обработкой шаблонов, а ответы будем передавать не в HTML, а в формате JSON — как взрослые программисты :)

Для начала удалим в файле internal/app/app.go эндпоинт /, отвечающий за главную страницу приложения, так как за него теперь будет отвечать фронтенд. К эндпоинотам remember и say, которые отвечают за сохранение и отображение сообщений из БД, добавим подпуть /api (должно получиться вот так: remember/api, say/api), который пригодится для настройки распределения запросов по нужным путям при развертывании в кластере и Docker Compose в процессе организации локального стенда. После этого у нас получится следующий код функции инициализации сервера:

func Run() {
	route := gin.New()
	route.Use(gin.Recovery())
	route.Use(common.JsonLogger())


	route.GET("/api/remember", controllers.RememberController)
	route.GET("/api/say", controllers.SayController)


	err := route.Run()
	if err != nil {
		return
	}
}

Примечание

Лучше заранее продумать, как будут группироваться эндпоинты приложения. Такая предусмотрительность позволит сразу организовать версионность и обратную совместимость API. Например, при изменении мажорной версии приложения все новые методы API будут доступны по пути /api/v2, а старые останутся на /api/v1.

Заменим в контроллерах тип ответа с HTML на JSON, оставив его содержимое в неизменном виде.

Ниже представлен контроллер сохранения данных, расположенный в файле internal/controllers/db_controllers.go:

func RememberController(c *gin.Context) {
	dbType, dbPath := services.GetDBCredentials()


	db, err := sql.Open(dbType, dbPath)
	if err != nil {
		panic(err)
	}


	message := c.Query("message")
	name := c.Query("name")


	_, err = db.Exec("INSERT INTO talkers (message, name) VALUES (?, ?)",
		message, name)
	if err != nil {
		panic(err)
	}


	c.JSON(http.StatusOK, gin.H{
		"Name":    name,
		"Message": message,
	})


	defer db.Close()
}

В нем все осталось как прежде: получение данных от формы на сайте, переданных по GET-запросу, и их сохранение в БД. Изменен только ответ с вызова шаблона HTML на генерацию JSON (второй блок кода, если считать снизу, который теперь начинается с c.JSON).

Код контроллера чтения данных из БД расположен в том же файле и выглядит теперь так:

func SayController(c *gin.Context) {
	dbType, dbPath := services.GetDBCredentials()


	db, err := sql.Open(dbType, dbPath)
	if err != nil {
		panic(err)
	}


	result, err := db.Query("SELECT * FROM talkers")
	if err != nil {
		panic(err)
	}


	count := 0
	var data []map[string]string


	for result.Next() {
		count++
		var id int
		var message string
		var name string


		err = result.Scan(&id, &message, &name)
		if err != nil {
			panic(err)
		}


		data = append(data, map[string]string{
			"Name":    name,
			"Message": message})
	}
	if count == 0 {
		c.JSON(http.StatusOK, gin.H{
			"Error": "There are no messages from talkers!",
		})
	} else {
		c.JSON(http.StatusOK, gin.H{
			"Messages": data,
		})
	}
}

Здесь все также осталось практически без изменений — мы лишь заменили HTML на JSON (два последних блока кода, теперь они начинаются с c.JSON).

Ура! Отделение бэкенда завершено.

Можно проверить, что все работает, перейдя в терминале в каталог backend и развернув приложение в кластере:

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

После развертывания проверим, что по эндпоинту /say данные возвращаются из БД в виде JSON-файла следующего содержания:

$ curl https://habrapp.example.com/say | jq

{
  "Messages": [
    {
      "Message": "Привет, Хабр!",
      "Name": "Zhbert"
    }
  ]
}

Если вы разворачивали кластер с этим приложением по инструкции из предыдущей статьи этого цикла, то данные уже должны быть в базе — после тестирования первой версии приложения. В противном случае вам также вернется JSON, но с ошибкой, которая будет ругаться на отсутствие данных — это нормально, не пугайтесь.

Заметим, что, так как мы удалили из приложения генерацию главной страницы, при запросе на / возвращается 404-я ошибка, как в примере ниже:

$ curl https://habrapp.example.com

404 page not found

В дальнейшем мы это исправим, так что не обращайте внимания.

Перенесем из каталога backend файл werf.yaml на уровень выше — в корневой каталог проекта, так как теперь он будет отвечать за сборку сразу двух контейнеров вместо одного.

Ну что ж, можно приступать к созданию фронтенда.

Разработка фронтенда приложения

В этой части мы разработаем веб-приложение на Vue.js, которое будет отвечать за фронтенд нашего проекта: отображать страницы, а также отправлять и получать данные от бэкенда.

Установите Vue CLI на вашу машину. Лучше сразу делать это через npm, так как в дальнейшем он нам еще понадобится.

Теперь инициализируем новое Vue-приложение в корневом каталоге нашего проекта, выполнив соответствующую команду:

vue create frontend

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

> Default ([Vue 3] babel, eslint

Процесс подготовки займет некоторое время в зависимости от скорости интернет-соединения. В результате отобразятся статус созданного проекта и подсказка о том, как его запустить локально:

Vue CLI v5.0.8
✨  Creating project in /Users/zhbert/Programming/habr-werf-local-stend/frontend.
⚙️  Installing CLI plugins. This might take a while...

added 862 packages, and audited 863 packages in 24s

packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
????  Invoking generators...
????  Installing additional dependencies...

added 101 packages, and audited 964 packages in 3s

packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
⚓  Running completion hooks...

????  Generating README.md...

????  Successfully created project frontend.
????  Get started with the following commands:

 $ cd frontend
 $ npm run serve

Последуем предложенному совету в самом конце сообщения и запустим приложение:

npm run serve

> frontend@0.1.0 serve
> vue-cli-service serve

 INFO  Starting development server...


 DONE  Compiled successfully in 1699ms                                                                                                                                                                                                       14:48:03

  App running at:
  - Local:   http://localhost:8080/
  - Network: http://192.168.1.100:8080/

  Note that the development build is not optimized.
  To create a production build, run npm run build.

Перейдем по предложенному адресу и посмотрим, что получилось:

Приложение запускается и работает. Теперь изменим его под наши нужды — удалим содержимое по умолчанию, подготовим все для работы с запросами к БД: добавим форму отправки сообщений и необходимые для их отображения компоненты.

Для начала установим Bootsrtap, чтобы использовать его как часть приложения, а не подгружать со стороннего CDN, выполнив команду в терминале на рабочей машине:

$ npm i bootstrap@5.3.1

added 2 packages, and audited 966 packages in 4s

Packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Это набор готовых CSS-стилей, содержащий всевозможные компоненты для верстки сайтов любой сложности: разметку, таблицы, кнопки, чекбоксы и так далее.

Подключим Bootstrap к нашему приложению, добавив импорт в главном файле src/App.vue:

<style>
@import "bootstrap";
</style>

Vue CLI создает два компонента: основной App и дополнительный HelloWorld, вызываемый из основного. Они представлены файлами *.vue и расположены в каталоге src/components. По умолчанию в HelloWorld передается сообщение, которое отображается в ненужной нам HTML-разметке шаблона, вместо которой мы будем создавать свою. Поэтому уберем передачу сообщения и оставим только вызов компонента HelloWorld. В итоге главный компонент приложения (frontend/src/App.vue) у нас выглядит следующим образом:

<template>
  <HelloWorld/>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'


export default {
  name: 'App',
  components: {
    HelloWorld
  }
}
</script>

<style>
@import "bootstrap";
</style>

Здесь представлены три раздела: template, script и style. В первом содержится HTML-разметка компонента, во втором — JS-часть, а в третьем — стили CSS, которые будут применяться в этом компоненте. При сборке проекта Vue объединяет все эти данные из разных компонентов между собой и генерирует окончательное приложение.

Перенесем содержимое шаблона index.html из бэкенда в компонент components/HelloWorld.vue, добавив его в раздел template:

<template>
  <div class="container mt-5">
    <form class="row g-3" action="/api/remember">
      <div class="col-auto">
        <div class="input-group mb-3">
          <span class="input-group-text" id="name">Name</span>
          <input type="text" class="form-control" placeholder="Name" name="name">
        </div>
      </div>
      <div class="col-auto">
        <div class="input-group mb-3">
          <span class="input-group-text" id="message">Message</span>
          <input type="text" class="form-control" placeholder="Message" name="message">
        </div>
      </div>
      <div class="col-auto">
        <button type="submit" class="btn btn-primary mb-3">Send</button>
      </div>
    </form>
  </div>
</template>

Vue связывает части HTML-разметки с моделью данных, описанных в разделе script, но пока такая привязка к Vue не осуществляется. Для исправления ситуации потребуются несколько простых шагов.

Создадим переменные для полей ввода, в которые мы вводим имя и сообщение:

data() {
    return {
      name: '',
      message: '',
    }
  },

и привяжем их к HTML-разметке:

...
 <input type="text" class="form-control" placeholder="Name" name="name" v-model="name">
...

В поле ввода имени мы добавили директиву v-model="name", которая говорит Vue о том, что это поле связано с параметром name в разделе data. Аналогично поступим и с полем ввода сообщения (message).

Примечание

Vue.js использует модель MVVM («Model → View → ViewModel» или «Модель → Представление → Представление модели»), которая является следующим шагом в развитии привычной MVC с отличительной особенностью в связывании данных: значение переменной и отображение ее в поле ввода будут изменяться одновременно — при изменении переменной обновится значение поля ввода, а правка текста в поле ввода отразится на значении переменной.

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

methods: {
    send: function()
    {
      axios.get('/api/remember', {
        params: {
          name: this.name,
          message: this.message
        }
      }).catch(error => alert(error));
    },
  },

Функция отправляет запрос на эндпоинт /api/remember и выводит ошибку во всплывающем окне, если что-то пошло не так.

Свяжем функцию с кнопкой отправки запроса:

...
<button v-on:click="send" type="submit" class="btn btn-primary mb-3">Send</button>
...

Осталось только отобразить содержимое БД, для этого совершим несколько простых действий.

Первым делом создадим в этом же компоненте отдельное окошко для отображения содержимого БД или сообщения об отсутствии в ней записей.

Ниже формы с полями ввода перенесем содержимое шаблона say.html из прошлой реализации бэкенда:

    <div class="container mt-5">
      <h2 class="text-center">Messages from talkers</h2>
      <button v-on:click="get" type="button" class="btn btn-outline-success mb-2">Проверить записи</button>

      <div class="alert alert-warning" role="alert" v-if="error">
        Nothing to show!
      </div>

      <table class="table" v-else>
        <thead>
        <th>Name</th>
        <th>Message</th>
        </thead>
        <tbody>
        <tr v-for="talker in talkers.Messages" :key="talker.Name">
          <td>{{ talker.Name }}</td>
          <td>{{ talker.Message }}</td>
        </tr>
        </tbody>
      </table>
    </div>

Обратите внимание на несколько моментов:

  • в таблице убраны шаблоны генерации строк — они заменены на директиву v-for Vue.js, обеспечивающую перебор данных в массиве;

  • ниже заголовка с названием таблицы добавлено сообщение о том, что в БД ничего нет;

  • таблица и сообщение об ошибке связаны директивой v-if, проверяющей наличие соответствующего флага error: если он установлен в true, отображается сообщение об ошибке, если в false — таблица с данными.

Внимание!

Важно соблюсти расположение блоков в соответствии с условными директивами — иначе все может работать не совсем так, как ожидалось.

Добавим в раздел с данными нужные поля и функции:

  data() {
    return {
      name: '',
      message: '',
      talkers: [],
      error: true
    }
  },
  methods: {
    send: function()
    {
      axios.get('/api/remember', {
        params: {
          name: this.name,
          message: this.message
        }
      }).catch(error => alert(error));
    },
    get: function () {
      axios.get("/api/say")
          .then(response => {
            this.talkers = response.data;
            this.error = false
        })
    }
  }

Произошли следующие изменения:

  • в раздел data добавлен массив talkers с содержимым сообщений;

  • в methods добавлена функция get для запроса данных от бэкенда, а полученное содержимое складывается в массив talkers;

  • после получения данных флаг error устанавливается в false.

Примечание

Проверка тут выполняется только «для галочки» с целью добавить интерактивности по принципу «Получили сообщения — убрали флаг» для того, чтобы отображать либо таблицу, либо сообщение об отсутствии записей. В идеале, конечно же, нужно добавлять валидацию данных и проверку на возвращаемую запросом ошибку.

Весь код компонента HelloWorld теперь выглядит так:
<template>
  <div class="container mt-5">
    <form class="row g-3">
      <div class="col-auto">
        <div class="input-group mb-3">
          <span class="input-group-text" id="name">Name</span>
          <input type="text" class="form-control" placeholder="Name" name="name" v-model="name">
        </div>
      </div>
      <div class="col-auto">
        <div class="input-group mb-3">
          <span class="input-group-text" id="message">Message</span>
          <input type="text" class="form-control" placeholder="Message" name="message" v-model="message">
        </div>
      </div>
      <div class="col-auto">
        <button v-on:click="send" type="submit" class="btn btn-primary mb-3">Send</button>
      </div>
    </form>
    <div class="container mt-5">
      <h2 class="text-center">Messages from talkers</h2>
      <button v-on:click="get" type="button" class="btn btn-outline-success mb-2">Проверить записи</button>

      <div class="alert alert-warning" role="alert" v-if="error">
        Nothing to show!
      </div>

      <table class="table" v-else>
        <thead>
        <th>Name</th>
        <th>Message</th>
        </thead>
        <tbody>
        <tr v-for="talker in talkers.Messages" :key="talker.Name">
          <td>{{ talker.Name }}</td>
          <td>{{ talker.Message }}</td>
        </tr>
        </tbody>
      </table>
    </div>
  </div>

</template>

<script>
import axios from 'axios';
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  data() {
    return {
      name: '',
      message: '',
      talkers: [],
      error: true
    }
  },
  methods: {
    send: function()
    {
      axios.get('/api/remember', {
        params: {
          name: this.name,
          message: this.message
        }
      }).catch(error => alert(error));
    },
    get: function () {
      axios.get("/api/say")
          .then(response => {
            this.talkers = response.data;
            this.error = false
        })
    }
  }
}
</script>

<style></style>

Фронтенд готов к работе.

Изменение развертывания

С появлением фронтенда появилась необходимость развертывать в кластере и его тоже. Создадим Dockerfile в каталоге frontend:

# Этап сборки (build stage).
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY frontend/package*.json ./
RUN npm install
COPY frontend/. .
RUN npm run build


# Этап production (production-stage).
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

По аналогии с бэкендом мы использовали мультистейдж-сборку. В сборочном контейнере устанавливаются нужные модули и собирается приложение, которое затем копируется в чистый контейнер с nginx.

Обновим Dockerfile бэкенда, указав новый путь и убрав копирование шаблонов:

# Используем многоступенчатую сборку образа (multi-stage build).
# Образ, в котором будет собираться проект.
FROM golang:1.18-alpine AS build
# Устанавливаем curl и tar.
RUN apk add curl tar
# Копируем исходники приложения.
COPY backend/. /app
WORKDIR /app
# Скачиваем утилиту migrate и распаковываем полученный архив.
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.16.2/migrate.linux-amd64.tar.gz | tar xvz
# Запускаем загрузку нужных пакетов.
RUN go mod download
# Запускаем сборку приложения.
RUN go build -o /goapp cmd/main.go

# Образ, который будет разворачиваться в кластере.
FROM alpine:latest
WORKDIR /
# Копируем из сборочного образа исполняемый файл проекта.
COPY --from=build /goapp /goapp
# Копируем из сборочного образа распакованный файл утилиты migrate и схемы миграции.
COPY --from=build /app/migrate /migrations/migrate
COPY backend/db/migrations /migrations/schemes
EXPOSE 8080
ENTRYPOINT ["/goapp"]

Удалим каталог backend/templates — он больше не нужен, так как за отрисовку страниц теперь отвечает фронтенд приложения.

Добавим в файл werf.yaml информацию о контейнере с фронтендом (как мы получили исходный файл, можно узнать из предыдущей статьи цикла):

project: habr-app
configVersion: 1

---
image: backend
dockerfile: backend/Dockerfile

---
image: frontend
dockerfile: frontend/Dockerfile

Перенесем каталог .helm из backend в корневой каталог проекта. Переименуем Deployment бэкенда, заменим образ приложения с app на backend в соответствии с наименованиями контейнеров в werf.yaml и подправим названия — по ним будут формироваться имена подов в кластере.

Содержимое deployment-backend.yaml получится следующим:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: habr-app-backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: habr-app-backend
  template:
    metadata:
      labels:
        app: habr-app-backend
    spec:
      imagePullSecrets:
      - name: registrysecret
      containers:
      - name: app-backend
        image: {{ .Values.werf.image.backend }}
        ports:
        - containerPort: 8080
        env:
          - name: GIN_MODE
            value: "release"
          - name: DB_TYPE
            value: "mysql"
          - name: DB_NAME
            value: "habr-app"
          - name: DB_USER
            value: "root"
          - name: DB_PASSWD
            value: "password"
          - name: DB_HOST
            value: "mysql"
          - name: DB_PORT
            value: "3306"

Service приложения также необходимо поправить, заменив название на новое, указанное в Deployment’е  выше:

apiVersion: v1
kind: Service
metadata:
  name: habr-app-backend
spec:
  selector:
    app: habr-app-backend
  ports:
  - name: http
    port: 8080

Подготовим Deployment и Service для фронтенда.

Deployment фронтенда аналогичен Deployment’у бэкенда — изменены только названия контейнеров и самого Deployment’а для соответствия содержимому файла werf.yaml и для того, чтобы отразить суть развертывания:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: habr-app-frontend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: habr-app-frontend
  template:
    metadata:
      labels:
        app: habr-app-frontend
    spec:
      imagePullSecrets:
      - name: registrysecret
      containers:
      - name: app-frontend
        image: {{ .Values.werf.image.frontend }}
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: habr-app-frontend
spec:
  selector:
    app: habr-app-frontend
  ports:
    - name: http
      port: 80

Изменим Ingress-контроллер так, чтобы запросы, приходящие на эндпоинт /, перенаправлялись на фронтенд, а приходящие на эндпоинт /api — на бэкенд:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
  name: habr-app
spec:
  rules:
  - host: habrapp.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: habr-app-frontend
            port:
              number: 80
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: habr-app-backend
            port:
              number: 8080
  tls:
    - hosts:
        - habrapp.example.com
      secretName: habr-app-tls

Наконец, заменим название образа в Job, который осуществляет миграцию БД:

image: {{ .Values.werf.image.backend }}

Приложение доработано и готово к развертыванию.

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

Закоммитим изменения и выкатим их в кластер. Для этого в корневом каталоге проекта выполним следующую команду:

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

Если всё собралось и запустилось, увидим следующее:

Release "habr-app" has been upgraded. Happy Helming!
NAME: habr-app
LAST DEPLOYED: Wed Aug 23 14:00:31 2023
LAST PHASE: rollout
LAST STAGE: 0
NAMESPACE: habr-app
STATUS: deployed
REVISION: 3
TEST SUITE: None
Running time 15.52 seconds

Перейдем в браузере по адресу приложения:

Запишем сообщение и проверим, что оно отобразилось в таблице:

И фронтенд, и бэкенд работают.

Организация локального стенда для разработки и автоматического развертывания

Мы плавно подошли к основному вопросу статьи.

Да, в кластере можно развернуть приложение, и оно заработает. Но делать это каждый раз вручную, без настройки CI/CD, очень утомительно. Кроме того, не всегда под руками есть кластер, а приложение разрабатывать как-то надо.

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

Примечание

Расмотренный подход актуален для небольших или средних приложений. Если речь идет о тяжелом софте, одна сборка которого занимает десятки или сотни минут на мощной сборочной машине, — такой метод уже не покажется удобным.

Варианты локального стенда

Итак, у нас есть бэкенд и фронтенд, которые взаимодействуют друг с другом. Запросы с фронтенда отправляются на API бэкенда, находящегося по тому же адресу, что и само приложение. Как видно из примера выше, основное приложение расположено по адресу /, а его API — /api/*. И отвечают за них два разных микросервиса.

Можно попытаться просто запустить одновременно и фронтенд, и бэкенд, но… система не даст этого сделать. Если мы попытаемся запустить оба сервиса на порте 80, запустится только один из них — тот, который успеет захватить порт. Если задействовать разные порты, запросы на API не пойдут куда нужно, так как API будет расположен по адресу localhost:8080/api/ вместо /localhost/api/*. Плюс еще понадобится база данных MySQL, которую придется ставить локально на компьютер.

Выйти из этой ситуации можно несколькими способами — рассмотрим каждый из них, а вы можете выбрать тот, который придется по душе именно вам.

Способ №1 — локальный кластер Kubernetes на minikube

В качестве замены полноценного удаленного кластера можно установить небольшой локальный кластер Kubernetes (например, minikube) и развертывать приложение в нем. Обычно он использует Docker для работы своих компонентов и не сильно требователен в системным ресурсам.

Из плюсов здесь: можно использовать все подготовленные Helm-чарты, которые понадобятся для деплоя в рабочий кластер. Но есть и минусы: все равно потребуется registry для загрузки и хранения собранных образов и сопутствующих метаданных. Запуск при этом будет выполняться той же самой командой, что и развертывание в реальный кластер (после переключения контекста на minikube).

Установим minikube по официальной инструкции.

Запустим кластер:

$ minikube start --driver=docker
????  minikube v1.31.2 на Darwin 13.6 (arm64)
✨  Automatically selected the docker driver
????  Using Docker Desktop driver with root privileges
????  Запускается control plane узел minikube в кластере minikube
????  Скачивается базовый образ ...
????  Creating docker container (CPUs=2, Memory=4000MB) ...
????  Подготавливается Kubernetes v1.27.4 на Docker 24.0.4 ...
    ▪ Generating certificates and keys ...
    ▪ Booting up control plane ...
    ▪ Configuring RBAC rules ...
????  Configuring bridge CNI (Container Networking Interface) ...
????  Компоненты Kubernetes проверяются ...
    ▪ Используется образ gcr.io/k8s-minikube/storage-provisioner:v5
????  Включенные дополнения: storage-provisioner, default-storageclass
????  Готово! kubectl настроен для использования кластера "minikube" и "default" пространства имен по умолчанию

Кластер запустился.

Активируем плагин Ingress-контроллера:

$ minikube addons enable ingress
????  ingress is an addon maintained by Kubernetes. For any concerns contact minikube on GitHub.
You can view the list of minikube maintainers at: https://github.com/kubernetes/minikube/blob/master/OWNERS
????  After the addon is enabled, please run "minikube tunnel" and your ingress resources would be available at "127.0.0.1"
    ▪ Используется образ registry.k8s.io/ingress-nginx/kube-webhook-certgen:v20230407
    ▪ Используется образ registry.k8s.io/ingress-nginx/kube-webhook-certgen:v20230407
    ▪ Используется образ registry.k8s.io/ingress-nginx/controller:v1.8.1
????  Verifying ingress addon...
????  The 'ingress' addon is enabled

Создадим в нем новое пространство имен для нашего приложения:

$ kubectl create namespace habr-app
namespace/habr-app created

Примечание

Если на вашей машине не установлен kubectl, то можно воспользоваться либо встроенным в minikube (третий шаг в официальной инструкции по установке), либо утилитой werf, которая имеет всю необходимую функциональность.

Теперь настроим контекст на использование его по умолчанию:

$ kubectl config set-context minikube --namespace=habr-app
Context "minikube" modified.

Создадим Secret для доступа к container registry:

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

Также необходимо войти в registry:

werf cr login -u username -p password https://index.docker.io/v1/

Все готово, осталось только настроить DNS-имя для доступа к приложению в кластере — пропишем его в /etc/hosts. Для Linux выполните команду:

echo "$(minikube ip) habrapp.example.com" | sudo tee -a /etc/hosts

Для macOS с установленным Docker Desktop вместо $(minikube ip) нужно указать адрес вашей сетевой карты:

echo "127.0.0.1 habrapp.example.com" | sudo tee -a /etc/hosts

Задеплоим приложение командой:

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

Если все пройдет нормально, в конце мы увидим такое:

...
│ ┌ Status progress
│ │ JOB                                                                                                                                                       ACTIVE                       DURATION                        SUCCEEDED/FAILED
│ │ setup-and-migrate-db-rev1                                                                                                                                 0                            88s                             0->1/0                                          ↵
│ │
│ │ │   POD                                                         READY                 RESTARTS                      STATUS
│ │ └── and-migrate-db-rev1-2dn7p                                   0/1                   0                             Completed
│ └ Status progress
└ Waiting for resources to become ready (86.54 seconds)

NAME: habr-app
LAST DEPLOYED: Tue Oct 31 13:47:56 2023
LAST PHASE: rollout
LAST STAGE: 0
NAMESPACE: habr-app
STATUS: deployed
REVISION: 1
TEST SUITE: None
Running time 175.69 seconds

Приложение развернуто в кластере minikube. Проверим, что все работает, перейдя в браузере по прописанному в hosts адресу:

Примечание

В macOS с Docker Desktop перед проверкой приложения необходимо запустить туннель для minikube, который пробросит созданный Ingress-контроллер в систему и позволит получить к нему доступ по адресу 127.0.0.1. Сделать это можно командой minikube tunnel, запущенной в отдельном окне терминала. Команда требует повышения привилегий и запросит пароль пользователя.

После завершения работы туннель можно отключить, нажав сочетание клавиш Ctrl + C, находясь в терминале с запущенным туннелем. 

Способ №2 — использование Docker Compose

У предыдущего способа есть один сильный недостаток: при любом изменении в одном из микросервисов все контейнеры приложения будут пересобираться, отправляться в registry и затем переразворачиваться в кластере.

Для контейнера с Go-приложением это в целом нормально — ведь всегда нужно перекомпилировать его и обновлять полученный исполняемый файл в контейнере. Но для микросервиса с фронтендом такой путь неудобен. npm поддерживает режим работы hot reload (горячей перезагрузки), когда все изменения сразу же после сохранения файлов становятся доступны в браузере практически без ожидания (на самом деле, конечно, оно есть, но по сравнению с полной пересборкой контейнера практически незаметно).

Поэтому теперь мы рассмотрим второй способ — развернем наше приложение в Docker Compose и тоже под управлением werf. Docker Compose — инструмент, входящий в состав Docker и позволяющий настроить минимальную оркестрацию контейнеров для развертывания составного приложения.

Для начала добавим в werf.yaml отдельный контейнер для сборочного образа Vue-приложения:

...
---
image: frontend-compose
dockerfile: frontend/Dockerfile
target: build-stage

Здесь мы указали конкретную стадию образа из Dockerfile фронтенда: build-stage.

Теперь создадим в корневом каталоге проекта файл docker-compose.yml:

version: "3.7"
services:
  frontend:
    container_name: frontend
    image: $WERF_FRONTEND_COMPOSE_DOCKER_IMAGE_NAME
    working_dir: "/app"
    command: npm run serve
    volumes:
      - "./frontend:/app"
    networks:
      - nginx_net

  backend:
    container_name: backend
    image: $WERF_BACKEND_DOCKER_IMAGE_NAME
    networks:
      - nginx_net
      - backend_net
    environment:
      - GIN_MODE=release
      - DB_TYPE=mysql
      - DB_NAME=habr-app
      - DB_USER=root
      - DB_PASSWD=password
      - DB_HOST=mysql
      - DB_PORT=3306
    depends_on:
      mysql:
        condition: service_started
  mysql:
    image: mysql:8
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=password
      - MYSQL_DATABASE=habr-app
    networks:
      - backend_net
    volumes:
      - ./mysql:/var/lib/mysql
  nginx:
    container_name: nginx
    image: nginx:stable-alpine
    restart: unless-stopped
    networks:
      - nginx_net
    volumes:
      - ./.configs/nginx.conf:/etc/nginx/conf.d/default.conf
    ports:
      - "80:80"
    command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'''

networks:
  nginx_net:
    external: false
  backend_net:
    external: false

В примере выше описаны все контейнеры, нужные для работы приложения. Обратите внимание на именование образов бэкенда и фронтенда: $WERF_BACKEND_DOCKER_IMAGE_NAME и $WERF_FRONTEND_COMPOSE_DOCKER_IMAGE_NAME. Они соответствуют наименованием контейнеров из werf.yaml. При запуске werf подставит в это место нужные теги или ID контейнеров, которые соберет.

В контейнере фронтенда мы монтируем содержимое каталога frontend проекта в каталог /app контейнера и указываем, что он является рабочей директорией контейнера. Затем заменяем команду, которую нужно выполнить после запуска контейнера, на npm run serve. Это запустит встроенный в npm веб-сервер с нашим приложением.

К контейнеру MySQL подключается Volume, где будет храниться содержимое БД. Рядом с файлом docker-compose, то есть в корне проекта, будет создан каталог для монтирования. Не забудьте добавить его в .gitignore.

Перед контейнерами бэкенда и фронтенда поднимается nginx в качестве реверс-прокси, который будет разбрасывать запросы между / и /api. Для этого внутри контейнера в разделе volumes мы монтируем конфигурационный файл nginx.conf для описания правил перенаправления запросов. Создадим этот файл в каталоге .configs/nginx.conf:

server {
  listen 80;

  server_name stats-informer;

  client_max_body_size 50m;

  location / {
    resolver 127.0.0.11;
    set $project http://frontend:8080;

    proxy_pass $project;

    proxy_http_version  1.1;
    proxy_cache_bypass  $http_upgrade;

    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        "upgrade";
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Port  $server_port;

  }

  location /api/ {
      resolver 127.0.0.11;
      set $project http://backend:8080;

      proxy_pass $project;

      proxy_http_version  1.1;
      proxy_cache_bypass  $http_upgrade;

      proxy_set_header Upgrade           $http_upgrade;
      proxy_set_header Connection        "upgrade";
      proxy_set_header Host              $host;
      proxy_set_header X-Real-IP         $remote_addr;
      proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header X-Forwarded-Host  $host;
      proxy_set_header X-Forwarded-Port  $server_port;

    }
}

Также в файле docker-compose определены две сети контейнеров: nginx_net и backend_net. В первую имеют доступ nginx и бэкенд с фронтендом. Во второй находятся бэкенд и БД.

Все готово к запуску. Вернемся в корневой каталог проекта и выполним команду:

werf compose up

В результате соберутся образы контейнеров приложения, nginx и MySQL загрузятся с Docker Hub, а наше приложение запустится.

Но работать оно не будет — не выполнены миграции БД.

Сделаем это. Посмотрим список всех запущенных контейнеров в Docker:

$ docker ps
CONTAINER ID   IMAGE                                                                             COMMAND                  CREATED             STATUS             PORTS                 NAMES
a9bc74606d42   habr-app:1935fb904d211c637e0fb5b0212bb992d435fb54c63b68fd8d2951bb-1692862816809   "/goapp"                 About an hour ago   Up About an hour   8080/tcp              backend
68713fd81022   nginx:stable-alpine                                                               "/docker-entrypoint.…"   About an hour ago   Up About an hour   0.0.0.0:80->80/tcp    nginx
bfe12808ade8   habr-app:9d323934c5233de86cbf4ac26f938419d3a1ac8055be8c23ea4d7212-1692863007929   "/docker-entrypoint.…"   About an hour ago   Up About an hour   80/tcp                frontend
86725a5995ff   mysql:8 "docker-entrypoint.s…"   About an hour ago   Up About an hour 3306/tcp, 33060/tcp habr-werf-local-stend-mysql-1

Найдем ID контейнера с бэкендом — a9bc74606d42. Войдем в контейнер и запустим утилиту миграции:

$ docker exec -ti a9bc74606d42 /migrations/migrate -database "mysql://root:password@tcp(mysql:3306)/habr-app" -path /migrations/schemes up
1/u create_talkers_table (20.315917ms)

Приложение готово к работе! Запустим его в фоновом режиме:

werf compose up --docker-compose-command-options="-d"

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

Using werf config render file: /private/var/folders/t_/zx9l_6896snf1qw2q6h1n0940000gn/T/werf-config-render-859276243
┌ ⛵ image frontend-compose [linux/amd64]
│ Use previously built image for frontend-compose/dockerfile
│      name: habr-app:8c7bf32a905c70f355bf1dbe1d7f0c2e606dd2d2191037c5f9529ec1-1698752438734
│        id: 5fc2525a0fc9
│   created: 2023-10-31 14:40:38.646684918 +0300 MSK
│      size: 328.6 MiB
│  platform: linux/amd64
└ ⛵ image frontend-compose [linux/amd64] (0.11 seconds)

┌ ⛵ image backend [linux/amd64]
│ Use previously built image for backend/dockerfile
│      name: habr-app:1935fb904d211c637e0fb5b0212bb992d435fb54c63b68fd8d2951bb-1698752435818
│        id: c38113ce852c
│   created: 2023-10-31 14:40:35.732990167 +0300 MSK
│      size: 66.4 MiB
│  platform: linux/amd64
└ ⛵ image backend [linux/amd64] (0.08 seconds)

┌ ⛵ image frontend [linux/amd64]
│ Use previously built image for frontend/dockerfile
│      name: habr-app:9d323934c5233de86cbf4ac26f938419d3a1ac8055be8c23ea4d7212-1698752437277
│        id: f08cb789a0d4
│   created: 2023-10-31 14:40:37.190123376 +0300 MSK
│      size: 44.8 MiB
│  platform: linux/amd64
└ ⛵ image frontend [linux/amd64] (0.08 seconds)
[+] Building 0.0s (0/0)                                                                                                                                                                                                                         docker-container:happy_mendel
[+] Running 4/0
 ✔ Container app-mysql-1  Running                                                                                                                                                                                                                                        0.0s
 ✔ Container frontend     Running                                                                                                                                                                                                                                        0.0s
 ✔ Container nginx        Running                                                                                                                                                                                                                                        0.0s
 ✔ Container backend      Running

Проверим, что все работает, перейдя по адресу http://localhost и записав сообщение:

Ура! Оно и правда работает!

Теперь запустим все без опции --docker-compose-command-options="-d", чтобы убедиться, что пересборка проекта Vue происходит в реальном времени и не затрагивает контейнер. После запуска мы сразу увидим лог загрузки всех контейнеров. Посмотрим на лог работы контейнера с фронтендом:

frontend     |
frontend     | > frontend@0.1.0 serve
frontend     | > vue-cli-service serve
frontend     |
frontend     |  INFO  Starting development server...
 DONE  Compiled successfully in 15131ms1:16:28 PM
frontend     |

frontend     |   App running at:
frontend     |   - Local:   http://localhost:8080/
frontend     |   - Network: http://172.19.0.2:8080/
frontend     |
frontend     |   Note that the development build is not optimized.
frontend     |   To create a production build, run npm run build.
frontend     |
Build finished at 13:16:28 by 0.000s

Перейдем в браузер и посмотрим на приложение:

Изменим название «Messages from talkers» на просто «Messages» в файле /frontend/src/components/HelloWorld.vue и сохраним его. Обратите внимание, что в терминале с работающим контейнером сразу запустится пересборка Vue-проекта:

frontend     |
frontend     | Compiling...
 DONE  Compiled successfully in 4643ms1:20:27 PM
frontend     |

frontend     |   App running at:
frontend     |   - Local:   http://localhost:8080/
frontend     |   - Network: http://172.19.0.2:8080/
frontend     |
Build finished at 13:20:27 by 0.000s

Проверим браузер:

Сообщение обновилось, и заняло это от силы пару секунд. А целиком контейнер пересобирался бы гораздо дольше.

Добавляем интерактивности

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

Для этого у werf существуют два специальных флага:

  • --dev — в этом режиме запуск развертывания не требует явного коммита изменений в рабочую ветку, потому что werf сам создаст времененную ветку и коммит в нее, в соответствии с которым произведет сборку;

  • --follow — в этом режиме werf будет осуществлять пересборку и переразвертывание при каждом коммите; заметьте, что если задан флаг --dev, то во временной ветке отслеживается изменение всех, даже untracked-файлов.

Группируя эти два флага, можно добиться следующего поведения: 

  • сборка автоматически перезапускается при коммите изменений в Git;

  • сборка автоматически перезапускается при любых изменениях файлов в проекте.

Для запуска сборки в таком режиме выполним команду со следующими флагами:

werf compose up --dev --follow

Исходные коды приложения можно найти в репозитории.

Заключение

Мы разработали веб-приложение, разбив его на независимые фронтенд и бэкенд, настроили конфигурацию развертывания в кластере Kubernetes, а также добавили возможность локальной сборки и запуска всех сервисов на машине разработчика. Такие изменения позволят ускорить разработку благодаря мгновенной пересборке сервисов при измении проекта, которую обеспечивает werf.

P. S.

Полезные ссылки:

  • Самоучитель «Как организовать CI/CD и настроить работу с Kubernetes». Самоучитель разбит на несколько частей: с примерами реальных приложений на Node.js, Java Spring Boot, Django, Python, Go, Ruby on Rails, Laravel. В самоучителе можно пройти весь путь создания приложений и их развертывания в production.

  • Чат werf, где на вопросы отвечает команда разработки утилиты — если что-то будет непонятно или пойдет не так.

Читайте также в нашем блоге:

Комментарии (0)