В статье мы рассмотрим, как подступиться к миру 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 для компонентов кластера

Для работы с кластером нужно настроить 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. Подготовим файл 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-разметка главной страницы.

Настройка HTTPS

За получение и подключение сертификатов отвечает модуль 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.

P. S.

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

Комментарии (0)