Привет, Хабр!
Начну с того, что немного уточню, о каких именно устройствах пойдёт речь. Ни для кого не секрет, что для организации мобильной связи используются базовые станции, на которых стоит много разного электрооборудования. А значит, за энергопотреблением надо следить, отчитываться и оплачивать его. Естественно, всё это логично делать удалённо, для чего на базовых станциях установлены специальные устройства сбора и передачи данных (далее УСПД).
Основная задача УСПД — это опрос подключённого к нему оборудования (электросчётчиков, резервных генераторов и других устройств, необходимых для работы базовых станций) с последующей передачей собранных данных на серверы МегаФона, где в дальнейшем они используются для формирования отчётности, анализа и управления работой базовых станций. По сути, это классическая IoT-система.
Речь пойдёт как раз о перенастройке УСПД.
Что именно и зачем перенастраивать?
Как водится, перенастройкой работающей системы никто просто так не занимается — она понадобилась для оптимизации процесса получения данных от УСПД. Всё, что нужно было сделать, это:
изменить адрес сервера, на который должны отправляться данные;
изменить параметры NTP-сервера для синхронизации времени;
немного изменить скрипт, осуществляющий отправку данных.
Вариантов сделать эти нехитрые действия не так много:
Вариант 1 — поехать на базовую станцию, подключиться к УСПД с помощью ноутбука, изменить настройки. Понятное дело, что это долго и дорого, потому как базовых станций у МегаФона немало.
Вариант 2 — подключиться удалённо по SSH, изменить соответствующие файлы конфигурации и перезапустить соответствующие службы. Это явно более приемлемый вариант, но, как обычно, есть нюансы:
доступ до устройств есть только с нескольких серверов в DMZ (безопасность превыше всего), ну и на этих серверах по сути нет ничего, кроме ОС и прокси для перенаправления и фильтрации трафика;
устройств много, поэтому вручную подключаться к каждому и перенастраивать всё ещё долго.
При этом вполне логично, что такой процесс можно автоматизировать — собственно, эта задача и прилетела на разработку. Так как основной язык в команде — это Java, и уже имелся определённый опыт написания SSH-клиентов, было решено написать небольшую консольную утилиту для перенастройки УСПД на Java. А для того чтобы запускать её на серверах в DMZ, где нет ни Java, ни Docker и установить их нельзя, решили поэкспериментировать с GraalVM и native executable.
Подход первый — Spring Boot + JSCH
Какие изначально были требования к приложению?
подключиться к УСПД по SSH;
выполнить порядка 10 строго определённых shell-команд;
отключиться.
Для начала просто пробуем собрать приложение, которое будет подключаться по SSH, и сделать из него native executable. Первый подход был с использованием Spring Boot + JSCH. Это уже проверенное комбо, да и основной стек — это как раз Java + Spring. Но на момент этих экспериментов (примерно год назад) Spring ещё не сильно дружил с GraalVM, и, потратив примерно день на безуспешные попытки скомпилировать нативное приложение, было решено переключиться на другие варианты.
Подход второй — Quarkus
После изучения интернета обнаружилось, что Quarkus достаточно просто подружить с GraalVM. К тому же для Quarkus уже имеется адаптированная версия JSCH. Плюс есть прекрасная библиотека picocli для создания консольных приложений. Делаем наш MVP и получаем что-то вроде:
public class SshShellExecutor implements Runnable {
public static void main(String[] args) {
int exitCode = new CommandLine(new SshShellExecutor()).execute(args);
System.exit(exitCode);
}
@Override
public void run() {
Session session = null;
ChannelExec channel = null;
try {
session = new JSch().getSession(sshUser, host, port);
session.setSocketFactory(socketFactory);
session.connect();
channel = (ChannelExec) session.openChannel("exec");
// тут выполняем какую-нибудь безобидную команду вроде date
} catch (Exception e) {
// тут как-то обрабатываем исключения
} finally {
if (channel != null) {
channel.disconnect(); // закрываем канал
}
if (session != null) {
session.disconnect(); // закрываем сессию
}
}
}
}
И пробуем собрать из этого наше нативное приложение. Для сборки используем образ ghcr.io/graalvm/native-image-community:21-muslib
Собираем следующим образом:
./mvnw clean install -Dnative -Dquarkus.native.additional-build-args="--static","--libc=musl","-march=compatibility"
При сборке передаём несколько дополнительных параметров:
--static — чтобы собрать полностью независимое от компонентов ОС приложение;
--libc=musl — чтобы отработал предыдущий параметр --static;
-march=compatibility — потому что мы точно не знаем, какая архитектура будет у целевой машины.
Теперь всё собирается и запускается — успех!
Подход третий - докручиваем JSCH
После того как мы убедились, что наш MVP работает, начинаем докручивать JSCH для реальных условий:
надо получать список хостов для подключения;
нужен список кредов для этих хостов, а также прочие параметры ключей;
необходимо иметь возможность указать local bind address.
Для этого реализуем следующий набор опций для нашего приложения:
| Порт, используемый для подключения через SSH. По умолчанию 22 |
| SSH host key algorithm |
| SSH kex value |
| Таймаут в мс (по умолчанию 20000) |
| SSH local bind address |
Для получения списка хостов будем использовать обычные текстовые файлы, что-то наподобие csv (на этом останавливаться не буду).
Благодаря picocli получение этих параметров командной строки мы можем реализовать всего одной аннотацией:
@Option(names = {"-b", "--bind-address"}, description = "local bind address")
// тут значения по умолчанию нет, соответственно, если параметр не передан socket binding, не используем
private String bindAddress;
@Option(names = {"-t", "--timeout"}, description = "SSH timeout")
private int timeout = 20000; // тут сразу укажем значение по умолчанию
```
А для конфигурирования JSCH делаем свою простенькую реализацию для com.jcraft.jsch.SocketFactory
public class CustomSocketFactory implements SocketFactory {
private final String bindAddress;
private final int timeout;
public CustomSocketFactory(String bindAddress, int timeout) {
this.bindAddress = bindAddress;
this.timeout = timeout;
}
@Override
public Socket createSocket(String host, int port) throws IOException {
Socket socket = new Socket();
if (bindAddress != null) {
socket.bind(new InetSocketAddress(bindAddress, 0));
}
socket.connect(new InetSocketAddress(host, port), timeout);
return socket;
}
@Override
public InputStream getInputStream(Socket socket) throws IOException {
return socket.getInputStream();
}
@Override
public OutputStream getOutputStream(Socket socket) throws IOException {
return socket.getOutputStream();
}
}
Параметры kex и server host key закидываем в объект Properties
и используем при установке соединения:
Properties config = new Properties();
config.put("kex", kex);
config.put("server_host_key", serverHostKey);
session = new JSch().getSession(sshUser, host, port);
session.setSocketFactory(socketFactory);
if (bindAddress != null) {
session.setPortForwardingL(0, bindAddress, port);
}
session.setConfig(config);
session.connect();
Теперь с SSH закончили, и, казалось бы, осталось просто захардкодить нужные команды и вперёд — можно перенастраивать наши УСПД. Но всплыл очередной нюанс.
Подход четвертый - неожиданные нюансы
Казалось бы - 10 команд. Что сложного? Кладём их в стек, достаём по одной и выполняем по образцу из документации к JSCH. Однако в процессе выяснилось, что при настройке вручную инженеры используют, в том числе, утилиту cat
, и часть команд, которые надо выполнить, выглядят следующим образом:
cat > some.config
>Some
>multiline
>settings
>go here
>to save press ctrl-d
Как такое можно повторить средствами JSCH, было непонятно. Но после некоторых изысканий и экспериментов оказалось, что можно заменить cat
на echo
, сохранив переносы строк, и привести это к команде вида:
String command = """
echo \
'Some
multiline
settings
go here
' \
> some.config""";
Тут как нельзя лучше подошли текстовые блоки, чтобы сохранить и переносы строк, и читаемость команд, благо мы использовали Java 21 для нашего проекта.
Теперь все наши команды для перенастройки скорректированы и захардкожены, подключение по SSH полностью конфигурируемое. Можно проверять. Для проверки была отобрана небольшая партия устройств. Запускаем приложение: всё работает, устройства переключаются, но медленно, и на перенастройку всех устройств уйдёт всё равно слишком много времени.
Подход пятый - многопоточность
Оказалось, что не все УСПД работают одинаково быстро. Где-то довольно старые УСПД, которые достаточно долго обрабатывают команды, из-за чего пришлось добавить задержку в 300 мс между выполнениями команд. Где-то ещё не заменили старые SIM-карты, которые работают только по 2G, на более новые. Поэтому на перенастройку некоторых УСПД могло уходить до нескольких минут. В общем, проанализировав логи работы приложения, выяснилось, что довольно много времени уходит просто на ожидание ответов от устройств.
Чтобы ускориться, первое, что приходит в голову, — распараллеливание работы с устройствами. Ну и раз уж у нас в проекте выбрана Java 21, отчего не поэкспериментировать с новыми виртуальными потоками? Ведь из-за задержек между отправками команд мы довольно много времени просто простаиваем, и тут виртуальные потоки должны нам помочь.
Добавляем нашему приложению новую опцию:
@Option(names = {"-m", "--multi-thread"}, description = "Запускать в многопоточном режиме")
private boolean multithreaded;
И оборачиваем нашу логику в потоки:
try (BufferedReader br = new BufferedReader(new FileReader(targets))) {
// targets - это файл с адресами хостов
String target;
final ThreadFactory factory = Thread.ofVirtual().factory();
try (ExecutorService service = multithreaded
// если передан параметр многопоточности, создаем пул с числом потоков в 2 раза больше системных
// чтобы с одной стороны задействовать виртуальные потоки, а с другой не перегружать систему
? Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2, factory)
// в противном случае пул с одним потоком
: Executors.newSingleThreadExecutor()) {
while ((target = br.readLine()) != null) {
// запускаем подключение к хосту и выполнение команд в отдельном потоке для каждого хоста
service.execute(() -> sshAndRunCommands(target));
}
}
} catch (IOException e) {
// тут как-то обрабатываем исключение
}
Добавленная возможность запуска в многопоточном режиме позволила на порядок ускорить работу приложения и, соответственно, процесс конфигурирования УСПД. Как итог — тысячи устройств были успешно перенастроены за несколько часов и исключена возможность ошибок при вводе команд, как это может быть при ручном переключении.