Бързо мащабиране на онлайн съхранението за над 1 милиард потребители на ChatGPT (част II)
Как оптимизирахме Azure Cosmos DB в Habitat, нашата платформа за съхранение на данни за приложения, за да се справим с безпрецедентен растеж.
Всички продукти на OpenAI се нуждаят от бърз и надежден достъп до данни, осигуряван от нашата платформа за съхранение Habitat. Тя трябва да отговаря за милисекунди на над 1 милиард потребители в 40 географски региона. Инфраструктурата ни беше изправена пред изключително предизвикателство: да поддържа взривен растеж и нови сценарии на употреба, като същевременно подобрява надеждността, производителността и скоростта на работа на продуктовите разработчици. За да се справим, трябваше да преосмислим архитектурата и експлоатацията на нашата платформа за онлайн съхранение. Постоянно преценявахме компромисите, следвайки този ред: първо достатъчна надеждност за критичния продуктов трафик, после достатъчна бързина за потребителите по света и накрая — умело управление в огромен мащаб.
Тази поредица от две части обяснява как развихме Habitat от проста клиентска библиотека и база данни до глобално разпределена платформа за съхранение, обслужваща над 500 петабайта данни. Част I разгледа развитието на Habitat от Python клиент до услуга. Тя обхвана и техниките, които ни позволиха да развием услуга, базирана на Python, далеч отвъд границите, които мнозина биха сметнали за практически постижими.
В тази публикация, част II, разглеждаме как мащабирахме основния компонент за съхранение: Azure Cosmos DB. В тясно сътрудничество с екипа на Azure стигнахме до работещ мултитенантен подход със съвместно управлявани контейнери, осигуряващ отлична производителност при четене в огромен глобален мащаб.
Фигура 01 · Какво е Habitat?
Платформа за онлайн съхранение
Habitat е платформата за онлайн съхранение, която създадохме, за да могат продуктите на OpenAI да осъществяват бърз и надежден достъп до необходимата информация.
- Заявка
- Отговор
- Промени (CDC)
С разрастването на компаниите инфраструктурните екипи често преминават от еднотенантни към мултитенантни системи. При еднотенантността всеки екип, отговорен за отделна продуктова част, поддържа собствена система. Това ограничава обхвата на инцидентите, ясно разделя отговорностите и изолира натоварванията. С разрастването на екипите и продуктовите части този подход става скъп за поддръжка. Еднотенантността прави всеки продуктов екип отговорен за планирането на капацитета и често води до значително презапасяване с ресурси.
За разлика от нея, мултитенантността поверява контрола на централен екип за бази данни с повече знания и опит в заделянето на ресурси. Но мултитенантността създава свои проблеми, например „шумни съседи“, при които един тенант потребява необичайно голям дял от ресурсите за сметка на останалите.
В Habitat трябваше да преценим всички тези компромиси, за да проектираме надеждна и ефективна система за съхранение в голям мащаб. Днес Habitat е мултитенантен слой за бази данни за много продукти на OpenAI. Но изминахме пълен кръг: от един мултитенантен контейнер в Azure Cosmos DB, през отделни еднотенантни контейнери за съхранение, до връщане към мултитенантен контейнер по подразбиране. Все още сме в началото на развитието на мултитенантността в Habitat: поддържаме множество сценарии на употреба, но успяхме да отложим инвестициите в тази област, като използваме вградените примитиви на Azure Cosmos DB навсякъде, където е възможно.
Преди да разкажем за опита си с Azure Cosmos DB, ето малко основна информация:
Акаунтът в Azure Cosmos DB определя набор от контейнери и политика за репликация
За всеки контейнер се заделя определена производителност в единици заявки в секунда (RU/s)
Данните във всеки контейнер се разпределят по стандартния начин — в сегменти според зададен ключ на логически дял, с документирани лимити за съхранение и производителност за всеки ключ
Azure Cosmos DB разпределя ключовете на логическите дялове между по-малко на брой физически дялове. Разделя физическите дялове на няколко, когато обемът данни надхвърли даден праг или контейнерът се нуждае от повече физически дялове, за да осигури заделената производителност
Първоначално пропускахме всякакво клиентско натоварване, разчитайки на обявения лимит от 10 хил. RU/s за ключ на логически дял да ограничава потока към системата. В началото използвахме и само един контейнер по подразбиране в Azure Cosmos DB за всеки акаунт. Това работеше достатъчно добре, но се оказа проблематично по няколко причини:
Заради ранни решения в дизайна на API предоставихме избора на ключ на логически дял директно на клиентите. В резултат често имаме колизии между различни продуктови сценарии при често използвани ключове на дялове като user_id или workspace_id. Тъй като тези сценарии споделяха лимитите на Azure Cosmos DB за едни и същи ключове на логически дялове, дизайнът ограничаваше общата производителност за даден потребител.
Преди да преминем към Habitat и Azure Cosmos DB, често срещан проблем при мащабирането на Postgres беше, че множество заявки от силно натоварена организация или ключ — един тенант, генериращ непропорционално голямо натоварване — можеха да сринат цялата база данни. Положихме значителни съвместни усилия за кеширане на тези ключове и въвеждане на временни заключвания при липса в кеша, така че произтичащото натоварване да не претоварва основната база данни.
Figure 02a · Logical partition key limits
Different clients, the same partition key
Макар документираните лимити за обем данни и производителност да се отнасят до ключовете на логическите дялове, те всъщност се прилагат на ниво физически дял. На практика това означава, че логическите дялове не могат да достигнат 10 000 RU/s. Вместо това те споделят лимитите на физическия дял с всички останали ключове на логически дялове, разположени в него.
Така „шумните съседи“ можеха да засягат много несвързани ключове на логически дялове: натоварвания, обработващи данни за нишови продуктови функции, нарушаваха основни процеси като вход и регистрация.
Figure 02b · Noisy neighbors
Different users, the same physical partition
Лимитът на производителността за даден физически дял също е променлив и зависи от общата производителност, заделена за контейнера на Azure Cosmos DB. Заделената производителност на контейнера се разпределя приблизително равномерно между физическите му дялове.
С нарастването на обема данни физическите дялове се разделят и броят им се увеличава, а общата заделена производителност остава непроменена. В резултат реалният лимит на производителността за всеки дял намалява с увеличаването на обема данни.
При най-обемните ни сценарии за съхранение на данни, подобни на BLOB обекти, това създава проблем: само нарастването на съхраняваните данни намалява наличната производителност за активните натоварвания, дори когато честотата на заявките и използването остават стабилни.
Figure 02C · Weak partitions
The limit falls below demand
A partition splits when it reaches its storage limit. Each child receives half the throughput capacity.
Отделните контейнери помагат частично да се решат проблемите с тези лимити на ключовете на дялове и с „шумните съседи“. На всеки отделен контейнер могат да се заделят собствени независими физически дялове. Така само различните натоварвания в самия контейнер могат да си пречат като „шумни съседи“ или да използват припокриващи се ключове на дялове. Недостатъкът е, че създаването на нови контейнери изисква допълнителна работа — за всеки трябва да се планира капацитет поотделно. Затова искаме да създаваме контейнери само там, където има смисъл да обособим услуга или сценарий на употреба.
Започнахме да използваме отделни контейнери в Azure Cosmos DB за най-критичните нови сценарии, изискващи силна изолация. В комбинация със скрипт за управление на слабите дялове, който съгласува общата заделена производителност на всеки контейнер с броя физически дялове, това ни позволи до голяма степен да избегнем всички проблеми, с които се сблъсквахме.
Тъй като обаче бяхме започнали с контейнер по подразбиране, много от старите критични сценарии за съжаление оставаха обвързани с него, докато не мигрираме данните. За да защитим критичните услуги, които още разчитаха на данни в споделения контейнер по подразбиране, често съветвахме екипите да създават отделни контейнери за нови услуги или големи продуктови функции. С разрастването ни натоварването на контейнера по подразбиране се увеличаваше, както и броят на инцидентите в продукционната среда. Започнахме да създаваме контейнери за всяка нова услуга или голяма продуктова функция просто за да предпазим контейнера по подразбиране от допълнително натоварване.
Това ни спести много сривове на целия сайт, като ограничи последиците до прекъсвания или влошена работа на отделни функции или услуги. В крайна сметка обаче допълнителната работа по създаването на нови контейнери се превърна в проблем:
В стария споделен контейнер по подразбиране ръстът на отделен нов сценарий от 0 до 1 милион заявки в секунда (QPS) едва ли би променил съществено общото използване. В изолиран контейнер обаче ръстът на нов сценарий от 0 до 1 милион QPS може да означава 1000 пъти по-голямо натоварване спрямо базовото ниво, генерирано само от вътрешно тестване чрез реална употреба.
При самостоятелните контейнери екипите вече трябваше да помнят да заделят предварително очакваната производителност за пускането на продуктите си. Продуктовите екипи често пускаха функциите си в продукционна среда и забравяха да увеличат капацитета предварително. Така в деня на пускането се озовавахме в кризисни срещи за сериозни инциденти, опитвайки се да увеличим капацитета на контейнерите в последния момент.
Това е основно предимство на мултитенантните системи — смесването на натоварвания — което губим в полза на по-добрата изолация при тази стратегия с контейнери.
Figure 03 · Lack of stable aggregate growth
The same workload, different headroom
1K RU/s during load testing → 1M RU/s at launch
С времето наблюдението на използването на самостоятелните контейнери изискваше и задълбочено познаване на продукта, за да знаем кога трафикът ще се промени или увеличи.
Можем да предоставим прост скрипт в управляващия слой, който увеличава заделената производителност на всеки контейнер според тенденциите на растеж. Но без стабилен общ ръст правилното мащабиране на практика въвлече всеки продуктов екип в OpenAI в планирането на капацитета.
Този подход не издържа на организационното разрастване: екипите, отговорни за отделните услуги, отделяха много различно внимание на заделянето на ресурси в Azure Cosmos DB.
С разрастването на организацията тези оперативни проблеми ставаха все по-трудни за овладяване. Липсата на единен централен екип за планиране доведе до лошо планиране в много екипи. В резултат се върнахме към мултитенантно решение и работихме в тясно сътрудничество с екипа на Azure, за да отстраним първоначалните проблеми със споделянето чрез следните решения:
Когато първо ограничаваме заявките към най-натоварените ключове, не позволяваме на „шумните съседи“ да изчерпят цялата налична производителност на физическия дял. Това намалява ефекта, при който един натоварен ключ може да засегне целия сайт.
По подобен начин QoS с нива на заявките не позволява на офлайн натоварвания, като масово вмъкване или извличане на данни, да изчерпят цялата производителност на даден физически дял. Задаваме по-нисък приоритет на заявките за тези натоварвания, така че те да бъдат ограничавани първи. Това предпазва от ограничаване интерактивните заявки с по-висок приоритет, които обслужват крайните потребители.
Azure Cosmos DB вече имаше безсървърен режим, който би решил проблема със слабите дялове при режима със заделена производителност. Той обаче имаше сериозен недостатък: мащабираше се само според използваното място за съхранение и ограничаваше потреблението на RU до 5 хил. RU/s за физически дял. Това би решило проблема със слабите дялове, но би ни попречило да мащабираме сценарии с високо потребление на RU.
Екипът на Azure Cosmos DB разработи за нас подобрен безсървърен режим, който позволява всеки физически дял да достигне 10 хил. RU/s. Освен разделяне според използваното място той въведе и разделяне според потреблението на RU. Така вече не се налагаше ръчно да наблюдаваме и увеличаваме капацитета на контейнерите.
Тези промени ни позволиха да се върнем към един споделен контейнер. Това значително улесни предварителното и текущото планиране на капацитета за продуктовите екипи, като запази изолацията между натоварванията. През месеците оттогава не сме имали инциденти със заделянето на ресурси или „шумни съседи“, въпреки ускорената продуктова разработка.
Когато компаниите достигнат мащаба на OpenAI, управляваните услуги стават по-малко „управлявани“. Много натоварвания излизат извън типичните случаи в инфраструктурата на облачния доставчик. Когато OpenAI се превърна в един от най-големите потребители на Azure Cosmos DB, успешната ѝ експлоатация изискваше различен модел: съвместни тестове, споделени табла за наблюдение, преки инженерни канали и обща отговорност за откази, които възникват само при нашия мащаб. Днес най-големият ни акаунт в Azure Cosmos DB съхранява десетки петабайти данни и обслужва над 20 милиона QPS. Доколкото ни е известно, сме единствената компания, внедрила Azure Cosmos DB в такъв мащаб.
В края на 2024 г. регионален срив показа ясно защо това сътрудничество е необходимо. Тогава прекъсване на електрозахранването засегна основния регион на Azure за един от най-големите ни акаунти в Azure Cosmos DB. При последвалото аварийно превключване установихме, че прехвърлянето на най-големите ни акаунти към вторичен регион ще отнеме няколко часа — нещо, което нито нашият екип, нито екипът на Azure Cosmos DB беше тествал в нашия мащаб.
Около месец по-късно, при връщането на тези акаунти към първоначалния регион, предизвикахме друг сериозен срив. Лавина от връзки към първоначалния регион претовари неговия шлюз на Azure Cosmos DB. Особеност в обработката на аварийното превключване от клиента на Azure Cosmos DB в режим с шлюз накара всички клиенти незабавно да установят връзки и да изпращат заявки към първоначалния регион, въпреки че само част от физическите дялове се бяха върнали там.
При всеки срив и OpenAI, и Azure следваха процедурите си за управление на инциденти. Бяха установени редица първопричини и внедрени корекции. Но тези сривове бяха симптоми на по-голям системен проблем в начина, по който нашият екип и екипът на Azure Cosmos DB работеха и тестваха. В началото управляваните услуги ни позволяваха да напредваме бързо и да се съсредоточим върху бизнес целите. В голям мащаб управляваните услуги стават по-малко управлявани и изискват дълбока интеграция с доставчика на всички нива — от проектирането до експлоатацията.
Днес инженерните екипи на Habitat и Azure съвместно наблюдават всеки слой на Habitat — от нашите услуги до физическите слоеве за съхранение на Azure Cosmos DB. Работим и тестваме промените в акаунт в Azure Cosmos DB с продукционен размер, който обаче не обслужва продукционен трафик. Заедно тестваме и усъвършенстваме SDK и определяме най-добрите практики за мащабиране и оразмеряване на акаунтите ни в Azure Cosmos DB. Инженерните ни екипи имат пряка връзка помежду си чрез Teams. Дори осигурихме начини да уведомяваме директно дежурните си колеги, без да минаваме през нивата на поддръжка.
През последната година положихме значителни съвместни усилия за подобряване на мащабируемостта на управляващия слой и шлюза на Azure Cosmos DB. Добавихме защита от претоварване и кеширане на различни метаданни на Azure Cosmos DB в шлюза, за да защитим дела с метаданни. Намалихме натоварването от множеството ни Python процеси на habitat-service чрез пулове от връзки и по-честото им повторно използване. Разположихме натоварванията съвместно, където е възможно, и изолирахме критичните от офлайн натоварванията чрез различни VPC.
Това ниво на инженерно сътрудничество беше решаващо за стабилността на нашата Azure Cosmos DB през последната година. Всяка достатъчно голяма или критична система изисква такава екипна работа: преодоляване на бариерите между отговорностите и сътрудничество отвъд организационните граници.
Както споменахме в част I, основната ни философия е: първо да работи, после да е бързо, накрая да е евтино. Първо надеждност, после производителност, след това ефективност. Habitat се беше превърнала в критична инфраструктура за OpenAI, затова първият приоритет беше да я направим надеждна. Със стабилизирането на системата през 2025 г. и глобалното разрастване на OpenAI времето за отговор стана следващото ограничение за потребителите и продуктовите екипи.
Изчислителните ресурси и капацитетът за съхранение често се намираха в различни региони, което правеше междурегионалните заявки неизбежни.
Figure 04 · Improving performance at a global scale
Global traffic
Compute and storage capacity were often located in different regions, making cross-region requests unavoidable.
Географските местоположения на изчислителните ресурси и хранилищата са само илюстративни.
Времето за отговор варираше значително според това къде се съхраняват данните и къде се обслужва заявката. За да ускорят заявките към Habitat, разработчиците на продукти разчитаха на допълнително клиентско кеширане, групиране на заявки, свеждане до минимум на четенето и записа в базата данни и изнасяне на операции извън критичния път, когато е възможно. Това доведе до някои интересни продуктови решения, по-устойчиви на проблеми с базата данни, но също усложни продуктите и забави развитието им. Така не успяхме да изпълним целта на Habitat: бърза продуктова разработка без нужда от познаване на базата данни.
Това е основен наш приоритет през 2026 г. Разработихме многостепенна стратегия за оптимизиране на четенето, което представлява над 90% от заявките към Habitat:
Имаме редица акаунти в Azure Cosmos DB в различни региони на Azure по света за потребители с изисквания за местонахождение на данните. Дори за потребителите без строги изисквания за местонахождение на данните съхраняването им наблизо подобрява работата чрез по-кратко време за отговор.
На практика не можем да прилагаме този подход широко извън случаите с изисквания за местонахождение на данните, защото разполагането е ограничено от физическия капацитет на облака. Предвид размера на нашите набори от данни много региони не могат да осигурят огромния обем за съхранение, необходим за съответния им дял от потребителския трафик.
В региони с ограничен капацитет на центровете за данни, електрозахранването или охлаждането понякога трябва да избираме: да запазим капацитет за локално съхранение и по-бърз отговор от Habitat или да го използваме за допълнителни изчислителни ресурси за обслужване на заявки, а заявките за съхранение да насочим по-далеч.
Затова решенията за разполагане често изискват продуктов компромис между: А) оптимизиране на времето за отговор за различни продукти и групи потребители и Б) наличния капацитет за съхранение и изчисления.
За най-чувствителните към забавяне натоварвания, като метаданните за удостоверяване, нужни при всяка заявка, намаляваме времето за двупосочен обмен (RTT) между изчислителните ресурси и Habitat чрез акаунти в Azure Cosmos DB с широка репликация в много вторични региони. Някои акаунти се репликират във всичките 40 региона на Azure, в които обслужваме трафик; други — в поне един регион на континент.
Наложи се по-рано от планираното да изградим в Habitat прозрачен за клиентите слой за преместване и маршрутизиране на данни, за да местим набори от данни между акаунти с различни топологии на репликация.
И този подход обаче в крайна сметка е ограничен от капацитета, затова го прилагаме само за няколко внимателно подбрани набора от данни.
При натоварванията в Habitat силно преобладава четенето: приложенията постоянно извличат настройки, състояние и други свойства. Непропорционално голям дял от трафика се дължи на сравнително малък работен набор от често достъпвани типове данни. Това прави системата особено подходяща за разпределено кеширане. Кеширането подобрява производителността в еднорегионална инфраструктура, но ползите се умножават при работа в няколко региона, където то може да „скрие“ междурегионалното забавяне.
Интегрирахме в Habitat разпределен кеширащ слой от типа lookaside. Той съхранява копие на наскоро достъпвани обекти и връзки между тях в сървъри Valkey, разположени в нашата инфраструктура близо до обработката на потребителските заявки. Обикновено такива кешове използват срок на валидност (TTL), който балансира между дела попадения в кеша и остаряването на данните. Краткият TTL намалява остаряването, но отслабва ползата от кеширането, защото записите бързо изтичат и трябва да се извличат по-често. По-дългият TTL увеличава дела попадения, но подава остарели данни за по-дълго време, което влошава работата на потребителите и прави поведението на продукта непредвидимо.
Друг подход е изричното обезсилване на кеша, когато приложението записва промени в същите данни в основното хранилище. Това значително подобрява актуалността на кешираните данни, но може да е трудно за правилно изпълнение. Остават и редица гранични случаи, като частични откази, които оставят кеша неактуален. Внедряването в няколко региона усложнява тези проблеми: междурегионалните мрежи са по-ненадеждни, а операциите с кеша се надпреварват с репликацията на основното хранилище. Добавянето на TTL ограничава максималното остаряване, но запазва същия основен компромис, описан по-горе.
Избрахме по-усъвършенстван подход към обезсилването по примера на системи като TAO на Meta. Интегрираният кеш на Habitat разчита на улавяне на промените в данните (CDC) чрез потока от промени на Azure Cosmos DB. Така обезсилванията се разпространяват глобално за секунди и се постига висок дял попадения при малко остаряване на данните. Услуга в нашата инфраструктура чете този поток и обезсилва записите в локалните кешове, когато основните данни се обновят. Това води до липса в кеша при следващото четене и той се попълва с най-новата версия на данните.
Значително съкратихме времето за отговор и чрез премахване на допълнителното натоварване в самата услуга Habitat: пренаписахме кода на Rust, който осигурява по-висока производителност от първоначалната реализация на Python.
Заедно тези промени намалиха общото ни глобално време за четене при p50 от около 120 ms на около 10 ms — 12-кратно подобрение, откакто се съсредоточихме върху производителността по-рано тази година.
Figure 05 · Improving performance at a global scale
p50 latency for reads
Three major design changes improved read latency
Кеширането значително намали медианното време за отговор, но най-бавните заявки все още изискват извличане на некеширани данни от основното хранилище, често от далечни региони. С добавянето на капацитет и подобряването на ефективността на съхранението планираме да решим по-фундаментален проблем: още отначало да разполагаме данните по-близо до мястото, откъдето ще се достъпват.
Надеждното и мащабируемо CDC – разпространяването на промени в базата данни към последващите системи – е критично не само за стратегията за кеширане на Habitat. Милиони уловени промени в секунда от хиляди контейнери и милиони техни дялове преминават през над сто теми в Kafka. Те захранват и вторични индекси с отложена съгласуваност, конвейери за преместване на данни, офлайн анализи и други продуктови функции в реално време. С глобалното разрастване на базовата ни инфраструктура следенето на промени в редовете става все по-сложно. За да регистрира функция, извиквана при промяна на редове от неговия тип, клиентът трябва първо да установи кои акаунти и контейнери в Cosmos съдържат данните му и кое внедряване на CDC услугата ги следи в съответните региони. Трябва също да мащабира правилно съвкупността от изпълняващи процеси на CDC услугата, Kafka и своя компонент за четене на потока. Нашата CDC платформа цели да скрие всичко това от продуктовите разработчици. Тя предлага прост интерфейс, чрез който клиентите регистрират функция за извикване при промяна на техните редове, а ние поемаме цялата свързваща инфраструктура и мащабирането.
Вътрешен управляващ слой непрекъснато гарантира, че промените във всички нужни контейнери се улавят, избира от кои региони на Cosmos да се чете и разпределя ефективно работата между хиляди работни процеси в множество географски региони. Използването на региони с реплики и четене на потока с по-нисък приоритет защитава чувствителния към забавяне трафик на Habitat от фоновото улавяне на промени. В толкова голяма инфраструктура непрекъснато възникват сривове на работни процеси, проблеми с контейнери, грешки в хранилището за временни заключвания, невалидни конфигурации и откази в целевите системи. Подобрихме устойчивостта на жизнения цикъл на CDC процесите и обработката на временните заключвания, изолирахме отказите между потоците и добавихме защити при промени в конфигурацията и внедрявания. Използваме опашки за недоставени съобщения, за да не блокират трайните грешки при доставка обработката на следващите съобщения. При регионални сривове превключваме четящите процеси към други региони на Cosmos. При нашия мащаб се наложи дори да променим CDC SDK на Azure: направихме контролните точки по-ефективни, като ги групираме в паметта и ги записваме трайно само периодично.
Макар вътрешното използване на Azure Cosmos DB в Habitat да е сложно, стойността на платформата винаги е била в това да скрива инфраструктурните подробности от разработчиците и да ускорява продуктовата разработка. CDC платформата ни следва същия принцип.
Малък екип успя да поддържа бързо растяща платформа за съхранение, като съзнателно ограничи обхвата на API за съхранение, продължи да мащабира Python услугата и даде приоритет на най-неотложните инженерни предизвикателства. Първо се съсредоточихме върху надеждността за критичния продуктов трафик, после върху производителността при четене за глобалните потребители и накрая върху оперативната ефективност в голям мащаб. Този подход позволи бърз растеж и нови сценарии на употреба, като същевременно подобри надеждността, производителността и скоростта на продуктовата разработка.
За да мащабираме Habitat и съхранението на разговори за следващия милиард потребители, трябва да преодолеем трудностите при репликирането на данните в инфраструктурата ни в още по-голям мащаб. За да изпреварим този проблем, разработваме собствена система за онлайн съхранение в екзабайтов мащаб, оптимизирана за обемните типове данни на Habitat. Тази система, която още се разработва, съчетава ядрото RocksDB Cloud от Rockset с репликация, базирана на Raft. Тя използва същия документен модел като Azure Cosmos DB, но отделя съхранението от изчислителните ресурси и използва физическа репликация, за да намали необходимия капацитет на флаш паметта за сметка на по-нисък пиков брой заявки в секунда.
Ако искате да работите по подобни проекти за инфраструктура за данни в авангарден мащаб, разгледайте свободните ни позиции.


