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