Как стать автором
Поиск
Написать публикацию
Обновить

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

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров1.1K

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

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

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

Добавленная возможность запуска в многопоточном режиме позволила на порядок ускорить работу приложения и, соответственно, процесс конфигурирования УСПД. Как итог — тысячи устройств были успешно перенастроены за несколько часов и исключена возможность ошибок при вводе команд, как это может быть при ручном переключении.

Теги:
Хабы:
+6
Комментарии10

Публикации

Информация

Сайт
job.megafon.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия