Перенастроить тысячи удаленных устройств — Java, SSH, Native executable

от автора

Привет, Хабр!

Начну с того, что немного уточню, о каких именно устройствах пойдёт речь. Ни для кого не секрет, что для организации мобильной связи используются базовые станции, на которых стоит много разного электрооборудования. А значит, за энергопотреблением надо следить, отчитываться и оплачивать его. Естественно, всё это логично делать удалённо, для чего на базовых станциях установлены специальные устройства сбора и передачи данных (далее УСПД).

Основная задача УСПД — это опрос подключённого к нему оборудования (электросчётчиков, резервных генераторов и других устройств, необходимых для работы базовых станций) с последующей передачей собранных данных на серверы МегаФона, где в дальнейшем они используются для формирования отчётности, анализа и управления работой базовых станций. По сути, это классическая 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.

Для этого реализуем следующий набор опций для нашего приложения:

-p, --port

Порт, используемый для подключения через SSH. По умолчанию 22

-k, --server-host-key

SSH host key algorithm

-x, --kex

SSH kex value

-t, --timeout

Таймаут в мс (по умолчанию 20000)

-b, --bind-address

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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *