Предположим, у нас есть два окружения Staging и Production
На Staging окружении деплоится ветка c именем staging, а на Produсtion – с именем master
Тип сборки в Jenkins – pipeline multibranch
Запуск сборки выполняется автоматически при коммите в репозитарий(Bitbucket)
Настройка автоматического запуска сборки при коммите в репозитарий Bitbucket описана здесь
https://kamaok.org.ua/?p=2833
При использовании типа сборки Pipeline Multibranch становится доступна имя ветки, в которую сделали коммит в виде переменной BRANCH_NAME
Кроме того, необходимо запускать сборку только для веток master и staging, а все остальные ветки сборка должна игнорировать при коммитах в эти ветки(даже не вызываться на запуск)
Это достигается фильтрацией по имени ветки в настройках сборки — в поле Include указываем только интересующие нас ветки master и staging
Предварительно создаем Credentials в Jenkins для аутентификации на Bitbucket
Все остальные настройки и сам процесс сборки и деплоя содержится в Jenkinsfile, который вытягивается из репозитария, указанного в поле Repository Name, доступного для пользователя указанного в поле Owner на изображении с настройками фильтрации веток.
Весь pipeline имеет следующий вид:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
pipeline { agent { label 'linux-slave1' } options { buildDiscarder logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '3') disableConcurrentBuilds() timestamps() } triggers { bitbucketPush() } environment { BranchName = "${BRANCH_NAME}" DockerRegistryURL = 'mydocker.repo.servername' DockerImageName = 'myapp' HashCommit = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim() BuildTimestamp = "${BUILD_TIMESTAMP}" } stages { stage('Unit-tests') { steps { echo 'Unit-testing...' } } stage('Integration-tests') { steps { echo 'Integration-testing...' } } stage('Docker build') { steps { sh "docker build -t ${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit} ." } } stage('Docker push') { steps { withCredentials([usernamePassword(credentialsId: 'docker-login-password-authentification', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASSWORD')]) { sh "docker login --username ${DOCKER_USER} --password ${DOCKER_PASSWORD} https://${DockerRegistryURL}" sh "docker push ${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit}" sh "docker rmi -f ${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit}" } } } stage('Deploy - Master') { when { environment name: 'BranchName', value: 'master' } steps { deploy('master') } } stage('Deploy - Staging') { when { environment name: 'BranchName', value: 'staging' } steps { deploy('staging') } } } } def deploy(BranchName) { def DOCKER_SWARM_MANAGER_NODE = '' if ("${BranchName}" == 'master') { DOCKER_SWARM_MANAGER_NODE = "swarm-manager-production.mydomain.com" sh ''' cat << 'EOF' >> .env SERVICE_NAME=login HOST=https://login.mydomain.com VAULT_ROLE_ID=login EOF ''' } else if ("${BranchName}" == 'staging') { DOCKER_SWARM_MANAGER_NODE = "swarm-manager-staging.mydomain.com" sh''' cat << 'EOF' >> .env SERVICE_NAME=login-staging HOST=https://login-staging.mydomain.com VAULT_ROLE_ID=login-staging EOF ''' } else { currentBuild.result = 'ABORTED' error('Aborting Build: branch isn\'t correct') } withCredentials([sshUserPrivateKey(credentialsId: 'ssh-connection-to-swarm', keyFileVariable: 'SWARM_KEY', passphraseVariable: '', usernameVariable: 'SWARM_USER')]) { sh "ssh -p2222 -i ${SWARM_KEY} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -NL localhost:2371:/var/run/docker.sock ${SWARM_USER}@${DOCKER_SWARM_MANAGER_NODE} &" withDockerRegistry(credentialsId: 'docker-login-password-authentification', url: 'https://${DockerRegistryURL}') { sh "docker -H localhost:2371 stack deploy -c docker-compose-cats.yaml --with-registry-auth cats" } } } |
Разберем содержание pipeline
Запуск сборки на агенте с меткой linux-slave1
1 2 3 |
agent { label 'linux-slave1' } |
Общие/глобальные настройки
Хранить 3 копии билдов(выполненных сборок)
Отключить параллельный запуск/выполнение сборки
Включать временные метки в вывод консоли
1 2 3 4 5 |
options { buildDiscarder logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '3') disableConcurrentBuilds() timestamps() } |
Запускать сборку при коммите в репозитарий Bitbucket
1 2 3 |
triggers { bitbucketPush() } |
Имя Docker-образа,который будет собираться имеет формат
1 |
${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit} |
1 2 3 4 5 |
DockerRegistryURL – URL Docker-реестра/репозитария mydocker.repo.servername DockerImageName – имя приложения – например, myapp BranchName – имя ветки (staging или master)(значение принимается из значения переменной BRANCH_NAME, которая по умолчанию в multibranch-сборке имеет имя ветки, которая вызвала запуск этой сборки. BuildTimestamp – временная метка( в формате yyyy-MM-dd-HH-mm)(Требуется установка плагина Build Timestamp(его установка и настройка описана в этой статье https://kamaok.org.ua/?p=2961) HashCommit – хеш коммита (в данном случае 7-ми символьный) |
1 2 3 4 5 6 7 |
environment { BranchName = "${BRANCH_NAME}" DockerRegistryURL = 'mydocker.repo.servername' DockerImageName = 'myapp' HashCommit = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim() BuildTimestamp = "${BUILD_TIMESTAMP}" } |
Имитация/эмулирование выполнения Unit и Integration-тестов
1 2 3 4 5 6 7 8 9 10 11 |
stage('Unit-tests') { steps { echo 'Unit-testing...' } } stage('Integration-tests') { steps { echo 'Integration-testing...' } } |
Сборки Docker-образа в формате
1 |
${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit} |
из Docker-файла
1 2 3 4 5 |
stage('Docker build') { steps { sh "docker build -t ${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit} ." } } |
Dockerfile имеет вид
1 |
# nano Dockerfile |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# base image FROM alpine:3.7 # Install python 3 and pip RUN apk add --update python3 # Install Python modules needed by the Python app COPY requirements.txt /usr/src/app/ RUN pip3 install --no-cache-dir -r /usr/src/app/requirements.txt # Copy files required for the app to run COPY app.py /usr/src/app/ COPY templates/index.html /usr/src/app/templates/ # Tell the port number the container should expose EXPOSE 5000 # Run the application CMD ["python3", "/usr/src/app/app.py"] |
Содержимое файлов app.py, requirements.txt, templates/index.html такое же, как и в предыдущей статье https://kamaok.org.ua/?p=3004
Далее по pipeline:
1 2 3 4 5 6 7 8 9 10 |
stage('Docker push') { steps { withCredentials([usernamePassword(credentialsId: 'docker-login-password-authentification', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASSWORD')]) { sh "docker login --username ${DOCKER_USER} --password ${DOCKER_PASSWORD} https://${DockerRegistryURL}" sh "docker push ${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit}" sh "docker rmi -f ${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit}" } } } |
Аутентификация в Docker-реестре/репозитарии — docker login с логином/паролем(DOCKER_USER/DOCKER_PASSWORD), значения этих переменных подставляется из Jenkins Credentials, которые необходимо создать заранее
Благодаря конструкции
1 2 3 4 |
withCredentials([usernamePassword(credentialsId: 'docker-login-password-authentification', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASSWORD')]) { … } |
Загрузка Docker-образа в удаленный Docker-репозитарий — docker push
Удаление вновь созданного(локального) Docker-образа — docker rmi
Далее, проверяется имя ветки и в зависимости от имени ветки вызывается функция по деплою либо в master либо в staging – окружение
1 2 3 4 5 6 7 8 9 10 11 12 13 |
stage('Deploy - Master') { when { environment name: 'BranchName', value: 'master' } steps { deploy('master') } } stage('Deploy - Staging') { when { environment name: 'BranchName', value: 'staging' } steps { deploy('staging') } } |
Далее в зависимости от установленного значения (master или staging) в строке
1 |
deploy('……') |
1.Устанавливается имя сервера Docker Swarm-менеджера т.к. для staging и production-окружений они различные
DOCKER_SWARM_MANAGER_NODE
( предварительно обнуляется значение переменной DOCKER_SWARM_MANAGER_NODE с помощью def DOCKER_SWARM_MANAGER_NODE = »)
2. Динамически дополняется файл .env, который содержит переменные и их значения,которые будут использоваться/доступны внутри контейнера
Все переменные, которые различаются для staging и production-окружений динамически добавляются в файл .env, который уже содержит общие для обоих окружений переменные
Например, общая для обоих окружений переменная, которая будет доступна внутри контейнера имеет имя FROM_EMAIL
1 |
# nano .env |
1 |
FROM_EMAIL=noreply@mydomain.com |
Например, различные для окружений переменные
Для master
1 2 3 4 5 6 7 |
sh ''' cat << 'EOF' >> .env SERVICE_NAME=login HOST=https://login.mydomain.com VAULT_ROLE_ID=login EOF ''' |
Для staging
1 2 3 4 5 6 7 |
sh ''' cat << 'EOF' >> .env SERVICE_NAME=login-staging HOST=https://login-staging.mydomain.com VAULT_ROLE_ID=login-staging EOF ''' |
Если ветка ни staging ни master, то прервать дальнейшее выполнение сборки
1 2 3 4 |
else { currentBuild.result = 'ABORTED' error('Aborting Build: branch isn\'t correct') } |
Далее с Jenkins-сервера устанавливается ssh-туннель(в фоновом режиме) с соответствующей окружению(master или staging) Docker Swarm менеджер нодой с пробросом Docker-сокета(/var/run/docker.sock) с менеджер ноды на Jenkins-сервер на localhost на порт 2371.Это позволяет использовать этот порт для передачи docker-команд на сокет удаленного Swarm-менеджера
1 |
sh "ssh -p2222 -i ${SWARM_KEY} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -NL localhost:2371:/var/run/docker.sock ${SWARM_USER}@${DOCKER_SWARM_MANAGER_NODE} &" |
Необходимо отметить, что подключение по SSH выполняется с помощью имени пользователя и SSH-приватного ключа, которые определяются как переменные SWARM_USER и SWARM_KEY соответственно. Значения этих переменных берутся из Jenkins Credentials благодаря конструкции
1 2 3 |
withCredentials([sshUserPrivateKey(credentialsId: 'ssh-connection-to-swarm', keyFileVariable: 'SWARM_KEY', passphraseVariable: '', usernameVariable: 'SWARM_USER')]) { … } |
Такие Jenkins Credentials необходимо создать заранее.
Далее выполняется deploy сервиса ,указанного в docker-compose-cats.yaml файле, в Docker Swarm кластере
1 |
sh "docker -H localhost:2371 stack deploy -c docker-compose-cats.yaml --with-registry-auth cats" |
Передача параметра —with-registry-auth cats позволяет worker-нодам успешно аутентифицироваться в Docker-реестре/репозитарии и скачать обновленный образ с хранилища
Благодаря тому, что jenkins-сервер успешно аутентифицируется в Docker-репозитарии с помощью конструкции
1 2 3 |
withDockerRegistry(credentialsId: 'docker-login-password-authentification', url: 'https://${DockerRegistryURL}') { … } |
Внутри этой конструкции и выполняется команда по деплою сервиса в кластере
Файл docker-compose-cats.yaml-файл имеет вид
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
version: "3" services: cats: image: ${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit} env_file: .env deploy: replicas: 2 placement: constraints: - node.role == worker ports: - "8085:5000" logging: driver: "json-file" options: max-size: "2m" |
т.е. запускается 2 реплики на worker-нодах приложения в сервисе cats с образа, собранного и загруженного в Docker-репозитарий в начале pipeline
Сделаем коммит в master-ветку в Bitbucket и проверим автоматический запуск сборки с веткой master и ее корректное выполнение
Сборка запущена коммитом в Bitbucket
Успешная сборка образа в нужном нам формате
Успешная аутентификация на удаленном Docker-репозитарии и загрузка в него собранного образа
Удаление локального собранного образа
Деплой на Docker Swarm-кластер
Пропуск стадии деплоя на staging-окружение т.к. деплоим только на production-окружение по причине имени ветки(master), в которую был сделан коммит
Успешное завершение выполнения всей сборки
Создание Jenkins pipeline rollback-сборки для ручного отката/передеплоя приложения с необходимым Docker-образом
Для выполнения rollback на нужную версию образа вручную запускаем эту сборку
При таком запуске необходимо выбрать/определить несколько опций
Имя ветки: staging или master
1 2 3 |
choice(name: 'BranchName', choices: 'staging\nmaster', description: 'Chose correct branch name') |
Указать необходимую временную метку, которая содержится в имени Docker-образа
1 2 3 |
string(name: 'BuildTimestamp', defaultValue: '', description: 'Chose correct build timestamp. Format: yyyy-MM-dd-HH-mm. As example, 2018-12-22-12-34') |
Указать необходимый hash-коммита(7-и символьный),которая содержится в имени Docker-образа
1 2 3 |
string(name: 'HashCommit', defaultValue: '', description: 'Chose correct hash short commit. Format: 7 characters. As example, d8fe255') |
Напомним, что имя Docker-образа имеет формат
1 |
${DockerRegistryURL}/${DockerImageName}:${BranchName}-${BuildTimestamp}-${HashCommit} |
Pipeline rollback сборки
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
pipeline { agent { label 'linux-slave1' } options { buildDiscarder logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '3') disableConcurrentBuilds() timestamps() } parameters { choice(name: 'BranchName', choices: 'staging\nmaster', description: 'Chose correct branch name') string(name: 'BuildTimestamp', defaultValue: '', description: 'Chose correct build timestamp. Format: yyyy-MM-dd-HH-mm. As example, 2018-12-22-12-34') string(name: 'HashCommit', defaultValue: '', description: 'Chose correct hash short commit. Format: 7 characters. As example, d8fe255') } environment { DockerRegistryURL = 'mydocker.repo.servername' DockerImageName = 'myapp' BranchName = "${params.BranchName}" BuildTimestamp = "${params.BuildTimestamp}" HashCommit = "${params.HashCommit}" } stages { stage('Deploy - Master') { when { environment name: 'BranchName', value: 'master' } steps { deploy('master') } } stage('Deploy - Staging') { when { environment name: 'BranchName', value: 'staging' } steps { deploy('staging') } } } } def deploy(BranchName) { def DOCKER_SWARM_MANAGER_NODE = '' if ("${BranchName}" == 'master') { DOCKER_SWARM_MANAGER_NODE = "swarm-manager-production.mydomain.com" sh ''' cat << 'EOF' >> .env SERVICE_NAME=login HOST=https://login.mydomain.com VAULT_ROLE_ID=login EOF ''' } else if ("${BranchName}" == 'staging') { DOCKER_SWARM_MANAGER_NODE = "swarm-manager-staging.mydomain.com" sh''' cat << 'EOF' >> .env SERVICE_NAME=login-staging HOST=https://login-staging.mydomain.com VAULT_ROLE_ID=login-staging EOF ''' } else { currentBuild.result = 'ABORTED' error('Aborting Build: branch isn\'t correct') } withCredentials([sshUserPrivateKey(credentialsId: 'ssh-connection-to-swarm', keyFileVariable: 'SWARM_KEY', passphraseVariable: '', usernameVariable: 'SWARM_USER')]) { sh "ssh -p2222 -i ${SWARM_KEY} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -NL localhost:2371:/var/run/docker.sock ${SWARM_USER}@${DOCKER_SWARM_MANAGER_NODE} &" withDockerRegistry(credentialsId: 'docker-login-password-authentification', url: 'https://${DockerRegistryURL}') { sh "echo Deploying image ${BranchName}-${BuildTimestamp}-${HashCommit}" sh "docker -H localhost:2371 stack deploy -c docker-compose-cats.yaml --with-registry-auth cats" } } } |