Автомасштабирование самостоятельно размещенных контейнеров GitHub runner с помощью Azure Container Apps (ACA)


Обзор

Весь код, использованный в этом руководстве, можно найти на моем проекте GitHub: docker-github-runner-linux.

Добро пожаловать в пятую часть моего цикла: Самостоятельное размещение контейнеров GitHub Runner на Azure.

В предыдущей части этой серии мы рассмотрели, как можно использовать Azure-CLI или рабочие процессы CI/CD в GitHub с помощью GitHub Actions для запуска саморазмещающихся докер-контейнеров GitHub runner в качестве Azure Container Instances (ACI) в Azure из удаленного реестра контейнеров, также размещенного в Azure (ACR).

Одним из недостатков самостоятельного размещения агентов-бегунов является то, что если не запущены рабочие процессы или задания GitHub Action, то GitHub runner будет просто сидеть без дела, потребляя затраты, независимо от того, является ли этот самостоятельный GitHub runner ACI или контейнером docker, размещенным на виртуальной машине.

Поэтому в продолжение предыдущей части мы рассмотрим, как можно использовать Azure Container Apps (ACA) для запуска образов из удаленного реестра, а также продемонстрируем, как мы можем автоматически масштабировать наши саморазмещающиеся GitHub runner’ы от отсутствия или 0 до увеличения и уменьшения в зависимости от нагрузки/спроса, используя Kubernetes Event-driven Autoscaling (KEDA).

Это позволит нам сэкономить на расходах и предоставлять самораспределяющиеся бегуны GitHub только в случае необходимости.

ПРИМЕЧАНИЕ: На момент написания этой статьи Azure Container Apps поддерживает:

  • Любой образ контейнера на базе Linux x86-64 (linux/amd64).
  • Контейнеры из любого публичного или частного реестра контейнеров.
  • На момент написания статьи не существует доступных масштабирующих модулей KEDA для GitHub runners.

Доказательство концепции

Поскольку на момент написания этой статьи не существует доступных KEDA-масштабаторов для бегунов GitHub, мы будем использовать очередь хранения Azure Storage Queue для управления масштабированием и обеспечением наших самостоятельно размещенных бегунов GitHub.

Мы создадим среду Container App Environment и очередь Azure Queue, затем создадим правило Azure Queue KEDA Scale Rule, которое будет иметь минимум 0 и максимум 3 контейнера для самостоятельного размещения бегунов. (Возможно масштабирование до максимум 30).

Мы будем использовать Azure Queue для ассоциирования рабочих процессов GitHub в качестве сообщений очереди, для обеспечения/масштабирования саморазмещающихся бегунов с помощью внешнего задания рабочего процесса GitHub, которое даст сигнал KEDA для обеспечения саморазмещающегося бегуна на лету для использования в любых последующих заданиях рабочего процесса внутри рабочего процесса GitHub.

После завершения выполнения всех последующих заданий рабочего процесса сообщение очереди, связанное с рабочим процессом, будет удалено из очереди, и KEDA сократит масштаб/уничтожит контейнер саморазмещающегося бегуна, по сути, сократив масштаб до 0, если нет других запущенных рабочих процессов GitHub.

Предварительные условия

Все, что нам понадобится для реализации этой пробной концепции контейнерного приложения:

  • Группа ресурсов развертывания Azure Container Apps (необязательно).
  • Azure Container Registry (ACR) – см. часть 3 этой серии блогов. (Учетная запись администратора должна быть включена).
  • Принцип службы GitHub, связанный с Azure – см. часть 3 этой серии блогов.
  • Рабочее пространство Log Analytics Workspace для связи с Azure Container Apps.
  • Учетная запись хранилища Azure и очередь, которая будет использоваться для масштабирования с помощью KEDA.
  • Среда Azure Container Apps.
  • Контейнерное приложение из образа docker (самостоятельный хостинг на GitHub), хранящегося в ACR.

Для этого шага я буду использовать сценарий PowerShell Deploy-ACA.ps1, выполняемый Azure-CLI, для создания всей среды и Container App, связанного с целевым GitHub Repo, где мы будем масштабировать бегуны с помощью KEDA.

#Log into Azure
#az login

#Add container app extension to Azure-CLI
az extension add --name containerapp

#Variables (ACA)
$randomInt = Get-Random -Maximum 9999
$region = "uksouth"
$acaResourceGroupName = "Demo-ACA-GitHub-Runners-RG" #Resource group created to deploy ACAs
$acaStorageName = "aca2keda2scaler$randomInt" #Storage account that will be used to scale runners/KEDA queue scaling
$acaEnvironment = "gh-runner-aca-env-$randomInt" #Azure Container Apps Environment Name
$acaLaws = "$acaEnvironment-laws" #Log Analytics Workspace to link to Container App Environment
$acaName = "myghprojectpool" #Azure Container App Name

#Variables (ACR) - ACR Admin account needs to be enabled
$acrLoginServer = "registryname.azurecr.io" #The login server name of the ACR (all lowercase). Example: _myregistry.azurecr.io_
$acrUsername = "acrAdminUser" #The Admin Account `Username` on the ACR
$acrPassword = "acrAdminPassword" #The Admin Account `Password` on the ACR
$acrImage = "$acrLoginServer/pwd9000-github-runner-lin:2.293.0" #Image reference to pull

#Variables (GitHub)
$pat = "ghPatToken" #GitHub PAT token
$githubOrg = "Pwd9000-ML" #GitHub Owner/Org
$githubRepo = "docker-github-runner-linux" #Target GitHub repository to register self hosted runners against
$appName = "GitHub-ACI-Deploy" #Previously created Service Principal linked to GitHub Repo (See part 3 of blog series)

# Create a resource group to deploy ACA
az group create --name "$acaResourceGroupName" --location "$region"
$acaRGId = az group show --name "$acaResourceGroupName" --query id --output tsv

# Create an azure storage account and queue to be used for scaling with KEDA
az storage account create `
    --name "$acaStorageName" `
    --location "$region" `
    --resource-group "$acaResourceGroupName" `
    --sku "Standard_LRS" `
    --kind "StorageV2" `
    --https-only true `
    --min-tls-version "TLS1_2"
$storageConnection = az storage account show-connection-string --resource-group "$acaResourceGroupName" --name "$acaStorageName" --output tsv
$storageId = az storage account show --name "$acaStorageName" --query id --output tsv

az storage queue create `
    --name "gh-runner-scaler" `
    --account-name "$acaStorageName" `
    --connection-string "$storageConnection"

#Create Log Analytics Workspace for ACA
az monitor log-analytics workspace create --resource-group "$acaResourceGroupName" --workspace-name "$acaLaws"
$acaLawsId = az monitor log-analytics workspace show -g $acaResourceGroupName -n $acaLaws --query customerId --output tsv
$acaLawsKey = az monitor log-analytics workspace get-shared-keys -g $acaResourceGroupName -n $acaLaws --query primarySharedKey --output tsv

#Create ACA Environment
az containerapp env create --name "$acaEnvironment" `
    --resource-group "$acaResourceGroupName" `
    --logs-workspace-id "$acaLawsId" `
    --logs-workspace-key "$acaLawsKey" `
    --location "$region"

# Grant AAD App and Service Principal Contributor to ACA deployment RG + `Storage Queue Data Contributor` on Storage account
az ad sp list --display-name $appName --query [].appId -o tsv | ForEach-Object {
    az role assignment create --assignee "$_" `
        --role "Contributor" `
        --scope "$acaRGId"

    az role assignment create --assignee "$_" `
        --role "Storage Queue Data Contributor" `
        --scope "$storageId"
}

#Create Container App from docker image (self hosted GitHub runner) stored in ACR
az containerapp create --resource-group "$acaResourceGroupName" `
    --name "$acaName" `
    --image "$acrImage" `
    --environment "$acaEnvironment" `
    --registry-server "$acrLoginServer" `
    --registry-username "$acrUsername" `
    --registry-password "$acrPassword" `
    --secrets gh-token="$pat" storage-connection-string="$storageConnection" `
    --env-vars GH_OWNER="$githubOrg" GH_REPOSITORY="$githubRepo" GH_TOKEN=secretref:gh-token `
    --cpu "1.75" --memory "3.5Gi" `
    --min-replicas 0 `
    --max-replicas 3
Вход в полноэкранный режим Выход из полноэкранного режима

ПРИМЕЧАНИЯ: Перед запуском приведенного выше сценария PowerShell необходимо включить учетную запись администратора в реестре контейнеров Azure и записать Username и Password, а также LoginSever и Image reference, поскольку они должны быть переданы в качестве переменных в сценарии:

#Variables (ACR) - ACR Admin account needs to be enabled
$acrLoginServer = "registryname.azurecr.io" #The login server name of the ACR (all lowercase). Example: _myregistry.azurecr.io_
$acrUsername = "acrAdminUser" #The Admin Account `Username` on the ACR
$acrPassword = "acrAdminPassword" #The Admin Account `Password` on the ACR
$acrImage = "$acrLoginServer/pwd9000-github-runner-lin:2.293.0" #Image reference to pull
Вход в полноэкранный режим Выход из полноэкранного режима

Вам также нужно будет предоставить переменные для GitHub Service Principal/AppName, который мы создали в третьей части серии блогов и который связан с Azure, токен GitHub PAT и указать владельца и репозиторий для связи с контейнерным приложением:

#Variables (GitHub)
$pat = "ghPatToken" #GitHub PAT token
$githubOrg = "Pwd9000-ML" #GitHub Owner/Org
$githubRepo = "docker-github-runner-linux" #Target GitHub repository to register self hosted runners against
$appName = "GitHub-ACI-Deploy" #Previously created Service Principal linked to GitHub Repo (See part 3 of blog series)
Вход в полноэкранный режим Выход из полноэкранного режима

О том, как создать персональный маркер доступа, см. в разделе Создание персонального маркера доступа о том, как создать маркер GitHub PAT. PAT-токены отображаются только один раз и являются конфиденциальными, поэтому позаботьтесь об их сохранности.

Для регистрации саморазмещаемого бегуна в токене PAT требуются следующие минимальные диапазоны разрешений: "repo", "read:org":

Давайте рассмотрим, что создал этот скрипт шаг за шагом.

Он создал группу ресурсов под названием: Demo-ACA-GitHub-Runners-RG, содержащую среду Azure Container Apps Environment, связанную с рабочим пространством Log Analytics Workspace, учетной записью Azure Storage и контейнерным приложением на основе образа GitHub runner, взятого из нашего Azure Container Registry.

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

Он также создал для нас пустую очередь (gh-runner-scaler), которую мы будем использовать для ассоциирования запущенных рабочих процессов GitHub в качестве сообщений очереди, когда мы начнем запускать и масштабировать рабочие процессы GitHub Action.

Приложение-контейнер

Давайте более подробно рассмотрим созданное приложение-контейнер:

#Create Container App from docker image (self hosted GitHub runner) stored in ACR
az containerapp create --resource-group "$acaResourceGroupName" `
    --name "$acaName" `
    --image "$acrImage" `
    --environment "$acaEnvironment" `
    --registry-server "$acrLoginServer" `
    --registry-username "$acrUsername" `
    --registry-password "$acrPassword" `
    --secrets gh-token="$pat" storage-connection-string="$storageConnection" `
    --env-vars GH_OWNER="$githubOrg" GH_REPOSITORY="$githubRepo" GH_TOKEN=secretref:gh-token `
    --cpu "1.75" --memory "3.5Gi" `
    --min-replicas 0 `
    --max-replicas 3
Вход в полноэкранный режим Выход из полноэкранного режима

Как вы можете видеть, созданное контейнерное приложение масштабируется на 0-3, и у нас еще не настроено правило масштабирования:

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

Если вы следили за этой серией блогов, вы должны знать, что когда мы хотим предоставить саморазмещаемый GitHub runner, используя созданный нами образ, через docker или как ACI, мы должны были передать некоторые переменные окружения, такие как: GH_OWNER, GH_REPOSITORY и GH_TOKEN, чтобы указать, в каком репозитории должен быть зарегистрирован бегун.

Вы заметите, что эти переменные хранятся внутри конфигурации Container App:

Обратите внимание, что на GH_TOKEN фактически ссылается секрет:

Сценарий также устанавливает строку подключения к учетной записи Azure Queue Storage в качестве секрета, поскольку она понадобится нам для настройки правила масштабирования KEDA.

Создание правила масштабирования

Далее мы создадим правило масштабирования KEDA. На портале Azure перейдите к приложению Container App. Перейдите в раздел 'Scale' и нажмите на 'Edit and deploy':

Затем перейдите на вкладку 'Scale' и выберите '+ Add':

Это вызовет панель конфигурации правила масштабирования. Заполните следующие поля:

Ключ Значение Описание
Rule Name 'queue-scaling' Имя для правила масштабирования
Type 'Azure queue' Тип масштабирования для использования
Queue name 'gh-runner-scaler' Имя очереди хранения Azure, созданной сценарием
Queue length '1' Порог срабатывания (Каждый запуск рабочего процесса).

Затем нажмите на '+ Add' в разделе Authentication.

В разделе 'Secret reference' вы увидите выпадающий список для выбора секрета 'storage-connection-string', который мы создали ранее. Для параметра 'Trigger parameter' введите 'connection'.

Затем нажмите на 'Add' и 'Create'. Через минуту вы увидите, что новое правило шкалы создано:

ПРИМЕЧАНИЕ: Когда вы создаете правило масштабирования в первый раз, во время инициализации контейнерного приложения вы заметите, что в репозитории GitHub появится бегунок с коротким периодом жизни. Причина этого заключается в том, что процесс инициализации обеспечит как минимум 1 экземпляр на мгновение, а затем уменьшит масштаб до 0 примерно через 5 минут.

Запуск и масштабирование рабочих процессов

Далее мы создадим рабочий процесс на GitHub, который будет использовать внешнее задание, чтобы связать наш рабочий процесс с сообщением Azure Queue, что автоматически запустит KEDA для обеспечения саморазмещающегося runner внутри нашего репозитория для всех последующих заданий рабочего процесса.

Как вы можете видеть, в настоящее время у нас нет саморазмещаемых бегунов в нашем репозитории GitHub:

Вы можете использовать следующий пример рабочего процесса: kedaScaleTest.yml

name: KEDA Scale self hosted

on:
  workflow_dispatch:

env:
  AZ_STORAGE_ACCOUNT: aca2keda2scaler126
  AZ_QUEUE_NAME: gh-runner-scaler

jobs:
  #External Job to create and associate workflow with a unique QueueId on Azure queue to scale up KEDA
  scale-keda-queue-up:
    runs-on: ubuntu-latest
    steps:
      - name: 'Login via Azure CLI'
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: scale up self hosted
        id: scaleJob
        run: |
          OUTPUT=$(az storage message put --queue-name "${{ env.AZ_QUEUE_NAME }}" --content "${{ github.run_id }}" --account-name "${{ env.AZ_STORAGE_ACCOUNT }}")
          echo "::set-output name=scaleJobId::$(echo "$OUTPUT" | grep "id" | sed 's/^.*: //' | sed 's/,*$//g')"
          echo "::set-output name=scaleJobPop::$(echo "$OUTPUT" | grep "popReceipt" | sed 's/^.*: //' | sed 's/,*$//g')"
    outputs:
      scaleJobId: ${{ steps.scaleJob.outputs.scaleJobId }}
      scaleJobPop: ${{ steps.scaleJob.outputs.scaleJobPop }}

  #Subsequent Jobs runs-on [self-hosted]. Job1, Job2, JobN etc etc
  testRunner:
    needs: scale-keda-queue-up
    runs-on: [self-hosted]
    steps:
      - uses: actions/checkout@v3
      - name: Install Terraform
        uses: hashicorp/setup-terraform@v2
      - name: Display Terraform Version
        run: terraform --version
      - name: Display Azure-CLI Version
        run: az --version
      - name: Delay runner finish (2min)
        run: sleep 2m

      #Remove unique QueueId on Azure queue associated with workflow as final step to scale down KEDA
      - name: 'Login via Azure CLI'
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: scale down self hosted
        run: |
          az storage message delete --id ${{needs.scale-keda-queue-up.outputs.scaleJobId}} --pop-receipt ${{needs.scale-keda-queue-up.outputs.scaleJobPop}} --queue-name "${{ env.AZ_QUEUE_NAME }}" --account-name "${{ env.AZ_STORAGE_ACCOUNT }}"
Вход в полноэкранный режим Выйти из полноэкранного режима

ПРИМЕЧАНИЕ: В приведенном выше рабочем процессе GitHub замените переменные окружения на учетную запись Azure Storage и имя очереди:

env:
  AZ_STORAGE_ACCOUNT: aca2keda2scaler126
  AZ_QUEUE_NAME: gh-runner-scaler
Войти в полноэкранный режим Выйти из полноэкранного режима

Давайте рассмотрим, что делает этот рабочий процесс шаг за шагом

Работа 1

jobs:
  #External Job to create and associate workflow with a unique QueueId on Azure queue to scale up KEDA
  scale-keda-queue-up:
    runs-on: ubuntu-latest
    steps:
      - name: 'Login via Azure CLI'
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: scale up self hosted
        id: scaleJob
        run: |
          OUTPUT=$(az storage message put --queue-name "${{ env.AZ_QUEUE_NAME }}" --content "${{ github.run_id }}" --account-name "${{ env.AZ_STORAGE_ACCOUNT }}")
          echo "::set-output name=scaleJobId::$(echo "$OUTPUT" | grep "id" | sed 's/^.*: //' | sed 's/,*$//g')"
          echo "::set-output name=scaleJobPop::$(echo "$OUTPUT" | grep "popReceipt" | sed 's/^.*: //' | sed 's/,*$//g')"
    outputs:
      scaleJobId: ${{ steps.scaleJob.outputs.scaleJobId }}
      scaleJobPop: ${{ steps.scaleJob.outputs.scaleJobPop }}
Вход в полноэкранный режим Выход из полноэкранного режима

Первое задание под названием scale-keda-queue-up: будет использовать внешний бегунок для отправки сообщения очереди в очередь Azure Queue, а затем сохранит уникальный идентификатор очереди и Queue popReceipt в качестве выходных данных, на которые мы можем позже ссылаться в других заданиях. Это уникальное сообщение очереди будет представлять запуск рабочего процесса:

KEDA будет видеть это сообщение очереди на основе правила масштабирования, которое мы создали ранее, а затем автоматически предоставит GitHub self hosted runner для этого репозитория, пока в очереди есть сообщения:

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

Эти сообщения очереди, представляющие наши рабочие процессы, автоматически заставят KEDA увеличить масштаб и создать больше саморазмещающихся бегунов на нашем репозитории в зависимости от спроса/нагрузки запущенных рабочих процессов. По сути, KEDA динамически масштабирует наши self hosted runners по мере одновременного выполнения большего количества рабочих процессов в зависимости от спроса:

Работа 2 <-> Работа n

Любые последующие задания рабочего процесса могут использовать саморазмещаемый бегун, как мы видим из следующего задания рабочего процесса, поскольку оно установлено на 'runs-on: [self-hosted]':

#Subsequent Jobs runs-on [self-hosted]. Job1, Job2, JobN etc etc
testRunner:
  needs: scale-keda-queue-up
  runs-on: [self-hosted]
  steps:
    - uses: actions/checkout@v3
    - name: Install Terraform
      uses: hashicorp/setup-terraform@v2
    - name: Display Terraform Version
      run: terraform --version
    - name: Display Azure-CLI Version
      run: az --version
    - name: Delay runner finish (2min)
      run: sleep 2m

    #Remove unique QueueId on Azure queue associated with workflow as final step to scale down KEDA
    - name: 'Login via Azure CLI'
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}

    - name: scale down self hosted
      run: |
        az storage message delete --id ${{needs.scale-keda-queue-up.outputs.scaleJobId}} --pop-receipt ${{needs.scale-keda-queue-up.outputs.scaleJobPop}} --queue-name "${{ env.AZ_QUEUE_NAME }}" --account-name "${{ env.AZ_STORAGE_ACCOUNT }}"
Войти в полноэкранный режим Выйти из полноэкранного режима

Это второе задание (или любые последующие задания) в нашем рабочем процессе (связанные с уникальным сообщением очереди), теперь может использовать и запускаться на саморазмещенном GitHub runner, который KEDA масштабирует на Azure Container Apps и устанавливает Terraform, а также отображает версию Terraform и Azure-CLI.

Последний шаг финального рабочего процесса Job, называемый scale down self hosted, используется для удаления уникального сообщения очереди, связанного с запуском рабочего процесса в очереди azure, чтобы сигнализировать о завершении рабочего процесса. Это заставит KEDA отмасштабироваться, и если больше нет запущенных рабочих процессов, KEDA может отмасштабироваться до 0:

- name: scale down self hosted
  run: |
    az storage message delete --id ${{needs.scale-keda-queue-up.outputs.scaleJobId}} --pop-receipt ${{needs.scale-keda-queue-up.outputs.scaleJobPop}} --queue-name "${{ env.AZ_QUEUE_NAME }}" --account-name "${{ env.AZ_STORAGE_ACCOUNT }}"
Вход в полноэкранный режим Выход из полноэкранного режима

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

Заключение

Как вы видите, было довольно просто создать среду Azure Container Apps и создать правило масштабирования Container App и KEDA с помощью очередей Azure для автоматического предоставления саморазмещаемых бегунов GitHub в репозиторий по нашему выбору, который имеет 0 или вообще не имеет бегунов.

Есть несколько оговорок и болевых точек, которые я хотел бы выделить в этом доказательстве концепции, и, надеюсь, они будут улучшены по мере развития продукта Container App. Как только эти болевые точки будут устранены, я создам еще одну часть этой серии блогов, посвященную этим улучшениям.

Проблема 1: Azure Container Apps еще не позволяет нам использовать назначенные системой Container Apps управляемые идентификаторы для получения образов из Azure Container Registry. Это означает, что мы должны включить учетную запись администратора ACRs, чтобы предоставлять образы из Azure Container Registry. Вы можете следить за этой проблемой на GitHub.

Проблема 2: На момент написания этой статьи для бегунов GitHub не было доступных масштабирующих устройств KEDA. Это означает, что мы должны использовать очередь Azure Storage Queue с нашими рабочими процессами GitHub для обеспечения/масштабирования бегунов с помощью KEDA, используя внешнее задание рабочего процесса GitHub, отправляя уникальное сообщение очереди (представляющее наш рабочий процесс), чтобы KEDA получил сигнал для обеспечения саморазмещаемого бегуна на лету для использования в последующих заданиях рабочего процесса.

Одним из преимуществ этого метода является то, что мы можем иметь минимальное количество контейнеров 0, что означает, что у нас никогда не будет простаивающих бегунов, ничего не делающих и потребляющих ненужные расходы, таким образом, по сути, мы платим за саморазмещающиеся бегуны только тогда, когда они действительно работают. Этот метод будет создавать саморазмещаемые бегуны GitHub только на основе длины очереди Azure Queue, по сути, ассоциируя выполнение нашего рабочего процесса с элементом очереди.

Как только рабочий процесс GitHub завершится, он удалит элемент очереди, и KEDA уменьшит масштаб до 0, если нет запущенных рабочих процессов GitHub.

На этом мы завершаем цикл из пяти частей, в котором мы подробно рассмотрели, как реализовать контейнеры Self Hosted GitHub Runner на Azure.

Надеюсь, вам понравилась эта статья и вы узнали что-то новое. Примеры кода, использованные в этой статье, вы можете найти на моем проекте GitHub: docker-github-runner-linux. ❤️

Автор

Like, share, follow me on: 🐙 GitHub | 🐧 Twitter | 👾 LinkedIn

Marcel.L

Microsoft DevOps MVP | Cloud Solutions & DevOps Architect | Технический спикер, специализирующийся на технологиях Microsoft, IaC и автоматизации в Azure. Найдите меня на GitHub: https://github.com/Pwd9000-ML

Оцените статью
Procodings.ru
Добавить комментарий