Организация стенда локальной разработки для самых маленьких с автоматической пересборкой приложения (фронтенд + бэкенд)

от автора

Вносить изменения в код приложения и тут же автоматически получать задеплоенные изменения, чтобы быстро тестировать его, — мечта разработчика. В этой статье мы посмотрим, как реализовать такой подход для небольшого приложения с фронтендом и бэкендом: организуем два варианта локального стенда на базе 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

Такой способ создания нового проекта считается устаревшим, и теперь рекомендуется использовать create-vue для создания нового каркаса приложения.

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

> 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, где на вопросы отвечает команда разработки утилиты — если что-то будет непонятно или пойдет не так.

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


ссылка на оригинал статьи https://habr.com/ru/articles/771678/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *