UMGUM.COM 

Docker + Jenkins ( Развёртывание несколько инстансов "Jenkins" в среде контейнеризации "Docker" с целью изоляции процедур CI/CD раздельно работающих команд разработки. )

4 февраля 2021  (обновлено 25 марта 2021)

OS: "Linux Debian 9/10", "Linux Ubuntu Server 16/18/20 LTS".
Apps: "Jenkins", "Java 8/11", "Nginx", "Let`s Encrypt", "Docker", "Docker Compose".

Задача: развернуть несколько инстансов "Jenkins" в среде контейнеризации "Docker" с целью изоляции процедур CI/CD раздельно работающих команд разработки.

Прежде всего надо бы рассказать, отчего такая задача - изоляция команд разработки путём запуска для них отдельных CI/CD-серверов - возникла. Дело в том, что у "Jenkins", который вырос из маленького подручного инструмента, вообще нет механизмов ограничения доступа к его же внутренностям - практически любой полноценный пользователь web-интерфейса может получить доступ к другим проектам на этом сервере, даже несмотря на то, явно ему туда ходить не разрешено или даже запрещено плагинами. Об этом я ранее уже рассказывал в предыдущей инструкции по запуску одного экземпляра "Jenkins". Ввиду невозможности изолировать проектные группы в рамках одного инстанса "Jenkins" приходится запускать по экземпляру "Jenkins" для каждой проектной группы по отдельности. Это несложно, как станет понятно далее.

Перед тем, как перейти к дальнейшим инсталляционным работам важно учесть следующий нюанс. Работа "Jenkins" поддерживается только в среде исполнения "Java 8" и "Java 11 (LTS)" (причём не основной ветви "Sun/Oracle Java", а не бесплатном "OpenJDK JVM"). "Jenkins" запустится и будет работать внешне одинаково, с точки зрения конечного пользователя, на любой из этих двух версий "Java". Но из "Java 11" вырезана поддержка устаревшей технологии "WebStart (JNLP)" посредством которой быстро и непринуждённо подключаются к серверу "Jenkins" агенты исполнения "Slaves" в среде операционных систем "Microsoft Windows" - прямо из браузера, в пару щелчков мыши. Если "Jenkins" запустить в среде "Java 11", то подключение агентов на windows-системах придётся осуществлять так же, как и для linux-систем - через сеансы SSH-подключений. Это влечёт за собой необходимость устанавливать на windows-системах службы "OpenSSH". Таким образом, мы стоим перед выбором: работать на старье, но проще подключать агентов исполнения, или идти в ногу с прогрессом, усложняя себе задачу предварительной подготовки к CI/CD-процедурам на операционных системах "Microsoft Windows". Здесь рассматривается инсталляция "Jenkins" в среде "Java 11".

Добавлю ещё немного рассуждений об инсталяции. "Jenkins" представляет собой монолитное приложение, запускаемое как java-сервлет в любом современном сервере приложений, вроде "Jetty", "Apache Tomcat", "GlassFish" или "WildFly". Ещё три года назад я бы собрал из распространяемого командой разработчиков "Jenkins" WAR-файла и "Tomcat" свой docker-образ. Однако сейчас вся эа работу уже проделана людьми гораздо опытнее меня и на "Docker Hub" нас ждёт всегда актуальные сборки "Jenkins in Docker", за качество сборки которых волноваться не приходится, судя по простоте и аккуратности "Dockerfile". Процесс развёртывания и конфигурирования разработчиками "Jenkins" хорошо расписан (здесь и здесь, с вариациями), так что просто сделаем это.

Последовательность дальнейших действий такова:

1. Подготовка системного окружения (отдельная инструкция);
2. Установка системы контейнеризации "Docker" (отдельная инструкция);
3. Подготовка несущей файловой структуры для приложений "Jenkins";
4. Модификация официального docker-образа "Jenkins";
5. Подготовка среды и запрос "Let`s Encrypt" SSL-сертификатов;
6. Подготовка конфигурации для фронтального web-сервера "Nginx";
7. Наладка запуска посредством "Docker Compose" и "Systemd";
8. Конфигурирование инстансов web-приложения "Jenkins".


Подготовка несущей файловой структуры для приложений "Jenkins".

Создаём корневые директории для каждого инстанса "Jenkins" по отдельности, а также директорию для журналов событий docker-контейнеров (непосредственно у каждого инстанса будут ещё свои журналы, управлять которыми удобнее уже в контексте приложений):

# mkdir -p /var/opt/jenkins/group0
# mkdir -p /var/opt/jenkins/group1
....
# mkdir -p /var/opt/jenkins/groupX
# mkdir -p /var/opt/jenkins/logs

Из Dockerfile официального образа "Jenkins" легко узнать, что процесс внутри него запускается в контексте пользователя "jenkins" с UID/GID:1000. Это неудобно, так как в несущей системе наверняка уже имеется пользователь с таким UID/GID и не получится его задать новому пользователю "jenkins" в несущей системе для очевидного отображения назначаемых прав доступа к монтируемым внутрь файлам. Мне удобнее выбрать произвольный UID/GID для пользователя "jenkins", который позже можно будет задать и в docker-контейнере:

# groupadd --system --gid 500 jenkins
# useradd --system --home-dir /var/opt/jenkins --shell /bin/false --gid jenkins --uid 500 jenkins

# chown -R jenkins:jenkins /var/opt/jenkins
# chmod -R o-rwx /var/opt/jenkins

Модификация официального docker-образа "Jenkins".

Как я выше отметил, по умолчанию "Jenkins" запускается в контексте пользователя с неудобными для эксплуатации UID:GID, и я предпочитаю на основе официального docker-образа делать свой:

Подготовим место в файловой системе для задач модификации dockder-образов:

# mkdir -p /usr/local/etc/devops/images/jenkins
# cd /usr/local/etc/devops/images/jenkins

Готовим описание docker-образа, предназначенного для модификации UID:GID, с которыми запускается "Jenkins":

# vi ./Dockerfile-jenkins-lts-jdk11-chuid

FROM jenkins/jenkins:lts-jdk11
LABEL maintainer="NSU, Andrey Narozhniy"

# set file paths used by default
ENV JENKINS_HOME /var/jenkins_home
ENV REF /usr/share/jenkins/ref

# use superuser to change configuration
USER root

# overriding file permissions
RUN mkdir -p "${JENKINS_HOME}" "${REF}" \
  && usermod --uid 500 jenkins && groupmod --gid 500 jenkins \
  && usermod -d "${JENKINS_HOME}" jenkins \
  && chown -R jenkins:jenkins "${JENKINS_HOME}" "${REF}"

# restore ENTRYPOINT to startup as jenkins user
VOLUME "${JENKINS_HOME}"
USER jenkins

Собираем "образ", явно отключив кеширование на основе слоёв от предыдущих попыток:

# docker build --no-cache --tag selfmade:jenkins-lts-jdk11-chuid --file ./Dockerfile-jenkins-lts-jdk11-chuid . >> ./build-jenkins-lts-jdk11-chuid.log

После успешной сборки можно видеть, что с перечне docker-образов с "Jenkins" появился и наш, кастомный, по размеру не практически отличающийся от исходного:

# docker images

REPOSITORY       TAG                      IMAGE ID      ...  SIZE
selfmade         jenkins-lts-jdk11-chuid  6576a6f378b3  ...  681MB
jenkins/jenkins  lts-jdk11                f50504bf2e45  ...  681MB

Ради интереса можно запустить docker-контейнер на основе нашего "образа", чтобы убедится, что web-приложение успешно развернётся в желаемом нами контексте:

# mkdir -p /tmp/jenkins && chown -R jenkins:jenkins /tmp/jenkins
# docker run --rm --name test-jenkins -u jenkins -v /tmp/jenkins:/var/jenkins_home selfmade:jenkins-lts-jdk11-chuid

Если зайти внутрь тестового контейнера, то можно убедится в успешной замене UID:GID целевого пользователя:

# docker exec -ti test-jenkins bash
jenkins@test-jenkins:/$ id

uid=500(jenkins) gid=500(jenkins) groups=500(jenkins)

Также при взгляде на контейнер с несущей операционной системы видно корректное сопоставление имени внешнего и внутреннего пользователя, в контексте которого исполняется приложение:

# docker top test-jenkins

UID      ...  CMD
jenkins  ...  java -Duser.home=/var/jenkins_home ... -jar /usr/share/jenkins/jenkins.war

Подготовка среды и запрос "Let`s Encrypt" SSL-сертификатов.

Если имеется wildcard-сертификат, который используется для терминирования SSL/TLS-подключений, то этот этап можно пропустить. Для мелких же проектов наладка работы с автоматически запрашиваемыми и продлеваемыми сертификатами от "Let`s Encrypt" в последние годы стала стандартом.

Создаём в файловой системе несущего сервера директорию для сертификатов и генерируем DH-файл:

# mkdir -p /var/opt/letsencrypt
# openssl dhparam -out /var/opt/letsencrypt/dhparam-2048.pem 2048
# chown -R root:root /var/opt/letsencrypt && chmod -R o-rwx /var/opt/letsencrypt

Подготавливаем структуру для агента "certbot", запрашивающего и продлевающего сертификаты:

# mkdir -p /var/opt/certbot/nginx/etc/conf.d
# mkdir -p /var/opt/certbot/logs
# mkdir -p /var/opt/certbot/webroot/.well-known/acme-challenge

Внутри docker-контейнера "Nginx" запущен в контексте пользователя с UID:101 - просто даём такому UID право чтения из директории "webroot" (процесс запроса и подтверждения владения web-ресурсом уже расписан на этом сайте):

# chown -R 101:root /var/opt/certbot && chmod -R o-rwx /var/opt/certbot

Опишем крайне простую конфигурацию сайта, предназначенного лишь для выдачи ключа валидации по запросу со стороны сервиса "Let`s Encrypt":

# vi /var/opt/certbot/nginx/etc/conf.d/group0.jenkins.example.net.conf

server {
  listen      80;
  listen [::]:80;
  server_name group0.jenkins.example.net
              group1.jenkins.example.net
              groupX.jenkins.example.net;
  server_tokens off;
  location /.well-known/acme-challenge {
    root /var/www/letsencrypt;
  }
}

Считая, что никаких других web-сервисов на несущем сервере ещё нет, запускаем docker-контейнер с "Nginx", только лишь для первичного запроса SSL-сертификатов:

# docker run -d --rm --name letsencrypt-nginx -p 80:80 -v /var/opt/certbot/nginx/etc/conf.d:/etc/nginx/conf.d -v /var/opt/certbot/webroot:/var/www/letsencrypt nginx:latest

Воспользовавшись готовой сборкой docker-образа агента "certbot" запрашиваем SSL-сертификаты у "Let`s Encrypt", пачкой для всех используемых инстансами "Jenkins" FQDN:

# docker run --rm --name letsencrypt-certbot -v /var/opt/certbot/webroot:/var/www/letsencrypt -v /var/opt/letsencrypt:/etc/letsencrypt certbot/certbot certonly --webroot --noninteractive --agree-tos --no-eff-email --register-unsafely-without-email -w /var/www/letsencrypt -d group0.jenkins.example.net group1.jenkins.example.net groupX.jenkins.example.net >> /var/opt/certbot/logs/docker-output-certbot.log 2>&1

После успешного получения SSL-сертификатов останавливаем вспомогательный инстанс "Nginx":

# docker stop letsencrypt-nginx

Автоматизация продления SSL-сертфикатов от "Let`s Encrypt".

Учитывая то, что в рабочем режиме фронтальный web-сервер всегда запущен, достаточно будет лишь наладить регулярный запуск агента "certbot" (в примере раз в неделю, каждый Вторник, в полночь):

# vi /etc/crontab

....
# Certificates renew using Let`s Encrypt "Certbot client" in Docker
0 0  * * 2  root docker run --rm --name letsencrypt-certbot -v /var/opt/certbot/webroot:/var/www/letsencrypt -v /var/opt/letsencrypt:/etc/letsencrypt certbot/certbot certonly --webroot --noninteractive --agree-tos --no-eff-email --register-unsafely-without-email -w /var/www/letsencrypt -d group0.jenkins.example.net group1.jenkins.example.net groupX.jenkins.example.net >> /var/opt/certbot/logs/docker-output-certbot.log 2>&1 &

Подготовка конфигурации для фронтального web-сервера "Nginx".

Создадим в файловой системе несущего сервера структуры для конфигурации "Nginx":

# mkdir -p /var/opt/nginx/etc/conf.d
# mkdir -p /var/opt/nginx/logs

Конфигурация сайтов принимающего подключения пользователей web-сервера "Nginx" проста и сводится к описанию параметров "проксирования" всех запросов web-сервису, запущенному внутри docker-контейнера:

# vi /var/opt/nginx/etc/conf.d/group0.jenkins.example.net.conf

# Обслуживание запросов "Let`s Encrypt" и редирект остальных
server {
  listen      80;
  listen [::]:80;
  server_name group0.jenkins.example.net;
  server_tokens off;
  location /.well-known/acme-challenge {
    root /var/www/letsencrypt;
  }
  location / {
    return 307 https://$host$request_uri;
  }
}

# Приём и доставка запросов соответствующему инстансу "Jenkins"
server {
  listen      443 ssl http2;
  listen [::]:443 ssl http2;
  server_name group0.jenkins.example.net;
  server_tokens off;
  ssl_certificate /etc/letsencrypt/live/group0.jenkins.example.net/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/group0.jenkins.example.net/privkey.pem;
  ssl_dhparam /etc/letsencrypt/dhparam-2048.pem;
  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
  ssl_prefer_server_ciphers on;
  ssl_ciphers "HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
  ssl_buffer_size 8k;
  ssl_stapling on;
  ssl_stapling_verify on;
  proxy_connect_timeout 3s; # (default: 60s)
  location / {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_set_header X-NginX-Proxy true;
    proxy_pass http://jenkins-group0:8080;
    proxy_redirect off;
  }
}

# Приём и доставка запросов соответствующему инстансу "Jenkins"
server {
  ....
  server_name group1.jenkins.example.net;
  ....
  location / {
    ....
    proxy_pass http://jenkins-group1:8080;
    ....
  }
}

# Приём и доставка запросов соответствующему инстансу "Jenkins"
server {
  ....
  server_name groupX.jenkins.example.net;
  ....
  location / {
    ....
    proxy_pass http://jenkins-groupX:8080;
    ....
  }
}

По умолчанию "Nginx" внутри docker-контейнера стартует в контексте суперпользователя "root", так что убираем доступ к его конфигурации для всех остальных:

# chown -R root:root /var/opt/nginx
# chmod -R go-rwx /var/opt/nginx

Наладка запуска посредством "Docker Compose".

Создаём директорию для размещения конфигурационных файлов "Docker Compose":

# mkdir -p /usr/local/etc/compose
# cd /usr/local/etc/compose

Воспользуемся для единственного в нашей схеме конфигурационного файла именем "по умолчанию":

# vi ./docker-compose.yml

version: "3"
services:
  nginx:
    depends_on:
      - jenkins-group0
      - jenkins-group1
      - jenkins-groupX
    container_name: nginx
    image: nginx:latest
    networks:
      jenkins:
        aliases:
          - "nginx"
    ports:
      - 80:80
      - 443:443
    environment:
      TZ: "/etc/timezone"
    volumes:
      - "/var/opt/nginx/etc/conf.d:/etc/nginx/conf.d:ro"
      - "/var/opt/letsencrypt:/etc/letsencrypt:ro"

  jenkins-group0:
    container_name: jenkins-group0
    image: selfmade:jenkins-lts-jdk11-chuid
    networks:
      jenkins:
        aliases:
          - "jenkins-group0"
    environment:
      TZ: "/etc/timezone"
      JAVA_OPTS: "-Djava.awt.headless=true -Dhudson.footerURL=https://group0.jenkins.example.net"
      #JENKINS_SLAVE_AGENT_PORT: "50000"
    working_dir: "/var/opt/jenkins/group0"
    volumes:
      - "/var/opt/jenkins/group0:/var/jenkins_home:rw"

  jenkins-group1:
    container_name: jenkins-group1
    ....
    volumes:
      - "/var/opt/jenkins/group1:/var/jenkins_home:rw"

  jenkins-groupX:
    container_name: jenkins-groupX
    ....
    volumes:
      - "/var/opt/jenkins/groupX:/var/jenkins_home:rw"

networks:
  jenkins:
    driver: bridge
    internal: false
    ipam:
      driver: default
      config:
        - subnet: 100.127.255.0/24

Запускаем посредством "Docker Compose" всю пачку контейнеров:

# cd /usr/local/etc/compose
# docker-compose up --remove-orphans --build --force-recreate -d

Пример точечного запуска определённого контейнера:

# docker-compose up --no-start jenkins-group0
# docker-compose start jenkins-group0

Подключаемся к выводу docker-контейнеров посредством базового функционала "Docker":

# docker logs --follow jenkins-group0 > /var/opt/jenkins/logs/docker-output-jenkins-group0.log 2>&1 &
# docker logs --follow nginx > /var/opt/nginx/logs/docker-output-nginx.log 2>&1 &

Пример точечной остановки и высвобождения ресурсов определённого контейнера:

# docker-compose stop jenkins-group0
# docker-compose rm -f -v jenkins-group0

Останавливаем разом все docker-контейнеры, описанные в конфигурации "Docker Compose":

# docker-compose down

Настройка автозапуска "Docker Compose" посредством "Systemd".

Создаём файл описания параметров запуска и остановки docker-контейнера посредством "Docker Compose" посредством короткоживущего сервиса "Systemd":

# vi /etc/systemd/system/jenkins-docker.service

[Unit]
Description=Jenkins in Docker Compose Service
Requires=network.target docker.service containerd.service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/usr/local/etc/compose

ExecStartPre=-/bin/bash -c 'chown -R jenkins:jenkins /var/opt/jenkins'
ExecStartPre=/usr/local/bin/docker-compose -f docker-compose.yml down --remove-orphans
ExecStart=/usr/local/bin/docker-compose -f /usr/local/etc/compose/docker-compose.yml up --remove-orphans --build --force-recreate --detach
#
ExecStartPost=/bin/sleep 5
#
ExecStartPost=-/bin/bash -c '/usr/bin/docker logs --follow jenkins-group0 > /var/opt/jenkins/logs/docker-output-jenkins-group0.log 2>&1 &'
ExecStartPost=-/bin/bash -c '/usr/bin/docker logs --follow jenkins-group1 > /var/opt/jenkins/logs/docker-output-jenkins-group1.log 2>&1 &'
ExecStartPost=-/bin/bash -c '/usr/bin/docker logs --follow jenkins-groupX > /var/opt/jenkins/logs/docker-output-jenkins-groupX.log 2>&1 &'
ExecStartPost=-/bin/bash -c '/usr/bin/docker logs --follow nginx > /var/opt/nginx/logs/docker-output-nginx.log 2>&1 &'
#
ExecStop=/usr/local/bin/docker-compose -f /usr/local/etc/compose/docker-compose.yml down

[Install]
WantedBy=multi-user.target

Указываем "Systemd" перечитать и принять новую конфигурацию, а потом явно активируем и запускаем новый сервис:

# systemctl daemon-reload
# systemctl enable jenkins-docker.service
# systemctl start jenkins-docker

Смотрим журнал событий "Systemd" если "что-то пошло не так":

# systemctl status jenkins-docker.service
# journalctl -xe

Наладка ротации файлов журнала событий.

Не забываем, что для самодельных сервисов ротация их журналов событий не ведётся, и нужно настраивать это отдельно:

# vi /etc/logrotate.d/docker-opt

/var/opt/*/logs/*.log {
  size 30M
  missingok
  notifempty
  rotate 5
  compress
  delaycompress
  copytruncate
  su root root
}

Проверяем корректность конфигурации, не воздействуя при этом на файлы журналов:

# logrotate -d /etc/logrotate.d/docker-opt

Конфигурирование инстансов web-приложения "Jenkins".

В дальнейшем все необходимые настройки "Jenkins" делаются через несколько неудобный, но вполне функциональный web-интерфейс. Просто заходим по очереди на сайты инстансов, аутентифицируемся как суперпользователь "admin" (в первый раз, до обязательной смены, пароль достаём из файла вроде "/var/opt/jenkins/group0/secrets/initialAdminPassword") и производим первичное конфигурирование.

Инсталлятор "Jenkins" потребует задать пароль и почтовый адрес для администратора, а также указать корректный FQDN для web-сервиса. После этого будет предоставлена возможность выбора устанавливаемых плагинов.

После получения доступа в web-интерфейс "Jenkins" прежде всего есть смысл активировать управление правами доступа пользователей посредством плагина "Matrix Authorization Strategy Plugin":

Jenkins -> Manage Jenkins -> Configure Global Security -> Authorization:
  Strategy: Matrix-based security

Таблица распределения прав доступа простая и очевидная.

Учитывая то, что "Jenkins" запускается внутри docker-контейнера и потому самостоятельно необновляем, рекомендую отключить назойливое предупреждение о выходе новых релизов:

Jenkins -> Manage Jenkins -> Configure System -> Administrative monitors configuration:
  Jenkins Update Notification: false

На этом всё. О более детальной настройке "Jenkins" как такового можно почитать в предыдущей публикации "Инсталляция Jenkins".


Заметки и комментарии к публикации:


Оставьте свой комментарий ( выразите мнение относительно публикации, поделитесь дополнительными сведениями или укажите на ошибку )