Практика с dapp. Часть 2. Деплой Docker-образов в Kubernetes с помощью Helm

в 6:23, , рубрики: continuous delivery, continuous deployment, dapp, devops, docker, helm, kubernetes, Блог компании Флант, системное администрирование

dapp — наша Open Source-утилита, помогающая DevOps-инженерам сопровождать процессы CI/CD (подробнее о ней читайте в анонсе). В русскоязычной документации к ней приведён пример сборки простого приложения, а подробнее этот процесс (с демонстрацией основных возможностей dapp) был представлен в первой части статьи. Теперь, на основе того же простого приложения, покажу, как dapp работает с кластером Kubernetes.

Практика с dapp. Часть 2. Деплой Docker-образов в Kubernetes с помощью Helm - 1

Как и в первой статье, все дополнения для кода приложения symfony-demo есть в нашем репозитории. Но обойтись Vagrantfile в этот раз не получится: Docker и dapp придётся поставить локально.

Чтобы пройти всё по шагам, нужно начать с ветки dapp_build, куда был добавлен Dappfile в первой статье.

$ git clone https://github.com/flant/symfony-demo.git
$ cd symfony-demo
$ git checkout dapp_build
$ git checkout -b kube_test
$ dapp dimg build

Запуск кластера с помощью Minikube

Теперь нужно создать кластер Kubernetes, где dapp запустит приложение. Для этого будем использовать Minikube как рекомендуемый способ запуска кластера на локальной машине.

Установка проста и заключается в скачивании Minikube и утилиты kubectl. Инструкции доступны по ссылкам:

Примечание: Читайте также наш перевод статьи «Начало работы в Kubernetes с помощью Minikube».

После установки нужно запустить minikube setup. Minikube скачает ISO и запустит из него виртуальную машину в VirtualBox.

После успешного старта можно посмотреть, что есть в кластере:

$ kubectl get all

NAME                                READY     STATUS    RESTARTS   AGE
po/hello-minikube-938614450-zx7m6   1/1       Running   3          71d

NAME                 CLUSTER-IP   EXTERNAL-IP   PORT(S)          AGE
svc/hello-minikube   10.0.0.102   <nodes>       8080:31429/TCP   71d
svc/kubernetes       10.0.0.1     <none>        443/TCP          71d

NAME                    DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
deploy/hello-minikube   1         1         1            1           71d

NAME                          DESIRED   CURRENT   READY     AGE
rs/hello-minikube-938614450   1         1         1         71d

Команда покажет все ресурсы в пространстве имён (namespace) по умолчанию (default). Список всех namespaces можно посмотреть через kubectl get ns.

Подготовка, шаг №1: реестр для образов

Итак, мы запустили кластер Kubernetes в виртуальной машине. Что ещё понадобится для запуска приложения?

Во-первых, для этого нужно загрузить образ туда, откуда кластер сможет его получить. Можно использовать общий Docker Registry или же установить свой Registry в кластере (мы так делаем для production-кластеров). Для локальной разработки тоже лучше подойдет второй вариант, а реализовать его с dapp совсем просто — для этого есть специальная команда:

$ dapp kube minikube setup
Restart minikube                                                                                                         [RUNNING]
minikube: Running
localkube: Running
kubectl: Correctly Configured: pointing to minikube-vm at 192.168.99.100
Starting local Kubernetes v1.6.4 cluster...
Starting VM...
Moving files into cluster...
Setting up certs...
Starting cluster components...
Connecting to cluster...
Setting up kubeconfig...
Kubectl is now configured to use the cluster.
Restart minikube                                                                                                              [OK] 34.18 sec
Wait till minikube ready                                                                                                 [RUNNING]
Wait till minikube ready                                                                                                      [OK] 0.05 sec
Run registry                                                                                                             [RUNNING]
Run registry                                                                                                                  [OK] 61.44 sec
Run registry forwarder daemon                                                                                            [RUNNING]
Run registry forwarder daemon                                                                                                 [OK] 5.01 sec

После её выполнения в списке системных процессах появляется такое перенаправление:

username  13317  0.5  0.4  57184 36076 pts/17   Sl   14:03   0:00 kubectl port-forward --namespace kube-system kube-registry-6nw7m 5000:5000

… а в namespace под названием kube-system создаётся Registry и прокси к нему:

$ kubectl get -n kube-system all
NAME                             READY     STATUS    RESTARTS   AGE
po/kube-addon-manager-minikube   1/1       Running   2          22m
po/kube-dns-1301475494-7kk6l     3/3       Running   3          22m
po/kube-dns-v20-g7hr9            3/3       Running   9          71d
po/kube-registry-6nw7m           1/1       Running   0          3m
po/kube-registry-proxy           1/1       Running   0          3m
po/kubernetes-dashboard-9zsv8    1/1       Running   3          71d
po/kubernetes-dashboard-f4tp1    1/1       Running   1          22m

NAME                      DESIRED   CURRENT   READY     AGE
rc/kube-dns-v20           1         1         1         71d
rc/kube-registry          1         1         1         3m
rc/kubernetes-dashboard   1         1         1         71d

NAME                       CLUSTER-IP   EXTERNAL-IP   PORT(S)         AGE
svc/kube-dns               10.0.0.10    <none>        53/UDP,53/TCP   71d
svc/kube-registry          10.0.0.142   <none>        5000/TCP        3m
svc/kubernetes-dashboard   10.0.0.249   <nodes>       80:30000/TCP    71d

NAME              DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
deploy/kube-dns   1         1         1            1           22m

NAME                     DESIRED   CURRENT   READY     AGE
rs/kube-dns-1301475494   1         1         1         22m

Протестируем запущенный Registry, выложив в него наш образ командой dapp dimg push --tag-branch :minikube. Используемый здесь :minikube — это встроенный в dapp алиас специально для Minikube, который будет преобразован в localhost:5000/symfony-demo.

$ dapp dimg push --tag-branch :minikube
symfony-demo-app
  localhost:5000/symfony-demo:symfony-demo-app-kube_test               [PUSHING]
    pushing image `localhost:5000/symfony-demo:symfony-demo-app-kube_test`                                     [RUNNING]
The push refers to a repository [localhost:5000/symfony-demo]
0ea2a2940c53: Preparing
ffe608c425e1: Preparing
5c2cc2aa6663: Preparing
edbfc49bce31: Preparing
308e5999b491: Preparing
9688e9ffce23: Preparing
0566c118947e: Preparing
6f9cf951edf5: Preparing
182d2a55830d: Preparing
5a4c2c9a24fc: Preparing
cb11ba605400: Preparing
6f9cf951edf5: Waiting
182d2a55830d: Waiting
5a4c2c9a24fc: Waiting
cb11ba605400: Waiting
9688e9ffce23: Waiting
0566c118947e: Waiting
0ea2a2940c53: Layer already exists
308e5999b491: Layer already exists
ffe608c425e1: Layer already exists
edbfc49bce31: Layer already exists
5c2cc2aa6663: Layer already exists
0566c118947e: Layer already exists
9688e9ffce23: Layer already exists
182d2a55830d: Layer already exists
6f9cf951edf5: Layer already exists
cb11ba605400: Layer already exists
5a4c2c9a24fc: Layer already exists
symfony-demo-app-kube_test: digest: sha256:5c55386de5f40895e0d8292b041d4dbb09373b78d398695a1f3e9bf23ee7e123 size: 2616
    pushing image `localhost:5000/symfony-demo:symfony-demo-app-kube_test`                                          [OK] 0.54 sec

Видно, что тег образа в Registry составлен из имени dimg и имени ветки (через дефис).

Подготовка, шаг №2: конфигурация ресурсов (Helm)

Вторая часть, необходимая для запуска приложения в кластере, — это конфигурация ресурсов. Стандартной утилитой управления кластером Kubernetes является kubectl. Если нужно создать новый ресурс (Deployment, Service, Ingress и т.д.) или изменить свойства существующего ресурса, то на вход утилите передаётся YAML-файл с конфигурацией.

Однако dapp не использует напрямую kubectl, а работает с так называемым пакетным менеджером — Helm, — который предоставляет шаблонизацию YAML-файлов и сам управляет выкатом в кластер.

Поэтому наш следующий шаг — это установка Helm. Официальную инструкцию можно найти в документации проекта.

После установки необходимо запустить helm init. Что она делает? Helm состоит из клиентской части, которую мы установили, и серверной. Команда helm init устанавливает серверную часть (tiller). Посмотрим, что появилось в namespace kube-system:

$ kubectl get -n kube-system all

NAME                                READY     STATUS    RESTARTS   AGE
po/kube-addon-manager-minikube      1/1       Running   2          1h
po/kube-dns-1301475494-7kk6l        3/3       Running   3          1h
po/kube-dns-v20-g7hr9               3/3       Running   9          71d
po/kube-registry-6nw7m              1/1       Running   0          1h
po/kube-registry-proxy              1/1       Running   0          1h
po/kubernetes-dashboard-9zsv8       1/1       Running   3          71d
po/kubernetes-dashboard-f4tp1       1/1       Running   1          1h
!!! po/tiller-deploy-3703072393-bdqn8   1/1       Running   0          3m

NAME                      DESIRED   CURRENT   READY     AGE
rc/kube-dns-v20           1         1         1         71d
rc/kube-registry          1         1         1         1h
rc/kubernetes-dashboard   1         1         1         71d

NAME                       CLUSTER-IP   EXTERNAL-IP   PORT(S)         AGE
svc/kube-dns               10.0.0.10    <none>        53/UDP,53/TCP   71d
svc/kube-registry          10.0.0.142   <none>        5000/TCP        1h
svc/kubernetes-dashboard   10.0.0.249   <nodes>       80:30000/TCP    71d
!!! svc/tiller-deploy          10.0.0.196   <none>        44134/TCP       3m

NAME                   DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
deploy/kube-dns        1         1         1            1           1h
!!! deploy/tiller-deploy   1         1         1            1           3m

NAME                          DESIRED   CURRENT   READY     AGE
rs/kube-dns-1301475494        1         1         1         1h
!!! rs/tiller-deploy-3703072393   1         1         1         3m

(Здесь и далее знаком «!!!» вручную выделены строки, на которые стоит обратить внимание.)

То есть: появился Deployment под названием tiller-deploy с одним ReplicaSet и одним подом (Pod). Для Deployment сделан одноимённый Service (tiller-deploy), который открывает доступ через порт 44134.

Подготовка, шаг №3: IngressController

Третья часть — сама конфигурация для приложения. На данном этапе нужно понять, что требуется выложить в кластер, чтобы приложение заработало.

Предлагается следующая схема:

  • приложение — это Deployment. Для начала это будет один ReplicaSet из одного пода, как сделано для Registry;
  • приложение отвечает на порту 8000, поэтому нужно определить Service, чтобы под смог отвечать на запросы извне;
  • у нас веб-приложение, поэтому нужен способ получать пакеты от пользователей на 80-м порту. Это делается ресурсом Ingress. Для работы таких ресурсов нужно настроить IngressController.

IngressController — это дополнительный компонент кластера Kubernetes для организации веб-приложений с балансировкой нагрузки. По сути это nginx, конфигурация которого зависит от ресурсов Ingress, добавляемых в кластер. Компонент нужно ставить отдельно, а для minikube существует addon. Подробнее о нём можно почитать в этой статье на английском, а пока просто запустим установку IngressController:

$ minikube addons enable ingress
ingress was successfully enabled

… и посмотрим, что появилось в кластере:

$ kubectl get -n kube-system all
NAME                                READY     STATUS    RESTARTS   AGE
!!! po/default-http-backend-vbrf3       1/1       Running   0          2m
po/kube-addon-manager-minikube      1/1       Running   2          3h
po/kube-dns-1301475494-7kk6l        3/3       Running   3          3h
po/kube-dns-v20-g7hr9               3/3       Running   9          72d
po/kube-registry-6nw7m              1/1       Running   0          3h
po/kube-registry-proxy              1/1       Running   0          3h
po/kubernetes-dashboard-9zsv8       1/1       Running   3          72d
po/kubernetes-dashboard-f4tp1       1/1       Running   1          3h
!!! po/nginx-ingress-controller-hmvg9   1/1       Running   0          2m
po/tiller-deploy-3703072393-bdqn8   1/1       Running   0          1h

NAME                          DESIRED   CURRENT   READY     AGE
!!! rc/default-http-backend       1         1         1         2m
rc/kube-dns-v20               1         1         1         72d
rc/kube-registry              1         1         1         3h
rc/kubernetes-dashboard       1         1         1         72d
!!! rc/nginx-ingress-controller   1         1         1         2m

NAME                       CLUSTER-IP   EXTERNAL-IP   PORT(S)         AGE
!!! svc/default-http-backend   10.0.0.131   <nodes>       80:30001/TCP    2m
svc/kube-dns               10.0.0.10    <none>        53/UDP,53/TCP   72d
svc/kube-registry          10.0.0.142   <none>        5000/TCP        3h
svc/kubernetes-dashboard   10.0.0.249   <nodes>       80:30000/TCP    72d
svc/tiller-deploy          10.0.0.196   <none>        44134/TCP       1h

NAME                   DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
deploy/kube-dns        1         1         1            1           3h
deploy/tiller-deploy   1         1         1            1           1h

NAME                          DESIRED   CURRENT   READY     AGE
rs/kube-dns-1301475494        1         1         1         3h
rs/tiller-deploy-3703072393   1         1         1         1h

Как проверить? IngressController в своём составе имеет default-http-backend, который отвечает ошибкой 404 на все страницы, для которых нет обработчика. Это можно увидеть такой командой:

$ curl -i $(minikube ip)
HTTP/1.1 404 Not Found
Server: nginx/1.13.1
Date: Fri, 14 Jul 2017 14:29:46 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 21
Connection: keep-alive
Strict-Transport-Security: max-age=15724800; includeSubDomains;

default backend - 404

Результат положительный — приходит ответ от nginx со строкой default backend - 404.

Описание конфигурации для Helm

Теперь можно описать конфигурацию приложения. Базовую конфигурацию поможет сгенерировать команда helm create имя_приложения:

$ helm create symfony-demo
$ tree symfony-demo
symfony-demo/
├── charts
├── Chart.yaml
├── templates
│   ├── deployment.yaml
│   ├── _helpers.tpl
│   ├── ingress.yaml
│   ├── NOTES.txt
│   └── service.yaml
└── values.yaml

dapp ожидает эту структуру в директории под названием .helm (см. документацию), поэтому нужно переименовать symfony-demo в .helm.

Мы сейчас создали описание chart'а. Chart — это единица конфигурации для Helm, можно думать о нём как о неком пакете. Например, есть chart для nginx, для MySQL, для Redis. И с помощью таких chart'ов можно собрать нужную конфигурацию в кластере. Helm выкладывает в Kubernetes не отдельные образы, а именно Chart'ы (официальная документация).

Файл Chart.yaml — это описание chart'а нашего приложения. Здесь нужно указать как минимум имя приложения и версию:

$ cat Chart.yaml
apiVersion: v1
description: A Helm chart for Kubernetes
name: symfony-demo
version: 0.1.0

Файл values.yaml — описание переменных, которые будут доступны в шаблонах. Например, в сгенерированном файле есть image: repository: nginx. Эта переменная будет доступна через такую конструкцию: {{ .Values.image.repository }}.

Директория charts пока пуста, потому что наш chart приложения пока не использует внешние chart'ы.

Наконец, директория templates — здесь хранятся шаблоны YAML-файлов с описанием ресурсов для их размещения в кластере. Сгенерированные шаблоны не сильно нужны, поэтому с ними можно ознакомиться и удалить.

Для начала опишем простой вариант Deployment для нашего приложения:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: {{ .Chart.Name }}-backend
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: {{ .Chart.Name }}-backend
    spec:
      containers:
      - command: [ '/opt/start.sh' ]
        image: {{ tuple "symfony-demo-app" . |  include "dimg" }}
        imagePullPolicy: Always
        name: {{ .Chart.Name }}-backend
        ports:
        - containerPort: 8000
          name: http
          protocol: TCP
        env:
       - name: KUBERNETES_DEPLOYED
          value: "{{ now }}"

В конфигурации описано, что нам нужна пока что одна реплика, а в template указано, какие поды нужно реплицировать. В этом описании указывается образ, который будет запускаться и порты, которые доступны другим контейнерам в поде.

Упомянутое в конфиге .Chart.Name — это значение из charts.yaml.

Переменая KUBERNETES_DEPLOYED нужна, чтобы Helm обновлял поды, если мы обновим образ без изменения тега. Это удобно для отладки и локальной разработки.

Далее опишем Service:

apiVersion: v1
kind: Service
metadata:
  name: {{ .Chart.Name }}-srv
spec:
  type: ClusterIP
  selector:
    app: {{ .Chart.Name }}-backend
  ports:
  - name: http
    port: 8000
    protocol: TCP

Этим ресурсом мы создаём DNS-запись symfony-demo-app-srv, по которой другие Deployments смогут получать доступ к приложению.

Эти два описания объединяются через --- и записываются в .helm/templates/backend.yaml, после чего можно разворачивать приложение!

Первый деплой

Теперь всё готово, чтобы запустить dapp kube deploy (подробнее о команде см. в документации):

$ dapp kube deploy :minikube --image-version kube_test


Deploy release symfony-demo-default                                              [RUNNING]
Release "symfony-demo-default" has been upgraded. Happy Helming!
LAST DEPLOYED: Fri Jul 14 18:32:38 2017
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1beta1/Deployment
NAME                      DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
symfony-demo-app-backend  1        1        1           0          7s

==> v1/Service
NAME                  CLUSTER-IP  EXTERNAL-IP  PORT(S)   AGE
symfony-demo-app-srv  10.0.0.173  <none>       8000/TCP  7s


Deploy release symfony-demo-default                                                   [OK] 7.02 sec

Видим, что в кластере появляется под в состоянии ContainerCreating:

po/symfony-demo-app-backend-3899272958-hzk4l   0/1       ContainerCreating   0          24s

… и через некоторое время всё работает:

$ kubectl get all
NAME                                           READY     STATUS    RESTARTS   AGE
po/hello-minikube-938614450-zx7m6              1/1       Running   3          72d
!!! po/symfony-demo-app-backend-3899272958-hzk4l   1/1       Running   0          47s

NAME                       CLUSTER-IP   EXTERNAL-IP   PORT(S)          AGE
svc/hello-minikube         10.0.0.102   <nodes>       8080:31429/TCP   72d
svc/kubernetes             10.0.0.1     <none>        443/TCP          72d
!!! svc/symfony-demo-app-srv   10.0.0.173   <none>        8000/TCP         47s

NAME                              DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
deploy/hello-minikube             1         1         1            1           72d
deploy/symfony-demo-app-backend   1         1         1            1           47s

NAME                                     DESIRED   CURRENT   READY     AGE
rs/hello-minikube-938614450              1         1         1         72d
!!! rs/symfony-demo-app-backend-3899272958   1         1         1         47s

Создан ReplicaSet, Pod, Service, то есть приложение запущено. Это можно проверить «по старинке», зайдя в контейнер:

$ kubectl exec -ti symfony-demo-app-backend-3899272958-hzk4l bash
root@symfony-demo-app-backend-3899272958-hzk4l:/# curl localhost:8000

Открываем доступ

Теперь, чтобы приложение стало доступно по $(minikube ip), добавим ресурс Ingress. Для этого опишем его в .helm/templates/backend-ingress.yaml так:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: {{ .Chart.Name }}
  annotations:
    kubernetes.io/ingress.class: "nginx"
spec:
  rules:
  - http:
      paths:
      - path: /
        backend:
          serviceName: {{ .Chart.Name }}-srv
          servicePort: 8000

serviceName должно совпадать с именем Service, которое было объявлено в backend.yaml. Разворачиваем приложение ещё раз:

$ dapp kube deploy :minikube --image-version kube_test
Deploy release symfony-demo-default                                              [RUNNING]
Release "symfony-demo-default" has been upgraded. Happy Helming!
LAST DEPLOYED: Fri Jul 14 19:00:28 2017
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Service
NAME                  CLUSTER-IP  EXTERNAL-IP  PORT(S)   AGE
symfony-demo-app-srv  10.0.0.173  <none>       8000/TCP  27m

==> v1beta1/Deployment
NAME                      DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
symfony-demo-app-backend  1        1        1           1          27m

==> v1beta1/Ingress
NAME              HOSTS  ADDRESS         PORTS  AGE
symfony-demo-app  *      192.168.99.100  80     2s


Deploy release symfony-demo-default                                                   [OK] 3.06 sec

Появился v1beta1/Ingress! Попробуем обратиться к приложению через IngressController. Это можно сделать через IP кластера:

$ curl -Lik $(minikube ip)
HTTP/1.1 301 Moved Permanently
Server: nginx/1.13.1
Date: Fri, 14 Jul 2017 16:13:45 GMT
Content-Type: text/html
Content-Length: 185
Connection: keep-alive
Location: https://192.168.99.100/
Strict-Transport-Security: max-age=15724800; includeSubDomains;

HTTP/1.1 403 Forbidden
Server: nginx/1.13.1
Date: Fri, 14 Jul 2017 16:13:45 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Host: 192.168.99.100
X-Powered-By: PHP/7.0.18-0ubuntu0.16.04.1
Strict-Transport-Security: max-age=15724800; includeSubDomains;

You are not allowed to access this file. Check app_dev.php for more information.

В целом можно считать, что развёртывание приложения в Minikube удалось. Из запроса видно, что IngressController перебрасывает на 443-й порт и приложение отвечает, что нужно проверить app_dev.php. Это уже специфика выбранного приложения (symfony), потому что в файле web/app_dev.php легко заметить:

// This check prevents access to debug front controllers that are deployed by
// accident to production servers. Feel free to remove this, extend it, or make
// something more sophisticated.
if (isset($_SERVER['HTTP_CLIENT_IP'])
    || isset($_SERVER['HTTP_X_FORWARDED_FOR'])
    || !(in_array(@$_SERVER['REMOTE_ADDR'], ['127.0.0.1', 'fe80::1', '::1']) || php_sapi_name() === 'cli-server')
) {
    header('HTTP/1.0 403 Forbidden');
    exit('You are not allowed to access this file. Check '.basename(__FILE__).' for more information.');
}

Чтобы увидеть нормальную страницу приложения, нужно развернуть приложение с другой настройкой либо для тестов закомментировать этот блок. Повторный деплой в Kubernetes (после правок в коде приложения) выглядит так:

$ dapp dimg build
...
 Git artifacts: latest patch ...                                                     [OK] 1.86 sec
    signature: dimgstage-symfony-demo:13a2487a078364c07999d1820d4496763c2143343fb94e0d608ce1a527254dd3
  Docker instructions ...                                                             [OK] 1.46 sec
    signature: dimgstage-symfony-demo:e0226872a5d324e7b695855b427e8b34a2ab6340ded1e06b907b165589a45c3b
    instructions:
      EXPOSE 8000


$ dapp dimg push --tag-branch :minikube
...
symfony-demo-app-kube_test: digest: sha256:eff826014809d5aed8a82a2c5cfb786a13192ae3c8f565b19bcd08c399e15fc2 size: 2824
    pushing image `localhost:5000/symfony-demo:symfony-demo-app-kube_test`            [OK] 1.16 sec
  localhost:5000/symfony-demo:symfony-demo-app-kube_test                              [OK] 1.41 sec



$ dapp kube deploy :minikube --image-version kube_test
$ kubectl get all
!!! po/symfony-demo-app-backend-3438105059-tgfsq   1/1       Running   0          1m

Под пересоздался, можно зайти браузером и увидеть красивую картинку:

Практика с dapp. Часть 2. Деплой Docker-образов в Kubernetes с помощью Helm - 2

Итог

С помощью Minikube и Helm можно тестировать свои приложения в кластере Kubernetes, а dapp поможет в сборке, развёртывании своего Registry и самого приложения.

В статье не упомянуты секретные переменные, которые можно использовать в шаблонах для приватных ключей, паролей и прочей закрытой информации. Об этом напишем отдельно.

P.S.

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

Автор: diafour

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js