Задача: централизованное логирование для группы сервисов — отказоустойчиво, на трёх машинах, в Docker Compose. Ниже — рабочая схема на Graylog 7 с DataNode, вход трафика через HAProxy с Keepalived и, самое интересное, методика расчёта ретенции: сколько дней логов мы реально можем хранить на дисках заданного размера. Методика выстрадана — однажды на одном из стендов диск заполнился, OpenSearch перевёл индексы в read-only, и приём логов встал. С тех пор ретенция у меня считается заранее, а не «по факту».

Архитектура

Схема кластера Graylog

Три одинаковые машины (log-01…03, 10.0.0.21–23), на каждой три контейнера:

  • graylog — приём, обработка и UI;
  • graylog-datanode — управляемый OpenSearch (с Graylog 5.2+ это рекомендуемый способ: DataNode сам поднимает и конфигурирует OpenSearch, не нужен отдельный кластер);
  • mongodb — конфигурация Graylog, replica set rs01 на те же три ноды.

Перед кластером — две машины с HAProxy и Keepalived: единый VIP (10.0.0.20) принимает и трафик Filebeat (Beats input, TCP 5044), и HTTP для веб-интерфейса, и балансирует на живые ноды. Агентам и людям не нужно знать про три ноды — только VIP.

Сеть — network_mode: host: контейнеры общаются напрямую по портам хостов, без оверлеев и проброса портов. Для статичного набора из трёх машин это самый простой и предсказуемый вариант; имена нод резолвятся через extra_hosts.

Подготовка хостов

На каждой машине:

1
2
3
4
5
# лимиты для OpenSearch
echo 'vm.max_map_count=262144' > /etc/sysctl.d/99-opensearch.conf
sysctl --system

mkdir -p /opt/graylog/config

Ключ для аутентификации узлов MongoDB replica set (генерируется один раз, копируется на все ноды):

1
2
3
openssl rand -base64 756 > /opt/graylog/config/mongo-keyfile
chmod 400 /opt/graylog/config/mongo-keyfile
chown 999:999 /opt/graylog/config/mongo-keyfile   # uid mongod в контейнере

Секреты — в .env рядом с компоузом (в git не коммитим):

1
2
3
4
5
MONGO_INITDB_ROOT_USERNAME=graylog-admin
MONGO_INITDB_ROOT_PASSWORD=<пароль>
GRAYLOG_PASSWORD_SECRET=$(openssl rand -base64 96)   # одинаковый на всех нодах!
DATANODE_PASSWORD_SECRET=<тот же секрет>
GRAYLOG_ROOT_PASSWORD_SHA2=$(echo -n '<пароль admin>' | sha256sum | cut -d' ' -f1)

GRAYLOG_PASSWORD_SECRET обязан совпадать на всех нодах — им шифруются данные в MongoDB, общей для кластера.

docker-compose.yml

Файл почти одинаков на всех трёх нодах — отличаются только имя своей ноды в GRAYLOG_DATANODE_OPENSEARCH_NETWORK_HOST и адреса в GRAYLOG_HTTP_*_URI. Полный файл для log-01:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
---
services:

  mongodb:
    image: mongo:7.0
    container_name: mongodb
    restart: unless-stopped
    command: mongod --replSet rs01 --bind_ip_all --keyFile /etc/mongo/mongo-keyfile
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
      TZ: Europe/Moscow
    volumes:
      - "mongodb:/data/db"
      - /opt/graylog/config/mongo-keyfile:/etc/mongo/mongo-keyfile:ro
    network_mode: host
    extra_hosts:
      - "log-01:10.0.0.21"
      - "log-02:10.0.0.22"
      - "log-03:10.0.0.23"
    healthcheck:
      test:
        - "CMD"
        - "mongosh"
        - "--quiet"
        - "--username"
        - "$MONGO_INITDB_ROOT_USERNAME"
        - "--password"
        - "$MONGO_INITDB_ROOT_PASSWORD"
        - "--authenticationDatabase"
        - "admin"
        - "--eval"
        - "const s = rs.status().myState; if(s !== 1 && s !== 2) throw 'not ready';"
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 40s
    ulimits:
      nofile:
        soft: 65536
        hard: 65536

  # только на log-01: одноразовая инициализация replica set
  mongo-init:
    image: mongo:7.0
    container_name: mongo-init
    restart: "no"
    depends_on:
      mongodb:
        condition: service_started
    network_mode: host
    extra_hosts:
      - "log-01:10.0.0.21"
      - "log-02:10.0.0.22"
      - "log-03:10.0.0.23"
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
    entrypoint:
      - bash
      - -c
      - |
        echo "Waiting for mongod to accept connections..."
        sleep 15
        mongosh --host log-01:27017 \
          -u $$MONGO_INITDB_ROOT_USERNAME \
          -p $$MONGO_INITDB_ROOT_PASSWORD \
          --authenticationDatabase admin \
          --eval "
            try {
              rs.status();
              print('RS already initialized');
            } catch(e) {
              rs.initiate({
                _id: 'rs01',
                members: [
                  { _id: 0, host: 'log-01:27017' },
                  { _id: 1, host: 'log-02:27017' },
                  { _id: 2, host: 'log-03:27017' }
                ]
              });
              print('RS initiated');
            }
          "

  datanode:
    image: graylog/graylog-datanode:7.0
    container_name: datanode
    restart: unless-stopped
    environment:
      GRAYLOG_DATANODE_NODE_ID_FILE: /var/lib/graylog-datanode/node-id
      GRAYLOG_DATANODE_PASSWORD_SECRET: ${DATANODE_PASSWORD_SECRET}
      GRAYLOG_DATANODE_MONGODB_URI: mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@log-01:27017,log-02:27017,log-03:27017/graylog?replicaSet=rs01&authSource=admin
      GRAYLOG_DATANODE_OPENSEARCH_CLUSTER_NAME: graylog-cluster
      # адрес ЭТОЙ ноды — на log-02/log-03 меняется
      GRAYLOG_DATANODE_OPENSEARCH_NETWORK_HOST: log-01
      GRAYLOG_DATANODE_OPENSEARCH_DISCOVERY_SEED_HOSTS: log-01:9300,log-02:9300,log-03:9300
      GRAYLOG_DATANODE_OPENSEARCH_CLUSTER_INITIAL_MANAGER_NODES: log-01,log-02,log-03
      GRAYLOG_DATANODE_OPENSEARCH_HEAP: "11g"
      # упрощение для закрытого сегмента; в проде — TLS между нодами
      GRAYLOG_DATANODE_INSECURE_STARTUP: "true"
      TZ: Europe/Moscow
      GRAYLOG_DATANODE_JAVA_OPTS: "-Duser.timezone=Europe/Moscow"
    volumes:
      - "datanode:/var/lib/graylog-datanode"
    network_mode: host
    extra_hosts:
      - "log-01:10.0.0.21"
      - "log-02:10.0.0.22"
      - "log-03:10.0.0.23"
    healthcheck:
      test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/8999"]
      interval: 15s
      timeout: 10s
      retries: 40
      start_period: 120s   # DataNode стартует долго
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    depends_on:
      mongodb:
        condition: service_healthy

  graylog:
    image: graylog/graylog:7.0
    container_name: graylog
    restart: unless-stopped
    environment:
      GRAYLOG_NODE_ID_FILE: /usr/share/graylog/data/config/node-id
      GRAYLOG_HTTP_BIND_ADDRESS: 0.0.0.0:9000
      # на log-02/log-03 — свои имена
      GRAYLOG_HTTP_EXTERNAL_URI: http://log-01:9000/
      GRAYLOG_HTTP_PUBLISH_URI: http://log-01:9000/
      GRAYLOG_MONGODB_URI: mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@log-01:27017,log-02:27017,log-03:27017/graylog?replicaSet=rs01&authSource=admin
      GRAYLOG_PASSWORD_SECRET: ${GRAYLOG_PASSWORD_SECRET}
      GRAYLOG_ROOT_PASSWORD_SHA2: ${GRAYLOG_ROOT_PASSWORD_SHA2}
      GRAYLOG_REPORT_DISABLE_SANDBOX: "true"
      GRAYLOG_ALLOW_HIGHLIGHTING: "true"
      TZ: Europe/Moscow
      GRAYLOG_ROOT_TIMEZONE: Europe/Moscow
      GRAYLOG_SERVER_JAVA_OPTS: "-Duser.timezone=Europe/Moscow"
      GRAYLOG_MESSAGE_JOURNAL_MAX_SIZE: "10gb"
      GRAYLOG_MESSAGE_JOURNAL_MAX_AGE: "12h"
    volumes:
      - "graylog-data:/usr/share/graylog/data"
      - "graylog-journal:/usr/share/graylog/data/journal"
      - "/etc/localtime:/etc/localtime:ro"
    network_mode: host
    extra_hosts:
      - "log-01:10.0.0.21"
      - "log-02:10.0.0.22"
      - "log-03:10.0.0.23"
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    depends_on:
      mongodb:
        condition: service_healthy
      datanode:
        condition: service_healthy

volumes:
  mongodb:
  datanode:
  graylog-data:
  graylog-journal:

Отличия log-02/log-03: убрать сервис mongo-init, поменять GRAYLOG_DATANODE_OPENSEARCH_NETWORK_HOST и оба GRAYLOG_HTTP_*_URI на своё имя.

Полезные детали, которые обычно узнают методом боли:

  • healthchecks + depends_on выстраивают порядок: MongoDB (healthy) → DataNode (healthy) → Graylog. Без этого при перезагрузке хоста Graylog стартует раньше базы и падает;
  • start_period: 120s у DataNode — он реально стартует долго, с коротким периодом healthcheck загонит его в restart loop;
  • журнал 10gb/12h — буфер на диске: если OpenSearch недоступен, приём логов не останавливается, сообщения копятся в журнале;
  • таймзона прописана везде (TZ, JAVA_OPTS, ROOT_TIMEZONE) — иначе время в UI, в логах JVM и в индексах живёт по-разному, и отладка превращается в квест.

Запуск

1
2
3
4
5
6
# 1. log-01 (с mongo-init), затем log-02 и log-03
docker compose up -d

# 2. проверить replica set
docker exec mongodb mongosh -u $USER -p $PASS --authenticationDatabase admin \
  --eval 'rs.status().members.map(m => m.name + " " + m.stateStr)'

Дальше — веб-интерфейс: при первом старте Graylog 7 проводит через preflight-настройку, где DataNode-ноды обнаруживаются автоматически (через общую MongoDB) и провижинятся в кластер OpenSearch. После этого System → Nodes должен показывать три ноды Graylog, System → Data Nodes — три DataNode.

Вход трафика: HAProxy + Keepalived

Две машины (lb-01, lb-02), между ними Keepalived с VIP 10.0.0.20. HAProxy балансирует два потока: Beats (TCP 5044) от Filebeat и HTTP UI.

frontend beats_in
    bind 10.0.0.20:5044
    mode tcp
    default_backend graylog_beats

backend graylog_beats
    mode tcp
    balance roundrobin
    server log-01 10.0.0.21:5044 check
    server log-02 10.0.0.22:5044 check
    server log-03 10.0.0.23:5044 check

frontend web_in
    bind 10.0.0.20:80
    mode http
    default_backend graylog_web

backend graylog_web
    mode http
    balance roundrobin
    option httpchk GET /api/system/lbstatus
    server log-01 10.0.0.21:9000 check
    server log-02 10.0.0.22:9000 check
    server log-03 10.0.0.23:9000 check

