Наша serverless story. Як ми створили generic-рішення завдяки сервісам Azure
Мене звати Ілля Чуйков, я Cloud Dev/DevOps Engineer у VISEO. Наша компанія працює за аутстафінговою моделлю, надає послуги своїх IT-спеціалістів різним організаціям.
У статті я розповім, як ми збудували рішення із serverless-архітектурою, яке завдяки сервісам Azure зекономило ресурси на його розробку та підтримку. Воно орієнтоване на збереження єдиної бази коду та управління модифікаціями проєкту лише через конфігураційні змінні. Я сподіваюсь, що матеріал буде корисний усім, хто розпочинає працювати або вже давно працює з Azure Functions та іншими ресурсами Azure. Також буду дуже радий почути ваші ідеї та коментарі щодо можливих удосконалень у нашому рішенні.
Проєкт і команда
Спершу розповім трохи про проєкт. Наш клієнт — одна з найбільших транспортних компаній Франції, що займається різними видами пасажирських перевезень. Компанія має матричну структуру, тобто в неї входять досить незалежні у своїх діях філіали, а головний офіс надає їм усе необхідне, зокрема програмне забезпечення.
Сам проєкт розпочався майже три роки тому, я ж долучився до нього понад рік тому як бекенд-розробник. Тоді якраз закінчилися підготовчі моменти та розпочалася активна трансформація рішення, були зроблені перші кроки для міграції в cloud, сформована нова бекенд-команда.
Наша команда складається з трьох бекенд-розробників та одного архітектора, ми співпрацюємо з багатьма іншими командами, що пишуть мобільні та вебзастосунки для кінцевих користувачів, водіїв, аналітиків, маркетологів, працівників підтримки. Але усі ці програми використовують наш back-end. Крім того, ми самостійно займаємося усіма активностями, пов’язаними з деплойментом рішень, тому кожен учасник розуміється не лише в розробці, а й у процесах, пов’язаних з девопсом.
Проблеми
Аналізуючи ситуацію на проєкті, ми виділили суттєві проблеми як з технічного погляду, так і з погляду бізнесу. Клієнт обмежений у фінансових ресурсах, особливо останнім часом через зрозумілі всім причини. Висока конкуренція на ринку зумовлює потребу у швидких та частих релізах, постійні зміни у вподобаннях користувачів змушують бізнес все частіше запускати нові продукти та оновлювати старі.
Кожен з філіалів компанії може самостійно обирати партнерів, тому використовує у роботі різноманітні сервіси оплати, побудови оптимальних маршрутів, надсилання повідомлень, геолокації тощо. З усіма ними ми зобов’язані інтегруватися, підтримуючи різні схеми взаємодії.
Серед проблем, які належать до розряду технічних, особливо виділялися три «кити», на яких була збудована стара система:
- величезний шматок legacy-коду з монолітною архітектурою, який здебільшого написаний близько 15 років тому;
- велика кількість on-premises ресурсів, що коштували великих грошей для бізнесу та мали безліч проблем у підтриманні;
- найрізноманітніші джерела даних (навіть файли Excel), що зберігали несистематизовану розрізнену інформацію про користувачів та робили всі зусилля команди маркетингу марними.
З-поміж інших проблем: відсутність чіткої та налагодженої системи взаємодії між командами, майже повна відсутність автоматизації процесів підтримки якості та DevOps, низький рівень безпеки рішень.
Отже, перед нами постало велике завдання. З одного боку, необхідно було мігрувати старий проєкт у хмару, модернізувати його код і побудувати чіткі процеси взаємодії з іншими командами. З іншого — постійно підтримувати амбіційні плани щодо релізу нових рішень для чималої кількості філіалів, кожен з яких має свої потреби. І водночас дотримуватися закладеного бюджету.
Архітектура та інструменти
Щоб модифікувати старе рішення, не відкладаючи надовго реліз нових проєктів, ми використали Strangler application pattern. Суть патерну полягає в тому, що наявне монолітне рішення має бути оточене новими API, що дасть змогу клієнтським застосункам використовувати функціонал моноліту вже не прямо, а за допомогою цих API.
Поступово функціонал моноліту має бути замінений новим, проте для клієнтських програм це не матиме жодних наслідків. Створивши нові API, ми значно полегшили працю фронтенд-команд, оскільки вони змогли використовувати нові RESTful API замість SOAP. Великий плюс для нас — можливість відкласти переписування старого коду і зосередити всю увагу на новому функціоналі та архітектурі generic-рішення.
Також вирішили максимально використовувати нативні хмарні сервіси. Побудували generic-рішення, в основі якого Azure Functions. Azure Functions — це хмарний обчислювальний сервіс, що дозволяє виконувати код, ініційований подіями, без попереднього налаштування середовища. Щоб знайти більш докладну інформацію, раджу користуватися офіційною документацією від Microsoft.
Нижче наведено приклад архітектури одного з наших солюшенів, що є досить типовим:
Переваги рішення
Низька вартість. Насамперед нас цікавило фінансове питання. Використовуючи Azure Functions, ми платимо лише за час їх виконання, а Azure надає на кожен місяць 2500 хвилин безкоштовно. Цього часу цілком достатньо для більшої частини сервісів невеликих філіалів клієнта.
Scale та monitoring. Крім того, навантаження на наші сервіси нерівномірне, а завдяки serverless-рішенню ми переносимо всі зусилля щодо scale та monitoring на хмарний провайдер і не переплачуємо за ресурси, які нам не потрібні.
Bindings та triggers. Функції мають вже вбудовані зв’язки практично з усіма сервісами Azure (Service Bus, Notification Hub, Cosmos DB тощо), які можуть слугувати як вхідними, так і вихідними даними. Функція має різноманітні тригери — елементи, що викликають її старт, найчастіше вони мають інформацію, що використовується під час її роботи. Такими тригерами можуть бути http-виклики, нові повідомлення в черзі, таймер тощо.
Докладніше про види тригерів і зв’язок ви можете дізнатися тут. Усе це допомагає побудувати гнучке рішення, при цьому зекономити зусилля для написання коду з нуля.
Недоліки рішення
Єдиною вагомою проблемою нашого рішення, яку ми виділили для себе, є значна залежність від конкретного клауд провайдеру. Наше рішення буде дуже важко мігрувати, якщо виникне така необхідність. Проте ми вирішили погодитися на цей ризик, натомість отримавши наведені вище переваги.
Також досить загальною проблемою при використанні Azure-функцій є проблема, що притаманна усім serverless-рішенням — холодний старт. Це час, необхідний провайдеру для налаштування середовища для виконання функції під час першого звернення. У нашому випадку цей час не є чимось критичним. За потреби, ми зможемо використати більш дорогий сервіс-план, який значно знизить цей час.
Dependency injection в Azure Functions
Значним поштовхом для використання Azure Functions у великих проєктах стало впровадження механізму Dependency injection (DI), починаючи з версії 2.0. Dependency injection дає змогу створювати залежні об’єкти поза класом і надає їх класу різними способами. До переваг використання DI належать можливості повторного використання коду, краща читабельність і тестованість, а також зменшення кількості залежностей.
Я не можу навести приклади коду з реального проєкту, тому створив невеликий демопроєкт, де містяться основні ідеї. Усі подальші приклади я буду брати з нього.
Використання DI у функціях майже ідентичне до використання у стандартних .NET Core проєктах, необхідно додати у проєкт пакунки Microsoft.Azure.Functions.Extensions та Microsoft.NET.Sdk.Functions від 1.0.28 версії. Тепер, наслідуючи FunctionStartup, можемо перевизначити метод Configure:
/// <summary> /// Contains configuration method to register services. /// </summary> internal class Startup : FunctionsStartup { /// <inheritdoc/> public override void Configure(IFunctionsHostBuilder builder) { builder.Services.AddLogging(); builder.Services.Configure<ServiceBusOptions>(opts => { opts.ConnectionString = Environment.GetEnvironmentVariable("ServiceBusConnection"); opts.ListenConnectionString = Environment.GetEnvironmentVariable("ServiceBusConnection"); opts.TimeToLive = Environment.GetEnvironmentVariable("TimeToLive"); }); builder.Services.AddServiceBusHelper(); builder.Services.AddTransient<ISenderService<SmsDto>, SmsSenderService>(); builder.Services.AddTransient<ISenderService<SendGridMessage>, EmailSenderService>(); builder.Services.AddTransient<IConfigurationHelper, ConfigurationHelper>(); builder.Services.AddTransient<INotificator, SmsNotificator>(); builder.Services.AddTransient<INotificator, EmailNotificator>(); builder.Services.AddTransient<INotificationRequestParser, NotificationRequestParser>(); builder.Services.AddTransient<INotificationTypeFactory, NotificationTypeFactory>(); } }
Саме використання впровадження залежностей дозволило нам створити generic-рішення. На мою думку, ми розв’язали проблему простим та елегантним способом за допомогою фабрики.
Пропоную розглянути проєкт, що займається надсиланням різних електронних листів. Оскільки маємо багато клієнтських застосунків, складно чітко визначити кількість типів повідомлень. Тому вирішили створити єдине API, що має отримувати на вхід різні типи повідомлень і надсилати їх. При цьому легко додавати нові види листів.
Для цього створили фабрику, яка реєструє всі наявні сервіси, що перелічені в EmailTypeEnum:
/// <summary> /// A factory to create the concrete email. /// </summary> public class EmailBuilderFactory : IEmailBuilderFactory { private readonly Dictionary<EmailTypeEnum, EmailAbstract> _factories; /// <summary> /// Initializes a new instance of the <see cref="EmailBuilderFactory"/> class. /// </summary> /// <param name="serviceProvider">The service provider to use.</param> public EmailBuilderFactory(IServiceProvider serviceProvider) { var emailTypes = (EmailAbstract[])serviceProvider.GetService(typeof(IEnumerable<EmailAbstract>)); _factories = new Dictionary<EmailTypeEnum, EmailAbstract>(); foreach (EmailTypeEnum emailType in Enum.GetValues(typeof(EmailTypeEnum))) { var type = Type.GetType($"Demo.AzureFunctions.Builder.{emailType}Email"); var instance = emailTypes.FirstOrDefault(x => x.GetType() == type); if (instance == null) { throw new ArgumentNullException($"The instance with {type} can not be null."); } _factories.Add(emailType, instance); } } /// <summary> /// Create an instance of IEmail. /// </summary> /// <param name="emailType">Email type.</param> /// <returns>The implementation of IEmail.</returns> public EmailAbstract Create(EmailTypeEnum emailType) => _factories[emailType]; }
Тепер зареєструємо необхідні сервіси у Startup:
builder.Services.AddTransient<IEmailBuilderFactory, EmailBuilderFactory>(); builder.Services.AddTransient<EmailAbstract, AccountConfirmationEmail>(); builder.Services.AddTransient<EmailAbstract, PersonalInfoModificationEmail>(); builder.Services.AddTransient<EmailAbstract, AccountActivationEmail>(); builder.Services.AddTransient<EmailAbstract, PasswordChangedEmail>();
Усі ці сервіси є досить типовими, ось приклад одного з них:
/// <summary> /// Creates an account activation email from generic email data. /// </summary> public class AccountActivationEmail : EmailAbstract { /// <summary> /// Initializes a new instance of the <see cref="AccountActivationEmail"/> class. /// </summary> /// <param name="configurationHelper">The configuration helper to work with config values.</param> public AccountActivationEmail(IConfigurationHelper configurationHelper) : base(configurationHelper) { } /// <inheritdoc/> public override SendGridMessage GetMessage(EmailDto emailDto) { var accountActivationEmail = emailDto.EmailData.ToObject<AccountActivationEmailModel>(); var fromEmail = string.IsNullOrEmpty(emailDto.FromEmail) ? FromEmail : new EmailAddress(emailDto.FromEmail); var toEmail = string.IsNullOrEmpty(emailDto.ToEmail) ? ToEmail : new EmailAddress(emailDto.ToEmail); var data = new Dictionary<string, string> { { SendGridConstants.ActivationLinkSendGridProperty, accountActivationEmail.ActivationLink } }; var message = MailHelper.CreateSingleTemplateEmail( fromEmail, toEmail, _configurationHelper.SendGridAccountActivationTemplateId(), data); return message; } }
Далі наведено приклад функції, що безпосередньо використовує наші сервіси:
/// <summary> /// Contains function that sends the email to the user. /// </summary> public class HandleEmails { private readonly ISenderService<SendGridMessage> _emailSenderService; private readonly IEmailBuilderFactory _emailBuilderFactory; /// <summary> /// Initializes a new instance of the <see cref="HandleEmails"/> class. /// </summary> /// <param name="emailSenderService">The service to send emails.</param> /// <param name="emailBuilderFactory">The service to create the emails.</param> public HandleEmails(ISenderService<SendGridMessage> emailSenderService, IEmailBuilderFactory emailBuilderFactory) { _emailSenderService = emailSenderService; _emailBuilderFactory = emailBuilderFactory; } /// <summary> /// Sends the email from the Service Bus queue. /// </summary> /// <param name="queueMessage">The email from the queque.</param> /// <param name="logger">The logger to log info messages.</param> /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns> [FunctionName("HandleEmails")] public async Task RunAsync( [ServiceBusTrigger("%emailQueueName%", Connection = "ServiceBusConnection")]string queueMessage, ILogger logger) { logger.LogInformation($"{nameof(HandleEmails)} function starts."); logger.LogInformation($"Email to send as JSON: {queueMessage}."); var emailDto = JsonConvert.DeserializeObject<EmailDto>(queueMessage); var email = _emailBuilderFactory .Create(emailDto.Type) .GetMessage(emailDto); await _emailSenderService.SendAsync(email); logger.LogInformation($"{nameof(HandleEmails)} function ends."); } }
Тригером, що запускає функцію, є нове повідомлення у черзі. Визначивши його тип, фабрика створює необхідний інстанс, що відповідає конкретному типу листа. Ми отримуємо його контент, який далі надсилає сервіс, що використовує Sendgrid для роботи з email-листуванням. Схожим чином побудовано усі наші рішення. Керуючи лише значеннями, доступними в EmailTypeEnum, ми можемо визначати, які з листів доступні конкретному філіалу, при цьому абсолютно не змінюючи код.
Деплоймент
Щоб автоматизувати процеси деплою наших рішень, зекономити час і зменшити ймовірність помилок, ми використали підхід «інфраструктура як код» за допомогою Azure Resource Manager (ARM) темплейтів. У цих темплейтах у форматі JSON описуються необхідна інфраструктура та конфігурація, що має бути задеплоєна. Для докладнішого ознайомлення скористайтесь посиланням.
Для того, щоб забезпечити незалежний деплоймент кожного проєкту окремо, залежно від потреб певного філіалу, ARM-темплейт для кожного рішення містить інструкції для деплойменту всіх необхідних ресурсів.
Щоб гарантувати високий рівень безпеки та значно спростити взаємне використання конфігураційних даних різними проєктами, ми обрали Azure Key Vault. Це хмарне рішення, що дає змогу надійно зберігати різноманітні секрети (строки підключення, ключі API тощо).
На цьому малюнку схематично представлено ідею, яка була покладена в основу Azure Key Vault. Таким чином навіть розробники не мають змоги отримати реальні значення, сховані в секретах сховища.
Проте ми мали вирішити проблему, пов’язану з деплойментом Azure Key Vault. Оскільки сховище містить дані, які є дуже цінними, і є, ймовірно, їх єдиним джерелом, операція його видалення реалізована як soft delete. Так є можливість ще протягом 90 днів відновити видалене сховище. Тому в темлейті є дві опції: create та recover. Але ми деплоїмо Key Vault з кожним проєктом, тому нам необхідно розуміти, сховище розміщується вперше чи воно вже було задеплоїне раніше. Інакше всі правила доступу, які було додано для інших функцій, будуть заміщуватися останньою функцію, для якої було виконано деплоймент.
Ми розв’язали цю проблему: додали наступну умову безпосередньо у темплейт і скрипт у релізі, що буде визначати значення цієї змінної. Це найкраще, що ми змогли реалізувати для повної автоматизації деплою.
{ "type": "Microsoft.KeyVault/vaults", "apiVersion": "[variables('keyVaultApiVersion')]", "name": "[variables('keyVaultName')]", "location": "[parameters('location')]", "tags":"[variables('resourceTags')]", "dependsOn": [ "[resourceId('Microsoft.Web/sites', variables('functionAppName'))]" ], "properties": { "enabledForDeployment": "true", "enabledForDiskEncryption": "false", "enabledForTemplateDeployment": "false", "createMode": "[if(parameters('firstDeploy'), 'create', 'recover')]", "tenantId": "[subscription().tenantId]", "sku": { "name": "Standard", "family": "A" }, "accessPolicies": "[if(parameters('firstDeploy'), json('[]'), json('null'))]" },
Тестування
Щоб забезпечити якість розроблених рішень, ми активно працюємо над підтриманням рівня покриття коду юніт-тестами. Ми відстежуємо цей показник безпосередньо під час
Аби перевірити інші показники, такі як час відповіді функції, правильність даних у відповідях, використовуємо API тести, які автоматизували за допомогою newman, що запускає колекцію Postman-тестів у кінці релізу кваліфікаційних енвайронментів.
Нещодавно ми почали працювати над реалізацією безпекових тестів. Тести ж презентаційного рівня виконуються іншими командами.
Висновок
Ми змогли досягти нашої мети: реалізували нове архітектурне рішення та успішно вивели в продакшн понад 10 філіалів клієнта, зекономивши час і ресурси на розробку. Крім того, підвищили рівень безпеки проєкту, змогли модернізувати або цілком замінити значну частину старого рішення і побудували чіткі процеси взаємодії. Коли одна команда повністю відповідає за створене рішення, починаючи від розробки та закінчуючи моніторингом вже розміщеного проєкту у продуктовому середовищі й активно взаємодіє з іншими командами.
Перед нами стоїть ще багато завдань, пов’язаних із поліпшенням та автоматизацією тестування, втіленням тестів безпеки та покращенням процесів міграції для SQL баз даних, що працюють з Azure Functions. Також хотілося б поекспериментувати з можливими рішеннями для найбільш використовуваного функціонала, щоб якимось чином зменшити проблему холодного старту або взагалі розв’язати її.
Сподіваюся, що зміг розкрити ідею, що була покладена в основу нашої архітектури, буду радий відповісти на ваші запитання та отримати оцінку від спільноти розробників.