Installing Jenkins using terraform in Kubernetes in Yandex Cloud with letsencypt

https://habr.com/ru/articles/683844/

В этой статье будет следующее:

  • Заведение DNS домена на reg.ru.

  • Управление DNS зоной в Yandex DNS c помощью terraform.

  • Создание Kubernetes в Yandex Cloud.

  • Резервируем внешний статический IP адрес.

  • Установка Jenkins c помощью terraform модуля helm_release.

  • Создание ClusterIssue(Issue) для создания letsencypt сертификата.

Быстрая установка Jenkins с помощью terraform в Kubernetes в Yandex Cloud с letsencypt.

Скачать репозиторий:

git clone https://github.com/patsevanton/infrastructure-as-a-code-example.git

Перейти в каталог terraform-helm-release-jenkins:

cd terraform-helm-release-jenkins

Заполнить private.auto.tfvars на базе шаблона private.auto.tfvars.example.

Запустить установку:

k8s_install.sh

Разберем подробнее.

Заводим DNS домен на reg.ru — mycompany.ru или mycompany.org.ru. В настройках домена на вкладке "DNS-серверы и управление зоной" указываем DNS Yandex Cloud:

  • ns1.yandexcloud.net

  • ns2.yandexcloud.net

DNS зона управляется с помощью terraform кода. Документация — https://cloud.yandex.ru/docs/dns/.

resource "yandex_dns_zone" "dns_domain" {
  name   = replace(var.dns_domain, ".", "-")
  zone   = join("", [var.dns_domain, "."])
  public = true
}

Так как у zone на конце должна быть точка, то используем метод join для соединения нашего домена и точки.

Создание DNS записи.

В имени DNS записи на конце необходима точка, поэтому используем метод join для соединения DNS записи и точки. Код:

resource "yandex_dns_recordset" "jenkins_dns_domain" {
  zone_id = yandex_dns_zone.dns_domain.id
  name    = join("", [var.jenkins_dns_name, "."])
  type    = "A"
  ttl     = 200
  data    = [yandex_vpc_address.addr.external_ipv4_address[0].address]
}

Поле data — список IP адресов, на которые должны резолвиться эта DNS запись.

  • Создание сервисного аккаунта.

Перед созданием Kubernetes кластера необходимо рассказать о создание сервисного аккаунта. Название сервисного аккаунта — sa-k8s-admin.

resource "yandex_iam_service_account" "sa-k8s-admin" {
  folder_id = var.yc_folder_id
  name      = "sa-k8s-admin"
}

resource "yandex_resourcemanager_folder_iam_member" "sa-k8s-admin-permissions" {
  folder_id = var.yc_folder_id
  role      = "admin"
  member    = "serviceAccount:${yandex_iam_service_account.sa-k8s-admin.id}"
}

Лучше использовать yandex_resourcemanager_folder_iam_member вместо yandex_resourcemanager_folder_iam_binding, так как не всегда работает корректно. У меня на другом коде вызывает ошибку. Issue — https://github.com/yandex-cloud/terraform-provider-yandex/issues/267 и https://github.com/yandex-cloud/terraform-provider-yandex/issues/283.

"Binding for role "mdb.dataproc.agent" not found in policy"

В role указываем какие права имеет сервисный аккаунт. Более подробно в документации https://cloud.yandex.ru/docs/iam/concepts/access-control/roles.

Создание kubernetes кластера в Yandex Cloud.

Для создание kubernetes кластера в Yandex Cloud используется уже обычный terraform код:

resource "yandex_kubernetes_cluster" "zonal_k8s_cluster" {
  name        = "my-cluster"
  description = "my-cluster description"
  network_id  = yandex_vpc_network.k8s-network.id

  master {
    version = "1.21"
    zonal {
      zone      = yandex_vpc_subnet.k8s-subnet.zone
      subnet_id = yandex_vpc_subnet.k8s-subnet.id
    }
    public_ip = true
  }

  service_account_id      = yandex_iam_service_account.sa-k8s-admin.id
  node_service_account_id = yandex_iam_service_account.sa-k8s-admin.id
  release_channel         = "STABLE"
  // to keep permissions of service account on destroy
  // until cluster will be destroyed
  depends_on = [yandex_resourcemanager_folder_iam_member.sa-k8s-admin-permissions]
}

# yandex_kubernetes_node_group

resource "yandex_kubernetes_node_group" "k8s_node_group" {
  cluster_id  = yandex_kubernetes_cluster.zonal_k8s_cluster.id
  name        = "name"
  description = "description"
  version     = "1.21"

  labels = {
    "key" = "value"
  }

  instance_template {
    platform_id = "standard-v3"

    network_interface {
      nat        = true
      subnet_ids = [yandex_vpc_subnet.k8s-subnet.id]
    }

    resources {
      cores         = 2
      memory        = 4
      core_fraction = 50
    }

    boot_disk {
      type = "network-hdd"
      size = 32
    }

    scheduling_policy {
      preemptible = true
    }

    metadata = {
      ssh-keys = "ubuntu:${file("~/.ssh/id_rsa.pub")}"
    }

  }

  scale_policy {
    fixed_scale {
      size = 1
    }
  }

  allocation_policy {
    location {
      zone = "ru-central1-b"
    }
  }

  maintenance_policy {
    auto_upgrade = true
    auto_repair  = true

    maintenance_window {
      day        = "monday"
      start_time = "15:00"
      duration   = "3h"
    }

    maintenance_window {
      day        = "friday"
      start_time = "10:00"
      duration   = "4h30m"
    }
  }
}

locals {
  kubeconfig = <<KUBECONFIG
apiVersion: v1
clusters:
- cluster:
    server: ${yandex_kubernetes_cluster.zonal_k8s_cluster.master[0].external_v4_endpoint}
    certificate-authority-data: ${base64encode(yandex_kubernetes_cluster.zonal_k8s_cluster.master[0].cluster_ca_certificate)}
  name: kubernetes
contexts:
- context:
    cluster: kubernetes
    user: yc
  name: ycmk8s
current-context: ycmk8s
users:
- name: yc
  user:
    exec:
      apiVersion: client.authentication.k8s.io/v1beta1
      command: yc
      args:
      - k8s
      - create-token
KUBECONFIG
}

output "kubeconfig" {
  value = local.kubeconfig
}

Если кластер тестовый, то можно снизить стоимость используя 50% ядра и использовать прерываемые виртуальные машины.

    resources {
      ...
      core_fraction = 50
    }

    scheduling_policy {
      preemptible = true
    }

Сеть и внешний статический IP адрес.

Указываем сеть:

resource "yandex_vpc_network" "k8s-network" {
  name = "k8s-network"
}

Указываем подсеть:

resource "yandex_vpc_subnet" "k8s-subnet" {
  zone           = "ru-central1-b"
  network_id     = yandex_vpc_network.k8s-network.id
  v4_cidr_blocks = ["10.5.0.0/24"]
  depends_on = [
    yandex_vpc_network.k8s-network,
  ]
}

Указываем внешний статический IP адрес:

resource "yandex_vpc_address" "addr" {
  name = "static-ip"
  external_ipv4_address {
    zone_id = "ru-central1-b"
  }
}

Неважно какой будет IP. В DNS запись будет добавлен этот IP.

Jenkins установливаем c помощью terraform модуля helm_release.

Рассмотрим helm_release.tf:

provider "helm" описывает настройки подключения к kubernetes для helm.

provider "helm" {
  kubernetes {
    host                   = yandex_kubernetes_cluster.zonal_k8s_cluster.master[0].external_v4_endpoint
    cluster_ca_certificate = yandex_kubernetes_cluster.zonal_k8s_cluster.master[0].cluster_ca_certificate
    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      args        = ["k8s", "create-token"]
      command     = "yc"
    }
  }
}

Создаем ingress-nginx.

resource "helm_release" "ingress_nginx" {
  name       = "ingress-nginx"
  repository = "https://kubernetes.github.io/ingress-nginx"
  chart      = "ingress-nginx"
  version    = "4.2.1"
  wait       = true
  depends_on = [
    yandex_kubernetes_node_group.k8s_node_group
  ]
  set {
    name  = "controller.service.loadBalancerIP"
    value = yandex_vpc_address.addr.external_ipv4_address[0].address
  }
}

В terraform опции --set key=value передаются так:

  set {
    name  = "controller.service.loadBalancerIP"
    value = yandex_vpc_address.addr.external_ipv4_address[0].address
  }

В данном случае для controller.service.loadBalancerIP указываем внешний статический IP адрес.

Устанавливаем cert-manager по аналогии с ingress-nginx.

resource "helm_release" "cert-manager" {
  namespace        = "cert-manager"
  create_namespace = true
  name             = "jetstack"
  repository       = "https://charts.jetstack.io"
  chart            = "cert-manager"
  version          = "v1.9.1"
  wait             = true
  depends_on = [
    yandex_kubernetes_node_group.k8s_node_group
  ]
  set {
    name  = "installCRDs"
    value = true
  }
}

Устанавливаем jenkins.

resource "helm_release" "jenkins" {
  namespace        = "jenkins"
  create_namespace = true
  name             = "jenkins"
  repository       = "https://charts.jenkins.io"
  chart            = "jenkins"
  wait             = true
  version          = "4.1.17"
  depends_on = [
    yandex_kubernetes_node_group.k8s_node_group
  ]
  values = [
    #file("${path.module}/jenkins-values-google-login.yaml")
    yamlencode(local.jenkins_values_google_login)
  ]
}

Jenkins helm чарту на вход необходимо указать value.yaml с нашими настройками.

Есть 2 способа указать value.yaml.

Первый способ использовать готовый и настроеный value.yaml в текущей директории:

  values = [
    file("${path.module}/jenkins-values-google-login.yaml")
  ]

Второй способ использовать yamlencode для перевода terraform кода в yaml код.

  values = [
    yamlencode(local.jenkins_values_google_login)
  ]

Как получить terraform код из yaml кода?

Подготавливаете и настраиваете value.yaml, а затем используя вот такую команду переводите в terraform код:

echo 'yamldecode(file("value.yaml"))' | terraform console

В моем случае value.yaml имеет другое название:

echo 'yamldecode(file("jenkins-values-google-login.yaml"))' | terraform console

Я использую yamlencode, потому что внутри yamlencode кода ссылаюсь на terraform переменные, а так же потому что job лежат отдельно.

Полученный код вставляете в local.jenkins_values_google_login

locals {
  jenkins_values_google_login = {
  сюда
  }
}

Затем вместо ваших данных:

"hostName"         = jenkins.mycompany.ru

используйте terraform переменные

"hostName"         = "${var.jenkins_dns_name}"

Рассмотрим local.jenkins_values_google_login.

В корне стоит controller от которого идут все настройки.

Для настройка jenkins из кода используются jcasc, job-dsl. Примеры jcasc https://github.com/jenkinsci/configuration-as-code-plugin

      "JCasC" = {
        "authorizationStrategy" = <<-EOT
        loggedInUsersCanDoAnything:
          allowAnonymousRead: false
        EOT

Более подробно — https://github.com/jenkinsci/configuration-as-code-plugin/blob/master/demos/global-matrix-auth/README.md

В configScripts каждый блок EOT это отдельный файл в контейнере jenkins. systemMessage системное сообщение.

        "configScripts" = {
          "jenkins-configuration" = <<-EOT
          jenkins:
            systemMessage: This Jenkins is configured and managed 'as code' by Managed Cloud team.
          EOT

Указываем файл job:

          "job-config" = yamlencode({
            jobs = [
              { script = file("${path.module}/job1.groovy") },
              { script = file("${path.module}/job2.groovy") }
            ]
          })

Указываем список view:

          jenkins:
            views:
              - all:
                  name: "all"
              - list:
                  columns:
                  - "status"
                  - "weather"
                  - "jobName"
                  - "lastSuccess"
                  - "lastFailure"
                  - "lastDuration"
                  - "buildButton"
                  jobNames:
                  - "job1"
                  name: "stage"
              - list:
                  columns:
                  - "status"
                  - "weather"
                  - "jobName"
                  - "lastSuccess"
                  - "lastFailure"
                  - "lastDuration"
                  - "buildButton"
                  jobNames:
                  - "job2"
                  name: "test"
            viewsTabBar: "standard"

Указываем как мы будет входить в jenkins:

        "securityRealm" = <<-EOT
        googleOAuth2:
          clientId: "${var.clientId}"
          clientSecret: "${var.clientSecret}"
          domain: "${var.google_domain}"
        EOT

Я использую googleOAuth2. Можно еще использовать local, ldap и другие.

Дополнительные плагины jenkins:

      "additionalPlugins" = [
        "google-login:1.6",
        "job-dsl:1.81",
        "allure-jenkins-plugin:2.30.2",
        "ws-cleanup:0.42",
        "build-timeout:1.21",
        "timestamper:1.18",
        "google-storage-plugin:1.5.6",
        "permissive-script-security:0.7",
        "ansicolor:1.0.2",
        "google-oauth-plugin:1.0.6",
      ]

Настройка Ingress:

      "ingress" = {
        "annotations" = {
          "cert-manager.io/cluster-issuer" = "letsencrypt-prod"
        }
        "apiVersion"       = "networking.k8s.io/v1"
        "enabled"          = true
        "hostName"         = "${var.jenkins_dns_name}"
        "ingressClassName" = "nginx"
        "tls" = [
          {
            "hosts" = [
              "${var.jenkins_dns_name}",
            ]
            "secretName" = "jenkins-tls"
          },
        ]
      }

Если у вас нет letsencrypt, то вы удаляете cert-manager.io/cluster-issuer.

Указываем javaOpts чтобы запускать Job-DSL скрипты:

javaOpts: '-Dpermissive-script-security.enabled=true'

Сравнение настроенный yaml и yamlencode из terraform кода.

Чем плох настроенный yaml в качестве value.yaml для helm чарта относительно yamlencode ?

  • Он длинный.

  • В yaml коде нельзя вынести кусок кода (например job) в отдельный файл.

  • Для формирования заполненого настроенного value.yaml необходимо использовать templatefile. Мне кажется это лишнее.

В JCasC.configScripts каждый блок до вертикальной черты (|) будет сохранен как отдельный файл в контейнере Jenkins.

В Yaml формате все job нужно расписывать. Если сравнить:

      job-config: |
        jobs:
          - script: >
              pipelineJob('job1') {
                logRotator(120, -1, 1, -1)
                authenticationToken('secret')
                definition {
                  cps {
                    script("""\
                      pipeline {
                        agent any
                        parameters {
                            string(name: 'Variable', defaultValue: '', description: 'Variable', trim: true)
                        }
                        options {
                          timestamps()
                          ansiColor('xterm')
                          timeout(time: 10, unit: 'MINUTES')
                        }
                        stages {
                          stage ('build') {
                            steps {
                              cleanWs()
                              echo "hello job1"
                            }
                          }
                        }
                      }""".stripIndent())
                    sandbox()
                  }
                }
              }

и чтение файла job, то вынос файла лучшее красивее и читабельнее:

          "job-config" = yamlencode({
            jobs = [
              { script = file("${path.module}/job1.groovy") },
              { script = file("${path.module}/job2.groovy") }
            ]
          })

Добавление kind: ClusterIssuer в Kubernetes.

Если мы будем добавлять kind: ClusterIssuer как resource "kubernetes_manifest" и добавим как подключатся к Kubernetes используя provider "kubernetes", то получим ошибку:

cannot create REST client: no client config

Мы не можем развернуть resource "kubernetes_manifest" в котором ссылаемся на другой ресрус — https://github.com/hashicorp/terraform-provider-kubernetes/issues/1380

Поэтому создадим файл ClusterIssuer.yaml.tpl и будем формировать его через templatefile передав всего 1 переменную email_letsencrypt.

Вот мы с вами и прошли то, что напланировали в самом начале.

Last updated