Мігруємо з IIS на Docker. Досвід та поради
Всім привіт! Я Максим Усатенко, .NET розробник у компанії TELEMART.UA, та сьогодні я хочу поділитись досвідом своєї команди, а саме як ми переїхали з морально застарілого IIS на базі Windows Server 2016, на Docker.
Навіщо все це
Причин було немало. Для структуризації та наглядності далі я наведу таблицю. Попереджую, що саме ці причини — це пріоритети бізнесу нашої компанії, тобто це не є чимось обов’язковим або золотим стандартом.
Причина міграції |
Опис |
Чи вирішена проблема |
Zero Downtime Deploy |
Кожен реліз раніше — це повна зупинка сайту та всієї компанії. Downtime навіть на декілька секунд — це втрачені клієнти та збитки. Раніше нам доводилося проводити релізи якомога рідше, та у часи з мінімальним навантаженням. |
Безумовно, так. Тепер неважливо, скільки зараз юзерів на сайті. Ми можемо проводити реліз по п’ять разів на день. |
Моніторинг у prometheus та логування за допомогою ELK |
Раніше логування було дуже примітивним да незграбним. Мабуть, так логували ще наші діди, а саме: звичайні файли у самому проєкті. По-перше, було дуже незручно шукати помилку, а по-друге, не всі мали до них доступ, тому що вони знаходилися на самому прод-сервері. Що ж до моніторингу, то його, можна сказати, зовсім не було. |
Цілком. Тепер фільтрувати логи, шукати щось — це одне задоволення. А моніторинг на базі prometheus дуже допомагає при підтримці та пошуку неполадок. |
Можливість реплікації сервісів |
Ми маємо так звану SOA — service oriented architecture. А як відомо, сервіси бувають різні: IO-bound, CPU-bound, або прості примитивні CRUD. Ми дуже хотіли отримати можливість реплікувати CPU-bound сервіси задля підвищення часу відповіді. На звичайному IIS це можливо, але дуже не зручно. Нам був потрібен зручний оркестратор. |
100% так. Тепер змінювати кількість інстансів стало дуже легко. Ми використали Docker Swarm. |
Адміністрування ресурсами в Docker |
Це, по-перше, безпека інфраструктури та стабільна робота самого серверу. Сервіс може просто лягти під час DDOS атаки та з’їсти усі ресурси, або мати у собі звичайний while(true). |
Безумовно, так. Тепер задавати обмеження для контейнерів — це дуже легко та зрозуміло. |
Редагування appsettings.json без рестарта сервісу |
Так, сам dotnet, починаючи з core-версій вміє підтягувати конфіги у runtime, але у нашому кейсі багато змінних було перевизначено у змінних сеердовища на Windows, та це вже потребувало рестарту. |
Зараз до кожного контейнера зроблено mount до хосту, де знаходяться конфігураційні файли. Далі на цьому зупинюсь детальніше, але вже зараз відмічу, що все працює безвідмовно. |
Уніфікування середовища виконання |
Раніше проблеми по типу «А на моєму компі працює» були для нашої команди дуже звичайними. Ми витрачали достатньо багато часу на фікс проблем з локалізацією та іншими кепськими налаштуваннями Windows. |
Як усі знають, Docker-контейнер працює однаково всюди. Неважливо, яке це середовище, які налаштування хостової ОС. Після міграції не було жодної проблемі з цим. |
Моральне застаріння серверної OS на базі Windows та дуже маленьке ком’юніті |
Кажучи чесно, підтримувати сервер на Windows не приносить ніякого задоволення, а маленьке ком’юніті ускладнювало вирішення проблем. |
Ubuntu 20.04 має велике ком’юніті, на будь-яке питання завжди є відповідь. А сама технологія Docker дуже перспективна та активно розвивається. |
Старе поточне залізо |
Нашому старому серверу вже 6 років, за цей час усе змінилося, dedicated сервер з такими же ресурсами, але свіжим залізом коштує стільки ж, але набагато швидший. Якщо нам вже необхідно переїхати на новий сервер та все одно буде downtime, то чому б не оновити технологічний стек та вирішити декілька проблем. |
Проблема вирішена. Нове залізо справді гарно показало себе за навантаженням. |
Перш ніж розпочнемо, хочу зазначити, що матеріал може бути корисним для .NET розробників та DevOps-інженерів, але й PM зможуть знайти для себе дещо цікаве, щоб у майбутньому мати змогу ухвалити рішення з необхідністю самої міграції. Також вам потрібні базові знання Docker. Я не буду зупинятись на тому «Що таке Docker».
Увесь переїзд зайняв майже пів року. Звісно, можна було б оперативніше, але ми не квапились, а війна внесла свої корективи у ведення бізнесу. Стаття виявилась велика, тому я вирішив поділити її на 5 розділів.
Пару слів про нашу інфраструктуру в цілому, щоб ви мали змогу трішки уявити це, та було легше орієнтуватись по ходу статті. Ми маємо SOA — service oriented architecture. Усього 8 сервісів на .NET6, клієнт на WPF та сайт на PHP. Одна велика база на MySQL, з якою комунікують майже усі сервіси. Також є ще декілька окремих баз: Redis, MongoDb та Postgre, вони добре вирішують свої задачі, але вони дуже маленькі у порівнянні з MySQL.
Комунікація між сервісами у 99% випадках — це класична REST-архітектура, винятки становлять WebSocket на базі фреймворка SignalR та gRPC.
Хочу звернути вашу увагу на те, що ми маємо орендований dedicated сервер. Деякі проблеми та рішення бувають тільки у нашому випадку та не стосуються cloud. Тому якщо у вас усе побудовано у клауді, можливо, буде інший план міграції.
Багато нюансів при міграції я пропустив та не розповідав у цій статті. Описати кожну деталь просто неможливо за раз. Я намагався зосередити увагу саме на принципі міграції та її ключових етапах. Звісно ж, для кожного проєкта план міграції буде відрізнятися. Наприклад, у нашому кейсі, окрім переїзда на Docker, ми запланували переїзд бази на окремий сервер, та RW-RO реплікація для MySQL. З цим також було багато проблем, але у цій стаття я сфокусуюсь саме на зв’язці dotnet-docker.
Покроковий план
Цей план розробили на самому початку цього шляху. Щось було вже готове на той момент, щось потребувало декілька місяців на реалізацію. Головна задача була зробити все надійно, безпечно, з мінімальним downtime.
Також важливий момент: ми не хотіли весь спрінт реалізовувати тільки міграцію. Наша задача була зробити її максимально непомітно для самого бізнесу, тому ми не витрачали більше 20% часу спринту на неї. Звісно ж, якщо 100% часу команди пішло на міграцію, вона пройшла б набагато скоріше. Отже, перейдемо до покрокового плану:
- Усі сервіси повинні бути написані на .NET core 1.0 або новіших версіях.
- Підняття тестового серверу Linux Ubuntu 20.04, налаштування усієї необхідної інфраструктури.
- Dockerfile та docker-compose.yml для кожного сервіса.
- Налаштування CI/CD.
- Налаштування зворотної сумісності з IIS на випадок відмови.
- Тестування поточних задач вже на новому тестовому сервері під докером.
- Підняття нового продсерверу з аналогічними налаштуваннями як на тесті.
- Regression тестування нового проду та load-тести.
- Обрання дати та часу переїзду, ще один покроковий план для міграції та сама міграція.
Реалізація
- Саме починаючи з core-версій, dotnet підтримує кросплатформенність та може бути запущений на Linux. Цей пункт у нас вже був реалізований. На той момент ми тільки-но змігрували всі сервіси на .NET 6. Якщо ви ще використовуєте .NET framework, раджу вам починати з міграції сервісів. Крім кросплатформенності, ви отримаєте значний приріст у швидкості, але це вже матеріал для окремої статті.
- Без тестового серверу на Linux ви просто не зможете добре перевіряти працездатність сервісів, коли будете писати Dockerfile та docker-compose.yml. Так, є і десктопна версія докера, але все ж таки раджу одразу все робити на самому тестовому сервері. Так ви не тільки зможете перевірити свої контейнери, а також отримаєте початковий досвід адміністрування докера, який дуже знадобиться вам у майбутньому. На самому сервері, крім вашої інфраструктури проєкта, вам знадобиться Docker та Docker Private Registry (або можете використовувати DockerHub) для зберігання ваших образів.
- Етап написання конфігураційних файлів я об’єднаю з налаштуваннями CI/CD. Тут вже потрібно розуміти сам процесс. Це допоможе при написанні Dockerfile та docker-compose.yml. Ми обрали TeamCity, тому що він нас цілком влаштовує, та в нас вже була експертиза у ньому, але ви можете обирати на свій смак.
Одна з дуже гарних переваг TeamCity — це можливість створення базових пайплійнів. У випадку мікросервісної архітектури це дуже зручно та економить ваш час. Ми розробили два базових пайплайни: build та deploy. Також TeamCity має хороші безкоштовні плани, яких достатньо для невеликих та середніх проєктів. У стандартному безкоштовному плані підтримується до трьох агентів, які можуть виконувати роботу.
Отже, CI/CD розділяємо на два пайплайни:
1. Build. Цей пайплайн триггериться за коммітом у git. Далі ви збираєте сам сервіс, виконуєте docker build та вже готовий image складаєте у ваш Docker Registry, тегуючи версією з файлу вашого проєкта .csproj. Більш детально дивіться на нашому реальному прикладі, це поетапний пайплайн у TeamCity:
2. Deploy. Цей пайплайн, як вже зрозуміло із назви, деплоїть готовий контейнер із Docker Registry на тестовий сервер або продакшен. Мы налаштували мануальний старт за кнопкою. Тобто спочатку ваш контейнер автоматом збирається після комміта, а потім деплоїте всього в 1 клік:
Нижче я навів наш реальний приклад деплою, але з міркувань безпеки замалював деякі змінні:
Можемо бачити, що цей пайплайн спочатку переносить docker-compose.yml файл на сам сервер, а далі виконує команду docker stack deploy. Звертаю увагу на те, що версія контейнера встановлюється як змінна середовища, а потім вже docker-compose.yml файл витягує цю змінну. Можна сказати, що уся динамічна інформація прокидається до docker-compose.yml файлу через середовище.
Щодо Dockerfile, одразу ж раджу створити власний базовий Docker Image. Так ви значно зекономите собі час, коли буде потрібно щось змінити в усіх сервісах одразу.
FROM mcr.microsoft.com/dotnet/aspnet:6.0.300 RUN apt-get update && apt-get install -y curl # Configure web servers to bind to port 80 when present ENV ASPNETCORE_URLS=http://+:80 \ # Enable detection of running in a container DOTNET_RUNNING_IN_CONTAINER=true \ # Set the invariant mode since icu_libs isn't included (see https://github.com/dotnet/announcements/issues/20) DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true \ # appsettings refresh auto-detect DOTNET_USE_POLLING_FILE_WATCHER=true \ # Allow to create not predefined cultures DOTNET_SYSTEM_GLOBALIZATION_PREDEFINED_CULTURES_ONLY=false \ # Set Kiev timezone TZ="Europe/Kiev" EXPOSE 80
Це наш базовий образ, на базі якого вже побудовані образи для всіх сервісів. Можемо бачити, що за основу використано образ від Microsoft для ASP.NET core 6 сервісів. Встановлено curl, він потрібен для healthcheck у середині контейнера. Із цікавого ще відмічу встановлену змінну середовища DOTNET_USE_POLLING_FILE_WATCHER. Саме з її допомогою нам не потрібно перезапускати сервіс, коли редагуємо appsettings.json, який примонтували у сам контейнер. Dockerfile самого сервіса дуже мінімалістичний саме завдяки тому, що усю однакову логіку між сервісами ми винесли в базовий образ:
FROM my-own-registry.com/telemart.base:6.0 RUN apt-get update && apt-get install -y apt-utils libgdiplus libc6-dev WORKDIR /app COPY out . ENTRYPOINT ["dotnet", "Telemart.Calculator.Service.dll"]
Далі пропоную розглянути docker-compose файли. На жаль, неможливо так само робити базові файли, тому багато логіки довелось копіювати. Можемо бачити багато налаштувань через змінні середовища, наприклад ${VERSION}, ${ENVIRONMENT} та інші. Це саме ті змінні, які ми задавали у пайплайні build. З їхньою допомогою ми задаємо версію контейнера, середовище виконання та ще багато іншого.
Саме тут ми можемо задати кількість реплік та описати політику реліза. Сам синтаксис дуже читабельний, тому я не бачу сенсу описувати кожен рядок.
version: '3.7' services: telemart_calculator: image: my-own-registry.ua/telemart.calculator:${VERSION} environment: - ASPNETCORE_ENVIRONMENT=${ENVIRONMENT} labels: - telemart-metrics=true ports: - "127.0.0.1:${PORT_PREFIX}003:80" deploy: replicas: 2 update_config: parallelism: 1 order: start-first failure_action: rollback delay: 10s rollback_config: parallelism: 1 order: stop-first resources: limits: cpus: '12' memory: 8000M healthcheck: test: curl --fail http://localhost:80/health || exit 1 interval: 10s timeout: 10s retries: 5 logging: driver: gelf options: gelf-address: "${GELF_ENDPOINT}" tag: telemart${ENVIRONMENT}.calculator volumes: - /config/${ENVIRONMENT}/shared.json:/app/shared.json - /config/${ENVIRONMENT}/calculator.json:/app/calculator.json
Коли ми розібралися з двома основними конфігураційними файлами Dockerfile та docker-compose.yml, настав час питання: де їх розмістити? Рекомендую їх класти кожен до свого сервіса поруч із файлом проєкту .csproj. Дуже гарно, якщо ці файли завжди будуть на одному рівні, та шлях до них буде відрізнятись тільки назвою самого сервіса. Це дуже допоможе вам під час написання пайплайнів — ви легко зможете винести шлях до базового пайплайна та задавати тільки назву самого сервіса.
Ще, як варіант, можна уніфікувати файл docker-compose.yml та перевизначати лише змінні для кожного сервісу. Там вам не доведеться редагувати при базових змінах файли у всіх сервісах.
У нашому кейсі зворотна підтримка IIS не потребує ніяких зусиль, але треба пам’ятати про це та мати план відкату. Наприклад, ми ще не працювали з окрестратором Docker Swarm та не знали, які факапи можуть з цього вийти.
З тестуванням усе доволі легко. На тестовому середовищі ми не знайшли ніякого багу, все працювало так, нібито ми завжди були на докері. Увесь поточний спрінт, таски вже тестувались на новому Linux сервері.
Окремо відзначу load та stress тестування. Для цього я обрав artillery.io, для мене це чудовий та зручний інструмент з тестування. Основна задача була перевірити те, що новий сервер не менш швидкий у порівнянні з минулим. Старий сервер нам довелося тестувати о п’ятій ранку, коли на сайті мінімальна кількість користувачів. Новий ми вже мали змогу перевірити, коли нам зручно. Перш за все, нас цікавила максимальна кількість запитів у секунду, коли наш сервер давав відповідь 200 OK у 99% випадках. На жаль, я не можу озвучувати конкретні цифри з міркувань безпеки, але за допомогою нового заліза та реплікації високонавантажених сервісів, наша пропускна здатність зросла майже втричі. Ми намагалися охопити якомога більше методів, щоб імітувати реальне навантаження поточного сайту TELEMART.UA.
З новим продсервером теж усе зрозуміло. Беремо залізяку або клауд, робимо майже ті ж самі налаштування, як і на тесті. Різниця може бути лише у конфігах та DNS. Цей крок — один з останніх, тому що інфраструктура коштує недешево, та чим пізніше ми її купимо, тим вигідніше для компанії. За цей період, доки ми не мігрували, нам доводиться платити за обидва продсервери одразу.
Коли все протестовано, ми почали обирати дату переїзду. Тут усе просто: переїжджаємо тоді, коли мінімальне навантаження. Для нас це був період з 4 до 6 ранку. План був наступний:
- Зупиняємо поточний продакшен.
- Робимо дамп бази та розгортаємо його на новому сервері (це найдовший крок, який зайняв близько години через розмір бази).
- Змінюємо DNS TELEMART.UA на новий IP-сервера.
- Запускаємо інтеграційні тести, які охоплюють якомога більше різних сервісів. Це найшвидший спосіб зрозуміти, чи все працює нормально.
Хочу звернути увагу на те, що я опустив деякі операції при міграції. Вважаю їх не цікавими, тому що вони стосуються тільки нашого бізнесу. В цілому, все пройшло нормально. Так, помилки були, але вони були пов’язані тільки з переплутаними конфігами та нашою необачністю. О сьомій ранку 99% функціоналу компанії вже працювало стабільно.
Отже, що ми маємо в результаті переїзду:
Звісно, я не відобразив багато нюансів на зображенні, але найголовніше — це розуміння усього флоу: хто з ким взаємодіє. Звертаю увагу на те, що NGINX працює у ролі зворотнього проксі-сервера, а потім вже оркестратор Docker Swarm розподіляє трафік за інстансами.
Окремо відзначу один з наших етапів міграції, а саме розгортання бази MySQL на окремому залізі. Раніше всі сервіси та база працювали разом, але їм у пікові часи не вистачало ресурсів. Під час розгортання одразу зробили реплікацію MySQL master-slave, тому що було декілька високонавантажених сервісів, які тільки запитували дані з бази, але ніколи їх не редагували. На жаль, зараз не можу багато сказати про позитивні моменти, тому що ще не пройшло багато часу, але із мінусів назву проблему, коли періодично slave починає відставати від основної репліки аж на пів години.
Також звертаю вашу увагу на один важливий факт: контейнери всередині докера взаємодіють по внутрішній мережі докера і не виходять за його рамки без потреби. Це дає приріст у продуктивності.
Проблеми та нюанси
На жаль, для нашою команди це був новий досвід, тому кожен етап для нас був новим, ми щось вигадували, гуглили, витрачали багато часу на елементарні пункти, але врешті-решт все вдалося. Далі я наведу кілька основних проблем, з якими нам довелося зіткнутися:
- Вибір оркестратора. Сам Docker — технологія контейнеризації, але не оркестрування. Ми витратили багато часу на вибір, та у якості оркестратора ми обрали Docker Swarm. Саме завдяки ньому ми маємо змогу реплікувати сервіси, а за допомогою команди docker stack deploy робити це без даунтайму. Одразу ж відповім на питання: чому не Kubernetes? Нас цікавила, перш за все, програмна відмовостійкість, яку дуже гарно вирішує Docker Swarm. Бачу сенс у Kubernetes тільки коли ми маємо великий кластер серверів, та крім програмної відмовостійкості бажаємо ще й апаратну.
- Отримання версії сервіса з .csproj. Ми не хотіли кожен раз при білді мануально вказувати версію, тому вирішили вказувати її у файлі .csproj у форматі
<Version>1.2.3</Version>
та на етапі CI/CD витягувати її звідси. Це виявилось не дуже легкою задачею. Спочатку ми вирішили це завдяки тулзі exiftool під Linux, але потім трішки спростили це та спарсили версію руками. Це рішення не дуже красиве, але воно працює та не потребує зайвих залежностей: - Не використовуйте образи «:latest». Під час налаштувань TeamCity зіткнувся з дуже дивовижною проблемою. У мене перестав працювати етап dotnet build. По-перше, він тільки вчора ввечері працював, а по-друге, чому там ламатися? Як пізніше виявилось, Microsoft у версії dotnet 6.302 зробив один дуже маленький breaking change, пов’язаний з форматом команди виводу результату, яку навіть я ніколи не використовував та не чув про неї. А от TeamCity активно її юзав, і він просто зламався на цьому етапі. Цієї проблеми б не було, якщо я використав би базовий образ з конкретною версією dotnet, але в мене було вказано просто aspnet:6.0, що означало останню версію шостого дотнета.
- SignalR та реплікація. Не можу не згадати ще один дуже важливий факт: SignalR не підтримує реплікацію сервісів із коробки. Для цього необхідно використати так звані Sticky сесії. На момент написання статті ми їх ще не реалізували, тому поки не можемо збільшити кількість реплік для цього сервісу більше 1. Рекомендую замислитись над тим, щоб real-time сервіс був зовсім окремо у архітектурі.
- Правильне налаштування NGINX. У нашому кейсі була помилка: після міграції ми зрозуміли, що в нас протокол HTTP/2 зачинено для gRPC. Тому рекомендую дуже ретельно передивитись технологічний стек, а саме, якi протоколи комунікують.
- Обачність та більш детальне планування самої міграції. Це не пов’язано із самим докером або технологіями. Потрібно розуміти, що інфраструктура — це не тільки декілька сервісів та БД. Завжди є щось, що додавали колись до вас, та воно якось працювало. У нашому випадку це була дуже маленька програма, яка мануально рахувала ціни та складала до бази даних. Проблема виявилась у тому, що коннект до бази в неї був захардкожен, ісходників давно не має, а декомпілювати Delphi не така й проста задача. Ось так після міграції нам довелося у дуже оперативному темпі за добу дописувати прототип програми, який ще не був готовий до використання на проді.
- Оверхед за рахунок віртуалізації. Все ж таки, якщо намагатись порівняти швидкість dotnet сервіса, який хоститься під звичайним NGINX або IIS з сервісом під Docker, то у першому випадку буде дуже невеликий виграш у швидкості за рахунок відсутності віртуалізації як такої. Але насправді ця різниця у кілька мілісекунд не грає ніякої ролі, та зовсім сходить на ні, коли наростає навантаження. Тут потрібно розуміти, що різниця між 20 та 40 мс не так важлива, як різниця між 500 та 1500 мс під час навантаження.
- Реплікація сервісів — це не панацея. Хочу попередити, що реплікація має сенс для сервісів, які активно використовують CPU. Якщо ваш сервіс просто читає з Redis, то реплікація зовсім не підвищить швидкість, а також зробить тільки гірше для ваших ресурсів. Тут одна порада — експериментувати. Складно описати словами ту грань, коли сенс є.
- Healthcheck та HTTP/2. Якщо ваш сервіс працює тільки за HTTP/2 на фреймворку gRPC, то стандартний curl працювати не буде. Треба додати лише
--http2-prior-knowledge
до нього. На виході healthcheck буде виглядати наступним чином:
version=$(sed -n 's/.*<Version>\(.*\)<\/Version>.*/\1/p' $(find -name %startup_project%))
curl --http2-prior-knowledge --fail http://localhost:80/health || exit 1
Ще як альтернативу можу порекомендувати підняти окремий порт для моніторинга та healthcheck. Якщо curl ще можна подружити з HTTP/2, то агент prometheus, на жаль, ще ні.
Висновок
Якщо коротко: все було круто. Усі наші плани здійснені. Зараз новий сервер працює стабільно та його дуже легко підтримувати. Уся наша команда отримала дуже цікавий та корисний досвід. Попереду ще великий шлях с оптимізування реплікацій, але вже зараз можна бачити: працює набагато швидше, ніж раньше. Окремий кайф — це деплой у TeamCity через кнопку. Раніше доводилося руками скачувати сервіс, підміняти та перезапускати його у IIS.
З боку IT все гарно та зручно, а які ми маємо переваги для бізнесу? По-перше, це стабільність. Вона обумовлюється оркестратором, якого раніше взагалі не було. По-друге, більш високий uptime. Це було досягнуто завдяки zero downtime deploy. По-третє, нове залізо та реплікація зробили свою справу — зменшився час відповіді від навантажених .NET сервісів, а це позитивно вплинуло на конверсію.
Сподіваюсь, ця стаття комусь була цікава та корисна. Дякую за увагу)
Де краще — тут чи там, — залежить від того, де задано питання. Симон Моісеєв
Найкращі коментарі пропустити