Механізм синхронізації запуску застосунків на базі shared-lock

💡 Усі статті, обговорення, новини про DevOps — в одному місці. Приєднуйтесь до DevOps спільноти!

Привіт, спільното DOU. Мене звати Владислав Папідоха, я DevOps Engineer в компанії TENTENS Tech. Маю досвід трансформації процесів у продуктових компаніях; проводив значних розмірів технічне рішення через багатоетапні трансформації; також активно займаюся навчанням інженерної справи колег й не тільки.

Під час роботи з масштабованими розподіленими системами я не раз стикався з ситуаціями, коли певний сервіс має бути запущений лише в одному екземплярі, незалежно від кількості кластерів. Але як зробити це так, щоб сервіс гарантовано працював в одному місці, а у разі збою миттєво перемикався на інший кластер?

Ми використовуємо shared-lock — API-сервер на Go, який надає всього два ендпоїнти для зовнішніх клієнтів. Він працює на базі etcd, що зазвичай є частиною будь-якого Kubernetes-кластера, і дозволяє будувати навколо себе систему, в якій синглтон-застосунки можуть запускатися в кількох кластерах одночасно з можливістю миттєвого фейловеру.

Наразі у компанії я основний мейнтейнер shared-lock. Сьогодні розберемо, що це за звір та як легко можна з ним інтегруватися. Якщо ви працюєте з багатопотоковими чи розподіленими системами й вам доводиться синхронізувати доступ до ресурсів або ви шукаєте способи керування конкурентним запуском застосунків — ця стаття стане для вас корисним гайдом.

У боротьбі за відмовостійкість

Наша інженерна команда постійно працює над підвищенням доступності та відмовостійкості наших застосунків. У певний момент ми вирішили не просто запускати кілька реплік кожного сервісу, а реплікувати весь Kubernetes-кластер у режимі активного failover. Це означає, що для критичних сервісів ми розгортаємо кілька незалежних кластерів у різних зонах доступності, що дозволяє уникнути проблем при виході з ладу будь-якого з них.

Однак це рішення зіткнулося з новим викликом: деякі навантаження не можуть бути репліковані, а натомість мають існувати в єдиному екземплярі через закладену бізнес-логіку. Ми називаємо такі процеси «сінглтонами».

Щоб розв’язати цю проблему, ми створили shared-lock — розподілений менеджер блокувань, який дозволяє синхронізувати запуск таких сервісів між усіма нашими production-кластерами.

Крім того, розподілене блокування — це типовий патерн у розробці, який багато інженерів змушені реалізовувати всередині своїх застосунків. Ми вирішили винести цей механізм в окремий сервіс з універсальним API, що дозволило позбутися потреби створення низки окремих рішень, які незалежно працюють у різноманітних частинах наших систем. Цей підхід зробив систему гнучкішою й зменшив витрати на підтримку унікальних реалізацій у кожному окремому сервісі.

Як це працює

Як вже згадувалося, API shared-lock має всього два ендпоїнти:

  • /lease — для отримання резервації;
  • /keepalive — для регулярного підтвердження своєї присутності та подовження резервації.

Щоб гарантувати, що в будь-який момент часу працює тільки один інстанс застосунку, нам потрібно реалізувати наступний механізм:

  • Відправляємо запит на /lease для отримання резервації:
    • якщо резервацію отримано — продовжуємо запуск.
      • опісля кожні Х секунд надсилаємо запит на /keepalive для продовження часу життя резервації;
    • якщо не вдалося — чекаємо таймаут і повторюємо спробу.

Якщо ж потрібен приклад представлення в коді, його можна знайти у репозиторії проєкту за посиланням.

Як запустити власний shared-lock сервер

Віднедавна наша реалізація shared-lock стала публічно доступною на Github, а готові docker-імеджі можна знайти в нашому публічному реджистрі. Ви можете вільно запустити цей застосунок в рамках власної інфраструктури. У репозиторії є вичерпний README, а також маніфести, що дозволяють швидко розгорнути shared-lock у k8s-кластері.

Все, що вам необхідно зробити:

  • Знайти адресу вашого etcd-сервера у Kubernetes (або розгорнути новий).
  • Проставити необхідні параметри у маніфесті.
  • Зробити kubectl apply -f kubernetes-example.yaml.

Відтепер ви можете користуватися застосунком.

Найбільша технічна конфа ТУТ! 👇

Можливі підводні камені

Ми на сто відсотків впевнені, що shared-lock здатний обробляти 500+ rps у конфігурації ворклоаду з 3 реплік. Втім, варто стежити за логами etcd інсталяції, оскільки під високим навантаженням вона може тимчасово припинити відповідати за визначений період часу. У такому випадку достатньо буде збільшити її ресурси.

Сподіваємося, що наявність готової відтестованої реалізації shared-lock спростить ваше життя. А якщо у вас будуть думки чи побажання щодо покращення застосунку — діліться з нами або ж закидуйте пул реквести. Будемо раді обговорити 😉

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Реалізацій того самого функціоналу є багато.
І в усіх у них є одна принципова проблема — якщо виконання тієї самої унікальної роботи, яка не має виконуватись паралельно, зав«язане на час — рассінхрон фізичних серверів по часу.

