В 26-м выпуске NP-полного подкаста я рассказывал, что начал переводить один из своих сервисов из Redis Sentinel на Redis Cluster. На этой неделе я захотел потестировать данный код, и, конечно же, выбрал Testcontainers для этого. К сожалению, Redis Cluster в тестовых контейнерах не завелся из коробки, и мне пришлось вставить несколько костылей. О них и пойдет речь далее.
Сначала я бы хотел описать все вводные, а потом рассказать про костыли. Мой проект построен на Spring Boot. Для взаимодействия с редисом используется Lettuce клиент. Для тестирования — testcontainers-java с JUnit. Версия обоих редисов — 6. В общем, всё типичное, нет ничего особенного с точки зрения стека.
Если кто-то еще не знаком с testcontainers, то пара слов о них. Это библиотека для интеграционного тестирования. Она построена на другой библиотеке — https://github.com/docker-java/docker-java. Тестконтейнеры, по сути говоря, помогают быстро и просто запускать контейнеры с разными зависимостями в ваших интеграционных тестах. Обычно это базы данных, очереди и другие сложные системы. Некоторые люди используют testcontainers и для запуска своих сервисов, от которых зависит тестируемое приложение (чтобы тестировать микросервисное взаимодействие).
Redis Cluster — это одна из нескольких реализаций распределнного режима Редиса. К сожалению, в Редисе нет единого правильного способа, как масштабировать базу. Есть Sentinel, есть Redis Cluster, а еще ребята активно разрабатывают RedisRaft — распредеделенный редис на базе протокола консенсуса Raft (у них там своя реализация, которая, как они сами заявляют, не совсем каноничный Рафт, но конкретно для Redis — самое то).
В целом, про Redis Cluster есть две замечательных статьи на официальном сайте — https://redis.io/topics/cluster-tutorial и https://redis.io/topics/cluster-spec. Большинство деталей описано там.
Для использования Redis Cluster в testcontainers важно знать несколько вещей из документации. Во-первых, Redis Cluster использует gossip протокол — поэтому каждый узел кластера имеет TCP-соединение со всеми другими узлами. Поэтому, между нодами должна быть сетевая связность, даже в тестах.
Вторая важная штука, которую надо знать при тестировании — это наличие в Redis Cluster bootstrap узлов для конфигурации. То есть, вы в настройках можете задать лишь подмножество узлов, которые будут использоваться для старта приложения. В последствие, Redis клиент сам получит Топологию кластера через взаимодействие с Редисом. Исходя из этого, получается вторая особенность — тестируемое приложение должно иметь сетевую связность с теми Redis URI, которые будут аннонсированы со стороны редис кластера (кстати, эти адреса можно сконфигурировать через cluster-announce-port и cluster-announce-ip).
Для тестирования я выбрал довольно популярный docker-образ — https://github.com/Grokzen/docker-redis-cluster. Он не подходит для продакшена, но очень прост в использовании в тестах. Особенность этого образа — все Редисы (а их 6 штук, по умолчанию — 3 мастера и 3 слейва) будут подняты в рамках одного контейнера. Поэтому, мы автоматически получаем сетевую связность между узлами кластера из коробки. Осталось решить вторую из двух проблем, связанную с получением приложением топологии кластера.
Я не хотел собирать свой docker-образ, а выбранный мной image не предоставляет возможности задавать настройки cluster-announce-port и cluster-announce-ip. Поэтому, если ничего не делать дополнительно, при запуске тестов вы увидите примерно такие ошибки:
Ошибка означает, что мы со стороны приложения пытаеся приконнектится к Узлу редис кластера, используя IP докер контейнера и внутренний порт (порт 7003 используется данным узлом, но наружу он отображается на какой-то случайный порт, который мы и должны использовать в нашем приложении; внутренний порт, по понятным причинам, не доступен из вне). Что касается данного IP-адреса — он доступен для приложения, если это Linux, и он не доступен для приложения, если это MacOs/Windows (из-за особенностей реализации докера на этих ОС).
Решение проблемы (а-ка костыль) я собрал по частичкам из разных статей. А давайте сделаем NAT RedisURI на стороне приложения. Ведь это нужно именно для тестов, и тут не так страшно вставлять такой ужас. Решение, на самом деле, состоит из пары строк (огромное спасибо Спрингу и Lettuce, где можно сконфигурировать практически всё, только и успевай, как переопределять бины).
Полный код выложен на гитхаб.
Идея кода супер простая. Будем хранить две Map. В первой — маппинг между внутренними портами редиса (7000..7005) и теми, что доступны для приложения (они могут быть чем-то типа 51343, 51344 и тд). Во-второй — внешние порты (типа, 51343) и SocketAddress, полученный для них. Теперь, когда мы получаем от Редиса при обновлении топологии что-то типа 172.17.0.3:7003, мы сможем легко найти нужный внешний порт, по которому сможем найти SocketAddress и переиспользовать его. То есть, с портами проблема решена. А что с IP?
С IP-адресом всё просто. Тут нам на помощь приходят Тест контейнеры в которых есть утилитный метод — DockerClientFactory.instance().dockerHostIpAddress(). Для MacOs/Windows он будет отдавать localhost, а для linux — IP-адрес контейнера.
Программирование — это супер интересно, но это вы и без меня знали. А ещё, порой приходится вспомнить, что было на первой лекции по сетям в универе, чтобы написать на своей любимой джавке пару новых интеграционных тестов. Приятно, когда знания из института пригождаются в самый неожиданный момент.
Вводные
Сначала я бы хотел описать все вводные, а потом рассказать про костыли. Мой проект построен на Spring Boot. Для взаимодействия с редисом используется Lettuce клиент. Для тестирования — testcontainers-java с JUnit. Версия обоих редисов — 6. В общем, всё типичное, нет ничего особенного с точки зрения стека.
Если кто-то еще не знаком с testcontainers, то пара слов о них. Это библиотека для интеграционного тестирования. Она построена на другой библиотеке — https://github.com/docker-java/docker-java. Тестконтейнеры, по сути говоря, помогают быстро и просто запускать контейнеры с разными зависимостями в ваших интеграционных тестах. Обычно это базы данных, очереди и другие сложные системы. Некоторые люди используют testcontainers и для запуска своих сервисов, от которых зависит тестируемое приложение (чтобы тестировать микросервисное взаимодействие).
Про Redis Cluster
Redis Cluster — это одна из нескольких реализаций распределнного режима Редиса. К сожалению, в Редисе нет единого правильного способа, как масштабировать базу. Есть Sentinel, есть Redis Cluster, а еще ребята активно разрабатывают RedisRaft — распредеделенный редис на базе протокола консенсуса Raft (у них там своя реализация, которая, как они сами заявляют, не совсем каноничный Рафт, но конкретно для Redis — самое то).
В целом, про Redis Cluster есть две замечательных статьи на официальном сайте — https://redis.io/topics/cluster-tutorial и https://redis.io/topics/cluster-spec. Большинство деталей описано там.
Для использования Redis Cluster в testcontainers важно знать несколько вещей из документации. Во-первых, Redis Cluster использует gossip протокол — поэтому каждый узел кластера имеет TCP-соединение со всеми другими узлами. Поэтому, между нодами должна быть сетевая связность, даже в тестах.
Вторая важная штука, которую надо знать при тестировании — это наличие в Redis Cluster bootstrap узлов для конфигурации. То есть, вы в настройках можете задать лишь подмножество узлов, которые будут использоваться для старта приложения. В последствие, Redis клиент сам получит Топологию кластера через взаимодействие с Редисом. Исходя из этого, получается вторая особенность — тестируемое приложение должно иметь сетевую связность с теми Redis URI, которые будут аннонсированы со стороны редис кластера (кстати, эти адреса можно сконфигурировать через cluster-announce-port и cluster-announce-ip).
Про костыли с Redis Cluster и testcontainers
Для тестирования я выбрал довольно популярный docker-образ — https://github.com/Grokzen/docker-redis-cluster. Он не подходит для продакшена, но очень прост в использовании в тестах. Особенность этого образа — все Редисы (а их 6 штук, по умолчанию — 3 мастера и 3 слейва) будут подняты в рамках одного контейнера. Поэтому, мы автоматически получаем сетевую связность между узлами кластера из коробки. Осталось решить вторую из двух проблем, связанную с получением приложением топологии кластера.
Я не хотел собирать свой docker-образ, а выбранный мной image не предоставляет возможности задавать настройки cluster-announce-port и cluster-announce-ip. Поэтому, если ничего не делать дополнительно, при запуске тестов вы увидите примерно такие ошибки:
Unable to connect to [172.17.0.3/<unresolved>:7003]: connection timed out: /172.17.0.3:7003
Ошибка означает, что мы со стороны приложения пытаеся приконнектится к Узлу редис кластера, используя IP докер контейнера и внутренний порт (порт 7003 используется данным узлом, но наружу он отображается на какой-то случайный порт, который мы и должны использовать в нашем приложении; внутренний порт, по понятным причинам, не доступен из вне). Что касается данного IP-адреса — он доступен для приложения, если это Linux, и он не доступен для приложения, если это MacOs/Windows (из-за особенностей реализации докера на этих ОС).
Решение проблемы (а-ка костыль) я собрал по частичкам из разных статей. А давайте сделаем NAT RedisURI на стороне приложения. Ведь это нужно именно для тестов, и тут не так страшно вставлять такой ужас. Решение, на самом деле, состоит из пары строк (огромное спасибо Спрингу и Lettuce, где можно сконфигурировать практически всё, только и успевай, как переопределять бины).
public SocketAddress resolve(RedisURI redisURI) {
Integer mappedPort = redisClusterNatPortMapping.get(redisURI.getPort());
if (mappedPort != null) {
SocketAddress socketAddress = redisClusterSocketAddresses.get(mappedPort);
if (socketAddress != null) {
return socketAddress;
}
redisURI.setPort(mappedPort);
}
redisURI.setHost(DockerClientFactory.instance().dockerHostIpAddress());
SocketAddress socketAddress = super.resolve(redisURI);
redisClusterSocketAddresses.putIfAbsent(redisURI.getPort(), socketAddress);
return socketAddress;
}
Полный код выложен на гитхаб.
Идея кода супер простая. Будем хранить две Map. В первой — маппинг между внутренними портами редиса (7000..7005) и теми, что доступны для приложения (они могут быть чем-то типа 51343, 51344 и тд). Во-второй — внешние порты (типа, 51343) и SocketAddress, полученный для них. Теперь, когда мы получаем от Редиса при обновлении топологии что-то типа 172.17.0.3:7003, мы сможем легко найти нужный внешний порт, по которому сможем найти SocketAddress и переиспользовать его. То есть, с портами проблема решена. А что с IP?
С IP-адресом всё просто. Тут нам на помощь приходят Тест контейнеры в которых есть утилитный метод — DockerClientFactory.instance().dockerHostIpAddress(). Для MacOs/Windows он будет отдавать localhost, а для linux — IP-адрес контейнера.
Выводы
Программирование — это супер интересно, но это вы и без меня знали. А ещё, порой приходится вспомнить, что было на первой лекции по сетям в универе, чтобы написать на своей любимой джавке пару новых интеграционных тестов. Приятно, когда знания из института пригождаются в самый неожиданный момент.