Всем привет! Я – Кирилл, и я работаю в команде Мир Plat.Form.
В какой-то момент мы столкнулись с ситуацией, где изначально спроектированная система начинает требовать много ресурсов с увеличением нагрузки на нее. Мы начали искать узкие места, как полностью технические на уровне кода, так и на уровне системных решений и архитектуры.
В этой статье я хотел разобрать одно из найденных нами бутылочных горлышек, когда применение криптографии помогло серьезно сэкономить, хотя обычно происходит наоборот.
К текущему моменту мы не используем этот прием — рефакторинг бизнес-логики позволяет сэкономить еще больше, чем использование любых технических решений! Но, думаю, поделиться, как могут решаться подобного рода проблемы, было бы полезно.
Дано
Есть финансовая транзакция. В ней участвуют три организации, у каждой из них есть несколько датацентров. Транзакция состоит из нескольких сообщений. Между сообщениями есть максимальное время ожидания следующего в их цепочке. При поступлении очередного сообщения необходимо проверить, что отправитель не пытается нас обмануть, и что полученное сообщение соответствует контексту, созданному предыдущими сообщениями.
Stateful сервис
Решение в лоб — запоминаем данные в памяти приложения, при получении очередных данных проверяем, что они соответствуют тому, что запомнили. Классический stateful со всеми его изъянами: следующее сообщение нужно направить именно в тот инстанс приложения, что обрабатывал предыдущие, или же реализовать синхронизацию контекста. Плюс не забыть об отказоустойчивости — при падении инстанса необходимо заново поднять контекст, чтобы транзакция могла продолжиться при поступлении следующего сообщения.
В целом, все уже выдумано. Предположим, что мы живем в JVM-based среде. Например, можно взять Akka Cluster Sharding или другую аналогичную платформу и получить шардирование с автоматическим восстановлением после сбоя из коробки. Тогда нам останется только написать реализацию поднятия контекста, если конкретный актор (единица исполнения в Akka) упадет. И придумать, где мы этот контекст будем хранить вне Akka, чтобы его оттуда прочитать при восстановлении. Для этого можно взять классическую БД или IMDG (In-Memory Data Grid), если есть серьезные требования к производительности. Такое решение требует внедрения двух сложных систем и их совместной интеграции: Akka кластера и IMDG/БД.
Расход по памяти будет O(n) в кластере приложений и O(n) в хранилище, где n – количество транзакций, которые еще не дошли до финального статуса. Причем если хранилище заточено на хранение больших объемов памяти в десятки и сотни гигабайт, то кластер приложений уже не так легко переносит такие объемы занимаемой приложениями памяти. Каждая нода в кластере — это Java-приложение, которое для хранения обсуждаемого нами контекста использует heap. Безразмерно его увеличивать не получится, так как начнутся проблемы с сборкой мусора: запускается чаще и/или работает дольше, требуется его тюнинг. Для увеличения памяти необходимо остановить приложение. И, если не выдумывать свою собственную IMDG, то получится использовать только самую дорогую память — именно heap. Для БД можно сразу смело задать пространство побольше, тем более что оно самое дешевое. Для IMDG зависит от конкретного решения, но оно хотя бы оптимизировано для управления огромным количеством памяти, может использовать off-heap и даже persistence.
Stateless сервис, но stateful система
Второй вариант — сделать сам сервис stateless, а контекст хранить только в БД или IMDG. То есть вынести состояние за пределы сервиса, но оставить его в системе. Восстановление инстанса приложения после падения можно отдать кубернетесу или, да простят меня коллеги из сопровождения, просто оставить на плечах администраторов. Все-таки падение приложения — дело редкое, а пока одна нода лежит, остальные Х продолжают работать. Теперь мы сэкономили на памяти приложений и отмели потенциальные проблемы, если в какой-то момент времени количество активных транзакций станет чересчур много. Из минусов по сравнению с вариантом выше — дополнительные задержки на I/O по сравнению с обычным обращением в локальную память для вычитывания контекста при каждом новом сообщении.
Stateless система
А как бы сделать так, чтобы в принципе нам не пришлось хранить контекст у себя, но он полностью бы находился в самом поступившем сообщении? Полный ванильный stateless. Речь, конечно, не о том, чтобы вообще ничего не хранить о транзакциях, а именно об исключении хранения состояния в самой критичной ее части – бизнес-логики обработки транзакций. Если составить структуру данных таким образом, чтобы вся необходимая для продолжения транзакции информация содержалась в каждом сообщении, то вопрос, кажется, решен. Осталась одна проблема — мы не верим другой стороне на слово. Не то, чтобы мы не джентльмены, но все-таки. В этом случае может выручить цифровая подпись.
Схема такая:
получили сообщение;
обработали его;
полученные данные подписали;
составили ответное сообщение, куда положили полученный новый контекст транзакции и его подпись.
В протоколе взаимодействия указывается, что каждое сообщение обязано содержать весь контекст транзакции, каким он был на момент обработки предыдущего сообщения, а также подпись этого контекста, которая была получена в предыдущем сообщении. Таким образом следующее полученное нашим сервисом сообщение будет содержать: предыдущий контекст транзакции, его подпись, новые данные уже самого сообщения. При получении сервис заново вычисляет подпись от контекста и сверяет ее с тем, что передано в сообщении. Если совпадает, значит все корректно, и можно продолжать обработку сообщения. Если не совпадает, то значит вторая сторона пытается подменить контекст, транзакция отклоняется.
Расход памяти хотя и остается O(n), но теперь n — это транзакции, чьи сообщения обрабатываются конкретно в данный момент. В предыдущих же вариантах n означало все транзакции, которые еще не закончены, даже если по ним в данный момент нет обработки, а сервис в состоянии ожидания следующего сообщения. И если допустимые таймауты между сообщениями составляют минуты или часы, это очень серьезная проблема.
Для примера. Положим, нагрузка составляет 1000 TPS. Транзакция состоит из 4 входящих, 4 ответных сообщений и еще 2 дополнительных, которые не влияют на разбираемую ситуацию. Допустимый таймаут между двумя последующими входящими сообщениями — 1 минута.
В первом рассматриваемом варианте необходима память для хранения контекста 1000 TPS * 60 с * 3 минуты допустимых суммарных таймаутов = 180 000 транзакций. В текущем же решении нужна память только для 1000 транзакций, если предположить, что обработка каждого сообщения составляет не более одной секунды, а обычно, конечно, в разы меньше. Разница на два порядка.
Из минусов третьего подхода — увеличенный объем самих сообщений. В зависимости от выбранного стандарта сообщений или собственной разработки оверхед может составлять от процентов до двукратного увеличения объема. Но в большинстве случаев все-таки память является более ценным и уязвимым ресурсом, чем пропускная способность сетей. Также необходимо реализовать функциональность подписи и управления ключами. При этом на самом деле от подписи практически ничего не используется — открытые ключи не распространяются между участниками, так как они в принципе не нужны, ведь подпись не проверяется в смысле подписи. Проверяется только то, что новая подпись от переданного контекста совпадает с ранее сделанной подписью, а для этого нам вполне хватит только секретного ключа. И тем более не нужна функциональность полноценных сертификатов, центра выдачи сертификатов и всего того, что обычно используется для полноценного ассиметричного шифрования.
Все это наводит на мысли, что можно обойтись одним ключом, проще говоря симметричным шифрованием. Симметричное шифрование более экономное в ресурсах и проще в управлении, так как ключей в два раза меньше. Но возникает новая проблема - размер зашифрованных данных напрямую зависит от размера изначальных. А что, если вторая сторона тоже хотела бы контролировать неизменность контекста? Получается, что в каждом сообщении кроме непосредственно самих данных будет ходить еще по экземпляру зашифрованного контекста транзакции на каждую сторону, участвующую в проведении транзакции, кратно раздувая размер. Хорошо, если один или два участника захотят проверять неизменность данных. А если по каким-то причинам их десять?
К тому же протокол взаимодействия может сам по себе предполагать, что каждое сообщение является не исключительно инкрементом по отношению к предыдущему контексту транзакции, а содержит как минимум основные ее данные: ID, таймстэмпы, саму бизнес-информацию. В этом случае к экземплярам зашифрованного контекста добавляется еще и сам контекст в открытом виде, что выглядит уже совсем избыточным решением. В подходе с подписью мы хотя бы экономили на том, что подпись вне зависимости от размера контекста не превысит 512 или 1024 бит.
HMAC
И тут на выручку приходит HMAC – Hash-based Message Authentication Code, криптографическая контрольная сумма сообщения. Она сочетает в себе плюсы обоих подходов, описанных выше. Для вычисления используется один секретный ключ. При этом размер полученных данных ограничен размером данных хэш-функции выбранного алгоритма.
Если упрощенно, то сначала операция xor выполняется над ключом и константой, к полученному массиву байт конкатенируется целевое сообщение, от полученного берется хэш. Далее xor выполняется над ключом с другой константой, и теперь уже к этому результату конкатенируется вычисленный на предыдущем шаге хэш. От полученного снова берется хэш, который и дает конечный результат.
Пример реализации HMAC на алгоритме ГОСТ Р 34.11-2012
Если к системе не предъявляются серьезные требования по безопасности, требующие, например, аппаратного генератора (датчика) случайных чисел, то вполне можно обойтись SecureRandom для генерации секретных ключей и библиотекой BouncyCastle для использования самой реализации нужного алгоритма HMAC.
Минималистический абстрактный пример для понимания, что все не так сложно.
package ru.mirplatform.habr.test.hmac;
import org.bouncycastle.crypto.digests.GOST3411_2012_256Digest;
import org.bouncycastle.crypto.macs.HMac;
import org.bouncycastle.crypto.params.KeyParameter;
import java.security.SecureRandom;
import java.util.Base64;
public class HmacGOST {
public static void main(String[] args) {
byte[] input = ("У попа была собака, он её любил, она съела кусок мяса, он её убил, в землю закопал, " +
"и надпись написал о том, что у попа была собака, он её любил, она съела кусок мяса, он её убил, " +
"в землю закопал, и надпись написал о том, что у попа была собака, он её любил, она съела кусок " +
"мяса, он её убил, в землю закопал, и надпись написал о том, что...")
.getBytes();
byte[] mac = hmac(input);
System.out.println(String.format("HMAC size: %d, BASE64 HMAC: %s",
mac.length, Base64.getEncoder().encodeToString(mac)));
}
private static byte[] hmac(byte[] input) {
SecureRandom secureRandom = new SecureRandom();
byte[] key = new byte[32];
secureRandom.nextBytes(key);
HMac gMac = new HMac(new GOST3411_2012_256Digest());
KeyParameter keyParameter = new KeyParameter(key);
byte[] mac = new byte[gMac.getMacSize()];
gMac.init(keyParameter);
gMac.update(input, 0, input.length);
gMac.doFinal(mac, 0);
return mac;
}
}
В консоли можно увидеть следующее:
Заключение
Грамотное использование криптографии и, в частности HMAC, позволяет расширить количество возможных архитектурных решений при проектировании системы. Конечно, это не является панацеей, и надо оценивать плюсы и минусы использования возможностей криптографии, прежде чем ее внедрять.
Но как минимум рассмотренное решение выглядит вполне типовым для определенного рода задач и позволяет значительно упростить архитектуру и сэкономить на ресурсах.