golang

Когда мейнтейнер молчит

  • понедельник, 3 ноября 2025 г. в 00:00:09
https://habr.com/ru/articles/962610/

Странно писать про форк open-source проекта для ушедшего в историю Docker Swarm. Но после Millau остался ещё один гештальт - периодические задачи. Посмотрел на Ofelia и Swarm-cronjob, их звезды, обновления, количество заброшенных репортов. Попытался связаться с автором - тишина. Так что с чистой совестью взял код и добавил недостающее. Получилась Cirona - job scheduling с телеметрией и дашбордами.

Почему не cron

Наивная наихудшая реализации расписания в Docker - это использовать cron контейнера. Скрипт entrypoint.sh, который запускается при старте контейнера, экспортирует переменные окружения и расписание в операционную систему. Затем по расписанию cron запускает процесс, который подхватывает окружение для задачи. Это работает какое-то время, но быстро упирается в ограничения:

  1. Вне зависимости от того, завершилась ли предыдущая задача или нет, cron запустит ещё одну.

  2. Если контейнер упадёт или будет удалён Swarm, задача не выполнится.

  3. Статус задачи неизвестен, все логи внутри контейнера.

С первой проблемой можно справиться программной блокировкой или штуками вроде supervisord. Получить статус выполнения и консольный вывод можно через системы централизованного логирования вроде Loki. Но это уже требование к инфраструктуре, которое выходит за рамки ответственности планировщика. Проблему с надёжностью выполнения средствами самого контейнера решить невозможно в принципе. Нужно переходить на уровень оркестратора.

Как это работает

Cirona - это приложение на Golang. Оно запускается как Swarm сервис, который слушает события Docker и реагирует на изменения в кластере. Минимальная конфигурация:

services:
  cirona:
    image: codelev/cirona:latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    deploy:
      placement:
        constraints:
          - node.role == manager

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

Задачи тоже запускаются как обычные сервисы Swarm. О себе Cirona они сообщают через свои labels. Вот пример задачи, которая выводит дату каждую минуту:

services:
  date:
    image: busybox
    command: date
    deploy:
      labels:
        - "cirona.enable=true"
        - "cirona.schedule=* * * * *"
      replicas: 0
      restart_policy:
        condition: none

Инструкция replicas: 0 указывает не запускать сервис при развёртывании.

Cirona обнаруживает сервис по меткам deploy, по расписанию поднимает реплику, ждёт завершения, возвращает реплику в ноль. Это главная фишка, пришедшее из проекта Swarm-cronjob: контейнеры задач не запускаются напрямую, а управляются через состояние их сервисов при помощи Docker API. То есть Swarm имеет обычный контроль над размещением контейнеров и их циклом жизни. Если нода упала - он перенесёт задачу вместе с её сервисом на другую. При развёртывании или удалении сервисов задач Cirona перечитывает их настройки.

Повторные запуски и рестарт

Когда задача может выполняться долго и не должна запускаться параллельно самой себе, нужна метка cirona.skip-running=true. Кейс - запуск задачи каждые 6 часов с повторами при ошибках:

deploy:
  labels:
    - "cirona.enable=true"
    - "cirona.schedule=0 */6 * * *"
    - "cirona.skip-running=true"
  replicas: 0
  restart_policy:
    condition: on-failure
    max_attempts: 3

При наступлении времени старта Cirona находит активные задачи через Docker API и пропускает их. Этот пример также показывает как сделать автоматический рестарт при ошибках. Вместо изобретения собственной retry-логики Cirona полагается на встроенные политики Swarm. Но есть и побочный эффект - между попытками оркестратор делает экспоненциальный backoff, который нельзя точно контролировать. Для задач чувствительных ко времени старта это стоит учитывать.

Глобальный режим

Ещё один кейс - запуск задачи на всех узлах кластера каждый час:

deploy:
  mode: global
  labels:
    - "cirona.enable=true"
    - "cirona.schedule=0 * * * *"
  replicas: 0
  restart_policy:
    condition: none

Cirona запускает по задаче на каждой ноде, затем запрашивает их список через API и ждёт, когда количество выполненных не станет равным количеству узлов. После этого возвращает сервис в ноль реплик.

Мониторинг

В отличие от ненаблюдаемого Swarm-cronjob, Cirona предоставляет два типа телеметрии: стандартный health-check для Swarm и метрики для Prometheus.

Prometheus:

  • cirona_service_status - статус обнаруженных сервисов задач,

  • cirona_job_executions_total - расписание задач и количество запусков,

  • cirona_job_execution_status - коды завершения, стандартный вывод и ошибки,

  • cirona_job_execution_duration_ms - время выполнения.

Для визуализации собрана борда Grafana.

Ограничения

  1. Точность расписания порядка половины секунды. Для sub-second задач не подходит.

  2. Один часовой пояс на все расписания, задаётся через переменную окружения TZ.

  3. Есть зависимость от Swarm manager. При потере кворума задачи продолжают работать, но новые не запустятся.

  4. Масштабируемость не проверялась. Теоретически на нескольких сотнях сервисов запросы к API и обработка событий могут стать узким местом.

  5. Хранение последней строки stdout/stderr в телеметрии было спорным решением. С одной стороны, удобно видеть результат выполнения задачи прямо в метрике. С другой, много разных ответов раздувают cardinality в памяти. Поэтому время хранения cirona_job_execution_status ограничено 1 часом. Вряд ли ваш Prometheus опрашивает метрики реже, поэтому данные не потеряются.

Постскриптум

Cirona продолжает жизнь Swarm-cronjob версии 1.4.0 на GitHub. Репозиторий содержит все исходники, тестовые задачи и стек мониторинга для локальных экспериментов. Примеры использования и багрепорты туда же.

Если вы практикуете Swarm в 2025 и вам нужен наблюдаемый job scheduling, возможно, Cirona подойдёт. Если вы используете другой планировщик или сделали свой - будет интересно узнать, как. И отдельное спасибо камрадам Хабра за мотивацию. Без вас этот текст не появился бы.