Русский
Русский
English
Статистика
Реклама

Werf

Проблема умной очистки образов контейнеров и её решение в werf

06.10.2020 10:05:04 | Автор: admin


В статье рассмотрена проблематика очистки образов, которые накапливаются в реестрах контейнеров (Docker Registry и его аналогах) в реалиях современных CI/CD-пайплайнов для cloud native-приложений, доставляемых в Kubernetes. Приведены основные критерии актуальности образов и вытекающие из них сложности при автоматизации очистки, сохранения места и удовлетворения потребностям команд. Наконец, на примере конкретного Open Source-проекта мы расскажем, как эти сложности можно преодолеть.

Введение


Количество образов в реестре контейнеров может стремительно расти, занимая больше места в хранилище и, соответственно, значительно увеличивая его стоимость. Для контроля, ограничения либо поддержания приемлемого роста места, занимаемого в registry, принято:

  1. использовать фиксированное количество тегов для образов;
  2. каким-либо образом очищать образы.

Первое ограничение иногда допустимо для небольших команд. Если разработчикам хватает постоянных тегов (latest, main, test, boris и т.п.), реестр не будет раздуваться в размерах и долгое время можно вообще не думать об очистке. Ведь все неактуальные образы перетираются, а для очистки просто не остаётся работы (всё делается штатным сборщиком мусора).

Тем не менее, такой подход сильно ограничивает разработку и редко применим к CI/CD современных проектов. Неотъемлемой частью разработки стала автоматизация, которая позволяет гораздо быстрее тестировать, развертывать и доставлять новый функционал пользователям. Например, у нас во всех проектах при каждом коммите автоматически создается CI-пайплайн. В нём собирается образ, тестируется, выкатывается в различные Kubernetes-контуры для отладки и оставшихся проверок, а если всё хорошо изменения доходят до конечного пользователя. И это давно не rocket science, а обыденность для многих скорее всего и для вас, раз вы читаете данную статью.

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

Но как вообще определить, актуален ли образ?

Критерии актуальности образа


В подавляющем большинстве случаев основные критерии будут таковы:

1. Первый (самый очевидный и самый критичный из всех) это образы, которые в настоящий момент используются в Kubernetes. Удаление этих образов может привести к серьезным издержкам в связи с простоем production (например, образы могут потребоваться при репликации) или свести на нет усилия команды, которая занимается отладкой на каком-либо из контуров. (По этой причине мы даже сделали специальный Prometheus exporter, отслеживающий отсутствие таких образов в любом Kubernetes-кластере.)

2. Второй (менее очевиден, но тоже очень важен и снова относится к эксплуатации) образы, которые требуются для отката в случае выявления серьёзных проблем в текущей версии. Например, в случае с Helm это образы, которые используются в сохраненных версиях релиза. (К слову, по умолчанию в Helm лимит в 256 ревизий, но вряд ли у кого-то реально есть потребность в сохранении такого большого количества версий?..) Ведь мы, в частности, для того и храним версии, чтобы можно было их потом использовать, т.е. откатываться на них в случае необходимости.

3. Третий потребности разработчиков: все образы, которые связаны с их текущими работами. К примеру, если мы рассматриваем PR, то имеет смысл оставлять образ, соответствующий последнему коммиту и, скажем, предыдущему коммиту: так разработчик сможет оперативно возвращаться к любой задаче и работать с последними изменениями.

4. Четвертый образы, которые соответствуют версиям нашего приложения, т.е. являются конечным продуктом: v1.0.0, 20.04.01, sierra и т.д.

NB: Определенные здесь критерии были сформулированы на основе опыта взаимодействия с десятками команд разработчиков из разных компаний. Однако, конечно, в зависимости от особенностей в процессах разработки и используемой инфраструктуры (например, не используется Kubernetes), эти критерии могут отличаться.

Соответствие критериям и существующие решения


Популярные сервисы с container registry, как правило, предлагают свои политики очистки образов: в них вы можете определять условия, при которых тег удаляется из registry. Однако возможности этих условий ограничиваются такими параметрами, как имена, время создания и количество тегов*.

* Зависит от конкретных реализаций container registry. Мы рассматривали возможности следующих решений: Azure CR, Docker Hub, ECR, GCR, GitHub Packages, GitLab Container Registry, Harbor Registry, JFrog Artifactory, Quay.io по состоянию на сентябрь'2020.

Такого набора параметров вполне достаточно, чтобы удовлетворить четвёртому критерию то есть отбирать образы, соответствующие версиям. Однако для всех остальных критериев приходится выбирать какое-то компромиссное решение (более жесткую или, наоборот, щадящую политику) в зависимости от ожиданий и финансовых возможностей.

Например, третий критерий связанный с потребностями разработчиков может решаться за счет организации процессов внутри команд: специфичного именования образов, ведения специальных allow lists и внутренних договоренностей. Но в конечном счете его всё равно необходимо автоматизировать. А если возможностей готовых решений не хватает, приходится делать что-то своё.

Аналогична и ситуация с двумя первыми критериями: их невозможно удовлетворить без получения данных от внешней системы той самой, где происходит развёртывание приложений (в нашем случае это Kubernetes).

Иллюстрация workflow в Git


Предположим, вы работаете примерно по такой схеме в Git:



Иконкой с головой на схеме отмечены образы контейнеров, которые в настоящий момент развёрнуты в Kubernetes для каких-либо пользователей (конечных пользователей, тестировщиков, менеджеров и т.д.) или используются разработчиками для отладки и подобных целей.

Что произойдёт, если политики очистки позволяют оставлять (не удалять) образы только по заданным названиям тегов?



Очевидно, такой сценарий никого не обрадует.

Что изменится, если политики позволяют не удалять образы по заданному временному интервалу / числу последних коммитов?



Результат стал значительно лучше, однако всё ещё далёк от идеала. Ведь у нас по-прежнему есть разработчики, которым нужны образы в реестре (или даже развёрнутые в K8s) для отладки багов

Резюмируя сложившуюся на рынке ситуацию: доступные в реестрах контейнеров функции не предлагают достаточной гибкости при очистке, а главная тому причина нет возможности взаимодействовать с внешним миром. Получается, что команды, которым требуется такая гибкость, вынуждены самостоятельно реализовывать удаление образов снаружи, используя Docker Registry API (или нативный API соответствующей реализации).

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

