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

Сегодня я расскажу, как создать кастомный контроллер для Kubernetes на Rust. Кастомные контроллеры нужны, чтобы автоматизировать действия, которые Kubernetes сам по себе не умеет. Например, представим, что вы хотите:

  • Динамически создавать ресурсы на основе пользовательских запросов.

  • Управлять сторонними сервисами, которые не понимают Kubernetes.

  • Делать всё это без лишних рук и прочих YAML‑файлов.

Сначала вы определяете свой CRD (Custom Resource Definition), который описывает, как выглядят данные. Затем пишете контроллер, который читает эти данные и выполняет соответствующие действия. В итоге получаете систему, которая работает автономно.

Почему Rust?

Почему не Go, ведь он де‑факто стандарт для Kubernetes? Go — хороший инструмент, но Rust лучше, когда дело доходит до:

  1. Безопасности: никаких гонок данных или утечек памяти.

  2. Производительности: Rust компилируется в машинный код и летает.

  3. Компактности: бинарники маленькие.

  4. Код на Rust просто красивый.

Основы кастомного контроллера

Прежде, чем написать хоть строчку кода, нужно понять, как вообще работают кастомные контроллеры в Kubernetes.

CRD

CRD — это расширение Kubernetes API, которое позволяет создавать собственные типы объектов. Если стандартных сущностей недостаточно, то нужно использовать CRD:

Когда создаёте CRD, вы описываете:

  • API‑версию: например, v1 — версия вашего ресурса.

  • Группу: уникальное имя для вашей сущности, например, pizzeria.example.com.

  • Имя сущности: как Kubernetes будет понимать ваш объект (например, PizzaOrder).

  • Спецификацию: поля, которые описывают ваш объект (например, размер пиццы, начинка).

CRD может выглядеть так:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: pizzaorders.pizzeria.example.com
spec:
  group: pizzeria.example.com
  names:
    kind: PizzaOrder
    plural: pizzaorders
    singular: pizzaorder
  scope: Namespaced
  versions:
  - name: v1
    served: true
    storage: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              size:
                type: string
              toppings:
                type: array
                items:
                  type: string

После применения такого CRD кластер начинает понимать новый тип объекта PizzaOrder. Теперь можно создавать такие ресурсы, как:

apiVersion: pizzeria.example.com/v1
kind: PizzaOrder
metadata:
  name: order-123
spec:
  size: large
  toppings:
    - cheese
    - pepperoni

CRD — это новый API‑тип, который становится доступным через стандартные инструменты Kubernetes.

Контроллер

Если CRD — это описание ресурса, то контроллер — это та часть, которая оживляет объекты. Контроллер — это программа, которая работает так:

  1. Следит за объектами вашего CRD: он получает уведомления о каждом изменении в ваших PizzaOrder (создание, обновление, удаление).

  2. Реагирует на изменения: на основе данных из объекта контроллер выполняет действия, которые приводят систему в желаемое состояние. Например, создаёт Pod для приготовления бутерброда.

  3. Постоянно проверяет состояние: если что‑то пошло не так, он возвращается к объекту и пытается исправить проблему.

Проще говоря, контроллер — это цикл:

  1. Узнаёт о новом или изменённом объекте.

  2. Проверяет текущее состояние объекта.

  3. Делает действия для достижения «идеального состояния».

  4. Повторяет цикл до бесконечности.

Контроллеры работают асинхронно, используя принципы управления событиями. Это позволяет обрабатывать тысячи объектов одновременно.

Kubernetes API

Kubernetes API — это основной механизм взаимодействия с кластером. Это HTTP‑интерфейс, через который можно:

  • Получать список объектов (например, все заказы пиццы в кластере).

  • Следить за изменениями объектов в реальном времени (streaming через watch).

  • Создавать, обновлять и удалять объекты.

Контроллер подключается к API‑серверу Kubernetes через клиентскую библиотеку. В случае Rust это kube-rs. Она упрощает работу с Kubernetes API, предоставляя инструменты для:

  • Создания клиента.

  • Отслеживания событий.

  • Управления ресурсами.

Как это работает на практике?

  1. Контроллер подписывается на события через API (например, изменения в PizzaOrder).

  2. Когда пользователь создаёт новый объект PizzaOrder, API‑сервер уведомляет контроллер.

  3. Контроллер обрабатывает данные и выполняет логику, например, создаёт Pod для приготовления пиццы.

Как всё это соединяется?

  1. Вы создаёте CRD, чтобы Kubernetes понял ваш новый тип объекта.

  2. Пользователи создают объекты вашего типа.

  3. Контроллер следит за этими объектами через Kubernetes API.

  4. На основе данных из объектов контроллер выполняет действия, приводя систему в желаемое состояние.

Важные моменты:

  1. Идём на события, а не на опрос. Контроллеры не опрашивают API каждые N секунд. Они подписываются на события, чтобы реагировать мгновенно.

  2. Идём к консистентности. Если состояние объекта неожиданно изменилось, контроллер всё равно пытается вернуть его в желаемое состояние.

  3. Не блокируйте основной поток. Контроллеры должны быть максимально асинхронными, чтобы не тормозить работу системы.

  4. Не паникуйте. Даже если контроллер «упал», ничего страшного не произойдёт. Kubernetes продолжит работу, и вы сможете перезапустить контроллер.

  5. Следите за расходом ресурсов. Контроллеры могут «съесть» CPU и память, если не настроены должным образом. Используйте ограничения ресурсов в манифестах Deployment.

