Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 14.02 · 50 мин
Продвинутый
Architecture SolutionCapstoneReference Design

Разбор эталонного решения

Это одно из корректных решений архитектурного challenge. Ваше решение может отличаться — главное, обоснованы ли ваши решения. Используйте этот разбор как эталон, с которым можно сравнить свой подход и проверить ключевые технические решения.

NOTE

Не существует единственно правильного решения для системного дизайна. Данный разбор отражает решения, принятые конкретным старшим инженером с конкретными допущениями. Если ваш выбор отличается — убедитесь, что у вас есть обоснование, и сравните trade-offs.


Раздел 1: Эталонный каталог топиков

Соглашение об именовании

Используем формат {domain}.{entity}.{event-type} из Модуля 12. Домен = команда-владелец. Entity = бизнес-сущность. Event-type = прошедшее время (что произошло).

Обоснование количества партиций

Исходные данные: 10 000 заказов/сек, средний размер события 500 байт.

Пропускная способность на партицию (конservативная оценка):
  1 000 сообщений/сек при 500 байт = 500 KB/s на партицию
  Kafka может обрабатывать до 10 MB/s per partition, но:
  1 consumer thread = практически ~1 000 msg/s при реальной обработке

Минимальное число партиций = ceil(10 000 / 1 000) = 10
Округление до кратного числу брокеров (6 брокеров): 12 партиций
Каталог топиков: эталонное решение
ТопикИмя топика в формате domain.entity.event-type
ПартицииЧисло партиций с обоснованием
RetentionПолитика хранения данных
CleanupПолитика очистки: delete или compact
КлючКлюч партиционирования — определяет упорядоченность
orders.order.createdОсновной топик событий заказа. 12 партиций: peak throughput 10K msg/s / 1K msg/s per partition. Все downstream сервисы стартуют отсюда.
12Peak 10K/s / 1K/s per partition = 10, округляем до 12 (кратно 6 брокерам).
30 дней30 дней: достаточно для replay при аварии или добавлении нового consumer. Не infinite — события не являются event store в строгом смысле.
deletedelete: устаревшие события удаляются по retention. Не compact — нам нужна полная история, не только последнее состояние.
orderIdКлюч = orderId. Все события одного заказа в одну партицию → строгий порядок событий одного заказа.
orders.order.confirmedПодтверждённые заказы. Публикуется сагой после успешных payment + inventory шагов.
12Соответствует orders.order.created — одинаковый throughput.
30 дней30 дней: финансовая сверка требует историю за месяц.
deletedelete: исторические события, не snapshot.
orderIdorderId: co-partitioning с orders.order.created для JOIN в Streams.
payments.payment.chargedУспешные платежи. Читается Inventory Service для резервирования товара. Ключевой топик в saga flow.
1212 партиций: тот же peak throughput. Inventory Service должен иметь 12 consumers для параллельной обработки.
30 дней30 дней: финансовая аудитность требует полной истории платежей.
deletedelete: события транзакций, не snapshot.
orderIdorderId: co-partitioning позволяет JOIN с orders в случае необходимости.
payments.payment.refundedВозвраты. Более редкие, чем charged — 6 партиций достаточно.
66 партиций: refund rate обычно 1-5% от charge rate. Половина партиций — достаточно.
30 дней30 дней: финансовый аудит.
deletedelete: исторические события.
orderIdorderId: связь с исходным заказом.
inventory.item.reservedРезервирование складских позиций. Ключ = itemId для партиционирования по товару.
1212 партиций: высокий throughput при 10K заказов/сек, каждый с несколькими позициями.
30 дней30 дней: достаточно для аудита складских операций.
deletedelete: события, не текущий складской остаток.
itemIditemId (не orderId): партиционирование по товару позволяет Inventory Service поддерживать счётчики остатков per-partition с гарантиями порядка.
orders.order.enrichedOutput Kafka Streams topology: JOIN заказов с профилями клиентов. Input для JDBC Sink и Analytics.
1212 партиций: соответствует входному топику — repartitioning не требуется.
7 дней7 дней: JDBC Sink реплицирует данные в PostgreSQL непрерывно. Replay за 7 дней достаточно при восстановлении.
compactcompact: обогащённый заказ — снепшот текущего состояния. Нужен только последний вариант по orderId.
orderIdorderId: JOIN по orderId в Streams.
saga.order-fulfillment.stateСостояние саги выполнения заказа. Компактный топик: только последнее состояние per saga.
1212 партиций: один активный saga per order, peak 10K/s.
7 дней7 дней: активные саги не живут больше нескольких минут. 7 дней — запас для crashed/stuck sagas.
compactcompact: сохраняем только последнее состояние саги по sagaId. Завершённые саги будут compacted автоматически.
sagaIdsagaId: уникальный идентификатор экземпляра саги (= orderId для 1:1 mapping).

Раздел 2: Архитектура сервисов

Решение: Orchestration Saga

При 4 сервисах в цепочке выполнения заказа (Order → Payment → Inventory → Notification) выбор между хореографией и оркестрацией принципиален.

Решение: Orchestration (Оркестрация)

Обоснование: При 4 сервисах хореография становится трудноотслеживаемой — нет единого взгляда на прогресс выполнения заказа. При сбое на шаге 3 (резервирование инвентаря) нужно компенсировать шаг 2 (платёж — возврат средств). Логика компенсации разбросана по 4 сервисам без центрального координатора.

Оркестрация: вся логика в одном месте — Saga Orchestrator. Добавить новый шаг = изменить только оркестратор. Debug: состояние видно в saga.order-fulfillment.state.

Архитектура сервисов: flow событий

Order Service

Order Service: принимает HTTP запрос на создание заказа. Записывает заказ в собственную БД И запись в outbox-таблицу — в ОДНОЙ транзакции. Debezium CDC или polling publisher читает outbox и публикует в Kafka.

orders.order.created

orders.order.created: 12 партиций, ключ=orderId. Order Service продюсирует через Debezium Outbox Connector — гарантия at-least-once без dual write проблемы.

Saga Orchestrator

Saga Orchestrator: stateful сервис, читает orders.order.created, запускает saga instance. Состояние сохраняет в saga.order-fulfillment.state (compact topic). Координирует Payment → Inventory → Notification.

saga.commands.payments

saga.commands.payments: команды от оркестратора к Payment Service. ChargePayment, RefundPayment. Ключ=sagaId для упорядоченности команд.

Payment Service

Payment Service: idempotent consumer — dedup по orderId (хранит processed_order_ids в Redis). Использует enable.idempotence=true + transactional.id для exactly-once produce.

payments.payment.charged

payments.payment.charged: результат успешного платежа. Ключ=orderId. Оркестратор читает и переходит к следующему шагу — резервированию инвентаря.

Inventory Service

Inventory Service: читает saga.commands.inventory. Резервирует товар. При успехе — публикует inventory.item.reserved. При нехватке — failure event, оркестратор компенсирует (RefundPayment).

Notification Service

Notification Service: читает orders.order.confirmed от оркестратора. Отправляет email/SMS. Fire-and-forget — сбой уведомления не блокирует saga.

Order Confirmed

Заказ подтверждён. Оркестратор публикует orders.order.confirmed. Состояние саги = COMPLETED. Analytics и Finance читают обогащённые данные.

Transactional Outbox для Order Service

Order Service использует паттерн transactional outbox из Модуля 12. Без него — dual write: запись в БД и отправка в Kafka не атомарны.

-- В одной транзакции PostgreSQL:
BEGIN;
  INSERT INTO orders (id, customer_id, status, amount)
  VALUES ('order-123', 'cust-1', 'CREATED', 99.99);

  INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
  VALUES (
    'Order',
    'order-123',
    'OrderCreated',
    '{"orderId":"order-123","customerId":"cust-1","amount":99.99}'
  );
COMMIT;
-- Обе записи атомарны. Debezium читает WAL и публикует outbox запись в Kafka.

Exactly-once для Payment Service

Решение: Idempotent consumer + transactional.id на producer.

  • Consumer: проверяет Redis cache по orderId перед обработкой. Если orderId уже обработан — skip (idempotent handling).
  • Producer: transactional.id=payment-service-{partition}, enable.idempotence=true. Атомарная публикация payments.payment.charged и обновления статуса.

Почему не только Kafka transactions для consumer? Idempotent consumer на стороне приложения более надёжен — не зависит от isolation.level=read_committed в downstream.


Раздел 3: Стриминговый конвейер

Kafka Streams Topology

// Топология обогащения заказов
StreamsBuilder builder = new StreamsBuilder();

// Исходный поток заказов
KStream<String, OrderCreated> orders = builder
    .stream("orders.order.created",
            Consumed.with(Serdes.String(), orderCreatedSerde));

// KTable профилей клиентов (changelog из customer-service)
KTable<String, CustomerProfile> customers = builder
    .table("customers.customer.profile",
           Consumed.with(Serdes.String(), customerProfileSerde),
           Materialized.as("customers-store"));

// Re-key заказов по customerId для JOIN
KStream<String, OrderCreated> ordersByCustomer = orders
    .selectKey((orderId, order) -> order.getCustomerId());

// JOIN заказа с профилем клиента
KStream<String, OrderEnriched> enriched = ordersByCustomer.join(
    customers,
    (order, customer) -> OrderEnriched.builder()
        .orderId(order.getOrderId())
        .customerId(order.getCustomerId())
        .customerName(customer.getFullName())
        .customerEmail(customer.getEmail())
        .amount(order.getAmount())
        .createdAt(order.getCreatedAt())
        .build(),
    Joined.with(Serdes.String(), orderCreatedSerde, customerProfileSerde)
);

// Re-key обратно по orderId для output топика
enriched
    .selectKey((customerId, enrichedOrder) -> enrichedOrder.getOrderId())
    .to("orders.order.enriched",
        Produced.with(Serdes.String(), orderEnrichedSerde));

Ключевой момент: Re-key по customerId перед JOIN необходим — Kafka Streams JOIN требует совпадения ключей. Затем повторный re-key обратно по orderId для downstream совместимости (JDBC Sink ожидает orderId как первичный ключ).

Connect Pipelines

JDBC Sink (PostgreSQL для Finance):

{
  "name": "orders-jdbc-sink",
  "config": {
    "connector.class": "io.confluent.connect.jdbc.JdbcSinkConnector",
    "connection.url": "jdbc:postgresql://postgres:5432/analytics",
    "topics": "orders.order.enriched",
    "table.name.format": "order_analytics",
    "insert.mode": "upsert",
    "pk.mode": "record_key",
    "pk.fields": "order_id",
    "auto.create": "false",
    "batch.size": "3000",
    "consumer.override.max.poll.records": "3000"
  }
}

insert.mode=upsert: при compact топике возможны дубликаты при redelivery — upsert безопасен.

Elasticsearch Sink (Search):

{
  "name": "products-es-sink",
  "config": {
    "connector.class": "io.confluent.connect.elasticsearch.ElasticsearchSinkConnector",
    "connection.url": "http://elasticsearch:9200",
    "topics": "products.product.updated",
    "type.name": "_doc",
    "key.ignore": "false",
    "schema.ignore": "false",
    "behavior.on.null.values": "delete",
    "batch.size": "1000",
    "linger.ms": "100"
  }
}

behavior.on.null.values=delete: tombstone записи (null value) в compact топике интерпретируются как удаление документа из Elasticsearch.


Раздел 4: Дизайн безопасности

SASL/SCRAM principals

Каждый сервис — отдельный SCRAM principal. Компрометация одного credential не затрагивает остальные.

Principals:
  order-service       — Order Service application
  payment-service     — Payment Service application
  inventory-service   — Inventory Service application
  notification-service — Notification Service application
  saga-orchestrator   — Saga Orchestrator application
  streams-app         — Kafka Streams enrichment app
  analytics-connector — Connect JDBC Sink for analytics
  search-connector    — Connect Elasticsearch Sink
  mirrormaker2        — MM2 replication process
  admin               — Operational super user (super.users)

ACL матрица

