До сих пор в нашем инженерном блоге мы видели несколько различных технологий и функций, таких как Hashicorp’s Vault и Kubernetes Autoscaling. На этот раз мы вернемся к основам, чтобы настроить конвейер CI/CD с помощью Jenkins.

Я думаю, можно с уверенностью предположить, что большинство инженеров DevOps уже знакомы с Jenkins, но на всякий случай вот как Jenkins описывает себя на своей домашней странице:

«Jenkins, ведущий сервер автоматизации с открытым исходным кодом, предоставляет сотни подключаемых модулей для поддержки создания, развертывания и автоматизации любого проекта».

Итак, в двух словах, это инструмент CI/CD. Теперь вернемся к сути.

Недавно нам было поручено перенести наш экземпляр Jenkins на другую учетную запись AWS в соответствии с лучшими практиками AWS. Будучи ярыми поклонниками Infrastructure as Code (IaC), мы хотели сделать это правильно, в стиле Terragrunt.

Итак, какую установку мы ищем?

  1. Jenkins может быть мешаниной плагинов и зависимостей. Задокументировав первоначальную настройку в коде, было бы легко увидеть, что используется и почему.
  2. Никаких настроек пользовательского интерфейса. Дело не в том, что мы ненавидим пользовательские интерфейсы, а в том, что изменение поведения и компонентов с помощью нескольких кликов часто не документируется. Это означает, что в будущем инженеры будут ломать голову, пытаясь понять настройку. Часто на ум приходит фраза:
    «Когда я это настраивал, только Бог и я понимали, что оно делало… Теперь только Богу известно».
  3. Ничто не сравнится с ощущением подготовки инфраструктуры с помощью пары команд командной строки.

Мы увидели три основных преимущества такого подхода:

  • Поскольку мы работаем на AWS и не хотим, чтобы Jenkins жил внутри нашего кластера Kubernetes, нам потребуется AMI сервера Jenkins, развернутого через Auto Scaling Group. Это позволило бы нам развертывать новые версии сервера Jenkins практически с нулевым временем простоя.
  • Нам нужно найти способ предоставить конфигурацию Jenkins (репозитории, задания, учетные данные и настройки плагинов), вообще не посещая пользовательский интерфейс Jenkins. Эта конфигурация должна загружаться при запуске сервера Jenkins, а также при обновлении конфигурации.

Создание AMI

Поскольку мы знали, что Jenkins является распространенным инструментом в отрасли, мы искали общедоступные AMI Jenkins. Нам не потребовалось много времени, чтобы договориться об использовании Bitnami’s Jenkins AMI.

Конечно, просто развернуть конкретный образ было недостаточно. Среди прочего, мы хотели, чтобы все плагины, которые мы будем использовать, были уже установлены.

Для переупаковки AMI мы использовали еще один инструмент Hashicorp — Packer. Мы также хотели интегрировать этот процесс в наш код Terragrunt. В итоге мы получили следующую файловую структуру в нашем репозитории Terragrunt:

Запуск terragrunt apply в папке jenkins-master должен создать AMI и предоставить его атрибуты, чтобы его можно было использовать в качестве зависимости в других модулях, например в группе автоматического масштабирования.

Для этого в файле terragrunt.hcl мы использовали простой модуль, реализующий источник данных Terraform’s AMI. Однако важным шагом была реализация before_hook, который создает AMI до того, как модуль извлечет и выставит его.

Содержимое файла в итоге выглядело так:

terragrunt.hcl

terraform {
 source = "git::[email protected]:transifex/terraform-modules.git//modules/data-ami?ref=v0.1.44"
 before_hook "before_hook" {
   commands = ["apply"]
   execute = [
     "packer",
     "build",
     "-var", "assumed_role=${local.iam_role}",
     "-var", "vpc_id=${local.vpc_id}",
     "-var", "subnet_id=${local.public_subnet}",
     "image/main.pkr.hcl",
   ]
 }
}
 
locals {
 # These can / should be loaded as dependencies from other modules
 iam_role = "arn:aws:iam::xxxxxxxx:role/my_role"
 public_subnet = "vpc-xxxxxxx"
 public_subnet = "sub-xxxxxxx"
}
 
include {
 path = find_in_parent_folders()
}
 
inputs = {
 filter_names = ["jenkins-master"]
}

Папка с изображением будет содержать конфигурацию Packer и сценарий подготовки, который мы написали.

setup.sh

#!/bin/bash
## Install apt packages that are needed or wanted
apt-get install locate && updatedb
 
## Wait for jenkins to start
function wait_for_jenkins()
{
 until $(curl --output /dev/null --silent --head --fail http://localhost/jnlpJars/jenkins-cli.jar); do
   printf '.'
   sleep 5
 done
 echo "Jenkins launched"
}
wait_for_jenkins
 
## Change shell of jenkins user
usermod --shell /bin/bash jenkins
 
## Download the jenkins-cli.jar
su - jenkins -c "wget localhost/jnlpJars/jenkins-cli.jar -O /opt/bitnami/jenkins/jenkins_home/jenkins-cli.jar"
 
## Retrieve the default admin user password
userpass=$(cat /home/bitnami/bitnami_credentials | grep 'The default username and password is' | rev | cut -d' ' -f1 | cut -d'.' -f2 | sed "s/'//g" | rev)
 
## Install Jenkins plugins that are needed (and restart Jenkins)
/opt/bitnami/java/bin/java -jar /opt/bitnami/jenkins/jenkins_home/jenkins-cli.jar -s http://localhost:8080/ -auth user:$userpass install-plugin configuration-as-code blueocean ec2 slack github-autostatus metrics github-pullrequest role-strategy google-login job-dsl basic-branch-build-strategies parameterized-scheduler

main.pkr.hcl

variable "vpc_id" {
 type = string
}
 
variable "subnet_id" {
 type = string
}
 
variable "assumed_role" {
 type = string
}
 
source "amazon-ebs" "jenkins-master" {
 assume_role {
   role_arn = "${var.assumed_role}"
 }
 
 ami_name                = "jenkins-master"
 instance_type           = "t2.medium"
 region                  = "eu-west-1"
 source_ami              = "ami-0562a5f132869dd5a"
 ssh_username            = "bitnami"
 ami_description         = "Jenkins Master AMI"
 shutdown_behavior       = "terminate"
 spot_price              = "auto"
 spot_price_auto_product = "Linux/UNIX"
 
 ssh_pty               = true
 force_delete_snapshot = true
 force_deregister      = true
 
 // Vpc && subnet to launch the spot instance
 vpc_id    = "${var.vpc_id}"
 subnet_id = "${var.subnet_id}"
 
 // Associate public ip so packer can ssh to provision
 associate_public_ip_address = true
 // Use the public IP to connect
 ssh_interface = "public_ip"
}
 
 
build {
 sources = ["source.amazon-ebs.jenkins-master"]
 provisioner "shell" {
   execute_command = "echo '' | sudo -S su - root -c '{{ .Vars }} {{ .Path }}'"
   script          = "image/setup.sh"
 }
}

И вуаля!

С помощью простой команды terragrunt apply мы можем создать образ и загрузить его атрибуты в будущих модулях.

Конфигурация Дженкинса как код

В поисках способов автоматизации подготовки Jenkins (и после пары часов борьбы с groovy-скриптами) мы наткнулись на следующий плагин: JCaC (Jenkins Configuration as Code). И да, это то, что следует из названия. Плагин, принятый сообществом Jenkins, единственной целью которого является предоставление возможности определять Jenkins и конфигурацию плагина в файле YAML.

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

Мы быстро взялись за дело и начали настраивать наши Jenkins и плагины, используя формат YAML. Конечный файл слишком велик и слишком специфичен для наших нужд, чтобы делиться им здесь, но вы можете найти множество примеров того, как его настроить, в демонстрациях, предоставленных JCaC.

В качестве альтернативы, если вы в настоящее время используете Jenkins, вы можете установить плагин и экспортировать текущую конфигурацию. После того, как мы закончили создание файла конфигурации, чтобы сделать его доступным для Jenkins, мы решили добавить его в частную корзину S3, к которой может получить доступ только Jenkins. Как всегда, для этого мы использовали Terragrunt. Теперь осталось два предмета:

  1. Попросите Дженкинса перезагрузить файл после внесения изменений.
  2. Укажите URL-адрес файла при инициализации Jenkins, чтобы его можно было обнаружить при запуске.

Для первого элемента это было так же просто, как добавить «after_hook» в «применить terragrunt», который загружал файл в s3:

terragrunt.hcl

terraform {
 source = "git::[email protected]:terraform-aws-modules/terraform-aws-s3-bucket.git//modules/object?ref=v1.25.0"
 
 after_hook "after_hook" {
   commands     = ["apply"]
   execute      = ["curl", "-X", "POST", "jenkins.domain/reload-configuration-as-code/?casc-reload-token=xxxxxxxx"
 }
}
...

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

Чтобы убедиться, что Jenkins также загрузит файл при инициализации, мы вернулись к AMI, созданному на предыдущем шаге, и изменили сценарий подготовки, чтобы обеспечить необходимую конфигурацию:

main.pkr.hcl

...
build {
 sources = ["source.amazon-ebs.jenkins-master"]
 provisioner "shell" {
   environment_vars = [
     "jenkins_casc_token=${var.jenkins_casc_token}",
     "vpc_endpoint=${var.vpc_endpoint}"
   ]
   execute_command = "echo '' | sudo -S su - root -c '{{ .Vars }} {{ .Path }}'"
   script          = "image/setup.sh"
 }
}

setup.sh

...
## Jenkins configuration as code options
sed "s,\"javaOpts\": \"\",\"javaOpts\": \"-Dcasc.reload.token=$jenkins_casc_token -Dcasc.jenkins.config=$vpc_endpoint\",g" -i /root/.nami/registry.json

terragrunt.hcl

terraform {
 source = "git::[email protected]:transifex/terraform-modules.git//modules/data-ami?ref=v0.1.44"
 before_hook "before_hook" {
   commands = ["apply"]
   execute = [
     "packer",
     "build",
     "-var", "assumed_role=${local.iam_role}",
     "-var", "vpc_id=${local.vpc_id}",
     "-var", "subnet_id=${local.public_subnet}",
     "-var", "jenkins_casc_token=${local.casc_token"]}",
     "-var", "vpc_endpoint=${local.s3_endpoint}",
     "image/main.pkr.hcl",
   ]
 }
}
 
locals {
 # These can / should be loaded as dependencies from other modules
 iam_role = "arn:aws:iam::xxxxxxxx:role/my_role"
 public_subnet = "vpc-xxxxxxx"
 public_subnet = "sub-xxxxxxx"
 casc_token = "xxxxxxxxxxx"
 s3_endpoint = "xxxxxxxxxxx"
}
 
...

И теперь, с этими изменениями, файл конфигурации может загружаться при запуске!

Посев ваших рабочих мест

Хотя CasC решил большую часть наших проблем с конфигурацией, одну вещь он не мог сделать, по крайней мере, сам по себе, — это создать рабочие места Jenkins. Для этого вам понадобится еще один плагин, job-dsl, который может работать в паре с CasC.

job-dsl позволяет вам программно определять задания внутри файла конфигурации Jenkins, созданного ранее. Было бы упущением, если бы я не упомянул здесь, что это не для слабонервных. Несмотря на то, что с помощью этого плагина вы можете создать удивительную автоматизацию, во многих местах отсутствует документация, и для достижения хороших результатов требуется много проб и ошибок.

В нашем случае мы хотели предоставить нашу организацию github и несколько отобранных репозиториев, которые содержат свои собственные Jenkinsfiles. Для этого мы использовали OrganizationFolder. Это сценарий, который мы использовали (находится в файле YAML вместе с остальной частью нашей конфигурации).

jobs:
 - script: >
     organizationFolder('Transifex') {
       description("Transifex organization folder configured with JCasC")
       displayName('Transifex')
       buildStrategies {
         buildChangeRequests {
           ignoreTargetOnlyChanges(true)
           ignoreUntrustedChanges(true)
         }
         skipInitialBuildOnFirstBranchIndexing()
       }
       triggers {}
       // "Projects"
       organizations {
         github {
           repoOwner("transifex")
           credentialsId("xxxxxxx")
           traits {
             // Discovers branches on the repository.
             gitHubBranchDiscovery {
               // Determines which branches are discovered.
               // 1 = Exclude branches that are also filed as PRs
               strategyId(1)
             }
             gitHubPullRequestDiscovery {
               // Determines how pull requests are discovered: Merging the pull request with the current target branch revision Discover each pull request once with the discovered revision corresponding to the result of merging with the current revision of the target branch.
               // 1 = Merging the pull request with the current target branch revision
               strategyId(1)
             }
             gitHubExcludeArchivedRepositories()
             sourceWildcardFilter {
               // Space-separated list of project (repository) name patterns to consider.
               includes("xxxxx yyyyyy zzzzzz")
               excludes("")
             }
           }
         }
       }
     }
 
...

Заключительные примечания

В общем, мы должны признать, что обеспечение Jenkins принципами IaC было утомительным для нашей команды. Многие вопросы не были задокументированы или были плохо задокументированы, и решение наших проблем никогда не было очевидным. Но как только это было сделано, его было легко изменить и отслеживать.

Скажем, нам понадобится новый Jenkins в будущем, раскрутить его будет так же просто, как написать несколько строк и отредактировать пару файлов. И все это будет навсегда задокументировано в нашем репозитории. Мы надеемся, что эта статья станет путеводной звездой для отважных инженеров, которые осмелятся вступить на этот путь в будущем.