Разворачиваем приложение в кластере Kubernetes под управлением Deckhouse c помощью werf
- среда, 13 сентября 2023 г. в 00:00:19
В статье мы рассмотрим, как подступиться к миру Kubernetes в первый раз — развернуть кластер под управлением платформы Deckhouse, разработать и подготовить приложение, развернуть его с помощью утилиты werf, предназначенной для построения рабочего процесса по принципам CI/CD, а также настроить сертификаты для доступа по HTTPS.
Развертывание кластера
Вводные данные
Подготовка конфигурации
Настройка кластера
Проверка работоспособности
Включение HTTPS для компонентов кластера
Настройка контекста кластера на рабочей машине
Подготовка приложения
Разработка приложения
Подготовка шаблонов страниц
Подготовка бэкенда приложения
Подготовка к развертыванию
Сборка и Helm-чарты приложения
Миграция базы данных
Развертывание приложения в кластере
Подготовка кластера
Развертывание приложения
Настройка HTTPS
Проверка работоспособности
Заключение
P. S.
Для начала нужно подготовить кластер. Для этого понадобится одна виртуальная машина (или bare-metal-сервер) со следующими минимальными требованиями:
4 ядра CPU;
8 ГБ RAM;
не менее 40 ГБ на диске;
HTTPS-доступ к хранилищу образов контейнеров registry.deckhouse.io
.
Примечание
В качестве диска лучше использовать шустрый SSD- или NVME-диск, т. к. при работе с неповоротливым HDD компоненты кластера могут упереться в лимит его скорости — пойдут задержки, может появиться риск полного отказа кластера.
Подойдет любая поддерживаемая операционная система:
РЕД ОС 7.3*;
AlterOS 7*;
ALT Linux p10, 10.1*;
Astra Linux Special Edition 1.7.2, 1.7.3*;
CentOS 8, 9;
Debian 9, 10, 11;
Rocky Linux 8, 9;
Ubuntu 20.04, 22.04.
Примечание
Поддержка операционных систем, отмеченных звездочкой, предоставляется только в редакции Deckhouse EE. Работоспособность в редакции Deckhouse CE не гарантируется.
Для нашей установки возьмем Ubuntu 22.04 LTS.
Разумеется, потребуется компьютер, имеющий доступ по SSH-ключу к серверу или виртуальной машине — туда, где будет развернут кластер. На компьютере должен быть установлен Docker для запуска инсталлятора Deckhouse и одна из рекомендуемых ОС: Windows 10+, macOS 10.15+, Linux (Ubuntu 18.04+, Fedora 35+).
Создадим в отдельном каталоге файл конфигурации config.yml
, в котором укажем конфигурацию будущего кластера:
# Секция с общими параметрами кластера.
# https://deckhouse.ru/documentation/v1/installing/configuration.html#clusterconfiguration
apiVersion: deckhouse.io/v1
kind: ClusterConfiguration
clusterType: Static
# Адресное пространство подов кластера.
podSubnetCIDR: 10.111.0.0/16
# Адресное пространство сети сервисов кластера.
serviceSubnetCIDR: 10.222.0.0/16
kubernetesVersion: "Automatic"
# Домен кластера.
clusterDomain: "cluster.local"---# Секция первичной инициализации кластера Deckhouse.
# https://deckhouse.ru/documentation/v1/installing/configuration.html#initconfiguration
apiVersion: deckhouse.io/v1
kind: InitConfiguration
deckhouse:
releaseChannel: Stable
configOverrides:
global:
modules:
# Шаблон, который будет использоваться для составления адресов системных приложений в кластере.
# Например, Grafana для %s.example.com будет доступна на домене 'grafana.example.com'.
# Можете изменить на свой сразу либо следовать шагам руководства и сменить его после установки.
publicDomainTemplate: "%s.kube.example.com"
userAuthn:
# Включение доступа к API-серверу Kubernetes через Ingress.
# https://deckhouse.ru/documentation/v1/modules/150-user-authn/configuration.html#parameters-publishapi
publishAPI:
enable: true
https:
mode: Global
# Включить модуль cni-cilium
cniCiliumEnabled: true
# Настройки модуля cni-cilium
# https://deckhouse.ru/documentation/v1/modules/021-cni-cilium/configuration.html
cniCilium:
tunnelMode: VXLAN
Здесь нужно обратить внимание на параметр publicDomainTemplate
— в нем указывается шаблон доменных имен внутренних веб-интерфейсов кластера.
Внимание!
Не забудьте подставить правильное значение параметра
publicDomainTemplate
— он должен указывать на ваш URL!
Теперь запустим специальный контейнер, содержащий установщик платформы:
docker run --pull=always -it -v "$PWD/config.yml:/config.yml" -v "$HOME/.ssh/:/tmp/.ssh/" registry.deckhouse.io/deckhouse/ce/install:stable bash
Здесь мы пробросили в него созданный ранее конфигурационный файл и наши системные SSH-ключи, по которым будет происходить доступ к виртуальной машине.
После загрузки образа отобразится приглашение командной строки внутри контейнера:
[deckhouse] root@dfb8aafd3d62 / #
Запустим установку платформы:
dhctl bootstrap --ssh-user=<username> --ssh-host=<master_ip> --ssh-agent-private-keys=/tmp/.ssh/id_rsa \
--config=/config.yml \
--ask-become-pass
Внимание!
Не забудьте подставить правильные значения: имя пользователя
<username>
и IP-адрес сервера<master_ip>
.
На запрос «указать пароль sudo» введите пароль для виртуальной машины либо оставьте поле пустым, если он не задавался.
Установка может занять длительное время: около 10–15 минут в зависимости от скорости соединения с интернетом. По окончании процесса должна отобразиться информация об успешно пройденных шагах:
│ │ Running pod found! Checking logs...
│ │ Module "priority-class" run successfully
│ │ Deckhouse pod is Ready!
│ └ Waiting for Deckhouse to become Ready (70.85 seconds)
└ ⛵ ~ Bootstrap: Install Deckhouse (71.46 seconds)
❗ ~ Some resources require at least one non-master node to be added to the cluster.
┌ ⛵ ~ Bootstrap: Clear cache
│ ❗ ~ Next run of "dhctl bootstrap" will create a new Kubernetes cluster.
└ ⛵ ~ Bootstrap: Clear cache (0.00 seconds)
Deckhouse развернут.
Теперь необходимо подготовить кластер.
Так как у нас всего один узел, который одновременно и master, и worker, то необходимо снять с него taint, либо добавить узел в кластер:
kubectl patch nodegroup master --type json -p '[{"op": "remove", "path": "/spec/nodeTemplate/taints"}]'
Если не снимать taint с master-узла, то на нем сможет работать только ограниченный набор системных подов и развернуть приложение в кластере будет невозможно.
В ответ отобразится следующая информация:
nodegroup.deckhouse.io/master patched
Подождем некоторое время и проверим, что всё запустилось и отработало. Сначала убедимся, что под Deckhouse закончил работу:
$ kubectl -n d8-system get po
NAME READY STATUS RESTARTS AGE
deckhouse-9cb4d4b5d-mcl8j 1/1 Running 0 6d
Если он находится в состоянии Running 0/1
— процесс еще не завершен. Дождемся, когда состояние изменится на 1/1
, и затем проверим очередь Deckhouse:
kubectl -n d8-system exec deploy/deckhouse -- deckhouse-controller queue list
Должен появиться длинный лог. Нас интересует самая последняя его часть:
Defaulted container "deckhouse" out of: deckhouse, init-external-modules (init)
Summary:
- 'main' queue: empty.
- 76 other queues (0 active, 76 empty): 0 tasks.
- no tasks to handle.
Если в строчке с главной очередью стоит empty
, значит, все действия завершились. Ожидание может занять несколько минут, потому что Deckhouse выполняет довольно много неявных фоновых задач.
Также проверим, что под Kruise controller manager модуля ingress-nginx запустился и находится в статусе Ready:
kubectl -n d8-ingress-nginx get po -l app=kruise
Вывод команды должен показать примерно следующее сообщение:
NAME READY STATUS RESTARTS AGE
kruise-controller-manager-7dfcbdc549-b4wk7 3/3 Running 0 15m
Вышеприведенные шаги необходимы, чтобы убедиться в том, что Deckhouse нормально отработал все задачи, связанные со снятием taint’а. Если попробовать создать Ingress-контроллер при незаконченной настройке, то возможно появление ошибок.
Добавим Ingress-контроллер, через который будет осуществляться доступ к веб-интерфейсам кластера. Создадим на мастер-узле файл ingress.yml
со следующим содержимым:
# Секция, описывающая параметры Nginx Ingress controller.
# https://deckhouse.ru/documentation/v1/modules/402-ingress-nginx/cr.html
apiVersion: deckhouse.io/v1
kind: IngressNginxController
metadata:
name: nginx
spec:
ingressClass: nginx
# Способ поступления трафика из внешнего мира.
inlet: HostPort
hostPort:
httpPort: 80
httpsPort: 443
# Описывает, на каких узлах будет находиться Ingress-контроллер.
# Возможно, захотите изменить.
nodeSelector:
node-role.kubernetes.io/control-plane: ""
tolerations:
- operator: Exists
Применим его в кластере:
kubectl create -f ingress.yml
ingressnginxcontroller.deckhouse.io/nginx created
Установка потребует некоторого времени. Статус контроллера можно проверить следующей командой:
$ kubectl -n d8-ingress-nginx get po -l app=controller
NAME READY STATUS RESTARTS AGE
controller-nginx-rn5wx 3/3 Running 0 48s
Создадим пользователя, от лица которого будем заходить в веб-интерфейсы. Для этого подготовим файл user.yml:
apiVersion: deckhouse.io/v1
kind: ClusterAuthorizationRule
metadata:
name: admin
spec:
# список учетных записей Kubernetes RBAC
subjects:
- kind: User
name: admin@example.com
# предустановленный шаблон уровня доступа
accessLevel: SuperAdmin
# разрешить пользователю делать kubectl port-forward
portForwarding: true
---
# секция, описывающая параметры статического пользователя
# используемая версия API Deckhouse
apiVersion: deckhouse.io/v1
kind: User
metadata:
name: admin
spec:
# e-mail пользователя
email: admin@example.com
# это хэш пароля 4r08kujfp2, сгенерированного сейчас
# сгенерируйте свой или используйте этот, но только для тестирования
# echo "4r08kujfp2" | htpasswd -BinC 10 "" | cut -d: -f2
# возможно, захотите изменить
password: '$2a$10$SV3eqxigtCc7v8vcI6fubeIwU8YpdL64xOrvTI4qS2k6nc1hUX6Oa'
И применим его в кластере:
$ kubectl create -f user.yml
clusterauthorizationrule.deckhouse.io/admin created
user.deckhouse.io/admin created
Теперь осталось настроить DNS-записи. Это можно сделать разными способами: указать их в записях DNS-сервера для существующего домена или прописать в файле /etc/hosts
нашей рабочей машины. Вот список адресов, которые необходимо направить на IP-адрес мастер-узла кластера:
api.kube.example.com
argocd.kube.example.com
cdi-uploadproxy.kube.example.com
dashboard.kube.example.com
deckhouse.kube.example.com
dex.kube.example.com
grafana.kube.example.com
hubble.kube.example.com
istio.kube.example.com
istio-api-proxy.kube.example.com
kubeconfig.kube.example.com
openvpn-admin.kube.example.com
prometheus.kube.example.com
status.kube.example.com
upmeter.kube.example.com
Внимание!
Рекомендуем в DNS-записях использовать имена доменов, доступные из интернета, а не только из локальной сети. Это дает несколько преимуществ:
• сможем использовать все возможности кластера на рабочей машине, включая развертывание приложений, которые будут доступны из внешней сети;
• не возникнет проблем с получением HTTPS-сертификатов для компонентов кластера;
• можно будет использовать kubectl для получения доступа к кластеру.
Для успешного совершения дальнейших шагов по получению сертификатов необходимо выполнить следующие условия:
• доменные имена, ведущие на IP-адрес мастер-узла по указанным выше адресам, должны быть реальными;
• порты 80 и 443 на мастер-узле, через которые будет проводиться проверка валидности адреса Let's Encrypt в процессе выдачи сертификата, должны быть доступны из интернета.
Если возможности настроить свой домен нет, можно воспользоваться сервисами наподобие sslip.io или nip.io, которые позволяют получить временное доменное имя для любого IP-адреса.
Можно пропустить дальнейшие шаги по получению сертификатов — Deckhouse способен работать и без них. Однако в таком случае ни kubectl, ни werf не смогут использовать защищенное соединение.
Проверим, что кластер работает. Для этого перейдем по адресу upmeter.kube.example.com
:
Здесь необходимо ввести данные пользователя, которого мы создали ранее. При успешном входе появится страница с состоянием элементов кластера:
Для работы с кластером нужно настроить HTTPS для всех его компонентов. Отредактируем глобальный ModuleConfig
:
kubectl edit moduleconfigs.deckhouse.io global
В разделе modules
нужно добавить следующее:
https:
certManager:
clusterIssuerName: letsencrypt
mode: CertManager
После выхода из редактора или сохранения можно передохнуть — получение сертификатов займет довольно длительное время.
Остался последний шаг: на рабочем компьютере настроить kubectl для доступа к кластеру, который будем использовать для разработки и развертывания приложения.
Перейдем по адресу kubeconfig.kube.example.com
:
Выполним указанные команды для настройки контекста кластера.
Внимание!
Не забудьте выбрать вкладку с вашей ОС.
Если все прошло успешно, отобразится следующее сообщение:
Switched to context "admin-api.kube.example.com".
Проверим, что kubectl на рабочей машине получил доступ к кластеру:
$ kubectl get no
NAME STATUS ROLES AGE VERSION
habr-deckhouse-werf Ready control-plane,master 173m v1.23.17
На этом подготовка кластера завершена! Переходим к приложению.
Для развертывания в кластере подготовим простое приложение с парой основных функций для работы с базой данных: запись сообщения и отображение всех имеющихся сообщений.
На рабочей машине создадим каталог, в котором будем работать с будущим приложением:
$ touch habr_app
Инициализируем в нем Git-репозиторий, т. к. он потребуется для работы werf:
$ git init
Initialized empty Git repository in /Users/zhbert/test/habr_app/.git/
У нас будет три страницы:
главная, на которой можно ввести сообщение и отправить его в БД;
страница с результатом обработки полученного сообщения (можно было бы выводить на ту же главную, но почему бы и не сделать отдельную?);
страница с запросом из БД и отображением всех сообщений.
Для простоты верстки воспользуемся CSS-фреймворком Bootstrap версии 5.3. Создадим шаблон главной страницы в файле templates/index.html
:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deckhouse and werf demo</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
</head>
<body>
<div class="container mt-5">
<form class="row g-3" action="/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>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm" crossorigin="anonymous"></script>
</body>
</html>
Примечание
Bootstrap мы подключаем как предлагается в документации CDN, чтобы не настраивать раздачу ассетов из приложения.
Также обратите внимание, что для простоты мы практически опускаем шаблонизацию: можно было бы вынести header в отдельный файл, импортируя его во все страницы, но, чтобы не отвлекаться от рассматриваемой темы, пренебрегаем подобными тонкостями.
На главной странице подготовлена простая форма с двумя полями ввода и кнопкой отправки сообщения.
Обрабатываться форма будет по адресу /remember
. Создадим шаблон для вывода результатов сохранения сообщения в файле templates/remember.html:
<div class="container mt-5">
Hello, {{ .Name }}. You message "{{ .Message }}" has been saved.
</div>
Примечание
Здесь указано только содержимое тега
<body></body>
.
Создадим вторую страницу. При получении данных из формы мы извлекаем имя и текст сообщения, после чего уведомляем, что данные получены и записаны.
Наконец подошла очередь последней страницы — той, на которой будем отображать содержимое БД. Создадим шаблон в файле templates/say.html
:
<div class="container mt-5">
{{if .Error}}
<p>{{ .Error }}</p>
{{else}}
<h2>Messages from talkers</h2>
<table class="table">
<thead>
<th>Name</th>
<th>Message</th>
</thead>
<tbody>
{{range .Data}}
<tr>
<td>{{ .Name }}</td>
<td>{{ .Message }}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
В этом есть простая логика: если в шаблон от бэкенда приходит .Error
, то выводим его содержимое, иначе — отображаем в виде таблички массив полученных сообщений. Такая проверка нужна, чтобы не показывать пустую табличку, когда еще не сохранено ни одного сообщения.
Теперь нужно сделать шаблоны интерактивными, для чего добавим в приложение нужные контроллеры и методы.
Взглянув на наши шаблоны, вы уже наверняка догадались — разрабатывать бэкенд мы будем на Go. Для построения веб-приложения воспользуемся фреймворком Gin.
Инициализируем приложение и пропишем в нем нужные эндпоинты:
func Run() {
route := gin.New()
route.Use(gin.Recovery())
route.Use(common.JsonLogger())
route.LoadHTMLGlob("templates/*")
route.GET("/", func(context *gin.Context) {
context.HTML(http.StatusOK, "index.html", gin.H{})
})
route.GET("/remember", controllers.RememberController)
route.GET("/say", controllers.SayController)
err := route.Run()
if err != nil {
return
}
}
В функции Run
мы создаем новый инстанс сервера Gin и прописываем в него три эндпоинта: /
, /remember
и /say
. В первом из них сразу вызываем шаблон index.html
, созданный ранее, а для оставшихся двух назначаем соответствующие контроллеры.
Также обратите внимание на строку route.Use(common.JsonLogger())
— в ней мы используем небольшое middleware, чтобы переопределить выдаваемые в процессе работы логи в формат JSON. Это необходимо, чтобы в дальнейшем упростить настройку системы сбора логов в кластере (подробнее об этом можно почитать в нашем руководстве по Kubernetes).
А вот и сама функция, которая переопределяет формат логов:
func JsonLogger() gin.HandlerFunc {
return gin.LoggerWithFormatter(
func(params gin.LogFormatterParams) string {
log := make(map[string]interface{})
log["status_code"] = params.StatusCode
log["path"] = params.Path
log["method"] = params.Method
log["start_time"] = params.TimeStamp.Format("2023/01/02 - 13:04:05")
log["remote_addr"] = params.ClientIP
log["response_time"] = params.Latency.String()
str, _ := json.Marshal(log)
return string(str) + "\n"
},
)
}
Поля JSON-лога, их формат и содержимое произвольны — можно задать структуру журнала, удовлетворяющую любым требованиям.
В результате на все запросы к приложению в логах будет отображаться примерно следующая информация:
{"method":"GET","path":"/ping","remote_addr":"192.168.49.1","response_time":"11.639µs","start_time":"161612/06/16 - 612:36:49","status_code":200}
{"method":"GET","path":"/not_found","remote_addr":"192.168.49.1","response_time":"264ns","start_time":"161612/06/16 - 612:36:56","status_code":404}
Настроим контроллеры для работы с БД и соответствующими страницами. Первым делом создадим контроллер для сохранения данных:
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.HTML(http.StatusOK, "remember.html", gin.H{
"Name": name,
"Message": message,
})
defer db.Close()
}
Здесь мы просто забираем переданные в GET-параметрах данные и сразу кладем их в БД.
Примечание
Как показывает практика, нужно также настраивать валидацию данных и осуществлять дополнительные проверки. Однако, поскольку наш пример демонстрационный, будем придерживаться принципа «как можно проще».
Для подключения к базе нам нужно знать ее параметры: адрес, имя пользователя, пароль и т. д. «Зашивать» их в код не стоит, так как, во-первых, это изменяемые данные, а во-вторых, они могут использоваться в разных местах программы. Поэтому разумным будет передавать их в приложение через переменные окружения, а извлекать оттуда с помощью одного-единственного сервиса, одинакового для всех вызовов БД и доступного из любого метода. Код сервиса следующий:
func GetDBCredentials() (string, string) {
dbType := os.Getenv("DB_TYPE")
dbName := os.Getenv("DB_NAME")
dbUser := os.Getenv("DB_USER")
dbPasswd := os.Getenv("DB_PASSWD")
dbHost := os.Getenv("DB_HOST")
dbPort := os.Getenv("DB_PORT")
return dbType, dbUser + ":" + dbPasswd + "@tcp(" + dbHost + ":" + dbPort + ")/" + dbName
}
Именно его мы используем в первой строке контроллера.
Теперь создадим контроллер для извлечения данных из БД:
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.HTML(http.StatusOK, "say.html", gin.H{
"Error": "There are no messages from talkers!",
})
} else {
c.HTML(http.StatusOK, "say.html", gin.H{
"Data": data,
})
}
}
Точно так же мы получаем из переменных окружения данные для подключения к БД, далее забираем из нее сохраненные там сообщения и, наконец, возвращаем их в шаблон. Перед возвратом делается проверка на наличие сообщений — если их нет, то возвращается сообщение об ошибке, которое проверяется в шаблоне перед генерацией таблицы.
Приложение готово. Теперь настроим все необходимое для развертывания его в кластере.
Для сборки и развертывания воспользуемся утилитой werf.
Сборка начинается с подготовки Dockerfile, в котором описывается, как собрать контейнер с нашим приложением. Создадим его в корневом каталоге проекта:
# Используем многоступенчатую сборку образа (multi-stage build)
# Образ, в котором будет собираться проект
FROM golang:1.18-alpine AS build
# Устанавливаем curl и tar.
RUN apk add curl tar
# Копируем исходники приложения
COPY . /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:3.12
WORKDIR /
# Копируем из сборочного образа исполняемый файл проекта.
COPY --from=build /goapp /goapp
# Копируем из сборочного образа распакованный файл утилиты migrate и схемы миграции.
COPY --from=build /app/migrate /migrations/migrate
COPY db/migrations /migrations/schemes
# Копируем файлы ассетов и шаблоны.
COPY ./templates /templates
EXPOSE 8080
ENTRYPOINT ["/goapp"]
Примечание
Мы воспользовались мультистейдж-сборкой — сначала в одном образе собираются приложения, а затем в финальный образ копируются результаты сборки. Такой подход позволяет использовать в production чистые минималистичные образы без мусора, оставшегося от сборки приложения, и других лишних сущностей.
Теперь создадим в корне главный файл для werf — werf.yaml
:
project: habr-app
configVersion: 1
---
image: app
dockerfile: Dockerfile
Он довольно небольшой: в нем указаны название проекта и Dockerfile, в соответствии с которым будет собираться контейнер.
Утилита werf использует Helm-чарты для развертывания приложений в кластере. В корневой директории проекта создадим для них каталог .helm
с подкаталогом templates
и файлом deployment.yaml
, в котором определим ресурс Deployment, описывающий создание ресурсов для запуска приложения:
apiVersion: apps/v1
kind: Deployment
metadata:
name: habr-app
spec:
replicas: 1
selector:
matchLabels:
app: habr-app
template:
metadata:
labels:
app: habr-app
spec:
imagePullSecrets:
- name: registrysecret
containers:
- name: app
image: {{ .Values.werf.image.app }}
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"
Здесь стоит обратить внимание на следующие поля:
imagePullSecrets
— имя Secret в кластере, в котором хранятся параметры доступа к container registry, из которого будет pull'иться собранный контейнер с приложением;
image: {{ .Values.werf.image.app }}
— имя контейнера, собираемого из werf.yaml
;
env
— переменные окружения, пробрасываемые в контейнер.
Примечание
Передаваемые через переменные окружения пароль и другие секретные данные для подключения к БД по-хорошему нужно шифровать в secret values, но для упрощения здесь мы этим пренебрегли. Подробнее про шифрование секретных данных можно прочитать в нашем самоучителе.
Для получения доступа к нашему приложению снаружи кластера создадим Ingress-контроллер, который будет располагаться в файле ingress.yaml
:
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
port:
number: 8080
Здесь мы настраиваем проброс запросов по адресу habrapp.example.com
на порт 8080 контейнера с приложением.
Создадим также Service service.yaml
, чтобы ресурсы кластера могли взаимодействовать с нашим приложением:
apiVersion: v1
kind: Service
metadata:
name: habr-app
spec:
selector:
app: habr-app
ports:
- name: http
port: 8080
Осталось подготовить БД и настроить миграции.
В качестве БД мы будем использовать MySQL. Подготовим файл database.yaml
, описывающий ее параметры:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: mysql
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8
args: ["--default-authentication-plugin=mysql_native_password"]
ports:
- containerPort: 3306
env:
- name: MYSQL_DATABASE
value: habr-app
- name: MYSQL_ROOT_PASSWORD
value: password
volumeMounts:
- name: mysql-persistent-storage
mountPath: /var/lib/mysql
volumes:
- name: mysql-persistent-storage
persistentVolumeClaim:
claimName: mysql-data-claim
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: mysql-data
labels:
type: local
spec:
storageClassName: manual
capacity:
storage: 100Mi
accessModes:
- ReadWriteOnce
hostPath:
path: "/mnt/data"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data-claim
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Mi
---
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
selector:
app: mysql
ports:
- port: 3306
В файле описаны:
имя базы данных, пароль пользователя, лимиты и версия MySQL, которая будет использоваться;
Service, через который с созданной БД будут общаться другие ресурсы кластера;
PersistentVolume и PersistentVolumeClaim, в которых будут храниться данные MySQL.
Перед тем как запустить приложение, нужно провести миграцию, чтобы приложение уже имело подготовленную базу данных с нужными таблицами. Для этого воспользуемся утилитой migrate.
Подготовим два файла миграций в каталоге db/migrations
в корневой директории проекта.
Один из них будет отвечать за развертывание новой БД и создание в ней таблиц (000001_create_talkers_table.up.sql
):
CREATE TABLE talkers (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
message TEXT NOT NULL,
name TEXT NOT NULL
);
А второй (000001_create_talkers_table.down.sql
) — за удаление таблицы из БД:
DROP TABLE IF EXISTS talkers;
Важно обратить внимание на два момента:
Номер в начале имени файла — 000001 — это порядковый номер, в котором будут выполняться миграции. Создавать их можно сколько угодно, задавая порядковые номера в нужной последовательности. Например, сначала создать базу и таблицы, затем перенести туда какие-то данные, затем что-то еще.
По ключевым словам down и up перед расширением файла утилита определяет цель его использования. При запуске команды миграции утилита выберет файлы up, а для очистки БД — файлы down.
Саму утилиту мы уже установили в образ с приложением в Dockerfile’е: сначала скачали ее с GitHub в билдере, а затем перенесли в конечный образ.
Напоминаем, что миграции должны выполняться перед запуском приложения. Лучше сделать это в отдельной Job, которая будет подготавливать БД:
apiVersion: batch/v1
kind: Job
metadata:
# Версия Helm-релиза в имени Job заставит Job каждый раз пересоздаваться.
# Так мы сможем обойти то, что Job неизменяема.
name: "setup-and-migrate-db-rev{{ .Release.Revision }}"
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
initContainers:
- name: waiting-mysql
image: alpine:3.12
command: [ '/bin/sh', '-c', 'while ! nc -z mysql 3306; do sleep 1; done' ]
containers:
- name: setup-and-migrate-db
image: {{ .Values.werf.image.app }}
command: ["/migrations/migrate", "-database", "mysql://root:password@tcp(mysql:3306)/habr-app", "-path", "/migrations/schemes", "up"]
Сначала запускаются init-контейнеры — проверяется работоспособность БД путем периодической проверки доступности порта 3306 MySQL-сервера до получения ответа. В противном случае преждевременно запущенные миграции не будут выполнены и, соответственно, попытки приложения обратиться к БД окажутся неудачными.
Как только БД ответит, утилита migrate выполнит миграции (обратите внимание на указание «up»).
И Job, и Deployment стартуют одновременно, но обращаться к БД приложение будет непосредственно в момент запроса из веб-интерфейса.
После завершения всех процессов получится следующее содержимое каталога с приложением:
$ tree -a .
.
├── .gitignore
├── .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
Приложение готово! Приступим к его развертыванию.
Создадим в кластере новое пространство имен для нашего приложения:
$ kubectl create namespace habr-app
namespace/habr-app created
Мы будем работать только с ним, поэтому для kubectl сделаем его используемым по умолчанию:
$ kubectl config set-context admin-api.kube.example.com --namespace=habr-app
Context "admin-api.kube.example.com" modified.
Для доступа к registry создадим Secret, описанный в Deployment, в секции imagePullSecrets
, где нужно указать параметры учетной записи и адрес хранилища:
kubectl create secret docker-registry registrysecret \
--docker-server='https://index.docker.io/v1/' \
--docker-username='<ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>' \
--docker-password='<ПАРОЛЬ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>'
Примечание
Утилита werf имеет все возможности kubectl в качестве встроенной функциональности. Чтобы выполнить kubctl отдельно, не обязательно его устанавливать — можно использовать
werf kubectl …
Необходимо войти в registry с машины, где будет выполняться сборка приложения:
docker login
# Введем ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB.
Username: <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>
# Введем ПАРОЛЬ ПОЛЬЗОВАТЕЛЯ DOCKER HUB.
Password: <ПАРОЛЬ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>
Примечание
Для примера мы воспользовались приватным репозиторием на Docker Hub, но это может быть совершенно любой container registry, к которому у вас есть доступ.
Также обратите внимание, что вход в registry можно выполнить средствами werf, используя команду werf cr login.
Осталось последнее: связать доменное имя habrapp.example.com
с IP-адресом кластера.
Чтобы собрать и развернуть приложение в кластере, достаточно одной команды:
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: Thu Aug 17 07:59:06 2023
LAST PHASE: rollout
LAST STAGE: 0
NAMESPACE: habr-app
STATUS: deployed
REVISION: 1
TEST SUITE: None
Running time 138.92 seconds
Все прошло успешно, приложение развернуто в кластере. В его работоспособности можно убедиться простейшим способом:
$ curl http://habrapp.example.com
В ответ должна отобразиться HTML-разметка главной страницы.
За получение и подключение сертификатов отвечает модуль Deckhouse cert-manager.
Создадим файл с его конфигурацией (cert.yaml
):
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: habr-app
namespace: habr-app
spec:
secretName: habr-app-tls
issuerRef:
kind: ClusterIssuer
name: letsencrypt
dnsNames:
- habrapp.example.com
Примечание
Манифест сертификата для удобства последующей работы лучше класть в тот же файл, что и деплоймент приложения. Здесь мы просто для удобства указали его в отдельном файле.
Здесь важно обратить внимание на следующие параметры:
secretName
— имя, с которым связывается полученный сертификат;
namespace
— пространство имен, для которого создается сертификат.
Применим ресурс в кластере:
kubectl create -f cert.yaml
certificate.cert-manager.io/habr-app created
Подождем некоторое время и убедимся, что сертификат создан:
$ kubectl get certificate
NAME READY SECRET AGE
habr-app True habr-app-tls 29s
Если статус сертификата имеет значение Pending, значит, сертификат не получен. Возможная причина: у Let’s Encrypt нет доступа к кластеру.
Можно посмотреть более подробную информацию:
$ kubectl describe certificate habr-app
Name: habr-app
Namespace: habr-app
Labels: <none>
Annotations: <none>
API Version: cert-manager.io/v1
Kind: Certificate
Metadata:
Creation Timestamp: 2023-08-17T09:36:51Z
Generation: 1
Resource Version: 2008181
UID: c31f088a-904b-4ec3-9897-17a19c1e8c32
Spec:
Dns Names:
habrapp.example.com
Issuer Ref:
Kind: ClusterIssuer
Name: letsencrypt
Secret Name: habr-app-tls
Status:
Conditions:
Last Transition Time: 2023-08-17T09:36:54Z
Message: Certificate is up to date and has not expired
Observed Generation: 1
Reason: Ready
Status: True
Type: Ready
Not After: 2023-11-15T08:36:52Z
Not Before: 2023-08-17T08:36:53Z
Renewal Time: 2023-10-16T08:36:52Z
Revision: 1
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Issuing 74s cert-manager-certificates-trigger Issuing certificate as Secret does not exist
Normal Generated 73s cert-manager-certificates-key-manager Stored new private key in temporary Secret resource "habr-app-l6277"
Normal Requested 73s cert-manager-certificates-request-manager Created new CertificateRequest resource "habr-app-5hq6f"
Normal Issuing 71s cert-manager-certificates-issuing The certificate has been successfully issued
Теперь нужно внести небольшие изменения в Ingress нашего приложения, чтобы тот подхватил полученный сертификат:
...
name: habr-app
port:
number: 8080
tls:
- hosts:
- habrapp.example.com
secretName: habr-app-tls
Здесь мы указали, что для адреса habrapp.example.com
необходимо использовать сертификат из Secret’а habr-app-tls
.
Создадим коммит наших изменений, после чего заново развернем приложение:
werf converge --repo <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/habr-app
Если все прошло успешно, увидим следующее:
Release "habr-app" has been upgraded. Happy Helming!
NAME: habr-app
LAST DEPLOYED: Thu Aug 17 09:44:46 2023
LAST PHASE: rollout
LAST STAGE: 0
NAMESPACE: habr-app
STATUS: deployed
REVISION: 3
TEST SUITE: None
Running time 8.21 seconds
Теперь наше приложение поддерживает протокол HTTPS.
Настало время проверить, как работает наше приложение.
Перейдем по ссылке https://habrapp.example.com:
Проверим, что в БД сейчас ничего нет, перейдя по пути /say
:
Вернемся на главную страницу, введем имя и сообщение, после чего нажмем «Отправить»:
Сообщение записано:
Проверим еще раз сообщения в БД:
Всё работает!
Примечание
Исходные коды проекта можно найти в репозитории на GitHub.
Мы рассмотрели, как с нуля развернуть кластер Kubernetes под управлением платформы Deckhouse, написать небольшое приложение с базой данных, подготовить его для развертывания в кластере, развернуть и убедиться в работоспособности.
Получение сертификатов для приложения взял на себя один из модулей Deckhouse, сведя всю работу по настройке к подготовке небольшого конфигурационного файла и применению ресурса в кластер. Deckhouse будет самостоятельно поддерживать актуальность сертификатов в дальнейшем, не требуя вмешательства пользователя.
С любыми вопросами и предложениями ждем вас в комментариях к статье, а также в Telegram-чате deckhouse_ru, где всегда готовы помочь. Будем рады issues (и, конечно, звездам) в GitHub-репозитории Deckhouse.
Читайте также в нашем блоге: