В статье мы рассмотрим, как подступиться к миру Kubernetes в первый раз — развернуть кластер под управлением платформы Deckhouse, разработать и подготовить приложение, развернуть его с помощью утилиты werf, предназначенной для построения рабочего процесса по принципам CI/CD, а также настроить сертификаты для доступа по HTTPS.

Развертывание кластера
Вводные данные
Подготовка конфигурации
Настройка кластера
Проверка работоспособности
Включение HTTPS для компонентов кластера
Настройка контекста кластера на рабочей машине
Подготовка приложения
Разработка приложения
Подготовка шаблонов страниц
Подготовка бэкенда приложения
Подготовка к развертыванию
Сборка и Helm-чарты приложения
Миграция базы данных
Развертывание приложения в кластере
Подготовка кластера
Развертывание приложения
Настройка HTTPS
Проверка работоспособности
Заключение
P. S.
Развертывание кластера
Вводные данные
Для начала нужно подготовить кластер. Устанавливать будем Deckhouse СЕ версии 1.50.6. Для этого понадобится одна виртуальная машина (или 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 выполняет довольно много неявных фоновых задач.
Вышеприведенные шаги необходимы, чтобы убедиться в том, что 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 dashboard.kube.example.com deckhouse.kube.example.com dex.kube.example.com grafana.kube.example.com kubeconfig.kube.example.com prometheus.kube.example.com status.kube.example.com upmeter.kube.example.com
Рекомендуем в DNS-записях использовать имена доменов, доступные из интернета, а не только из локальной сети. Это позволит избежать проблем с получением HTTPS-сертификатов для компонентов кластера.
Для успешного совершения дальнейших шагов по получению сертификатов необходимо выполнить следующие условия:
доменные имена, ведущие на IP-адрес мастер-узла по указанным выше адресам, реальные;
порты 80 и 443 на мастер-узле, через которые будет проводиться проверка валидности адреса Let's Encrypt в процессе выдачи сертификата, доступны из интернета.
Если возможности настроить свой домен нет, можно воспользоваться сервисами наподобие sslip.io или nip.io, которые позволяют получить временное доменное имя для любого IP-адреса.
Внимание!
Если вы пропустите дальнейшие шаги по получению сертификатов — Deckhouse продолжит работать. Однако в таком случае ни kubectl, ни werf не смогут использовать защищенное соединение.
Проверка работоспособности
Проверим, что кластер работает — воспользуемся модулем upmeter, который используется для наблюдения за доступностью элементов кластера. Для этого перейдем по адресу upmeter.kube.example.com:

Здесь необходимо ввести данные пользователя, которого мы создали ранее. При успешном входе появится страница с состоянием элементов кластера:

Включение HTTPS для компонентов кластера
Для работы с кластером нужно настроить 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
На этом подготовка кластера завершена! Переходим к приложению.
Подготовка приложения
Для развертывания в кластере подготовим простое приложение с парой основных функций для работы с базой данных: запись сообщения и отображение всех имеющихся сообщений.
Разработка приложения
На рабочей машине создадим каталог, в котором будем работать с будущим приложением:
$ mkdir 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.
Сборка и Helm-чарты приложения
Сборка начинается с подготовки 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.
Для этого воспользуемся модулем Deckhouse local-path-provisioner. Он создает локальное хранилище на узле, используя для этого директорию файловой системы этого узла. Подготовим файл storage-class.yml в любом месте рабочего компьютера со следующим содержимым:
apiVersion: deckhouse.io/v1alpha1 kind: LocalPathProvisioner metadata: name: localpath-system spec: nodeGroups: - master path: "/opt/local-path-provisioner"
Обратите внимание на название группы узлов nodeGroups, оно должно соответствовать названию этого ресурса кластера на вашем master-узле. Проверить его можно следующей командой:
$ kubectl get ng NAME TYPE READY NODES UPTODATE INSTANCES DESIRED MIN MAX STANDBY STATUS AGE master Static 1 1 1 31d
Применим файл в кластере:
kubectl create -f storage-class.yml
В качестве БД мы будем использовать 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-data mountPath: /var/lib/mysql volumeClaimTemplates: - metadata: name: mysql-data spec: storageClassName: localpath-system accessModes: ["ReadWriteOnce"] resources: requests: storage: 1Gi --- apiVersion: v1 kind: Service metadata: name: mysql spec: selector: app: mysql ports: - port: 3306
В файле описаны:
имя базы данных, пароль пользователя, лимиты и версия MySQL, которая будет использоваться;
Service, через который с созданной БД будут общаться другие ресурсы кластера;
volumeClaimTemplates— шаблон, по которому будет создаваться Persistent Volume Claim (PVC), использующий созданный ранееLocalPathProvisioner.
Перед тем как запустить приложение, нужно провести миграцию, чтобы приложение уже имело подготовленную базу данных с нужными таблицами. Для этого воспользуемся утилитой 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
Приложение готово! Приступим к его развертыванию.
Развертывание приложения в кластере
Подготовка кластера
Утилита werf имеет все возможности kubectl в качестве встроенной функциональности. Чтобы выполнить kubctl отдельно, не обязательно его устанавливать — можно использовать werf kubectl…
Создадим в кластере новое пространство имен для нашего приложения:
$ 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>'
Необходимо войти в registry с машины, где будет выполняться сборка приложения, используя для входа свои имя пользователя и пароль:
werf cr login -u username -p password https://index.docker.io/v1/
Примечание
Для примера мы воспользовались приватным репозиторием на 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-разметка главной страницы.
Настройка HTTPS
За получение и подключение сертификатов отвечает модуль Deckhouse cert-manager.
Добавим его описание в чарт Deployment’а:
- name: DB_HOST value: "mysql" - name: DB_PORT value: "3306" --- 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— пространство имен, для которого создается сертификат.
Перевыкатим приложение:
werf converge --repo <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/habr-app
Подождем некоторое время и убедимся, что сертификат создан:
$ 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.
P. S.
Полезные ссылки:
Чаты по Deckhouse и werf, в которых на вопросы пользователей отвечают разработчики — если вдруг что-то пойдет не так или не получится.
Самоучитель по Kubernetes от команды werf для DevOps-инженеров и разработчиков с уроками по CI/CD созданию приложений и разворачиванию окружения с примерами для node.js, Spring, django, Python, Go, Ruby on Rails, Laravel.
Читайте также в нашем блоге:
