Kotlin вместо bash. Прокачиваем автоматизацию на сервере
Для решения задач автоматизации рутинных процессов для системных администраторов и DevOps (которые, кроме всего прочего, нередко занимаются созданием сборочных скриптов, которые могут не только подготовить базовую среду выполнения, но и могут взаимодействовать с другими системами для обеспечения полного цикла CI/CD) чаще всего используются или bash-сценарии (zsh, ash или язык любой другой оболочки) или python. Первое решение косвенно используется и в описании Dockerfile, поскольку сценарий исполняемых команд принципиально ничем не отличается от запуска скрипта в какой-либо shell, второй подход чаще ассоциируется с автоматизацией, связанных с взаимодействием с хранилищами данных — например, для создания учетных записей в LDAP или базе данных, отправки уведомлений и тд.
Но несправедливо было бы обойти стороной возможность создания исполняемых сценариев на языке Kotlin, которые могут стать полноценной заменой bash-сценариям и могут использовать не только в сочетании с Gradle, но и как самостоятельные решения автоматизации. В этой статье мы рассмотрим несколько примеров использования Kotlin Scripting (KTS) для автоматизации в распределенной системе, будем использовать долгоживущие скрипты с ожиданием заданий через RabbitMQ, а также поработаем с файловой системой, внешними сервисами, а также попробуем использовать KTS для сборки Docker-контейнеров.
Прежде всего нужно отметить, что Kotlin Scripting (далее KTS) — не новая технология и она достаточно давно используется для описания сценария сборки приложений с использованием gradle (они могут быть созданы как для мобильной платформы, так и для бэкэнда, при этом исходный текст может быть написан на любой технологии, под которую есть поддержка в gradle, в том числе Java, Groovy, Scala и даже Python с проектов pygradle). При этом она может использоваться и без gradle и запускаться через консольный вариант компилятора Kotlin. Начнем с установки компилятора (например, через brew):
brew install kotlin
Сам файл сценария является обычным исходным текстом на Kotlin, но с несколькими важными дополнениями:
- поскольку KTS является самодостаточным и не использует систему сборки для подготовки к выполнению, то все зависимости указывается через аннотации
@file:DependsOn("…")
, в этом случае для запуска должен использоватьсяkscript
. - код сценария выполняется сразу (и напоминает режим REPL), при этом мы можем использовать определения функций, классов, задействовать объекты классов любых подключенных зависимостей
- как и в обычном Kotlin-приложении можно использовать корутины (если подключить зависимость
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1
- сценарий может быть быть интегрирован в существующее Kotlin-приложение (через запуск
ScriptingHost
и использование методаeval
для выполнения кода). - KTS-сценарий может использовать все возможности библиотек по взаимодействию с базами данных, другими серверами (например, LDAP через JNDI или вспомогательные библиотеки), а также встроенные возможности по манипуляции объектами файловой системы из java.nio.
Создадим простой KTS и попробуем его запустить, как обычно запускается bash-сценарий.
import java.io.* val username = System.getProperty("user.name") println("Hello $username") val home = System.getProperty("user.dir") println("Home dir is $home") val profile = File(home+"/.profile") println("Profile file is exists ${profile.exists()}") if (profile.exists()) { println("Total lines is ${profile.readLines().size}") }
Этот сценарий использует возможности доступа к файлам (проверка наличия, чтение содержимого) и извлечения информации об пользователе, запустившем java-процесс. Для запуска сценария используем консольную утилиту kotlinc
:
kotlinc -script test.kts
Но можно использовать и более привычный для сценариев синтаксис, для этого добавим в первую строку инструкцию для запуска (shebang):
#!/usr/bin/env -S kotlinc -script
Теперь файл может сделать исполняемым и запустить как любое другое приложение:
chmod +x test.kts ./test.kts
Аналогично могут быть выполнены другие операции с файловой системой (создание и удаление каталогов и файлов, копирование и перемещение файлов), при этом если запускать процесс от имени другого пользователя (например, с использованием SUID-флага и заменой владельца), то сценарий может получить доступ на модификацию и к каталогам за пределами домашнего каталога пользователя и /tmp. Рассмотрим более сложный случай, когда внутри сценария может быть выполнен какой-либо внешний процесс, для этого можно использовать класс Runtime.
val runtime = Runtime.getRuntime() val result = runtime.exec("ls -1 $home") val r = String(result.inputStream.readAllBytes(), Charsets.UTF_8).split("\n") println("Execution result: $r")
Из result также можно получить errorStream
для чтения из потока ошибок и outputStream
для отправки данных в стандартный поток ввода (stdin) в запущенном приложении.
Далее попробуем извлечь аргументы командной строки в сценарии, они будут доступны через предопределенную переменную args в тексте сценария (обратите внимание, что первый аргумент доступен по индексу 0, а не 1 как было бы в bash):
val filename = args[0] println("File $filename contains ${File(filename).readLines().size}")
Важно, что для взаимодействия с системными службами нам необязательно запускать внешние команды и, например, для управления Docker-контейнерами можно использовать Java/Kotlin реализацию API. Также можно взаимодействовать с графическим интерфейсом (например, отправлять уведомления) и управлять системными службами (systemd) через D-Bus (например, можно использовать этот API).
Аналогично могут быть использованы драйверы JDBC и JNDI для выполнения миграций базы данных (например, можно применить Flyway), управления каталогом учетных данных на основе LDAP (например, LDAPtive), взаимодействие с API (например, через okhttp или Ktor Client), а также можно использовать интерактивный режим через вызов readln() или иные формы работы с потоками ввода-вывода (можно даже использовать изменение цвета отображаемого в консоли текста через эту библиотеку).
Для выполнения сценариев внутри Docker можно использовать образ с предустановленным kscript (может также использоваться на основной системе):
docker run -i kscripting/kscript - < script.kts
Наиболее интересным выглядит сценарий фонового выполнения длительного задания, например подписки на задания из очереди сообщений RabbitMQ. Такие задачи разумно запускать в отдельном треде (через ThreadPoolExecutor) или использовать корутины.
val workerPool: ExecutorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) workerPool.submit { //код задачи }
Для корутин можно сделать простую реализацию метода launch:
@file:Repository("https://jcenter.bintray.com") @file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") import java.io.* import kotlin.coroutines.Continuation import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.intrinsics.createCoroutineUnintercepted import kotlinx.coroutines.delay fun launch(block: suspend () -> Unit) { val callback = object : Continuation<Unit> { override val context: CoroutineContext = EmptyCoroutineContext override fun resumeWith(result: Result<Unit>) {} } block.createCoroutineUnintercepted(callback).resumeWith(Result.success(Unit)) } launch { delay(1000) println("From coroutine") }
Тут можно будет заметить проблему, что корутина не будет ожидать завершения и при завершении выполнения основного кода в kts и здесь можно использовать встроенный способ запуска runBlocking
:
@file:Repository("https://jcenter.bintray.com") @file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking runBlocking { delay(10) println("From coroutine") }
Создадим скрипт для выполнения задач на сервере, которые поступают через очередь сообщений (в этом случае RabbitMQ), для этого будем завершать корутину когда consumer отключается:
@file:Repository("https://jcenter.bintray.com") @file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") @file:DependsOn("com.rabbitmq:amqp-client:5.9.0") import java.io.* import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlin.coroutines.suspendCoroutine runBlocking { suspendCoroutine<Unit> { coroutine -> val factory = ConnectionFactory() val connection = factory.newConnection("amqp://guest:guest@localhost:5672/") val channel = connection.createChannel() channel.queueDeclare("tasks") println("Waiting for messages...") val deliverCallback = DeliverCallback { consumerTag: String?, delivery: Delivery -> val message = String(delivery.body, StandardCharsets.UTF_8) //обработка задания из сообщения } val cancelCallback = CancelCallback { consumerTag: String? -> println("[$consumerTag] was canceled") coroutine.resumeWith() } channel.basicConsume(QUEUE_NAME, true, "worker", deliverCallback, cancelCallback) } }
Для запуска миграций или автоматизации внутри Dockerfile также можно использовать KTS:
FROM kscripting/kscript ADD migrate.kts /tmp kscript /tmp/migrate.kts
Мы рассмотрели несколько простых сценариев использования KTS для автоматизации задач на сервере, которые также могут быть интегрированы в CI/CD и позволяет выполнять сложные задачи по манипуляции с файлами и объектами операционной системы, применения миграции, управления внешними системами, при этом остаются все возможности языка Kotlin и любых подключаемых библиотек, совместимых с JVM.