Привет всем! В предыдущей статье мы подробно рассмотрели реализацию непрерывной интеграции Continuous Integration (CI) на базе Gitea/Forgejo в CI/CD платформе Gitorion. В данной статье предлагаем вашему вниманию подробнее познакомиться с внедрением непрерывной доставки Continuous Delivery (CD) в CI/CD платформу Gitorion на базе Jenkins.
Jenkins Agents
Jenkins выполняет все команды пайплайнов в агентах, которые запускает как модули в кластере Kubernetes по Web-хуку из Gitea/Forgejo. Спецификацию модуля агента Jenkins задайте в Jenkinsfile:
pipeline {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
metadata:
name: build-pod
annotations:
container.apparmor.security.beta.kubernetes.io/buildkitd: unconfined
labels:
app.kubernetes.io/component: jenkins-dind
app.kubernetes.io/instance: jenkins
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- "dc1-worker1"
containers:
- name: jnlp
resources: {}
- name: docker-client
image: docker:dind-rootless
imagePullPolicy: IfNotPresent
env:
- name: DOCKER_HOST
value: "unix:///run/user/1000/docker.sock"
securityContext:
privileged: true
- name: buildkitd
image: moby/buildkit:master-rootless
imagePullPolicy: IfNotPresent
args:
- --oci-worker-no-process-sandbox
- --addr
- unix:///run/user/1000/buildkit/buildkitd.sock
- --addr
- tcp://0.0.0.0:1234
readinessProbe:
exec:
command:
- buildctl
- debug
- workers
initialDelaySeconds: 5
periodSeconds: 30
livenessProbe:
exec:
command:
- buildctl
- debug
- workers
initialDelaySeconds: 5
periodSeconds: 30
securityContext:
seccompProfile:
type: Unconfined
runAsUser: 1000
runAsGroup: 1000
volumeMounts:
- mountPath: /home/user/.local/share/buildkit
name: buildkitd
volumes:
- name: buildkitd
emptyDir: {}
"""
}
}
stage ('build') {
steps {
container('docker-client') {
script {
...
}
}
}
}
stage('staging') {
steps {
script {
container('docker-client') {
script {
...
}
}
}
}
}
...
}
Агент вытягивает Git-репозиторий приложения из Gitea/Forgejo по Web-хуку и выполняет стадии пайплана в Jenkinsfile, расположенном в корне git-репозитория.

Безопасная сборка Docker-образов в Kubernetes
Для сборки Docker-образов в кластере Kubernetes запустите в модуле агента Jenkins "build-pod" контейнер "docker-client", созданный из Docker-образа "image: docker:dind-rootless". В данном контейнере будет запущен Docker-сервер, который выполнит все команды docker из пайплайна. Сборку Docker-образов Docker-сервер отправляет на Buildkitd-сервер, запущенный в контейнере "buildkitd" из образа "image: moby/buildkit:master-rootless".
Из соображений безопасности настоятельно рекомендуем использовать только rootless образы dind и buildkit, в которых все процессы запускаются от имени пользователя, не имеющего root-прав. В контейнерах, созданных из образов dind:latest и buildkit:latest, процессы запускаются с root-правами, что дает возможность получить root-доступ к хосту кластера Kubernetes. Также следует запретить в настройках git-сервера вносить изменения в Jenkinsfile всем, кроме доверенных лиц, чтобы лишить возможности переопределить модуль агента Jenkins. Как это сделать, мы подробно рассказали в статье про непрерывную интеграцию CI в пункте "Защита веток".
На рисунке ниже приведем пример сборки Docker-образа в пайплайне Jenkins

Кэш сборки Docker-образов
Как известно, Docker-образы последовательно собираются слоями, заданными в Dockerfile. Рассмотрим данный механизм на примере Dockerfile бэкенда:
FROM registry.gitorion.ru/gitorion/php:8.3.1-fpm-alpine3.19 as buildimage
RUN echo -e "https://mirror.yandex.ru/mirrors/alpine/v3.19/main\nhttps://mirror.yandex.ru/mirrors/alpine/v3.19/community" > /etc/apk/repositories \
&& apk update \
&& apk --no-cache add \
postgresql-dev \
libmemcached-libs \
zlib
RUN set -xe && \
cd /tmp/ && \
apk add --no-cache --update --virtual .phpize-deps $PHPIZE_DEPS && \
apk add --no-cache --update --virtual .memcached-deps zlib-dev libmemcached-dev cyrus-sasl-dev && \
pecl install igbinary && \
( \
pecl install --nobuild memcached && \
cd "$(pecl config-get temp_dir)/memcached" && \
phpize && \
./configure --enable-memcached-igbinary && \
make -j$(nproc) && \
make install && \
cd /tmp/ \
)
RUN docker-php-ext-install \
mysqli \
pdo \
pdo_mysql \
pgsql \
pdo \
pdo_pgsql
FROM registry.gitorion.ru/gitorion/php:8.3.1-fpm-alpine3.19
WORKDIR /var/www/html
EXPOSE 9000
COPY --from=buildimage /usr/local/lib/php/extensions/ \
/usr/local/lib/php/extensions/
RUN echo -e "https://mirror.yandex.ru/mirrors/alpine/v3.19/main\nhttps://mirror.yandex.ru/mirrors/alpine/v3.19/community" > /etc/apk/repositories \
&& apk update \
&& apk --no-cache add \
libpq \
libmemcached-libs \
&& docker-php-ext-enable \
mysqli \
pdo_mysql \
pgsql \
pdo_pgsql \
pdo \
igbinary \
memcached
COPY ./entrypoint.sh /app/entrypoint.sh
COPY ./src /var/www/html
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "/usr/local/sbin/php-fpm", "-F" ]
В слое RUN сначала выполняется компиляция расширений PHP из исходного кода, и чуть ниже установка пакетов командой apk. Оба действия затрачивают много времени и ресурсов, однако не требуются при каждой сборке Docker-образа, когда вносят правки в код сайта на PHP, который на финальной стадии сборки копируется в Docker-образ из git-репозитория:
COPY ./src /var/www/html
Существенно ускорить процесс сборки Docker-образа можно, используя кэш сборки. Docker позволяет не собирать каждый раз слои не претерпевшие изменения, а взять уже собранные из кэша предыдущей сборки. Вот так выглядит команда сборки Docker-образа:
docker buildx build --push -t registry.gitorion.ru/gitorion/owneruser/backend/feature-change-version:e0a232294efa0b3b73c6a9147a9919e2c35d6943 --cache-to 'type=registry,image-manifest=true,ref=registry.gitorion.ru/gitorion/owneruser/backend/feature-change-version:latest,mode=min' --cache-from 'type=registry,image-manifest=true,ref=registry.gitorion.ru/gitorion/owneruser/backend/feature-change-version:latest' --cache-from 'type=registry,image-manifest=true,ref=registry.gitorion.ru/gitorion/php:8.3.1-fpm-alpine3.19' .
Docker-образ "feature-change-version:e0a232294efa0b3b73c6a9147a9919e2c35d6943" собранный данной командой и кэш сборки "feature-change-version:latest" (параметр --cache-to) будут помещены в приватный репозиторий "https://registry.gitorion.ru". При следующей сборке уже будет задействован кэш предыдущей сборки "feature-change-version:latest" (параметр --cache-from).
повторная сборка Docker-образа с кэшем
docker buildx create --use '--driver=remote' tcp://127.0.0.1:1234 amazing_ride
[Pipeline] sh
docker buildx build --push -t registry.gitorion.kvm/gitorion/owneruser/backend/feature-change-version:c52901ce5e46f886cbc5529e70eacc9e525a9718 --cache-to 'type=registry,image-manifest=true,ref=registry.gitorion.kvm/gitorion/owneruser/backend/feature-change-version:latest,mode=min' --cache-from 'type=registry,image-manifest=true,ref=registry.gitorion.kvm/gitorion/owneruser/backend/feature-change-version:latest' --cache-from 'type=registry,image-manifest=true,ref=registry.gitorion.kvm/gitorion/php:8.3.1-fpm-alpine3.19' .
#0 building with "amazing_ride" instance using remote driver
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 1.98kB done
#1 DONE 0.1s
#2 [auth] gitorion/php:pull token for registry.gitorion.kvm
#2 DONE 0.0s
#3 [internal] load metadata for registry.gitorion.kvm/gitorion/php:8.3.1-fpm-alpine3.19
#3 DONE 0.4s
#4 [internal] load .dockerignore
#4 transferring context: 2B done
#4 DONE 0.1s
#5 [internal] load build context
#5 DONE 0.0s
#6 [auth] gitorion/owneruser/backend/feature-change-version:pull token for registry.gitorion.kvm
#6 DONE 0.0s
#7 [buildimage 1/4] FROM registry.gitorion.kvm/gitorion/php:8.3.1-fpm-alpine3.19@sha256:26756a4f0f716c9e16e2d19a875d229150523d25e93ebd1e2f45dc229987166f
#7 resolve registry.gitorion.kvm/gitorion/php:8.3.1-fpm-alpine3.19@sha256:26756a4f0f716c9e16e2d19a875d229150523d25e93ebd1e2f45dc229987166f 0.0s done
#7 DONE 0.1s
#8 importing cache manifest from registry.gitorion.kvm/gitorion/php:8.3.1-fpm-alpine3.19
#8 inferred cache manifest type: application/vnd.docker.distribution.manifest.v2+json done
#8 DONE 0.2s
#9 importing cache manifest from registry.gitorion.kvm/gitorion/owneruser/backend/feature-change-version:latest
#9 inferred cache manifest type: application/vnd.oci.image.manifest.v1+json done
#9 DONE 0.2s
#5 [internal] load build context
#5 transferring context: 2.41kB done
#5 DONE 0.1s
#10 [stage-1 4/6] RUN echo -e "https://mirror.yandex.ru/mirrors/alpine/v3.19/main\nhttps://mirror.yandex.ru/mirrors/alpine/v3.19/community" > /etc/apk/repositories && apk update && apk --no-cache add libpq libmemcached-libs && docker-php-ext-enable mysqli pdo_mysql pgsql pdo_pgsql pdo igbinary memcached
#10 CACHED
#11 [stage-1 2/6] WORKDIR /var/www/html
#11 CACHED
#12 [buildimage 2/4] RUN echo -e "https://mirror.yandex.ru/mirrors/alpine/v3.19/main\nhttps://mirror.yandex.ru/mirrors/alpine/v3.19/community" > /etc/apk/repositories && apk update && apk --no-cache add postgresql-dev libmemcached-libs zlib
#12 CACHED
#13 [buildimage 3/4] RUN set -xe && cd /tmp/ && apk add --no-cache --update --virtual .phpize-deps autoconf dpkg-dev dpkg file g++ gcc libc-dev make pkgconf re2c && apk add --no-cache --update --virtual .memcached-deps zlib-dev libmemcached-dev cyrus-sasl-dev && pecl install igbinary && ( pecl install --nobuild memcached && cd "(nproc) && make install && cd /tmp/ )
#13 CACHED
#14 [buildimage 4/4] RUN docker-php-ext-install mysqli pdo pdo_mysql pgsql pdo pdo_pgsql
#14 CACHED
#15 [stage-1 3/6] COPY --from=buildimage /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
#15 CACHED
#16 [stage-1 5/6] COPY ./entrypoint.sh /app/entrypoint.sh
#16 CACHED
#17 [stage-1 6/6] COPY ./src /var/www/html
#17 DONE 0.1s
Видим, что слои не претерпевшие изменений, взяты из кэша и помечены как CACHED.
В итоге, на первичную сборку Docker-образа без кэша потребовалось "1min45s" а на повторную сборку с кэшем "14s".

Приватный реестр Docker-образов
Для хранения собранных Docker-образов установили в кластер Kubernetes приватный реестр Docker-образов Harbor. Jenkins Agent выполняет команду docker push и отправляет Docker-образ, собранный на стадии Build, в приватный реестр Docker-образов. На стадии доставки приложений в кластер Kubernetes ресурсы Deployment или StatefulSet берут Docker-образы из приватного реестра Docker-образов и используют для запуска Docker-контейнеров приложений в кластере Kubernetes.

Multibranch Pipeline
В предыдущей статье про непрерывную интеграцию Continuous Integration на базе Gitea/Forgejo мы упоминали, что каждый программист вносит изменения в код только в пределах своей ветки. В Jenkins есть пайплайн типа Multibranch Pipeline, который автоматически обнаруживает новые ветки в git-репозитории и для каждой из них запускает свой пайплайн. Программист создает новую ветку командами:
git checkout -b feature/change-version
git push --set-upstream origin feature/change-version
Gitea/Forgejo посылает Web-хук в Jenkins. Jenkins по Web-хуку автоматически находит новую ветку и запускает для нее пайплайн.

Cтадии пайплайна
Независимо от имени ветки, первой запускается стадия Build, в которой собирается Docker-образ c разрабатываемым приложением и помещается в приватный реестр Docker-образов. Далее, пайплайн извлекает из Web-хука имя ветки. Для ветки "master" запускает стадию Staging и доставляет приложение в контур Staging.

Для всех остальных веток пайплайн запускает стадию Review и деплоит приложение в контур Development.

Деплой приложения с помощью Helm
После сборки приложение развертывается в кластер Kubernetes c помощью helm-чарта, хранящегося в git-репозитории приложения. Cтадии Staging и Review используют одни и те же шаблоны templates helm-чарта, чтобы на Review доставлялось то же самое, что и на Staging и в Production. В helm-чарте содержится директория configs с конфигурационными файлами приложения для каждого контура Development, Staging и Production.
Jenkins Agent в момент деплоя приложения в Staging контур извлекает SHA1 коммита из Web-хука Gitea/Forgejo.

Каждый новый деплой приложения - это очередная ревизия helm. Мы добавили SHA1 коммита к именам ревизий helm, тем самым связав SHA1 коммитов с номерами ревизий helm.

Также SHA1 коммитов мы использовали в качестве тэгов при именовании Docker-образов. Например, "master:0cf40b11aae994d1832a83d675d7305647106378".
Промоушен Promote со Staging на Production
После демонстрации в окружении Staging наработок программистов заказчику, тимлид вручную запускает пайплайн промоушена Promote приложения со Staging на Production. Пайплайн извлекает историю ревизий с помощью команды:
helm history staging-owneruser-backend -n staging
Берет SHA1 коммита, соответствующего ревизии, у которой в столбце STATUS стоит "deployed" (см. скрин выше). Из приватного реестра Docker-образов извлекает Docker-образ, тэг которого соответствует коммиту со статусом "deployed":
"master:0cf40b11aae994d1832a83d675d7305647106378"
и доставляет в Production контур. Стадия Build игнорируется, поскольку сборка Docker-образа не нужна на стадии промоушена Promote.

Откат Rollback на предыдущие версии
На случай, если в продакшен будет доставлено приложение с ошибкой, разработали параметризованный пайплайн отката Rollback на предыдущие версии. Чтобы иметь возможность оперативно восстановить работу продакшена, пока программисты будут исправлять ошибку.
Для отката Rollback тимлиду нужно определиться, до какого коммита в Gitea/Forgejo он хочет откатиться, задать SHA1 коммита в параметрах пайплайна и вручную запустить пайплайн.

Jenkins Agent выполнит откат командой helm rollback до ревизии, соответствующей выбранному коммиту.

Canary-релизы
Чтобы не перегружать текущую статью, мы рассказали, как протестировать новый релиз приложения на ограниченном количестве реальных пользователей в продакшене в отдельной статье по Canary-релизы.
Заключение
В следующей статье мы рассмотрим подробнее тонкости реализации единого входа Single Sign-On (SSO) во все сервисы CI/CD платформы Gitorion при помощи Keycloak. Спасибо за внимание!