ACL матрица: права доступа per service
PrincipalPrincipal — SCRAM-SHA-256 identity
ResourceТопик или ресурс Kafka
PatternPREFIXED — все топики с префиксом, LITERAL — конкретный топик
ОперацииРазрешённые операции
order-serviceOrder Service пишет только в topics с префиксом orders. Читает из orders для event sourcing replay.
orders.*Все topics в домене orders: orders.order.created, orders.order.confirmed, orders.order.cancelled.
PREFIXEDPREFIXED ACL: автоматически покрывает новые orders.* топики без изменения ACL.
Write, Read, DescribeWrite: publish events. Read: event sourcing replay. Describe: metadata для producer конфигурации.
payment-servicePayment Service читает команды от оркестратора, пишет результаты платежей.
saga.commands.paymentsКоманды от оркестратора: ChargePayment, RefundPayment.
LITERALLITERAL: конкретный топик команд. Не нужен широкий prefixed доступ.
Read, DescribeТолько чтение команд от оркестратора.
payment-servicePayment Service пишет результаты в payments.* домен.
payments.*payments.payment.charged, payments.payment.refunded.
PREFIXEDPREFIXED: покрывает все будущие payments.* топики.
Write, DescribeТолько запись результатов. Читать из payments не нужно.
inventory-serviceInventory Service читает команды оркестратора, пишет результаты резервирования.
saga.commands.inventoryКоманды: ReserveInventory, ReleaseInventory.
LITERALLITERAL: конкретный командный топик.
Read, DescribeТолько чтение команд.
inventory-serviceПишет события резервирования.
inventory.*inventory.item.reserved, inventory.item.released.
PREFIXEDPREFIXED для inventory домена.
Write, DescribeЗапись событий склада.
streams-appKafka Streams app читает orders и customers, пишет enriched.
orders.order.createdИсходный топик для enrichment topology.
LITERALКонкретный исходный топик.
Read, DescribeТолько чтение исходного потока.
streams-appStreams пишет обогащённые данные.
orders.order.enrichedOutput топик обогащения.
LITERALКонкретный output топик.
Write, DescribeЗапись обогащённых событий.
analytics-connectorJDBC Sink Connect connector читает обогащённые данные для PostgreSQL.
orders.order.enrichedЕдинственный топик для аналитического sink.
LITERALТочный доступ — только нужный топик.
Read, DescribeТолько чтение для sink connector.
mirrormaker2MM2 требует широкий доступ для репликации всех топиков и управления offset.
*Cluster-level: описание всех топиков. Topic-level: Read на все реплицируемые топики.
PREFIXEDPREFIXED на пустой строке = все топики.
Read, Describe, Cluster:DescribeMM2 читает все топики, описывает кластер. Дополнительно: Create на DC-2 для создания реплицированных топиков.

Раздел 5: Multi-DC и Disaster Recovery

Выбор топологии: Active-Passive

Обоснование: E-commerce платформа с единым основным DC и DR резервом. Активный-пассивный режим проще в управлении, не требует разрешения конфликтов при записи с обеих сторон.

Active-active подходит, когда:

  • Требуется нулевое время failover (near-zero RTO)
  • Два DC равнозначно обслуживают разные географические регионы
  • Приложение рассчитано на partition affinity (A-M ключи в DC-1, N-Z в DC-2)

Для нашего сценария (один основной DC) — active-passive с MM2.

MM2 конфигурация

# MirrorMaker 2 — Active-Passive: DC-1 -> DC-2
# Полная конфигурация для production

clusters = dc1, dc2
dc1.bootstrap.servers = kafka-dc1-broker1:9092,kafka-dc1-broker2:9092,kafka-dc1-broker3:9092
dc2.bootstrap.servers = kafka-dc2-broker1:9092,kafka-dc2-broker2:9092,kafka-dc2-broker3:9092

# Только направление DC-1 -> DC-2
dc1->dc2.enabled = true
dc1->dc2.topics = orders\..*, payments\..*, inventory\..*, saga\..*
dc1->dc2.groups = order-processor, payment-processor, inventory-processor

# DC-2 -> DC-1 отключено (active-passive)
dc2->dc1.enabled = false

# Checkpoint connector: синхронизация consumer group offsets
emit.checkpoints.enabled = true
emit.checkpoints.interval.seconds = 10
sync.group.offsets.enabled = true
sync.group.offsets.interval.seconds = 10

# Heartbeat connector: мониторинг здоровья репликации
emit.heartbeats.enabled = true
emit.heartbeats.interval.seconds = 5

# DefaultReplicationPolicy: топики появляются как dc1.orders.order.created на DC-2
replication.policy.class = org.apache.kafka.connect.mirror.DefaultReplicationPolicy

# Параллелизм: по одной task per partition group
tasks.max = 8

# Аутентификация MM2 -> DC-1 и DC-2
dc1.security.protocol = SASL_SSL
dc1.sasl.mechanism = SCRAM-SHA-256
dc2.security.protocol = SASL_SSL
dc2.sasl.mechanism = SCRAM-SHA-256

Почему DefaultReplicationPolicy, а не IdentityReplicationPolicy?

  • DefaultReplicationPolicy: топик orders.order.created на DC-1 появляется как dc1.orders.order.created на DC-2. Consumers на DC-2 явно настроены на чтение с префиксом dc1.. Это предотвращает случайную запись в “оригинальный” топик на DR кластере.
  • IdentityReplicationPolicy: те же имена на обоих кластерах. Требуется только при active-active, где приложения должны читать/писать независимо от DC. Для active-passive создаёт риск ambiguity.

RPO/RTO анализ

RPO (Recovery Point Objective = максимальная допустимая потеря данных):

MM2 replication latency при нормальной работе: 2–5 секунд
emit.checkpoints.interval.seconds = 10 секунд
Максимальный RPO = max(replication lag) + checkpoint interval
                 = 5 + 10 = ~15 секунд (значительно лучше требования 30 сек)

RTO (Recovery Time Objective = время восстановления):

ЭтапВремяКомментарий
Обнаружение сбоя (мониторинг)~2 минPrometheus alert + PagerDuty
Принятие решения о failover~1 минРучное или автоматическое
Дренирование MM2 лага~1 минИли принять текущий RPO
DNS failover (TTL 60 сек)~1 минОбновление A-записи load balancer
Offset translation (auto)~0 минsync.group.offsets.enabled=true — автоматически
Перезапуск сервисов на DC-2~2 минKubernetes rolling restart
Итого RTO~7 минМенее требования 10 мин

Runbook failover

  1. Prometheus алерт: kafka_mirror_heartbeat_age_seconds > 60 на DC-2 (репликация остановилась)
  2. On-call инженер проверяет доступность DC-1: kafka-topics.sh --bootstrap-server kafka-dc1:9092 --list
  3. Если DC-1 недоступен: принять решение о failover (согласно incident severity playbook)
  4. Остановить все producers на DC-1 (circuit breaker / feature flag в приложении)
  5. Зафиксировать текущий MM2 lag: kafka-consumer-groups.sh --bootstrap-server kafka-dc2:9092 --describe --group mirrormaker2-dc1-dc2
  6. Проверить наличие consumer group offsets на DC-2: kafka-consumer-groups.sh --bootstrap-server kafka-dc2:9092 --describe --group order-processor
  7. Обновить DNS: изменить A-запись load balancer с DC-1 endpoints на DC-2 endpoints
  8. Consumers на DC-2 стартуют и читают из dc1.orders.order.created (DefaultReplicationPolicy prefix)
  9. Проверить стабилизацию: consumer lag снижается, UnderReplicatedPartitions = 0, бизнес-метрики возобновились

Раздел 6: Мониторинг и ёмкость

Ключевые Prometheus алерты

groups:
  - name: kafka-production-alerts
    rules:
      # Критический: под-реплицированные партиции
      - alert: KafkaUnderReplicatedPartitions
        expr: kafka_server_replicamanager_underreplicatedpartitions > 0
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "UnderReplicatedPartitions > 0 на {{ $labels.instance }}"
          description: "Возможен сбой брокера или сетевая проблема. Немедленная диагностика."

      # Warning: высокий consumer lag
      - alert: KafkaConsumerLagHigh
        expr: kafka_consumer_group_lag > 5000
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Consumer lag {{ $value }} для группы {{ $labels.group }}"

      # Critical: очень высокий consumer lag
      - alert: KafkaConsumerLagCritical
        expr: kafka_consumer_group_lag > 50000
        for: 5m
        labels:
          severity: critical

      # Warning: задержка produce
      - alert: KafkaProduceLatencyHigh
        expr: kafka_network_requestmetrics_totaltimems{request="Produce", quantile="0.99"} > 200
        for: 5m
        labels:
          severity: warning

      # Critical: задержка репликации MM2
      - alert: MM2ReplicationLagHigh
        expr: kafka_mirror_replication_latency_ms_avg > 10000
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "MM2 replication lag > 10 секунд — RPO под угрозой"

Ёмкостный план (детальный расчёт)

Входные данные:
  Пиковый throughput: 10 000 заказов/сек
  Средний размер события: 500 байт
  Replication Factor: 3
  Retention: 30 дней (основные топики)
  Число топиков: ~10 (из каталога)

Расчёт:
  Суммарный produce rate: 10 000 × 500 байт = 5 МБ/с (на уровне приложения)
  С RF=3: 5 МБ/с × 3 = 15 МБ/с суммарная disk write rate (по всем брокерам)
  Суточный объём на диске: 15 МБ/с × 86 400 сек = ~1,3 ТБ/день
  30-дневное хранилище: 1,3 × 30 = ~39 ТБ суммарно

Конфигурация кластера:
  Минимум брокеров: 3 (для RF=3)
  Рекомендуется: 6 (для headroom + reassignment без деградации)
  На брокер: 39 / 6 = ~6,5 ТБ + 30% headroom = ~8,5 ТБ SSD

RAM на брокер:
  JVM heap: 6 ГБ (фиксированно, не увеличивать — Модуль 10)
  Page cache: 24 ГБ (15 МБ/с × 1 600 сек = ~24 ГБ "горячих" данных)
  Итого: 32 ГБ RAM per broker

Сеть:
  Inbound: 5 МБ/с producer
  Replication: 5 × 2 = 10 МБ/с (2 follower копии)
  Consumer: ~15 МБ/с (3 consumer groups × 5 МБ/с)
  Итого per broker: ~30 МБ/с = 0,24 Гбит/с
  При 1 Гбит/с NIC: 24% утилизация (значительно ниже 70% порога)

Итоговая спецификация на брокер:
  CPU: 16 ядер
  RAM: 32 ГБ
  Диск: 9 ТБ NVMe SSD (с 30% запасом)
  NIC: 1 Гбит/с
  Брокеров: 6 (3 в DC-1, 3 в DC-2)
TIP

Обратите внимание на соотношение JVM heap / page cache. 6 ГБ heap — жёсткое правило (Модуль 10: больший heap = GC паузы). Весь дополнительный RAM уходит в page cache. Именно page cache обеспечивает низкую latency — OS держит горячие log сегменты в памяти, consumer читает без disk I/O.


Ключевые решения и альтернативы

Резюме ключевых архитектурных решений
РешениеРешение
ВыборВыбранный вариант
Альтернатива (отклонена)Альтернатива и причина отклонения
Saga типПри 4+ сервисах и необходимости компенсации оркестрация выгоднее.
OrchestrationЦентральный координатор: ясная компенсация, единое место для debugging, легко добавить шаг.
Choreography: сложная компенсация при 4+ сервисахChoreography хороша при 2-3 сервисах. При 4+ трудно отследить прогресс и реализовать компенсацию.
MM2 политикаDefaultReplicationPolicy vs IdentityReplicationPolicy.
DefaultReplicationPolicyТопики на DC-2 с dc1. префиксом: явная изоляция от оригинальных топиков. Безопаснее для active-passive.
IdentityReplicationPolicy: нужна только при active-activeIdentityReplicationPolicy нужна при active-active для прозрачного переключения без изменения topic names.
Reliable publishКак Order Service гарантирует доставку событий в Kafka.
Transactional OutboxAtomicity: запись в БД + outbox в одной транзакции. Debezium CDC читает WAL и публикует. Гарантирует at-least-once.
Direct produce: риск потери при сбое между DB и KafkaЗапись в БД, затем produce в Kafka — не атомарно. При сбое между двумя операциями: или событие потеряно, или состояние БД не согласовано.
EnrichmentКак обогащать заказы данными клиентов.
Kafka Streams JOINStreams KTable JOIN: stateful, рестарт без потери состояния (RocksDB), масштабируется горизонтально.
Lookup в service: синхронный HTTP call, монолитный контрактОбращение к customer-service по HTTP при обработке заказа: latency, coupling, сбой сервиса блокирует обработку.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3