Начнем реализацию кастомного контроллера Kubernetes на Rust

Для начала создадим проект Rust, который станет основой для нашего контроллера. Выполните следующие команды:

cargo new pizza-controller
cd pizza-controller

Это создаст стандартный проект Rust с базовой структурой файлов.

Теперь обновим Cargo.toml, чтобы подключить необходимые зависимости:

[dependencies]
kube = "0.80" # Библиотека для взаимодействия с Kubernetes API
tokio = { version = "1", features = ["full"] } # Асинхронный рантайм для обработки событий
serde = { version = "1.0", features = ["derive"] } # Для сериализации и десериализации данных
tracing = "0.1" # Для структурированных логов

Запустим команду cargo build, чтобы убедиться, что зависимости успешно подтянулись и проект компилируется.

Важная часть любого кастомного контроллера — это CRD. В нашем случае это будет PizzaOrder, в котором пользователь сможет указать размер пиццы и список топпингов.

Открываем файл src/main.rs и добавляем следующий код:

use kube::CustomResource;
use serde::{Deserialize, Serialize};

#[derive(CustomResource, Serialize, Deserialize, Clone, Debug)]
#[kube(
    group = "pizzeria.example.com", // Группа API для CRD
    version = "v1",                // Версия API
    kind = "PizzaOrder",           // Имя ресурса
    namespaced                   // Привязка к пространству имён
)]
pub struct PizzaOrderSpec {
    pub size: String,             // Размер пиццы (small, medium, large)
    pub toppings: Vec<String>,    // Список топпингов
}

Этот код использует атрибуты из библиотеки kube, чтобы автоматом сгенерировать структуру CRD.

Теперь сгенерируем YAML для описания CRD:

cargo run --example crd-gen > pizzaorder-crd.yaml
kubectl apply -f pizzaorder-crd.yaml

После этого Kubernetes начнёт понимать новый тип ресурса — PizzaOrder.

Пример YAML‑файла, который описывает заказ пиццы:

apiVersion: "pizzeria.example.com/v1"
kind: PizzaOrder
metadata:
  name: order-123
spec:
  size: large
  toppings:
    - cheese
    - pepperoni

Можно применить его через kubectl:

kubectl apply -f pizza-order.yaml

Теперь создадим логику, которая будет обрабатывать объекты PizzaOrder. Контроллер будет отслеживать заказы и выполнять действия в зависимости от их состояния.

Обновляем src/main.rs, чтобы он выглядел так:

use kube::{
    api::{Api, PostParams},
    runtime::controller::{Controller, ReconcilerAction},
    Client,
};
use std::sync::Arc;
use tokio::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Инициализируем клиента для Kubernetes API
    let client = Client::try_default().await?;
    let orders: Api<PizzaOrder> = Api::all(client);

    // Логика контроллера
    let reconciler = |order: Arc<PizzaOrder>, _| async move {
        println!("Обрабатываем заказ: {:?}", order.spec);

        // Пример логики обработки заказа
        if order.spec.size == "large" {
            println!("Готовим большую пиццу с топпингами: {:?}", order.spec.toppings);
        } else {
            println!("Готовим пиццу стандартного размера: {:?}", order.spec.size);
        }

        Ok(ReconcilerAction {
            requeue_after: Some(Duration::from_secs(300)), // Перезапустить через 5 минут
        })
    };

    // Обработка ошибок
    let error_handler = |error: &str, _| {
        eprintln!("Ошибка: {:?}", error);
        ReconcilerAction {
            requeue_after: Some(Duration::from_secs(60)), // Перезапустить через минуту
        }
    };

    // Запуск контроллера
    Controller::new(orders.clone(), Default::default())
        .run(reconciler, error_handler, ())
        .await;

    Ok(())
}

Теперь нужно упаковать контроллер в Docker‑контейнер и развернуть его в Kubernetes.

Создаем Dockerfile:

FROM rust:1.70-slim
WORKDIR /app
COPY target/release/pizza-controller /app/
CMD ["./pizza-controller"]

Собераем и загружаем образ:

cargo build --release
docker build -t myrepo/pizza-controller:v1 .
docker push myrepo/pizza-controller:v1

Создаем манифест Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pizza-controller
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pizza-controller
  template:
    metadata:
      labels:
        app: pizza-controller
    spec:
      containers:
      - name: controller
        image: myrepo/pizza-controller:v1

Применяем Deployment:

kubectl apply -f deployment.yaml

А как вы используете кастомные контроллеры в Kubernetes? Делитесь своими кейсами в комментариях!

Также напоминаю об открытых уроках, которые пройдут в Otus в рамках онлайн-курсов:

  • 30 января: Хранение данных в Kubernetes: Volumes, Storages, Stateful-приложения. Подробнее

  • 11 февраля: Разбираем анатомию парсера на Rust. Подробнее

Список всех бесплатных уроков по IT-инфраструктуре и другим направлениям можно посмотреть в календаре.

Комментарии (2)


  1. segment
    24.01.2025 17:08

    Код на Rust просто красивый.

    Спорное утверждение.


    1. 4chemist
      24.01.2025 17:08

      Тоже иногда кажется, что в раст надергали самых вГлазаДолбительных практик с кучи языков. Вот как форматировать код который использует async await с безымянными функциями, да так чтобы он нормально читался? В этих случаях заставляю себя вспомнить трёхэтажные регэкспы в перле чтобы успокоиться.