Про це майже ніхто не думає, але це існує. Якщо ноди вашого застосунку задеплоєні на фізично різних машинах, між цими машинами може існувати різність у часі*, в такому випадку, «один і той же» час, наприклад, 00:00 наступить фізично в різний час, скажім, для сервера А це буде справжній 00:00, а для сервера Б це буде 00:00.099. Цього достатньо, щоб шаред лок спрацював не так, як потрібно, чи не так, як ви очікуєте. Якщо робота, яка має виконатись, довга, є дуже велика спокуса «синхронізувати» тільки її початок, і отут ви попадаєтесь.

Тому я взагалі не раджу використовувати такі механізми, які вони зав"язані на час. Те що вам потрібно — це вибори лідера в кластері (leader election), який буде виконувати роботу, або буде відповідальним за призначення 1 ноди з Х для виконання роботи.

* одного разу я бачив сервери, різниця між якими в часі досягла 8 хвилин.

У хлопців відбувається leader election на основі Distributed Lock, хто перший захватив Lock той і лідер, а цей сервіс і є координатором. Не завжди хочеться для банальних речей городити Raft, який у свою чергу накладає обмеження по запуску певної кількості реплік для можливості проведення голосування. По ідеї, вирішення проблем, які ви описали, візьме на себе ETCD (сторидж бекенд даного сервісу).

Доречі ETCD вміє гарно менеджити локи і без сервісів обгорток. Розумію, що всім потрібна абстракція і відсутність вендорлоку.

У хлопців відбувається leader election на основі Distributed Lock

Так ТС про лідер елекшн ніде не написав, тільки базовий юзкейс розподіленого локу.

Де ви взагалі про час побачили?

Якщо перечитати мій коментар ще раз, то можна зрозуміти про що я пишу :)

Дуже глибоко, не зрозумів. Бо про час чи синхронізацію мови не було. Мова про одночастність подій для спостерігача незалежно від годинників на серверах, нехай вони розсинхонізовані. Ну тобто одне з типових рішень — CAS (compare-and-swap), якому байдуже який там час на клієнтах.

Бо про час чи синхронізацію мови не було

Я почав свій пост з:

І в усіх у них є одна принципова проблема —
якщо виконання тієї самої унікальної роботи, яка не має виконуватись паралельно, зав"язане на час — рассінхрон фізичних серверів по часу.

Наприклад, це шедулер. Зустрічається частіше ніж завжди.

Мова про одночастність подій для спостерігача незалежно від годинників на серверах

Ну тобто, з другої спроби не прочитали мій пост, буває

тепер сервіс «shared-lock» "

має бути запущений лише в одному екземплярі, незалежно від кількості кластерів.

" ?

а у разі збою миттєво перемикався на інший кластер

здається тоді ні в коді ні на на схемі не має бути слів «ttl» «keepalive» «timeout».

Щодо першого питання, то ні. Так як дані про самі резервації зберігаються в etcd, shared-lock можна горизонтально масштабувати.

Тут можна казати, що миттєво гіперболізація, бо мова про час >= 1с. Втім, у залежності від конфігурації клієнта, на скільки береться резрвація і як часто оновлюється, залежить час перемикання. Тут користувач самостійно визначає який час на перемикання йому «ок».

А можна, будь ласка, приклади для чого потрібно підтримувати наявність одного та тільки одного інстансу репліки?

Звісно. Це наприклад запуск міграцій чи відправка тригерних нотифікацій клієнтам на апі-ендпоїнти (є логіки, де потрібно сповіщати клієнта про термін завершення підписки, а позначати «нотифікування» у базі не хочеться)

Якщо так його використовувати то існує ймовірність «database space exceeded». Чи були з цим проблеми ? Також є питання на рахунок використання кеша, це не призводить до обмеження в HA, так як кеш існує в рамках одної репліки сервісу?

І напевне у вас тут баг:

Документація:
204 No Content: Lease successfully prolonged.

Код:

	err = s.app.ReviveLease(leaseID)
	if err != nil {
		log.Warnf("Failed to prolong lease: %v", err)
		http.Error(w, "Failed to prolong lease", http.StatusNoContent)
		return
	}

Проблем з

«database space exceeded»

не ловили.

Щодо локальності кеша для кожної окремої репліки — знаємо про цей нюанс. У власні конфігурації використовуємо деплоймент з трьох shared-lock більше в режимі активного фейловера, ніж балансування, тому для нашого контексту це працює. Втім, саме функціонал кешування додали нещодавно, тож приглядаємося до нього у використанні.

Дякую, що підсвітили невідповідність опису та коду. Поправив сам опис, щоб він відповідав визначеному за кодом контракту.

Можливо, не найкраща ідея повертати статус-код 204 у випадку помилки. Це може ускладнити їх обробку.

Насправді згоден, це один з моментів на доопрацювати в майбутньому

Можна лочити клієнта в рдбмс та відправляти йому нотифікацію, це буде навіть надійніше та дозволяє масштабувати сервіс нотифікацій.

Можна лочити клієнта в рдбмс

Швидке, просте, надзвичайно погане рішення рівня круд-хеловорлда з мільйоном мінусів.

які мінуси?

Прив"язка локу до рдбм-транзакції, в якій виконався той SELECT... FOR UPDATE,
що в цьому поганого:

1. Не завжди дані для блокування взагалі лежать в БД. Для даних, які не лежать в БД, для цього блокування тобі потрібно буде створювати окрему таблицю, куди напихувати сторонні ідентифікатори

2. Ліз локу прив"язаний до коміта виключно цієї самої транзакції. Що робити, якщо лок треба відпустити ДО кінця транзакції, чи через деякий час ПІСЛЯ, наприклад, після комміту тобі треба зробити хттп кол, дочекатись його респонса, записать якісь результати і тільки потім відпустить лок? Або коли один лок має охоплювати кілька транзацій?

3. Заблокувавши, наприклад, юзера, щоб оновити якийсь сегмент його даних, ти заблокуєш ВСЬОГО юзера, для ВСІХ інших сценаріїв, які могли б виконуватись паралельно. Наприклад, отримавши лок на юзері для маніпуляцій з його корзиною, ти цим самим заблокуєш якусь іншу роботу, наприклад якусь фонову синхронізацію, яка могла б виконуватись в паралель.

4. Інколи велика кількість блокувань при інтенсивному використанній бд, а не там де 3,5 анонімуса раз на рік, може призводить до заторможення бд. Це дискусійне питання, так, але гіпотетично такий сценарій можливий.

5. Відсутність просунутих АПІшок для бд-блокувань. В 99% випадків це тупо SELECT... FOR UPDATE чи аналогічна низькорівнева інструкція яка не має ні апі-механізмів ні будь-яких інших механізмів керування окрім комміт/роллбек. Ні ліз тайму, ні фенсінг-токена, ні трай-лок, ні явного анлока. Тепер порівняй це з Hazelcast FencedLock API чи навіть з Redisson RLock.

6. Блокування — це логіка рантайму, фактично, бізнес-логіка твоїх дій. SELECT FOR UPDATE це частина стореджа даних. Це концептуально різні шари програми.

Не завжди дані для блокування взагалі лежать в БД.

Як на мене тоді у нас значно більша проблема ніж select for update 😅

Ні ліз тайму, ні фенсінг-токена, ні трай-лок, ні явного анлока.

є NOWAIT, але звісно це не повноцінний лок.

Що робити, якщо лок треба

і далі сам відповідаєш

для цього блокування тобі потрібно буде створювати окрему таблицю

🙃

Заблокувавши, наприклад, юзера, щоб оновити якийсь сегмент його даних, ти заблокуєш ВСЬОГО юзера, для ВСІХ інших сценаріїв, які могли б виконуватись паралельно

це так, тому механізм підійде не всюди. Автор писав про нотифікації, я теж використовую для нотифікацій. Звісно масштаби дрібні, але якщо операція виконується дуже швидко, а в мене це просто створення джоби на нотифікацію, то не бачу тут проблем.

SELECT FOR UPDATE це частина стореджа даних. Це концептуально різні шари програми.

А я навпаки бачу в цьому великий плюс, коли дані 100% не закораптяться через те що лок та дані в різних місцях. База працюватиме залізобетонно.

Звісно я укрпошту не робив тому сперечатися сильно не буду. Очевидно що з проєкту приблизно зрозуміло чи підійде тут така схема чи ні. Та й спробувати можна. Як на мене, робити сінглтон сервіс це дуже сумнівне вирішення проблеми exact-one-delivery, але автору видніше.

Як на мене тоді у нас значно більша проблема ніж select for update

Ну чому? Це цілком валідний кейс. Дані лежать на сфтп, в редісі, в кафці, в якійсь граф-дб, і т.д. і т.п. Купа прикладів сховищ, які не є рдб і які не зможуть фізично гратись в селект фор апдейт.

Маючи абстраговане рішення розподілених локів (зараз не важливо яке) ти маєш рішення яке буде працювати для всіх процедур, всіх робіт, всіх типів даних і сховищ.

А я навпаки бачу в цьому великий плюс, коли дані 100% не закораптяться через те що лок та дані в різних місцях. База працюватиме залізобетонно.

Власне це і є лок (не той лок шо ми кажем, а типу вендор-лок) на типі даних, те що я написав вище. Це дуже тимчасовий плюс, який з інших точок зору, або при додаванні інших технологій, бачиться як мінус.

і далі сам відповідаєш

Створення і підтримка окремої таблиці для локів імхо дуже сумнівне рішення. Почнім з того, що щоб на чомусь залочитись, туди це щось треба покласти, це вже рейс кондішн. Можна повісити unique, чи мати таблиця з одного стовпця, який же і ПК. Але питання інсерту (що інсертити і коли) лишається.

робити сінглтон сервіс це дуже сумнівне вирішення проблеми

Отут я погоджуюсь

Підписатись на коментарі