
Вносить изменения в код приложения и тут же автоматически получать задеплоенные изменения, чтобы быстро тестировать его, — мечта разработчика. В этой статье мы посмотрим, как реализовать такой подход для небольшого приложения с фронтендом и бэкендом: организуем два варианта локального стенда на базе 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, где на вопросы отвечает команда разработки утилиты — если что-то будет непонятно или пойдет не так.
Читайте также в нашем блоге:
Разворачиваем приложение в кластере Kubernetes под управлением Deckhouse c помощью werf
Первые шаги с werf: собираем и деплоим простое приложение в Kubernetes
Локальная разработка в Kubernetes с помощью werf 1.2 и minikube
Появился бесплатный самоучитель по CI/CD и Kubernetes для Go-разработчиков от команды werf