Наш путь к универсальной очистке образов


Откуда такая потребность? Дело в том, что мы не отдельно взятая группа разработчиков, а команда, которая обслуживает сразу множество таковых, помогая комплексно решать вопросы CI/CD. И главный технический инструмент для этого Open Source-утилита werf. Её особенность в том, что она не выполняет единственную функцию, а сопровождает процессы непрерывной доставки на всех этапах: от сборки до деплоя.

Публикация в реестре* образов (сразу после их сборки) очевидная функция такой утилиты. А раз образы туда помещаются на хранение, то если ваше хранилище не безгранично нужно отвечать и за их последующую очистку. О том, как мы добились успеха в этом, удовлетворяя всем заданным критериям, и будет рассказано далее.

* Хоть сами реестры могут быть различными (Docker Registry, GitLab Container Registry, Harbor и т.д.), их пользователи сталкиваются с одними и теми же проблемами. Универсальное решение в нашем случае не зависит от реализации реестра, т.к. выполняется вне самих реестров и предлагает одинаковое поведение для всех.

Несмотря на то, что мы используем werf как пример реализации, надеемся, что использованные подходы будут полезны и другим командам, столкнувшимся с аналогичными сложностями.

Итак, мы занялись внешней реализацией механизма для очистки образов вместо тех возможностей, что уже встроены в реестры для контейнеров. Первым шагом стало использование Docker Registry API для создания всё тех же примитивных политик по количеству тегов и времени их создания (упомянутых выше). К ним был добавлен allow list на основе образов, используемых в развёрнутой инфраструктуре, т.е. Kubernetes. Для последнего было достаточно через Kubernetes API перебирать все задеплоенные ресурсы и получать список из значений image.

Такое тривиальное решение закрыло самую критичную проблему (критерий 1), но стало только началом нашего пути по улучшению механизма очистки. Следующим и куда более интересным шагом стало решение связать публикуемые образы с историей Git.

Схемы тегирования


Для начала мы выбрали подход, при котором конечный образ должен хранить необходимую информацию для очистки, и выстроили процесс на схемах тегирования. При публикации образа пользователь выбирал определённую опцию тегирования (git-branch, git-commit или git-tag) и использовал соответствующее значение. В CI-системах установка этих значений выполнялась автоматически на основании переменных окружения. По сути конечный образ связывался с определённым Git-примитивом, храня необходимые данные для очистки в лейблах.

В рамках такого подхода получился набор политик, который позволял использовать Git как единственный источник правды:

  • При удалении ветки/тега в Git автоматически удалялись и связанные образы в registry.
  • Количество образов, связанное с Git-тегами и коммитами, можно было регулировать количеством тегов, использованных в выбранной схеме, и временем создания связанного коммита.

В целом, получившаяся реализация удовлетворяла нашим потребностям, но вскоре нас ожидал новый вызов. Дело в том, что за время использования схем тегирования по Git-примитивам мы столкнулись с рядом недостатков. (Поскольку их описание выходит за рамки темы этой статьи, все желающие могут ознакомиться с подробностями здесь.) Поэтому, приняв решение о переходе на более эффективный подход к тегированию (content-based tagging), нам пришлось пересмотреть и реализацию очистки образов.

Новый алгоритм


Почему? При тегировании в рамках content-based каждый тег может удовлетворять множеству коммитов в Git. При очистке образов больше нельзя исходить только из коммита, на котором новый тег был добавлен в реестр.

Для нового алгоритма очистки было решено уйти от схем тегирования и выстроить процесс на мета-образах, каждый из которых хранит связку из:

  • коммита, на котором выполнялась публикация (при этом не имеет значения, добавился, изменился или остался прежним образ в реестре контейнеров);
  • и нашего внутреннего идентификатора, соответствующего собранному образу.

Другими словами, была обеспечена связь публикуемых тегов с коммитами в Git.

Итоговая конфигурация и общий алгоритм


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

  • множеством references, т.е. Git-тегами или Git-ветками, которые используются при сканировании;
  • илимитом искомых образов для каждого reference из множества.

Для иллюстрации вот как стала выглядеть конфигурация политик по умолчанию:

cleanup:  keepPolicies:  - references:      tag: /.*/      limit:        last: 10  - references:      branch: /.*/      limit:        last: 10        in: 168h        operator: And    imagesPerReference:      last: 2      in: 168h      operator: And  - references:        branch: /^(main|staging|production)$/    imagesPerReference:      last: 10

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

  1. Сохранять образ для 10 последних Git-тегов (по дате создания тега).
  2. Сохранять по не более 2 образов, опубликованных за последнюю неделю, для не более 10 веток с активностью за последнюю неделю.
  3. Сохранять по 10 образов для веток main, staging и production.

Итоговый же алгоритм сводится к следующим шагам:

  • Получение манифестов из container registry.
  • Исключение образов, используемых в Kubernetes, т.к. их мы уже предварительно отобрали, опросив K8s API.
  • Сканирование Git-истории и исключение образов по заданным политикам.
  • Удаление оставшихся образов.

Возвращаясь к нашей иллюстрации, вот что получается с werf:



Однако, даже если вы не используете werf, подобный подход к продвинутой очистке образов в той или иной реализации (в соответствии с предпочтительным подходом к тегированию образов) может быть применен и в других системах/утилитах. Для этого достаточно помнить о тех проблемах, которые возникают, и найти те возможности в вашем стеке, что позволяют встроить их решение наиболее гладко. Надеемся, что пройденный нами путь поможет посмотреть и на ваш частный случай с новыми деталями и мыслями.

Заключение


  • Рано или поздно с проблемой переполнения registry сталкивается большинство команд.
  • При поиске решений в первую очередь необходимо определить критерии актуальности образа.
  • Инструменты, предлагаемые популярными сервисами container registry, позволяют организовать очень простую очистку, которая не учитывает внешний мир: образы, используемые в Kubernetes, и особенности рабочих процессов в команде.
  • Гибкий и эффективный алгоритм должен иметь представление о CI/CD-процессах, оперировать не только данными Docker-образов.

P.S.


Читайте также в нашем блоге:

Подробнее..

Werf vs. Helm корректно ли их вообще сравнивать?

29.04.2021 10:23:13 | Автор: admin

Эта статья развернутый ответ на вопрос, который нам периодически задают: чем werf отличается от Helm? На первый взгляд можно предположить, что задача у них примерно одинаковая: автоматизировать деплой приложений в Kubernetes. Но всё, конечно, немного сложнее

Роль в CI/CD

Если упрощенно показать утилиты в рамках полного цикла CI/CD, то их функции значительно отличаются:

Helm

werf

Сборка приложения (в Docker-образ)

Публикация образов в container registry и их автоматическая очистка со временем

Деплой в Kubernetes

Деплой в Kubernetes (на базе Helm), расширенный трекингом ресурсов, интеграцией с образами, встроенной поддержкой Giterminism и другими фичами

Как видно, в рамках CI/CD-пайплайна werf делает гораздо больше, участвуя в полном жизненном цикле приложения, от сборки до выката в Kubernetes.

Helm хороший инструмент, но довольно низкоуровневый. Для реального использования в CI/CD он требует надстроек, интеграции с другими инструментами в общем, заметного усложнения инфраструктуры и процессов.

Поэтому мы говорим, что werf это следующий уровень доставки приложений в Kubernetes. Утилита использует Helm как один из компонентов и интегрирует его с другими стандартными инструментами: Git, Docker и Kubernetes. Благодаря этому werf выступает в роли клея, который упрощает, унифицирует организацию CI/CD-пайплайнов на базе специализированных инструментов, уже ставших стандартом в индустрии, и выбранной вами CI-системы.

Что умеет werf (и не умеет Helm)

Мы упомянули, что werf это утилита, которая не только про деплой. Но даже если посмотреть только на данный этап, то и здесь у werf есть ряд доработок:

Возможности

Helm

werf

Ожидание готовности ресурсов во время деплоя

+

+

Трекинг ресурсов и обнаружение ошибок

+

Fail-fast во время деплоя

+

Защита от параллельных запусков деплоя одного и того же релиза

+

Интеграция с собираемыми образами

+

Поддержка автодобавления аннотаций и лейблов во все ресурсы релиза

+ /

+

Публикация файлов конфигурации и образов приложения в container registry (бандлы)

+ /

+

Базовая поддержка секретных values

+

Поддержка Giterminism и GitOps

+ /

+

Подробнее мы разберем каждый пункт таблицы ниже (см. Детальное сравнение werf и Helm ниже). Но сначала об истоках, которые привели к таким следствиям. Расскажем о том, к чему мы стремились, создавая werf.

Четыре благородные истины werf

1. werf должна использоваться в CI/CD-пайплайне как единый инструмент

В утилите необходима поддержка работы в любой существующей CI/CD-системе и легкой интеграции. Сейчас werf работает из коробки с GitLab CI/CD и GitHub Actions. Другие CI-системы тоже поддерживаются для интеграции достаточно написать скрипт, следуя инструкции.

2. werf должна оптимальным образом доставлять приложения в Kubernetes

В процессе доставки собираются только недостающие образы из container registry или недостающие слои для этих образов, а всё старое берется из прошлых сборок или кэша. Так экономится и время сборки, и место для хранения всех образов.

После доставки [обновлённого приложения] текущее состояние ресурсов в Kubernetes приводится к новому требуемому состоянию, которое определено в Git (мы назвали это словом гитерминизм, от слов Git + детерминизм) и здесь снова речь идёт о том, что для такой синхронизации вычисляются изменения и применяются только они.

3. werf должна давать четкую обратную связь

Итоговый отчет werf в идеале должен быть достаточным для диагностики проблемы. Если что-то пошло не так в процессе доставки и развертывания, пользователю не нужно запускать kubectl и искать информацию в кластере. werf сразу показывает и постоянно обновляет актуальный статус процесса деплоя и даёт достаточно информации для решения проблемы без привлечения системного администратора.

4. werf должна поддерживать GitOps-подход

GitOps в общем виде это подход, при котором для развертывания приложений в Kubernetes используется единственный источник правды Git-репозиторий. Через декларативные действия в Git вы управляете реальным состоянием инфраструктуры, запущенной в Kubernetes. Этот паттерн из коробки работает в werf.

У нас есть собственный взгляд на то, как должен быть реализован GitOps для CI/CD (уже упомянутый Giterminism). GitOps в werf поддерживает не только хранение Kubernetes-манифестов в Git, но и версионирование собираемых образов, связанное с Git. Откат до предыдущей версии приложения выполняется без сборки выкатывавшихся ранее образов (при условии, что версия учитывается политиками очистки). Другие существующие реализации GitOps не дают этой гарантии.

Зачем и как werf использует Helm

werf использует и расширяет Helm, чтобы следовать вышеприведенным принципам.

Helm популярный и проверенный инструмент. У него есть собственный шаблонизатор, чарты и т.п. Мы не стали переизобретать то, что уже отлично работает. Вместо этого сфокусировались на фичах, которые помогают оптимизировать CI/CD.

С точки зрения совместимости важно, что кодовая база Helm вкомпилирована в werf. Обновления из upstream приходят регулярно, руками Helm обновлять не нужно. (Как, впрочем, и у самой werf, у которой есть встроенный version manager multiwerf с 5 каналами стабильности и поддержкой автообновления.)

Мы даже стараемся по возможности участвовать в улучшении Helm через upstream. Один из примеров нашего вклада аннотация helm.sh/hook-delete-policy=before-hook-creation (Удалить предыдущий ресурс перед запуском нового хука), которая пришла в Helm из werf.

Плюсы и минусы Helm

У Helm есть ряд преимуществ:

  • Это стандартный package manager в K8s. Helm используют [практически] все, кто работает с Kubernetes; он стал стандартом индустрии.

  • Шаблонизация. Шаблоны Helm удобны для тиражирования манифестов при работе с несколькими окружениями. (Пусть и не все согласятся, что Go-templates удобный шаблонизатор, но это уже другой вопрос...)

  • Удобное управление чартами. Общепринятый формат описания ресурсов и объединения их в чарты (charts это пакеты для Helm).

  • Переиспользование чартов. Helm поддерживает публикацию переиспользуемых чартов в Chart Repository или container registry.

  • Удобное управление жизненным циклом релизов. В Helm легко управлять релизами и откатываться на нужные версии.

Что до минусов, то основной в том, что Helm это маленькое звено в CI/CD-цепи, которое мало или совсем не связано с другими важными звеньями.

CI/CD приложения предполагает непрерывное слияние изменений в основную кодовую базу проекта и непрерывную доставку этих изменений до пользователя. Это включает периодическую сборку новых образов приложения с обновлениями, тестирование этих образов и выкат новой версии приложения. В CI/CD у нас обычно несколько окружений (production, staging, development и т. д.). И конфигурация приложения может отличаться для разных окружений.

Технически Helm позволяет конфигурировать описанный chart параметрами, через которые передаются образы приложения и выбранное окружение. Однако конкретный путь как это делать не стандартизован. Пользователю Helm приходится решать это самому.

Детальное сравнение werf иHelm

А теперь вернемся к таблице и разберем подробнее каждый её пункт.

1. Трекинг ресурсов и обнаружение ошибок

И werf, и Helm умеют ждать готовности ресурсов в процессе развертывания. Но werf дает обратную связь и более проактивна. Три главных отличия:

1. В процессе деплоя werf выводит логи по выкатываемым ресурсам. Для отдельных ресурсов логирование отключается автоматически по мере их перехода в состояние готовности. Можно отключить трекинг конкретных контейнеров или логировать вывод только по определенным контейнерам ресурса, отключив остальные; можно включать и выключать вывод сервисной информации Kubernetes. Всё это настраивается с помощью аннотаций, которые объявляются в шаблонах чарта.

2. Как только werf замечает проблему в одном из ресурсов, он завершается с ошибкой и ненулевым кодом выхода. werf следует принципу fail-fast. Он выдает наиболее полезную информацию по ошибке, чтобы пользователь сразу мог понять, в чём проблема, по выводу в CI/CD job.

Helm, в отличие от werf, в случае проблем с конфигурацией ждет истечения таймаута. А такие проблемы распространенная ситуация в CI/CD. Выяснить, в чём их причина, можно только после подключения к кластеру через kubectl. Это неудобно:

  • нужно настраивать права доступа к kubectl;

  • kubectl требует знаний и умений: куда смотреть, что искать и как это интерпретировать.

В идеале разработчик по выводу werf сможет понять причину проблемы и пофиксить ее, не привлекая админа. После этого достаточно создать в Git новый коммит с исправлением.

3. Защита от параллельных запусков деплоя одного и того же релиза. Helm может упасть с ошибкой, если будут работать одновременно два выката одного и того же релиза. werf автоматически предотвращает одновременный выкат: эти процессы работают по очереди.

2. Интеграция со сборкой образов

В Helm приходится явно передавать полные имена образов через values и заботиться об актуализации имен при изменениях.

werf решает эту проблему за пользователя: не нужно думать о способах передачи имен образов, т. к. они передаются через те же values автоматически. Более того, werf оптимальным образом решает проблему именования образов так, чтобы в процессе деплоя менялись имена только у изменившихся образов.

К тому же, werf дает возможность откатиться на старую версию приложения со старыми образами без повторной пересборки этих образов. Часто при использовании Helm в CI/CD через values для упрощения передаются статические имена образов (вроде registry.example.com/myproject:production) в этом случае имя образа ссылается только на последнюю собранную версию образа. При такой схеме тегирования приходиться пересобирать старый образ, чтобы откатиться до предыдущей версии. werf использует схему с content-based-тегами, которые связаны с историей Git. Помимо прочего, такая схема тегирования полностью решает вопрос с откатом на старую версию.

Что дает интеграция с собранными образами:

  • werf может использовать уже существующие Dockerfile'ы в своей конфигурации.

  • werf автоматически и оптимально именует собираемые образы, чтобы имена зависели от контента внутри образа и обновлялись только при его изменении.

  • Нет лишних перевыкатов компонентов приложения в Kubernetes и, как следствие, лишнего простоя компонентов. Перевыкат происходит быстро и только для измененных компонентов, а не приложения целиком.

  • Со стороны конфигурации Helm остается только использовать имена образов, которые werf предоставляет через специальные values.

  • Можно откатиться на образы старой версии приложения.

3. Добавление аннотаций и лейблов в ресурсы релиза

В Helm есть механизм post rendering, чтобы дописывать в ресурсы аннотации или лейблы и менять другие поля. Однако в Helm нет простой встроенной функции для добавления конкретно лейблов и аннотаций.

werf добавляет во все ресурсы релиза автоматические аннотации вроде ссылки на CI/CD job, из которого ресурс был в последний раз выкачен, или ссылки на Git-коммит.

Также в werf через CLI-опции можно указать произвольные аннотации или лейблы, которые будут добавлены во все ресурсы релиза. Пример такой команды:

werf converge --add-annotation pipeline-id=$CI_PIPELINE_ID --add-annotation git-branch=$CI_COMMIT_REF_NAME

Это удобно:

  • для интроспекции, когда надо понять, с каким CI/CD job связана версия ресурса;

  • для мониторинга, чтобы собирать по кластеру информацию о ресурсах из аннотаций или лейблов.

4. Бандлы: публикация файлов конфигурации и образов приложения в container registry

Helm поддерживает публикацию чартов в OCI container registry либо Chart Repository, однако не отвечает за образы, которые нужны этому чарту для выката. Такие образы должны быть отдельно собраны и правильно протегированы, а опубликованный чарт должен быть настроен на использование образов по правильным именам. Эти проблемы пользователю Helm приходиться решать самостоятельно.

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

Пользователь не думает об именах образов, публикуемых вместе с чартом: werf делает это автоматически и оптимальным способом. Неизменные образы будут переиспользованы. Публикуются лишь те слои, которые требуются для текущего коммита. Пользователю werf достаточно выбрать версию бандла и обеспечить его публикацию в требуемом Git-коммите.

5. Встроенная базовая поддержка значений секретов

В Helm поддержка секретов возможна через подключение сторонних плагинов. Это усложняет установку Helm на новые хосты.

werf из коробки дает возможность закодировать значения values через алгоритмы AES-128, AES-192 и AES-256. Ключи шифрования можно менять.

6. Поддержка Giterminism и GitOps

Helm не регулирует привязку используемых конфигурационных файлов к Git-коммитам.

werf (с версии v1.2) форсирует использование конфигурации из текущего Git-коммита и реализует режим гитерминизма, в том числе и для конфигурации Helm.

werf читает конфигурационные файлы сборочного контекста из текущего коммита репозитория проекта и исключает внешние зависимости. Это обеспечивает надежность и воспроизводимость. Конфигурации легко воспроизводятся: разработчики используют образы, только что собранные в CI-системе, переключившись на нужный коммит. werf запрещает работать с незакоммиченными и неотслеживаемыми файлами.

Подытожим

Прямое противопоставление werf vs Helm не совсем корректно, потому что утилита werf реализует не только деплой, а нацелена на поддержку полного жизненного цикла доставки приложений. Сам по себе Helm хороший инструмент для деплоя в Kubernetes, поэтому он (с некоторыми улучшениями) встроен в werf для решения этой задачи. Однако и в контексте деплоя у werf есть ряд преимуществ, общий смысл которых сводится к более полной и удобной интеграции с CI/CD-системами.

P.S.

Читайте также в нашем блоге:

Подробнее..

Настраиваем Continuous Integration для Jenkins и Bitbucket с werf

18.12.2020 12:07:33 | Автор: admin


Утилита werf создана так, чтобы её было легко интегрировать с любыми CI/CD-системами. Подробнее об этом процессе в общем случае читайте в эпилоге этой статьи, но основное её содержимое практический пример по организации CI в Jenkins и Bitbucket.

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

  1. Shared Library для Jenkins, чтобы все сценарии CI хранились в одном месте и их можно было править единым коммитом.
  2. Интеграцию Jenkins с Bitbucket, чтобы запускать CI по коммиту в определенные ветки или по созданию тега.

Поехали!

Конфигурация Jenkins


Для реализации задуманного в статье будут задействованы:


В Jenkins для проектов используется multibranch pipeline.

Начнем с того, что подключим к Jenkins репозиторий, в котором будет храниться наша Shared Library. Shared Library это единая библиотека, что может содержать в себе код для исполнения CI и хранится отдельно в своем собственном репозитории. Это значительно упрощает процесс модернизации и работы над CI (вместо использования для хранения CI стандартного Jenkinsfile, который нужно подкладывать в каждый проект).

Итак, подключаем: Manage Jenkins Configure System Global Pipeline Libraries.



Нужно указать имя, ветвь репозитория, из которой Jenkins будет забирать код библиотеки, а в Source Code Management указать адрес и доступ до репозитория (в нашем случае SSH-ключ для доступа ReadOnly).

Структура Shared Library


Теперь приступим к описанию самой библиотеки. Структура очень проста и состоит всего из трёх директорий:



  • vars директория для глобальных методов библиотеки, что будут вызываться из пайплайна;
  • src тоже директория для скриптов, но в основном используется для вашего кастомного кода;
  • resources всё, что не является скриптом и может понадобиться в исполнении.

Для наших целей в Jenkins будет достаточно только нескольких методов в директории vars, потому как мы настроим сам werf, что и сделает всю основную работу.

К тому же, хотелось бы, чтобы весь пайплайн был полностью описан внутри библиотеки, а в Jenkinsfile мы передавали только некоторые параметры деплоя, которые в 99,9% случаев вообще не будут меняться.

Реализуем методы


Итак, реализуем 2 метода.

Для вызова утилиты werf -runWerf.groovy.

#!/usr/bin/env groovydef call(String dockerCreds, String werfargs){  // логин в registry  // первый аргумент - url (пуст, т.к. используем DockerHub)  // второй - имя Jenkins-секрета, где лежат доступы (login, password)  docker.withRegistry("", "${dockerCreds}") {    sh """#!/bin/bash -el          set -o pipefail          type multiwerf && source <(multiwerf use 1.1 stable --as-file)          werf version          werf ${werfargs}""".trim()    }}

Все параметры в библиотеку для пайплайна передаются как Map, что удобно:

#!/usr/bin/env groovydef call( Map parameters = [:] ) { // функция принимает в качестве аргумента Map с параметрами  def namespace = parameters.namespace // имя неймспейса для выката  // имя ключа по умолчанию для расшифровки секретов (если не указан в параметрах)  def werf_secret_key = parameters.werfCreds != null ? parameters.werfCreds : "werf-secret-key-default"  // имя секрета по умолчанию для логина в docker registry  def dockerCreds = parameters.dockerCreds != null ? parameters.dockerCreds : "docker-credentials-default"  // получаем имя проекта из имени multibranch pipeline  def PROJ_NAME = "${env.JOB_NAME}".split('/').first()  // имя registry в docker hub или адрес до кастомного registry  def imagesRepo = parameters.imagesRepo != null ? parameters.imagesRepo : "myrepo"  if( namespace == null ) { // единственный обязательный аргумент и проверка на его наличие    currentBuild.result = 'FAILED'    return  }  pipeline {    agent { label 'werf' }    options { disableConcurrentBuilds() } // запрещаем параллельную сборку для пайплайна    environment { // переменные для работы werf      WERF_IMAGES_REPO="${imagesRepo}"             WERF_STAGES_STORAGE=":local"      WERF_TAG_BY_STAGES_SIGNATURE=true      WERF_ADD_ANNOTATION_PROJECT_GIT="project.werf.io/git=${GIT_URL}"      WERF_ADD_ANNOTATION_CI_COMMIT="ci.werf.io/commit=${GIT_COMMIT}"      WERF_LOG_COLOR_MODE="off"      WERF_LOG_PROJECT_DIR=1      WERF_ENABLE_PROCESS_EXTERMINATOR=1      WERF_LOG_TERMINAL_WIDTH=95      PATH="$PATH:$HOME/bin"      WERF_KUBECONFIG="$HOME/.kube/config"      WERF_SECRET_KEY = credentials("${werf_secret_key}")    }    triggers {      // Execute weekdays every four hours starting at minute 0      cron('H 21 * * *')     // для werf cleanup, что будет чистить registry и хост-раннер от устаревших кэшей и образов    }    stages {      stage('Checkout') {        steps {          checkout scm // получаем код из репозитория        }      }      stage('Build & Publish image') {        when {            not { triggeredBy 'TimerTrigger' } // чтобы stage не запускался по крону        }        steps {          script {            // запуск нашего метода из runWerf.groovy            runWerf("${dockerCreds}","build-and-publish")          }        }      }      stage('Deploy app') {        when {            not { triggeredBy 'TimerTrigger' }          }        environment {          // название окружения, куда осуществляется деплой (важно для шаблонизации Helm-чарта)          WERF_ENV="production"        }        steps {          runWerf("${dockerCreds}","deploy --stages-storage :local --images-repo ${imagesRepo}")        }      }      stage('Cleanup werf Images') {        when {          allOf {            triggeredBy 'TimerTrigger'            branch 'master'           }        }        steps {          sh "echo 'Cleaning up werf images'"            runWerf("${dockerCreds}","cleanup --stages-storage :local --images-repo ${imagesRepo}")        }      }    }  }}

