Привет, Хабр!
Начну с того, что немного уточню, о каких именно устройствах пойдёт речь. Ни для кого не секрет, что для организации мобильной связи используются базовые станции, на которых стоит много разного электрооборудования. А значит, за энергопотреблением надо следить, отчитываться и оплачивать его. Естественно, всё это логично делать удалённо, для чего на базовых станциях установлены специальные устройства сбора и передачи данных (далее УСПД).
Основная задача УСПД — это опрос подключённого к нему оборудования (электросчётчиков, резервных генераторов и других устройств, необходимых для работы базовых станций) с последующей передачей собранных данных на серверы МегаФона, где в дальнейшем они используются для формирования отчётности, анализа и управления работой базовых станций. По сути, это классическая 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) { // тут как-то обрабатываем исключение }
Добавленная возможность запуска в многопоточном режиме позволила на порядок ускорить работу приложения и, соответственно, процесс конфигурирования УСПД. Как итог — тысячи устройств были успешно перенастроены за несколько часов и исключена возможность ошибок при вводе команд, как это может быть при ручном переключении.
ссылка на оригинал статьи https://habr.com/ru/articles/929832/
Добавить комментарий