/api/system/lbstatus — специальный эндпоинт Graylog для балансировщиков: нода, которая жива, но не готова принимать (например, догоняет журнал), отвечает не-200, и HAProxy выводит её из ротации.

Filebeat на хостах приложений смотрит только на VIP:

1
2
output.logstash:
  hosts: ["10.0.0.20:5044"]

Beats input создаётся в Graylog как Global — тогда он слушает 5044 на всех нодах сразу.

Расчёт ретенции

Самая частая ошибка — задать ретенцию временем («храним 30 дней») и не сверить с дисками. Правильный порядок обратный: от размера диска — к количеству дней.

Вводные для примера: 3 ноды по 200 ГБ под данные.

Шаг 1. Бюджет хранения. OpenSearch следит за заполнением диска: на 85 % (low watermark) перестаёт размещать новые шарды на ноде, на 90 % (high) — начинает эвакуировать шарды, на 95 % (flood stage) — переводит индексы в read-only, и приём логов останавливается. Планировать нужно с запасом до нижней границы, плюс место для журнала Graylog и служебных данных. Берём рабочий потолок 75 %:

Бюджет = 3 × 200 ГБ × 0.75 = 450 ГБ на индексы (включая реплики)

Шаг 2. Скорость записи. Считается только по факту: запускаем кластер, льём реальный трафик несколько дней и смотрим прирост индексов (System → Indices или _cat/indices). Важно: размер данных в индексе ≠ размеру сырых логов — после индексации объём обычно ×1.1–1.3. Допустим, намерили 12 ГБ/день прироста primary-шардов.

Шаг 3. Реплики. Для отказоустойчивости replicas = 1: каждый индекс хранится дважды, потеря любой ноды не теряет данные. Расход удваивается:

Расход = 12 ГБ/день × (1 + 1 реплика) = 24 ГБ/день

(На тестовых стендах, где потеря логов не страшна, replicas = 0 — и та же дисковая ёмкость даёт вдвое больше дней.)

Шаг 4. Дни хранения:

Ретенция = 450 ГБ / 24 ГБ/день ≈ 18 дней

Шаг 5. Настройка index set. В Graylog (System → Indices → index set):

  • Rotation strategy: Index Size — ротация по размеру, а не по времени: предсказуемый размер каждого индекса. Max index size: 10 ГБ;
  • Retention strategy: Delete, max number of indices: 22 (22 × 10 ГБ × 2 копии = 440 ГБ ≤ бюджета 450 ГБ);
  • Shards: 3 (по числу нод), replicas: 1.

Итог: кластер физически не может занять больше рассчитанного объёма, сколько бы трафика ни прилетело — при росте потока сокращается глубина хранения в днях, а не стабильность кластера.

Мониторинг. Даже с расчётом — следим за заполнением дисков (алерт на 70 %) и за приростом индексов: поток логов имеет свойство расти незаметно. Именно так у нас однажды и заполнился диск на стенде: за несколько недель приложения стали писать заметно больше, ретенция была задана «по времени», и OpenSearch дошёл до flood stage. С ротацией по размеру такой сценарий исключён by design.

Грабли

  • INSECURE_STARTUP: true — в этой конфигурации трафик между DataNode-нодами не шифруется. Допустимо в изолированном сегменте; для продакшена настраивайте TLS (DataNode умеет управлять сертификатами сам, см. документацию Graylog);
  • PASSWORD_SECRET разошёлся между нодами — ноды видят друг друга, но не могут прочитать общие данные; симптомы странные, причина неочевидная. Секрет должен быть строго одинаковым;
  • mongo-keyfile с неправильными правами — mongod молча не стартует; нужны 400 и владелец uid 999;
  • vm.max_map_count забыт — OpenSearch падает на старте;
  • ротация по времени вместо размера — см. историю выше.