Примечания:

  • Сборка и выкат происходят для любой ветки, указанной в секции discover у Jenkins. После наших манипуляций в следующей главе это будет происходить автоматически.
  • Все секреты, такие как werf-secret-key-default и docker-credential-default, хранятся в Jenkins Credentials:



Сам Jenkinsfile, что находится внутри репозитория с проектом, в большинстве случаев теперь выглядит так:

@Library('common-ci') _multiStage ([namespace: 'yournamespace'])

Имя метода это название файла в каталоге vars.

Если необходимо выкатывать на несколько окружений, можно добавить условие для определенных веток в самом начале, где идет определение пространства имен. И убрать проверку на наличие аргумента namespace в Map, а также само его определение по умолчанию.

Пример реализации:

def namespace = "test"def werf_env = "test"if (env.JOB_BASE_NAME == 'master') { namespace = "stage" werf_env = "stage"}if (env.TAG_NAME) { namespace = "production" werf_env = "production"}# и добавляем в environment стадииenvironment {  WERF_ENV="${werf_env}" }

Если вы хотите автоматический запуск stage со всех веток, а с тегов в production только при нажатии кнопки в Jenkins, то можно использовать такое условие: currentBuild.rawBuild.getCauses()[0].toString().contains('UserIdCause'). Оно позволяет отследить, сборка была запущена человеком или началась как событие от webhook'а.

Триггеры по коммитам из Bitbucket


По умолчанию Jenkins сам не умеет интегрироваться в Bitbucket. Для этого нужно установить уже упомянутые плагины:

  • Bitbucket Branch Source Plugin добавляет Bitbucket как source для multibranch pipeline;
  • Basic Branch Build Strategies Plugin позволит запуск тегов по webhook. По умолчанию Jenkins не позволяет любые автоматизированные действия с тегами, т.к. не понимает какой из тегов последний.

Если вы используете cloud-версию Bitbucket, то нужно только поставить разрешение на создание webhook'ов автоматически.

Также требуется создать служебного пользователя с доступом к репозиториям, т.к. Jenkins будет обнаруживать весь репозиторий через API. Это касается настройки как для cloud-версии, так и для собственного Bitbucket-сервера.

Пример из глобальных настроек Jenkins:



Далее понадобится настроить source в Multibranch Pipeline, что происходит в интерактивном режиме. Это означает, что, когда вы добавите credentials bitbucket пользователя и имя команды или пользователя с проектами, которых мы будем работать, Jenkins найдет все доступные пользователю репозитории и позволит выбрать один из списка.

В самом репозитории мы полагались на поиск только определенных веток, т.к. не уверены, как много веток может быть, а Jenkins может надолго задуматься, если начнет исследовать каждую ветку. Это накладывает определенные ограничения, т.к. теги тоже попадают под регулярное выражение. Однако Java Regular Expressions довольно гибкие, так что большой проблемы нет.

Альтернативный путь: если есть желание совсем отделить теги от веток, можно добавить еще один абсолютно такой же Source в репозиторий и настроить его только на обнаружение тегов.

Итак, конфигурация:



После этого Jenkins с помощью сервис-аккаунта сам сходит в Bitbucket и создаст webhook:



Теперь при каждом коммите Bitbucket будет триггерить пайплайны (но только для тех веток и тегов, что мы отфильтровали) и даже посылать статус пайплайна обратно в Bitbucket в последнем столбце коммита:


Статусы кликабельные: при нажатии перекидывают в нужный пайплайн в Jenkins

Последний штрих про Jenkins, который находится за nginx proxy и работает с определенного location. Тогда нужно в основных настройках исправить его location, чтобы он сам знал, как выглядит его endpoint:



Без этого ссылки на pipeline в Bitbucket будут генерироваться некорректно.

Заключение


В статье рассмотрен вариант настройки CI с использованием Jenkins, Bitbucket и werf. Это очень общий пример, который не является панацеей для организации процесса разработки, однако даёт представление о том, как вообще подойти к построению своего CI с использованием werf.

Важная деталь: даже учитывая, что статус пайплайна отдается в Bitbucket, нам всё равно приходится ходить в Jenkins, чтобы разобрать результат в случае неудачи. Выкат по тегу через webhook, очевидно, может отрабатывать только один раз: любой откат на предыдущий тег придется делать вручную из Jenkins.

У данного подхода также есть большой плюс это гибкость. Мы буквально можем прописать в CI всё что угодно. Хотя и порог вхождения для того, чтобы понимать, как именно это сделать, чуть выше, чем у других CI-систем.

Эпилог: про werf и CI/CD в целом


Общий подход к интеграции werf с CI/CD-системами описан в документации. Вкратце рекомендуемые для любых проектов шаги сводятся к следующим:

  1. Создание временного DOCKER_CONFIG для исключения конфликтов между параллельными job'ами на одном runner'е (подробнее здесь).
  2. Выполнение авторизации Docker для используемых Docker Registry. Это может быть родная реализация Docker Registry внутри CI-системы либо какая-то сторонняя. В случае со встроенными имплементациями (к примеру, GitLab Container Registry или GitHub Docker Package) все необходимые параметры доступны среди переменных окружения. Выполнять авторизацию для альтернативных registry можно вручную на каждом runner'е или через параметры, хранящиеся в секретах (также для каждого job'а).
  3. Простановка WERF_IMAGES_REPO, WERF_STAGES_STORAGE, а также необходимых параметров, которые варьируются в зависимости от имплементации. Утилита werf должна знать, с какой реализацией работает, так как часть требует использования нативного API. Стоит отметить, что по умолчанию werf пытается определить, с какой имплементацией работает, исходя из адреса registry, но это задача часто невыполнима (и тогда требует явного указания имплементации).
  4. Простановка опций тегирования WERF_TAG_*: используя переменные окружения CI, определяем, чем инициирован текущий job, и выбираем подходящую опцию тегирования или всегда используем content-based тегирование (рекомендованный путь).
  5. Использование окружения CI-системы для последующего использования при выкате. Для понимания environment в GitLab.
  6. Простановка автоматических аннотаций для всех выкатываемых ресурсов WERF_ADD_ANNOTATION_*. Среди этих аннотаций могут быть произвольные данные, которые помогут вам работать и отлаживать ресурсы приложения в Kubernetes. Мы пришли к тому, что все ресурсы должны содержать следующий набор:
    1. WERF_ADD_ANNOTATION_PROJECT_GIT адрес проекта в Git;
    2. WERF_ADD_ANNOTATION_CI_COMMIT коммит, соответствующий выкату;
    3. WERF_ADD_ANNOTATION_JOB или WERF_ADD_ANNOTATION_PIPELINE адрес job или pipeline (зависит от CI-системы и желания), который связан с выкатом.
  7. Простановка по умолчанию комфортной работы с логом werf:
    1. WERF_LOG_COLOR_MODE=on включение цветного вывода (werf запускается не в интерактивном терминале, по умолчанию цвета отключены);
    2. WERF_LOG_PROJECT_DIR=1 вывод полного пути директории проекта;
    3. WERF_LOG_TERMINAL_WIDTH=95 установка ширины вывода (werf запускается не в интерактивном терминале, по умолчанию ширина равна 140).

За время применения werf в большом количестве проектов у нас сформировался набор решений, который унифицирует конфигурацию, решает общие проблемы и делает сопровождение проще и нагляднее. В настоящий момент все описанные выше шаги с учетом этих решений уже встроены в команду werf ci-env для GitLab CI/CD и GitHub Actions. Пользователям других CI-систем необходимо реализовывать аналогичные действия самостоятельно подобно тому, как описано в этой статье для примера с Jenkins.

P.S.


Читайте также в нашем блоге:

Подробнее..

Запускаем тесты на GitLab Runner с werf на примере SonarQube

09.11.2020 10:07:58 | Автор: admin


Если в качестве инфраструктуры, где разворачивается приложение, выступает Kubernetes, можно сказать, что существует два способа запуска тестов (и других утилит для анализа кода) в CI/CD:

  • непосредственно в кластере K8s с помощью отдельных Job или Helm hooks;
  • снаружи K8s например, на сервере сборки/деплоя или локально у разработчиков.

Первый подход мы достаточно подробно описывали на интересном примере с базами данных в статье Как мы выносили СУБД (и не только) из review-окружений в статическое. В этой статье рассмотрен более простой путь запуск вне K8s-кластера. Делать мы это будем на примере SonarQube-тестов в рамках CI/CD, построенного на базе GitLab с использованием werf.

Стоит помнить, что такой метод подходит только для тех тестов, которые не требуют подключения к другим объектам в инфраструктуре. С другой стороны, этот вариант хорош тем, что позволяет экономить кластерные ресурсы: нам не нужно выделять дополнительные ресурсы в K8s, поскольку вместо этого используются внешние мощности (локальных машин или сервера сборки/деплоя).

Для запуска тестов будет использоваться CI/CD-утилита werf, которая позволяет работать с полным жизненным циклом доставки приложений, а в частности удобно собирать Docker-образы, а потом запускать их локально. Если вы уже используете werf, встраивание дополнительных команд будет совсем тривиальным, а если нет на данном примере можно увидеть, как удобно контролировать все процессы CI/CD в одном месте.

Теперь к непосредственной практике.

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


Основная особенность использования тестов и других анализаторов кода в CI/CD необходимость устанавливать дополнительные пакеты/конфигурационные файлы в Docker-образ приложения. Установка все в один образ увеличивает размер итогового образа и добавляет неиспользуемые файлы во время запуска самого приложения.

В качестве альтернативного варианта* можно использовать отдельную сборку Docker-образа со всеми необходимыми зависимостями для тестов. Такая сборка будет выполняться параллельно сборке основного образа и использоваться только там, где требуется.

* Известная многим лучшая практика гласит, что для всех целей применения (от тестов и отладки до запуска в production) должен собираться единственный образ. Поэтому вариант со сборкой другого образа может быть допустим разве что для случаев, когда требуется тестировать только исходный код приложения. В таком варианте можно основной образ приложения использовать в виде артефакта в образе для тестов.

Также обращаю внимание, что хотя последующие примеры основаны на использовании тестов с SonarQube, это не мешает выполнять аналогичные действия и с другими инструментами.

В werf реализация задуманного осуществляется с помощью переменных окружения, устанавливаемых в определенной Job в CI/CD-пайплайне.

Для конфигурации на стороне GitLab CI/CD потребуется определить Job с тестами (в .gitlab-ci.yml) примерно так:

Build sonar-tests:  stage: build  script:    - werf build-and-publish  variables:    SONAR_TESTS: "yes"  tags:    - werf  only:    - schedules

Как видно, в variables определяется переменная SONAR_TESTS, которая и будет задействована в сборке через werf.

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

В рамках подготовительных работ важно заметить, что в конфигурации сборки (werf.yaml) появится следующий блок:

{{ if eq (env "SONAR_TESTS") "yes" }}---image: sonar-testsfrom: node:10.16.0# остальные этапы сборки# ...{{ end }}

Т.е. теперь мы можем обратиться к той переменной SONAR_TESTS, что задали в GitLab CI/CD. Так легко контролировать этапы сборки с помощью переменных.

Собираем образы тестов в werf


Перейдем к непосредственному созданию инструкций для сборки Docker-образа с помощью werf. В этом образе требуются следующие элементы:

  1. исходный код приложения;
  2. необходимые пакеты/файлы для запуска Sonar-тестов;
  3. информация о токене, URL до Sonarqube dashboard и названии приложения в конфигурационном файле.

Получается следующий набор инструкций для werf:

{{ $sonar_version := "4.4.0.2170"}}{{ if eq (env "SONAR_TESTS") "yes" }}---image: sonar-testsfrom: node:10.16.0git:  - add: /    to: /appansible:  install:  - name: "npm install sonar"    shell: |      npm install --no-save -D sonarqube-scanner    args:      chdir: /app  - name: "Install default-jdk"    apt:      update_cache: yes      state: present      name:      - default-jdk  - name: "install sonarqube scanner"    args:      chdir: /app    shell: |      wget https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-{{ $sonar_version }}.zip      mkdir -p /root/.sonar/native-sonar-scanner/      unzip -qq sonar-scanner-cli-{{ $sonar_version }}.zip -d /root/.sonar/native-sonar-scanner/      mv /root/.sonar/native-sonar-scanner/sonar-scanner-{{ $sonar_version }}/ /root/.sonar/native-sonar-scanner/sonar-scanner-{{ $sonar_version }}-linux/  - name: "Setup /app/sonar.js"    copy:      content: |{{ tpl (.Files.Get ".werf/sonar.js") . | indent 8 }}      dest: /app/sonar.js{{ end }}{{ end }}

Опять же, полную информацию по структуре и содержанию werf.yaml можно найти в документации. Здесь мы рассмотрим только основные инструкции. Итак, установка необходимых компонентов в образ состоит из следующих операций:

  1. Добавление исходного кода приложения в образ с помощью Git-модуля werf.
  2. Инсталляция через NPM пакетов sonar и sonarqube-scanner. Эти пакеты требуются для поддержки SonarQube в Node.js-приложении.
  3. Установка JDK, который потребуется для запуска тестов.
  4. Загрузка .jar-файла sonar-scanner, для которого мы и устанавливаем JDK.
  5. Добавление конфигурационного файла sonar.js с помощью функции .Files.Get. Подробнее см. далее.

Что за файл, находящийся по адресу .werf/sonar.js? Это конфиг, в котором хранится вся необходимая информация для запуска Sonar-тестов. Посмотрим на его содержимое:

{{ $_ := env "SONAR_TOKEN" | set . "Token" }}{{ $_ := env "SONAR_PROJECT" | set . "Project" }}{{ $_ := env "SONAR_URL" | set . "Url" }}    const sonarqubeScanner = require('sonarqube-scanner');    sonarqubeScanner( {      serverUrl : "{{ .Url }}",       token : "{{ .Token }}",      options: {        'sonar.projectName': "{{ .Project }}",        'sonar.projectDescription': 'Description for "{{ .Project }}" project...',        'sonar.sources': '.',        'sonar.projectKey': "{{ .Project }}",        'sonar.projectBaseDir': '/app'      }      },      () => process.exit()    )

В трёх первых строчках перечисляются переменные окружения, которые определяются, например, в GitLab CI/CD Variables. В дальнейшем, т.е. во время сборки/деплоя, эти переменные могут использоваться аналогично тому, как мы уже показывали в werf.yaml (через встроенную конструкцию werf).

Подробнее об определяемых переменных:

  1. SONAR_TOKEN токен, который вы создаете в самом SonarQube; именно через него производится авторизация в SonarQube;
  2. SONAR_PROJECT название созданного проекта в SonarQube;
  3. SONAR_URL URL к SonarQube (может быть как внешним URL, так и адресом до сервиса).

В результате сборки (werf build) по таким конфигам (werf.yaml
и sonar.js) получается Docker-образ (под названием sonar-tests), который можно использовать для запуска Sonar-тестов, чем мы и займемся в следующей главе.

NB: Аналогичным образом можно собирать и запускать linting-тесты (речь не про что-то компилируемое), интеграционные тесты и т.д., причем даже в Kubernetes-окружении, а не только локально (в любом случае как минимум экономится место в основном образе).

Запускаем тесты с помощью команды werf run


Последний этап запуск тестов локально на GitLab Runner.

Для этого в GitLab CI/CD определяется отдельная Job, в которой будут запускаться тесты. Например:

Deploy sonar-tests:  script:    - werf run sonar-tests -- bash -c "node /app/sonar.js"  tags:    - werf  stage: deploy  environment:    name: sonar-tests  only:    - schedules  variables:    SONAR_TESTS: "yes"  after_script:    - echo "Scan results are here https://sonar/dashboard?id=${SONAR_PROJECT}"  when: always  dependencies:    - Build sonar-tests

Здесь используется единственная команда:

werf run sonar-tests -- bash -c "node /app/sonar.js"

В её первой части указывается название образа для запуска (sonar-tests, берётся из werf.yaml), во второй команда для выполнения внутри контейнера.

Теперь пайплайн будет запускать Sonar-тесты на GitLab Runner, а их результаты публиковать в SonarQube Dashboard.



Заключение


Описанный вариант запуска тестов в первую очередь будет полезен тем, кто уже использует werf или собирается его внедрить в своем проекте. С werf run можно выполнять тесты непосредственно на самом Runner'е и вместо отдельных Docker-инструкций описывать всё непосредственно в werf, аккумулируя подобные элементы в одном месте.

Этот подход, конечно, позволяет запускать не только SonarQube-тесты: подойдут и любые другие решения, не требующие создания отдельного окружения для них (в виде отдельного сервера БД, очередей сообщений и т.д.).

Также он подойдет, если вы хотите запускать тесты не в кластере, а в локальном окружении или на самом Runner'е, не используя ресурсы кластера.

P.S.


Кстати, для разработчиков, только начинающих знакомиться с werf, мы подготовили увлекательный онлайн-самоучитель по этой утилите попробуйте!

Читайте также в нашем блоге:

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru