Konteneryzacja i Serverless — czy to ma sens? Przykład wdrażania kodu do Azure Function przez Azure Pipelines

W tym artykule autor przedstawi jak pracować z Azure Functions na Azure z wykorzystaniem do tego platformy .NET. Oto co znajdziesz w artykule:

Zagadnienia, jakie będą poruszane:

  • Czym jest serverless
  • Krótkie porównanie Azure App Service i Azure Function
  • Przykładowa implementacja Azure Function
  • Budowa Dockerfile
  • Konteneryzacja Azure Function
  • Jak utworzyć zasób na Azure przy użyciu Terraform
  • Jak wdrożyć Azure Function przy użyciu Azure Pipelines

Do wdrożenia Azure Function będziemy potrzebować konto Azure, a dodatkowo projekt w Azure DevOps, co zapewni nam system kontroli wersji oraz dostępność Azurowego CI/CD. Z narzędzi potrzebnych na komputerze będziemy potrzebować Terraform, aby móc utworzyć zasoby na Azure, oraz Azure CLI, aby móc uwierzytelnić się, w celu potwierdzenia naszej tożsamości i możliwości tworzenia zasobów w swojej subskrypcji.


Kontenery vs Serverless

W 2014 roku przedstawiona została koncepcja, jaką jest serverless computing. Popularność rozwiązań takich jak Azure Function czy AWS lambda stale rośnie. Budzi jednak nadal wiele wątpliwości, czy już nie potrzebujemy fizycznych serwerów? Na to pytanie nie ma jednoznacznej odpowiedzi. Wszystko zależy od założeń tworzonych przez nas rozwiązań. Część z nich może być zbudowana z małych, bezstanowych klocków, które razem połączone mogą tworzyć jedną całość.

Jednak zbudowanie skomplikowanego systemu, który może agregować wiele setek różnych rodzajów requestów HTTP lub innych triggerów, może spędzać sen z powiek. W przypadku zmiany wspólnego elementu (np. wersji biblioteki) może nastąpić konieczność wdrożenia kilkuset Azure Functions.

Warto pamiętać, że tworzone przez nas systemy mogą zawierać w sobie zarówno  aplikacje skonteneryzowane, dostępne cały czas, jak i elementy, które będą kodem uruchamianym na żądanie – czyli Azure Function.

Ale czy można tworzyć aplikacje uruchamiane na żądanie w oparciu o kontenery? Można, co zostanie przedstawione w tym artykule.


Azure App Service vs Azure Function

Azure App Service jest zasobem, który raz utworzony, jest cały czas aktywny. Jest to podstawowa różnica w porównaniu do Azure Function, które jest tworzone na żądanie. Co może być takim żądaniem? Jest ich wiele, m.in.: żądanie HTTP, Timer, Service Bus, oraz inne (więcej na ten temat można przeczytać Tutaj). Natomiast Azure App Service może hostować dowolną aplikację. Może to być zarówno API, jak i aplikacja frontendowa. 

W wielkim skrócie można określić, że w Azure Function płacimy za czas i zasoby potrzebne na wykonanie żądań, a w Azure App Service płacimy za zaalokowane zasoby na poczet aplikacji (czy są używane w 100%, czy też nie). Więcej o różnicach pomiędzy tymi zasobami można przeczytać Tutaj.


Przykładowa implementacja Azure Function

Aby zaprezentować sposób, w jaki wdrożymy Azure Function, musimy utworzyć przykładową implementację. Przygotujmy zatem funkcję, która na żądanie HTTP odpowie wartością typu string. W tym celu należy utworzyć nowy projekt, jakim jest Azure Function.

Na początku, jeśli nie mamy zainstalowanego dodatku do Azure Function w Visual Studio Code, musimy go zainstalować. Najprościej jest to zrobić przy użyciu CTRL + SHIFT + P, wpisać Azure Function Install i wybrać zasugerowaną opcję.

Azure Function Install w Visual Studio Code

Po zainstalowaniu tego dodatku należy utworzyć nowy projekt, analogicznie korzystając z okna dostępnego pod skrótem klawiszowym CTRL + SHIFT + P, wybierając opcję Create New Project.

Azure Functions: Create New Project

Po utworzeniu projektu kreator zapyta jaki rodzaj Triggera chcemy utworzyć. Należy wybrać HTTPTrigger, a następnie skonfigurować nazwy. Po zakończeniu zobaczymy w IDE nową klasę, którą możemy zastąpić jeszcze prostszą implementacją, zwracającą jedynie ciąg znaków:

public class OurHttpTriggerUnprotected
{
   [FunctionName("OurHttpTriggerUnprotected")]
   public async Task<IActionResult> RunAsync(
       [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req, ILogger log)
   {
       return new OkObjectResult("W Kontenerach : Wdrażanie azure function jako kontener - not secured");
   }
}

Po uruchomieniu tej funkcji i wywołaniu żądania z poziomu przeglądarki powinniśmy zobaczyć poniższy rezultat: 

Rezultat po uruchomieniu funkcji i wywołaniu żądania z poziomu przeglądarki


Konteneryzacja Azure Function

Po uruchomieniu i zweryfikowaniu poprawności działania możemy przygotować plik Dockerfile.

Na początku musimy utworzyć .NET SDK, aby mieć możliwość kompilacji Azure Function. Następnie musimy skopiować wszystkie pliki .csproj, aby zbudować wymagany zestaw paczek. Gdy zostanie to wykonane, należy skopiować cały kod źródłowy i wymusić budowanie paczki wykonywalnej.

Kolejnym krokiem jest utworzenie obrazu, bazującego na specjalnym obrazie do hostowania Azure Functions. Następnym, ostatnim krokiem z wyłączeniem ustawienia zmiennych środowiskowych jest skopiowanie kodu wykonywalnego z kontenera budującego, powstałego na bazie .net SDK. Plik Dockerfile dla podanego przykładu Azure Function zamieszczony został poniżej.

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build

WORKDIR /src
COPY ["InContainers.AzureFunction/InContainers.AzureFunction.csproj","InContainers.AzureFunction/InContainers.AzureFunction.csproj"]
RUN dotnet restore "InContainers.AzureFunction/InContainers.AzureFunction.csproj"
COPY . .

RUN dotnet publish "InContainers.AzureFunction/InContainers.AzureFunction.csproj" -c Release -o /app/publish

FROM mcr.microsoft.com/azure-functions/dotnet:4 AS base

WORKDIR /home/site/wwwroot
COPY --from=build /app/publish .
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
   AzureFunctionsJobHost__Logging__Console__IsEnabled=true


Tworzenie Azure Function przy użyciu Terraform

Portal Azure pozwala na tworzenie zasobów za pomocą GUI, jednak na potrzeby pokazania sposobu wdrażania Azure Function jako kontenera, przygotujmy skrypt terraform, który utworzy dla nas potrzebne zasoby.

Pierwsza część skryptu definiuje backend skryptu terraform. Umożliwia to zarządzanie stanem zasobów nie tylko na jednym komputerze. W tym przypadku backendem jest utworzony ręcznie na platformie Azure Blob Storage, przechowujący aktualny stan zasobów.

backend "azurerm" {
   resource_group_name  = "incontainersinfrastructure-azure-function-rg"
   storage_account_name = "incontainersinfra"
   container_name       = "terraformstate"
   key                  = "incontainers.tfstate"
 }

Kolejna część skryptu tworzy nową grupę zasobów, która będzie agregować kolejne konieczne do utworzenia zasoby.

resource "azurerm_resource_group" "in_containers_resource_group" {
 name     = "incontainers-resource-group"
 location = "West Europe"
}

Następna część to utworzenie wymaganego dla Azure Function – Storage Account. 

resource "azurerm_storage_account" "incontainersinfrastructurestorageaccount" {
 name                     = "incontainersinfrasa"
 resource_group_name      = azurerm_resource_group.in_containers_resource_group.name
 location                 = azurerm_resource_group.in_containers_resource_group.location
 account_tier             = "Standard"
 account_replication_type = "LRS"
}

Kolejna część to utworzenie Service Plan, którego zadaniem jest ustalenie systemu operacyjnego, na którym uruchamiane będą Azure Functions, oraz planu płatności, który określa jak duże koszty będzie generować Azure Function.

Warto się zatrzymać, ponieważ jego błędna konfiguracja może prowadzić do problemów potrafiących kosztować odrobinę nerwów i czasu. Chcąc wdrożyć Azure Function jako kontener, Service Plan musi być Linuxem, oraz mieć ustawioną flagę reserved (która jest wymagana przy rodzaju serwisu Linux).

resource "azurerm_app_service_plan" "incontainersazurefunctionserviceplan" {
 name                = "incontainers-azure-function-service-plan"
 resource_group_name      = azurerm_resource_group.in_containers_resource_group.name
 location                 = azurerm_resource_group.in_containers_resource_group.location
 kind                = "Linux"
 reserved            = true
 sku {
   tier = "Standard"
   size = "S1"
 }
}

Następny tworzony zasób to właśnie funkcja. Konfigurujemy tutaj połączenia z Service Planem, Storage Accountem, definiujemy wersję Azure Function, oraz kolejna ważna rzecz – site config. Musimy w nim określić, że uruchamiamy to za pośrednictwem Dockera, następnie określamy repozytorium i obraz wraz z jego wersją. Po ustawieniu w sekcji app_settings kluczy wymaganych do połączenia z prywatnym Docker Registry, jesteśmy gotowi do uruchomienia skryptu.

resource "azurerm_function_app" "customer_azure_function" {
 name                       = "incontainers-azure-function"
 resource_group_name      = azurerm_resource_group.in_containers_resource_group.name
 location                 = azurerm_resource_group.in_containers_resource_group.location
 app_service_plan_id        = azurerm_app_service_plan.incontainersazurefunctionserviceplan.id
 storage_account_name       = azurerm_storage_account.incontainersinfrastructurestorageaccount.name
 storage_account_access_key = azurerm_storage_account.incontainersinfrastructurestorageaccount.primary_access_key
 version = "~4"
 app_settings = {
   DOCKER_REGISTRY_SERVER_URL = local.env_variables.DOCKER_REGISTRY_SERVER_URL
   DOCKER_REGISTRY_SERVER_USERNAME = local.env_variables.DOCKER_REGISTRY_SERVER_USERNAME
   DOCKER_REGISTRY_SERVER_PASSWORD = local.env_variables.DOCKER_REGISTRY_SERVER_PASSWORD
   WEBSITE_ENABLE_SYNC_UPDATE_SITE = true
   WEBSITES_ENABLE_APP_SERVICE_STORAGE = false
 }
 site_config {
   linux_fx_version = "DOCKER|<registry-name>.azurecr.io/incontainers-azure-function:108"
 }
}

Cały przygotowany skrypt terraform, który agreguje wszystkie powyższe kroki, prezentuje się następująco:

terraform {
 backend "azurerm" {
   resource_group_name  = "incontainersinfrastructure-azure-function-rg"
   storage_account_name = "incontainersinfra"
   container_name       = "terraformstate"
   key                  = "incontainers.tfstate"
 }

 required_providers {
   azurerm = {
     source  = "hashicorp/azurerm"
     version = "=2.91.0"
   }
 }
}

provider "azurerm" {
 features {}
}

resource "azurerm_resource_group" "in_containers_resource_group" {
 name     = "incontainers-resource-group"
 location = "West Europe"
}

resource "azurerm_storage_account" "incontainersinfrastructurestorageaccount" {
 name                     = "incontainersinfrasa"
 resource_group_name      = azurerm_resource_group.in_containers_resource_group.name
 location                 = azurerm_resource_group.in_containers_resource_group.location
 account_tier             = "Standard"
 account_replication_type = "LRS"
}

resource "azurerm_app_service_plan" "incontainersazurefunctionserviceplan" {
 name                = "incontainers-azure-function-service-plan"
 resource_group_name      = azurerm_resource_group.in_containers_resource_group.name
 location                 = azurerm_resource_group.in_containers_resource_group.location
 kind                = "Linux"
 reserved            = true
 sku {
   tier = "Standard"
   size = "S1"
 }
}

resource "azurerm_function_app" "customer_azure_function" {
 name                       = "incontainers-azure-function"
 resource_group_name      = azurerm_resource_group.in_containers_resource_group.name
 location                 = 
 azurerm_resource_group.in_containers_resource_group.location
 app_service_plan_id        = azurerm_app_service_plan.incontainersazurefunctionserviceplan.id
 storage_account_name       = azurerm_storage_account.incontainersinfrastructurestorageaccount.name
 storage_account_access_key = azurerm_storage_account.incontainersinfrastructurestorageaccount.primary_access_key
 version = "~4"
 app_settings = {
   DOCKER_REGISTRY_SERVER_URL = local.env_variables.DOCKER_REGISTRY_SERVER_URL
   DOCKER_REGISTRY_SERVER_USERNAME = local.env_variables.DOCKER_REGISTRY_SERVER_USERNAME
   DOCKER_REGISTRY_SERVER_PASSWORD = local.env_variables.DOCKER_REGISTRY_SERVER_PASSWORD
   WEBSITE_ENABLE_SYNC_UPDATE_SITE = true
   WEBSITES_ENABLE_APP_SERVICE_STORAGE = false
 }
 site_config {
   linux_fx_version = "DOCKER|<registry-name>.azurecr.io/incontainers-azure-function:108"
 }
}

Aby wykonać powyższy skrypt, należy zalogować się do Azure CLI przy pomocy az login. Spowoduje to uruchomienie okna przeglądarki, umożliwiającego zalogowanie się do panelu Azure.

Po zalogowaniu się, znajdując się w wierszu poleceń, poleceniem terraform init można zainicjalizować katalog z konfiguracją powyższego skryptu terraform.

Kolejnym krokiem jest uruchomienie terraform plan, które przygotuje plan wykonania całego skryptu terraform, z uwzględnieniem sprawdzenia stanów aktualnie istniejących zasobów. Sprawdzi również możliwości zmiany ich konfiguracji – jeśli to możliwe, a jeśli nie, usunięcie i utworzenie od nowa zasobów z poprawną konfiguracją.

Ostatni krok to terraform apply, który spowoduje wdrożenie powyższego planu, czyli wprowadzenie zmian na zasobach w Azure, lub utworzenie gdy jeszcze nie istnieją.

Terraform apply

Po wdrożeniu możemy zweryfikować, czy funkcja działa. Aby to sprawdzić, wystarczy wejść na adres jednego z triggerów HTTP. W tym przypadku jest to https://incontainers-azure-function.azurewebsites.net/api/OurHttpTriggerUnprotected.  Po przejściu na tę stronę widzimy komunikat zaimplementowany wewnątrz Azure Function.

Weryfikacja czy funkcja działa poprzez wejście na https://incontainers-azure-function.azurewebsites.net/api/OurHttpTriggerUnprotected


Automatyczne wdrażanie z repozytorium przy użyciu Azure Pipelines

Po wdrożeniu, gdy chcemy wprowadzić jakąś zmianę, nie będziemy chcieli wykonywać za każdym razem skryptu terraform. Oczywiście zadziałałoby to, ale szkoda tracić czas na rzeczy, które można zautomatyzować. Dlatego też zaprezentowany zostanie skrypt, który uruchomiony na Azure, w pipeline, wdroży zasób na Azure.

trigger:
 - main

pool:
 vmImage: ubuntu-latest

stages:

 - stage: buildAndPublishDockerImage
   dependsOn: BuildApplication
   displayName: Build and publish docker image
   jobs:
     - job: buildDockerImage
       displayName: Build and Push Docker Image
       steps:
         - task: Docker@2
           inputs:
             containerRegistry: <registry-name> container registry'
             repository: 'incontainers-azure-function'
             command: 'build'
             Dockerfile: '**/Dockerfile'
             arguments: '--build-arg PAT=$(PAT)'

         - task: Docker@2
           inputs:
             containerRegistry: <registry-name> container registry'
             repository: 'incontainers-azure-function'
             command: 'push'

 - stage: deploy
   dependsOn: buildAndPublishDockerImage
   jobs:
     - job: job
       steps:
       - task: AzureFunctionAppContainer@1
         inputs:
           azureSubscription: 'Microsoft Partner Network(87f5dac5-78c1-45c1-bf8c-00a22b01e843)'
           appName: 'incontainers-azure-function'
           imageName: '<registry-name>.azurecr.io/incontainers-azure-function:$(Build.BuildId)'
           AppSettings: '-DOCKER_REGISTRY_SERVER_URL "$(DOCKER_REGISTRY_SERVER_URL)" -DOCKER_REGISTRY_SERVER_USERNAME 
           "$(DOCKER_REGISTRY_SERVER_USERNAME)" -DOCKER_REGISTRY_SERVER_PASSWORD "$(DOCKER_REGISTRY_SERVER_PASSWORD)"'

Powyższy skrypt, który jest w stanie wdrożyć nową wersję Azure Function, podzielony jest na dwa główne etapy. 

Pierwszy z nich jest budowaniem aplikacji, do czego wykorzystywany jest przedstawiony powyżej plik Dockerfile. Drugim krokiem etapu pierwszego jest wypchnięcie zbugowanego obrazu do prywatnego Docker Registry. 

Gdy to się wykona, uruchamiany jest drugi etap, którego zadaniem jest podmiana obrazu Dockerowego wykonywanego wewnątrz zasobu na Azure. Gdy zmieniamy w Azure Function jej bazowy obraz, musimy ponownie podać zmienne, umożliwiające zalogowanie się do prywatnego repozytorium obrazów, aby pobranie obrazów było możliwe. Jak możemy zobaczyć na zrzucie ekranu z Azure Function jest literówka. Poprawimy ją, i wdrożymy zmiany za pośrednictwem azurowego pipeline’a.

Etapy wdrażania zasobu na Azure.

Po zrobieniu pull requesta do brancha main, pipeline został automatycznie wystartowany.

Azure pipeline

Po kilku minutach możemy zobaczyć, że Azure Agent wykonał za nas całą robotę, gdyż status każdego z etapów zakończył się sukcesem:

Azure Agent

Teraz pozostało tylko zweryfikować, czy faktycznie wartość zwracana przez funkcję została zmieniona i literówka została wyeliminowana:

Ponowna weryfikacja czy funkcja działa poprzez wejście na https://incontainers-azure-function.azurewebsites.net/api/OurHttpTriggerUnprotected

Tak jak widzimy na powyższym zrzucie ekranu — Pipeline skonfigurowany na Azure również działa, co potwierdza tezę, że Azure Function można wdrażać również w postaci skonteneryzowanej.


Dlaczego warto użyć połączenia – konteneryzacji i serverless?

Wdrażanie Azure Functions jest także możliwe za pomocą kontenerów. Pozwala nam to w pełni wykorzystać potencjał kontenerów, czyli przechowywać wersje w rejestrze obrazów i w dowolnej chwili odtworzyć każdy poprzedni z nich. 

Nie jest tak proste w przypadku wdrażaniu Azure Functions jako kod. W takim przypadku należy wycofywać commity, a następnie wdrażać ponownie. W przypadku kontenerów sprowadzi się jedynie do zmiany numeru wersji w portalu Azure lub użycie skryptu terraform. Wystarczy zmienić numer w siteconfig, a następnie uruchomienie skryptu terraform apply, który zmieni wersję obrazu.


Autor

Maciej Cebula

Maciej Cebula — programista, który początki swojej przygody z programowaniem rozpoczął z Javą i Springiem. Po poznaniu platformy .NET nie mógł pisać już w niczym innym. Po kilku latach pracy zorientowanej wyłącznie na programowaniu zaczął interesować się konteneryzacją aplikacji oraz szeroko pojętą kulturą DevOps. Miłośnik aplikacji rozproszonych wdrażanych w modelu chmurowym, preferujący pracę z Azure DevOps. Jednak własne środowisko pracy opiera nie na rozwiązaniu Microsoft, a na Linuxie zaopatrzonym w produkty firmy Jetbrains.




.

Leave a Comment

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *