company_banner

Fault Injection: твоя система ненадежна, если ее не пробовали сломать

    Привет, Хабр! Меня зовут Павел Липский. Я инженер, работаю в компании Сбербанк-Технологии. Моя специализация — тестирование отказоустойчивости и производительности бэкендов крупных распределенных систем. Попросту говоря, я ломаю чужие программы. В этом посте я расскажу о fault injection — методе тестирования, который позволяет находить проблемы в системе путем создания искусственных сбоев. Начну с того, как я пришел к этому методу, потом поговорим о самом методе и о том, как мы его используем.


    В статье будут примеры на языке Java. Если вы не программируете на Java — ничего страшного, достаточно понять сам подход и основные принципы. В качестве базы данных используется Apache Ignite, но эти же подходы применимы и к любой другой СУБД. Все примеры можно скачать с моего GitHub.

    Зачем нам это все?


    Начну с истории. В 2005 году я работал в компании Rambler. К тому времени количество пользователей Rambler стремительно росло и наша двухзвенная архитектура «сервер – база данных – сервер – приложения» перестала справляться. Мы думали о том, как решить проблемы с производительностью, и обратили внимание на технологию memcached.



    Что такое memcached? Memcached — хэш-таблица в оперативной памяти с доступом к хранимым объектам по ключу. Например, нужно получить профиль пользователя. Приложение обращается в memcached (2). Если в нем объект есть, то он тут же возвращается пользователю. Если объекта нет, то идет обращение в базу данных (3), формируется объект и кладется в memcached (4). Затем, при следующем обращении, нам уже не надо делать дорогое по ресурсам обращение в базу данных — мы получим готовый объект из оперативной памяти — memcached.

    За счет memcached мы заметно разгрузили базу, а наши приложения начали работать намного быстрее. Но, как оказалось, радоваться было рано. Вместе с ростом производительности мы получили новые проблемы.



    Когда нужно изменить данные, то сначала приложение вносит исправление в базу данных (2), создает новый объект и затем пытается положить его в memcached (3). То есть старый объект должен быть заменен на новый. Представим, что в этот момент происходит страшное — обрывается связь между приложением и memcached, падает сервер memcached или даже само приложение. Значит, приложение не смогло обновить данные в memcached. В итоге пользователь зайдет на страницу сайта (например, своего профиля), увидит старые данные и не поймет, почему так произошло.

    Можно ли было обнаружить этот баг во время функционального тестирования или тестирования производительности? Думаю, что, скорее всего, мы бы его не нашли. Для поиска таких багов есть специальный вид тестирования — fault injection.

    Обычно во время fault injection тестирования находятся баги, которые в народе называют плавающими. Они проявляются под нагрузкой, когда в системе работает больше одного пользователя, когда происходят нештатные ситуации — выходит из строя оборудование, отключается электричество, сбоит сеть и т.п.

    Новая ИТ-система Сбербанка


    Несколько лет назад Сбербанк начал строить новую ИТ-систему. Зачем? Вот статистика с сайта Центробанка:



    Зеленая часть столбца — количество снятий наличных в банкоматах, голубая — количество операций по оплате товаров и услуг. Мы видим, что количество безналичных операций растет из года в год. Через несколько лет мы должны будем способны обработать растущую нагрузку и продолжать предлагать клиентам новые услуги. Это одна из причин создания новой ИТ-системы Сбербанка. Кроме того, мы хотели бы снизить зависимость от западных технологий и дорогих мейнфреймов, которые стоят миллионы долларов, и перейти на технологии с открытым исходным кодом и low-end сервера.   

    Изначально в основу новой архитектуры Сбербанка мы заложили технологию Apache Ignite. Точнее, мы используем платный плагин Gridgain. Технология обладает достаточно богатой функциональностью: совмещает в себе свойства реляционной базы данных (есть поддержка SQL-запросов), NoSQL, распределенную обработку и хранение данных в оперативной памяти. Причем, при перезагрузке данные, которые были в оперативной памяти, никуда не потеряются. Начиная с версии 2.1 в Apache Ignite появилось распределенное дисковое хранилище Apache Ignite Persistent Data Store с поддержкой SQL.

    Перечислю некоторые фичи этой технологии:

    • Хранение и обработка данных в оперативной памяти
    • Дисковое хранилище
    • Поддержка SQL
    • Распределенное выполнение задач
    • Горизонтальное масштабирование

    Технология относительно новая, поэтому требует особого внимания.

    Новая ИТ-система Сбербанка физически состоит из множества относительно небольших серверов, собранных в один кластер-облако. Все узлы одинаковые по структуре, равноправные между собой, выполняют функцию хранения и обработки данных.

    Внутри кластер разделен на так называемые ячейки. Одна ячейка — это 8 узлов. В каждом дата-центре по 4 узла.


    Так как мы используем Apache Ignite, in-memory data grid, то, соответственно, все это хранится в распределенных по серверам кэшах. Причем кэши, в свою очередь, делятся на одинаковые кусочки – партиции. На серверах они представлены в виде файлов. Партиции одного кэша могут хранится на разных серверах. Для каждой партиции в кластере есть основные (primary node) и резервные узлы (backup node).

    Основные узлы хранят основные партиции и обрабатывают запросы для них, реплицируют данные на резервные узлы (backup node), где хранятся резервные партиции (backup node).

    Проектируя новую архитектуру Сбербанка, мы пришли к тому, что компоненты системы могут и будут выходить из строя. Скажем, если у вас кластер из 1000 железных low-end серверов, то время от времени у вас будут случаться отказы оборудования. Будут выходить из строя планки оперативной памяти, сетевые карты и жесткие диски и т.д. Это поведение мы будем считать совершенно нормальным поведением системы. Такие ситуации должны корректно обрабатываться и наши клиенты не должны их замечать.

    Но мало проектировать устойчивость системы к сбою, надо обязательно тестировать системы во время этих сбоев. Как говорит известная исследовательница распределенных систем Caitie McCaffrey из Microsoft Research: «Вы никогда не узнаете, как поведет себя система во время сбоя нештатной ситуации, пока не воспроизведете сбой».

    Потерянные обновления


    Разберем простой пример, банковское приложение, которое симулирует денежные переводы. Приложение будет состоять из двух частей: Apache Ignite сервер и Apache Ignite клиент. Серверная часть – это хранилище данных.

    Клиентское приложение подключается к серверу Apache Ignite. Создает кэш, где ключ — ID cчета, а значение — объект счета. Всего в кэше будет храниться десять таких объектов. При этом изначально на каждый счет положим 100 долларов (чтобы было что переводить). Соответственно, общий баланс по всем счетам будет равен 1000 долларов.

    CacheConfiguration<Integer, Account> cfg = new CacheConfiguration<>(CACHE_NAME);
    cfg.setAtomicityMode(CacheAtomicityMode.ATOMIC);
    
    try (IgniteCache<Integer, Account> cache = ignite.getOrCreateCache(cfg)) {
       for (int i = 1; i <= ENTRIES_COUNT; i++)
           cache.put(i, new Account(i, 100));
    
       System.out.println("Accounts before transfers");
       printAccounts(cache);
       printTotalBalance(cache);
    
       for (int i = 1; i <= 100; i++) {
           int pairOfAccounts[] = getPairOfRandomAccounts();
           transferMoney(cache, pairOfAccounts[0], pairOfAccounts[1]);
       }
    }
    
    ...
    private static void transferMoney(IgniteCache<Integer, Account> cache, int fromAccountId, int toAccountId) {
       Account fromAccount = cache.get(fromAccountId);
       Account toAccount = cache.get(toAccountId);
    
       int amount = getRandomAmount(fromAccount.balance);
       if (amount < 1) {
           return;
       }
    
       fromAccount.withdraw(amount);
       toAccount.deposit(amount);
    
       cache.put(fromAccountId, fromAccount);
       cache.put(toAccountId, toAccount);
    }
    


    Затем делаем 100 случайных денежных переводов между этими 10 счетами. Например, со счета A на другой счет Б переводится 50 долларов.  Схематично этот процесс можно изобразить таким образом:



    Система замкнутая, переводы делаются только внутри, т.е. сумма общего баланса должна оставаться равной $1000.



    Запустим приложение.


    Мы получили ожидаемое значение общего баланса — $1000. Теперь давайте немного усложним наше приложение – сделаем его многозадачным.  В реальности с одним и тем же счетом одновременно могут работать несколько клиентских приложений. Запустим две задачи, которые параллельно будут делать денежные переводы между десятью счетами.

    CacheConfiguration<Integer, Account> cfg = new CacheConfiguration<>(CACHE_NAME);
    cfg.setAtomicityMode(CacheAtomicityMode.ATOMIC);
    cfg.setCacheMode(CacheMode.PARTITIONED);
    cfg.setIndexedTypes(Integer.class, Account.class);
    
    try (IgniteCache<Integer, Account> cache = ignite.getOrCreateCache(cfg)) {
       // Initializing the cache.
       for (int i = 1; i <= ENTRIES_COUNT; i++)
         cache.put(i, new Account(i, 100));
    
       System.out.println("Accounts before transfers");
       System.out.println();
       printAccounts(cache);
       printTotalBalance(cache);
    
       IgniteRunnable run1 = new MyIgniteRunnable(cache, ignite,1);
       IgniteRunnable run2 = new MyIgniteRunnable(cache, ignite,2);
       List<IgniteRunnable> arr = Arrays.asList(run1, run2);
       ignite.compute().run(arr);
    } 
    
    ...
    private void transferMoney(int fromAccountId, int toAccountId) {
       Account fromAccount = cache.get(fromAccountId);
       Account toAccount = cache.get(toAccountId);
    
       int amount = getRandomAmount(fromAccount.balance);
    
       if (amount < 1) {
           return;
       }
    
       int fromAccountBalanceBeforeTransfer = fromAccount.balance;
       int toAccountBalanceBeforeTransfer = toAccount.balance;
    
       fromAccount.withdraw(amount);
       toAccount.deposit(amount);
    
       cache.put(fromAccountId, fromAccount);
       cache.put(toAccountId, toAccount);
    }
    



    Общий баланс — $1296. Клиенты радуются, банк терпит убытки. Почему это произошло?



    Здесь мы видим, как две задачи одновременно меняют состояние счета А. Но вторая задача успевает записать свои изменения раньше, чем это сделает первая. Затем первая задача записывает свои изменения, и все изменения, сделанные второй задачей, сразу пропадают. Такая аномалия называется проблемой потерянных обновлений.

    Для того, чтобы приложение работало так, как нужно, необходимо, чтобы наша база данных поддерживала ACID-транзакции и наш код это учитывал.

    Давайте разберем свойства ACID применительно к нашему приложению, чтобы понять, почему это так важно.



    • A — Atomicity, атомарность. Либо все предлагаемые изменения будут внесены в базу данных, либо не будет внесено ничего. То есть, если между шагами 3 и 6 у нас был сбой, изменения не должны попасть в базу данных
    • C — Consistency, целостность. После выполнения транзакции база данных дожна остаться в консистентном состояние. В нашем примере это означает, что сумма А и Б всегда должно быть одинакова, общий баланс равен $1000.
    • I — Isolation, изолированность. Транзакции не должны влиять друг на друга. Если одна транзакция делает перевод, а другая получит значение счета А и Б после шага 3 и до шага 6, она думает, что в системе денег меньше, чем надо. Здесь есть нюансы, на которых я остановлюсь позже.
    • D — Durability, устойчивость. После того, как транзакция зафиксировала изменения в базе данных, эти изменения не должны пропасть в результате сбоев.

    Итак, в методе transferMoney будем делать денежный перевод внутри транзакции.

    private void transferMoney(int fromAccountId, int toAccountId) {
       try (Transaction tx = ignite.transactions().txStart()) {
           Account fromAccount = cache.get(fromAccountId);
           Account toAccount = cache.get(toAccountId);
    
           int amount = getRandomAmount(fromAccount.balance);
    
           if (amount < 1) {
               return;
           }
    
           int fromAccountBalanceBeforeTransfer = fromAccount.balance;
           int toAccountBalanceBeforeTransfer = toAccount.balance;
          
           fromAccount.withdraw(amount);
           toAccount.deposit(amount);
          
           cache.put(fromAccountId, fromAccount);
           cache.put(toAccountId, toAccount);
          
           tx.commit();
       } catch (Exception e){
           e.printStackTrace();
       }
    }
    
    

    Запустим приложение.


    Хм. Транзакции не помогли. Общий баланс — $6951! В чем проблема такого поведения приложения?

    Во первых, выбрали тип кэша ATOMIC, т.е. без поддержки ACID-транзакций:

    CacheConfiguration<Integer, Account> cfg = new CacheConfiguration<>(CACHE_NAME);
    cfg.setAtomicityMode(CacheAtomicityMode.АTOMIC);

    Во вторых, метод txStart имеет два важных параметра типа enum, которые хорошо бы указать: метод блокировки (concurrency mode в Apache Ignite) и уровень изоляции. В зависимости от значений этих параметров транзакция может по-разному выполнять чтение и запись данных. В Apache Ignite эти параметры задаются таким образом:  

    try (Transaction tx = ignite.transactions().txStart(МЕТОД БЛОКИРОВКИ, УРОВЕНЬ ИЗОЛЯЦИИ)) {
    	
    Account fromAccount = cache.get(fromAccountId);
    	
    Account toAccount = cache.get(toAccountId);
    
    	
    ...   
    
    	
    tx.commit();
    }
    
    

    В качестве значения параметра МЕТОД БЛОКИРОВКИ можно использовать PESSIMISTIC (пессимистическая блокировка) либо OPTIMISTIC (оптимистическая блокировка). Отличаются они моментом времени наложения блокировки. При использовании PESSIMISTIC блокировка накладывается при первом чтении/записи и удерживается до момента фиксации транзакции. Например, когда транзакция с пессимистической блокировкой делает перевод со счета А на счет Б, другие транзакции не смогут ни прочитать, ни записать значения этих счетов до тех пор, пока транзакция, делающая перевод, не будет зафиксирована. Понятно, что если другие транзакции хотят получить доступ к счетам А и Б, они вынуждены ждать выполнения транзакции, что оказывает отрицательное влияние на общую производительность приложения. Оптимистическая блокировка не ограничивает доступ к данным для других транзакций, однако на фазе подготовки транзакции к фиксации (prepare phase, Apache Ignite использует 2PC протокол) будет выполнена проверка — изменялись ли данные другими транзакциями? И если изменения имели место, то транзакция будет отменена. С точки зрения производительности OPTIMISTIC будет работать быстрее, но больше подходит для приложений, где нет конкурентной работы с данными.

    Параметр УРОВЕНЬ ИЗОЛЯЦИИ определяет степень изоляции транзакций друг от друга. В стандарте ANSI/ISO языка SQL определено 4 типа изоляции, и для каждого уровня изоляции один и тот же сценарий транзакции может привести к разном результату.

    • READ_UNCOMMITED – самый низкий уровень изоляции. Транзакции могут видеть «грязные» незафиксированные данные.
    • READ_COMMITTED — когда транзакция видит внутри себя только закомиченные данные
    • REPEATABLE_READ — означает, что, если внутри транзакции выполнено чтение, то это чтение должно быть повторяемым.
    • SERIALIZABLE — этот уровень предполагает максимальную степень изоляции транзакций – будто в системе нет других пользователей. Результат работы параллельно выполняющихся транзакций будет такой, как если бы они выполнялись по очереди (упорядоченно). Но вместе с высокой степенью изоляции мы получаем снижение производительности. Поэтому надо осторожно подходить к выбору этого уровня изоляции.

    Для многих современных СУБД (Microsoft SQL Server, PostgreSQL и Oracle) уровень изоляции по умолчанию — READ_COMMITTED. Для нашего примера это было бы фатально, так как не защитит нас от потерянных обновлений. Результат будет такой же, как если бы мы совсем не использовали транзакции.



    Из документации Apache Ignite по транзакциям следует, что нам подходят такие комбинации метода блокировки и уровня изоляции:  

    • PESSIMISTIC REPEATABLE_READ — блокировка накладывается при первом чтении или записи данных и удерживается до ей завершения.
    • PESSIMISTIC SERIALIZABLE — работает аналогично PESSIMISTIC REPEATABLE_READ
    • OPTIMISTIC SERIALIZABLE — запоминается версия данных, полученная после первого чтения, и если на фазе подготовке к фиксации эта версия будет отличаться (данные были изменены другой транзакцией), то транзакция будет отменена. Попробуем этот вариант.


    private void transferMoney(int fromAccountId, int toAccountId) {
       try (Transaction tx = ignite.transactions().txStart(OPTIMISTIC, SERIALIZABLE)) {
           Account fromAccount = cache.get(fromAccountId);
           Account toAccount = cache.get(toAccountId);
    
           int amount = getRandomAmount(fromAccount.balance);
    
           if (amount < 1) {
               return;
           }
    
           int fromAccountBalanceBeforeTransfer = fromAccount.balance;
           int toAccountBalanceBeforeTransfer = toAccount.balance;
    
           fromAccount.withdraw(amount);
           toAccount.deposit(amount);
    
           cache.put(fromAccountId, fromAccount);
           cache.put(toAccountId, toAccount);
    
           tx.commit();
       } catch (Exception e){
           e.printStackTrace();
       }
    }


    Ура, получили $1000, как и ожидали. С третьей попытки.

    Тестируем под нагрузкой


    Теперь сделаем наш тест более реалистичным — будем тестировать под нагрузкой. А еще добавим дополнительный серверный узел. Существует множество инструментов для проведения нагрузочного тестирования, в Сбербанке мы используем HP Performance Center. Это достаточно мощный инструмент, поддерживает более 50 протоколов, предназначен для больших команд и стоит много денег. Свой пример я написал на JMeter — он бесплатный и решает нашу задачу на 100%. Я бы не хотел переписывать код на Java, поэтому воспользуюсь семплером JSR223.

    Создадим из классов нашего приложения JAR-архив и подгрузим его в тест-план. Чтобы создать и наполнить кэш, запустим класс CreateCache. После инициализации кэша можно запускать скрипт JMeter.


    Все классно, получили $1000.

    Аварийная остановка узла кластера


    Теперь будем более деструктивны: во время работы кластера аварийно остановим один из двух серверных узлов. Через утилиту Visor, которая входит в поставку Gridgain, мы можем мониторить кластер Apache Ignite и делать разные выборки данных.  Во вкладке SQL Viewer выполним SQL-запрос, чтобы получить общий баланс по всем счетам.


    Что получается? 553 доллара. Клиенты в ужасе, банк терпит репутационные потери. Что мы на этот раз сделали не так?

    Оказывается, в Apache Ignite есть типы кэшей:

    • партиционированные — в рамках кластера хранится по одному или несколько экземпляров бэкапа
    • реплицированные кэши — в рамках одного сервера хранятся все партиции (все кусочки кэша). Такие кэши подходят прежде всего для справочников — того, что редко меняется и часто читается.
    • локальные — все на одном узле



    Мы будем наши данные часто менять, поэтому мы выберем партиционированный кэш и добавим в него дополнительный бэкап. То есть у нас будет две копии данных — основная и резервная.

    CacheConfiguration<Integer, Account> cfg = new CacheConfiguration<>(CACHE_NAME);
    cfg.setAtomicityMode(CacheAtomicityMode.TRANSACTIONAL);
    cfg.setCacheMode(CacheMode.PARTITIONED);
    cfg.setBackups(1);

    Запускаем приложение. Напоминаю, до переводов у нас $1000. Запускаем и во время работы «гасим» один из узлов


    В утилите Visor делаем SQL-запрос, чтобы получить общий баланс — $1000. Все отработало замечательно!

    Кейсы надежности


    Два года назад мы только начинали тестировать новую ИТ-систему Сбербанка. Как-то пошли мы к нашим инженерам сопровождения и спросили: а что вообще может сломаться? Нам ответили: сломаться может все, тестируйте все! Конечно, нас этот ответ не устроил. Мы вместе сели, проанализировали статистику отказов и поняли, что самый вероятный кейс, с которым мы можем столкнуться — это отказ узла.

    Причем, это может произойти по совершенно разным причинам. Например, может отказать приложение, упасть JVM, сбойнуть ОС или выйти из строя оборудование.



    Все возможные случаи отказов мы поделили на 4 группы:

    1. Оборудование
    2. Сетевые
    3. Программное обеспечение
    4. Другие

    Придумали для них тесты и назвали их кейсам надежности. Типичный кейс надежности состоит из описания состояния системы до тестов, шагов воспроизведения сбоя и описания ожидаемого поведения во время сбоя.



    Кейсы надежности: оборудование


    К этой группе относятся такие кейсы как:

    • Отказ электропитания
    • Полная потеря доступа к жесткому диску
    • Сбой одного пути доступа к жесткому диску
    • Загрузка CPU, оперативной памяти, дисков, сети

    В кластере хранится 4 одинаковых копии каждой партиции: одна основная партиция (primary) и три резервные партиции (backup). Предположим, из-за отказа оборудования из кластера выходит узел. В этом случае основные партиции должны переместиться на другие оставшиеся в живых узлы.

    Что еще может произойти? Потеря стойки в ячейке.



    Все узлы ячейки находятся в разных стойках. Т.е. выход стойки не приведет к отказу кластера или потерям данных. Мы будем иметь три копии из четырех. Но даже если мы потеряем целый дата-центр, то это также не будет для нас большой проблемой, т.к. у нас остались еще две копии данных из четырех.

    Часть кейсов выполняются непосредственно в самом дата-центре при участии инженеров сопровождения. Например, отключение жесткого диска, отключение электропитания сервера или стойки.

    Кейсы надежности: сеть


    Для тестирования кейсов, связанных c сетевой фрагментацией, используем iptables. А с помощью утилиты NetEm эмулируем:

    • сетевые задержки с различной функцией распределения
    • потерю пакетов
    • повтор пакетов
    • изменение порядка следования пакетов
    • искажение пакетов

    Еще одни интересный сетевой кейс, который мы тестируем – split-brain. Это когда все узлы кластера живые, но из-за сетевой сегментации не могут общаться между собой. Термин пришел из медицины и означает, что мозг разделился на два полушария, каждое из которых считает себя единственным. То же самое может произойти и с кластером.


    Бывает, что между дата-центрами пропадает связь. Например, в прошлом году из-за повреждения оптоволоконного кабеля экскаватором у клиента банков  «Точка», «Открытие» и «Рокетбанк» в течение нескольких часов не проводились операции через интернет, терминалы не принимали карты и не работали банкоматы. Про эту аварию много писали в Twitter.

    В нашем случае ситуация split-brain должна корректно обрабатываться. Грид идентифицирует split-brain – разделение кластера на две части. Одна из половин переводится в режим чтения. Это половина, где больше живых узлов или находится координатор (самый старый узел в кластере).

    Кейсы надежности: программное обеспечение


    Это кейсы связанные с отказом различным подсистем:

    • DPL ORM — модуль доступа к данным, типа Hibernate ORM
    • Межмодульный транспорт — обмен сообщениями между модулями (микросервисами)
    • Система логирования
    • Система предоставления доступов
    • Кластер Apache Ignite


    Поскольку большая часть программного обеспечения написана на Java, мы подвержены всем проблемам, присущим Java-приложениям. Тестирует различные настройки сборщика мусора (garbage collector). Проводим тесты с падением java virtual machine.

    Для кластера Apache Ignite есть специальные кейсы для off-heap — это такая область памяти, которую контролирует сам Apache Ignite. Она намного больше чем java heap и предназначена для хранения данных и индексов. Здесь можно, например, тестировать переполнение. Мы переполняем off-heap и смотрим, как кластер работает в случае, когда часть данных не влезла в оперативную память, т.е. читается с диска.



    Другие кейсы


    Это кейсы, которые не входят в первые три группы. К ним относятся утилиты, которые позволяют произвести восстановление данных в случае крупной аварии или при миграции данных в другой кластер.

    • Утилита создания снапшотов (бэкапа) данных — тестирование полного и инкрементального снапшота.
    • Восстановление на определенную точку во времени – механизм PITR (Point in-time recovery).

    Утилиты для fault injection


    Напомню ссылку на примеры из моего доклада. Дистрибутив Apache Ignite вы можете скачать с официального сайта — Apache Ignite Downloads. А теперь поделюсь утилитами, которыми мы пользуемся в Сбербанке, если вдруг вас заинтересовала тема.

    Фреймворки:


    Управление конфигурациями:


    Утилиты Linux:


    Инструменты нагрузочного тестирования:


    И в современном мире, и в Сбербанке все изменения происходят динамично и сложно предугадать, какие технологии будут использоваться в ближайшие пару лет. Но я точно знаю, что мы будем использовать метод Fault Injection. Метод универсальный — подходит для тестирования любых технологий, действительно работает, помогает отловить много багов и сделать продукты, которые мы разрабатываем, лучше.
    • +13
    • 4,6k
    • 1
    Сбербанк
    206,00
    Компания
    Поделиться публикацией

    Комментарии 1

      0
      Привет. Отличная статья.

      Заводишь ли ты баги в Ignite по найденным проблемам?

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое