собрать incident tooling в один аудируемый Slack-workflow

ChatOps при инцидентах: от алерта Alertmanager до решения в Slack

12 минут

On-call до сих пор прыгает между PagerDuty, Grafana, kubectl и wiki, пока горят минуты. В материале — как связать Prometheus Alertmanager со Slack-ботом: обогащение алертов, runbook-действия и remediation с RBAC.

Почему фрагментированный incident response расширяет blast radius

Когда срабатывает production-алерт, типичный путь on-call проходит через acknowledge в PagerDuty, dashboard'ы Grafana, runbook в Confluence, локальный терминал с kubectl и тред в Slack для координации. Каждый шаг — context switch под давлением. Диагностика живёт как tribal knowledge, и когда основной респондер недоступен, MTTR растёт. Узкое место редко в мониторинге. Узкое место — в workflow.

Архитектура ChatOps: ingest, orchestrate, execute

ChatOps сворачивает диагностику и координацию в чат, который команда и так использует во время инцидента. Для production-пилота достаточно трёх слоёв. Ingest отправляет webhook payload'ы Prometheus Alertmanager в бот при срабатывании правил. Orchestration разбирает labels и annotations, запрашивает enrichment из Prometheus или Grafana и публикует Block Kit-сообщения со ссылками на runbook и кнопками действий. Execution запускает утверждённые remediation-команды через sandboxed executor с Kubernetes RBAC, проверкой подписи Slack interactivity и структурированным audit log. Тот же паттерн работает в Microsoft Teams с другими SDK; ниже Slack, потому что большинство команд сначала стандартизирует incident-каналы именно там.

Подключите Alertmanager к Slack-боту с enrichment

Маршрутизируйте critical-алерты в отдельный webhook receiver, не дублируя каналы уведомлений. Grouping keys выравнивайте с тем, как думают респондеры — alertname плюс namespace обычно достаточно. Добавьте annotation promql_query в alert rules, чтобы бот мог запросить live-значение; никогда не передавайте alertname как PromQL-выражение. Зарегистрируйте interactivity URL бота в настройках Slack app, чтобы клики по кнопкам попадали на signed endpoint.

YAML · Alertmanager receiver для ChatOps webhook
route:
  receiver: chatops-bot
  group_by: ['alertname', 'namespace']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers:
        - severity="critical"
      receiver: chatops-bot
      continue: true

receivers:
  - name: chatops-bot
    webhook_configs:
      - url: 'https://chatops.example.com/api/alertmanager'
        send_resolved: true
Python · Alertmanager webhook и Slack actions через slack_bolt
import os
import logging
from flask import Flask, request
from slack_bolt import App
from slack_bolt.adapter.flask import SlackRequestHandler
from slack_sdk import WebClient
import requests

logger = logging.getLogger(__name__)
flask_app = Flask(__name__)
slack_app = App(
    token=os.environ["SLACK_BOT_TOKEN"],
    signing_secret=os.environ["SLACK_SIGNING_SECRET"],
)
handler = SlackRequestHandler(slack_app)
client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "C0123456789")
PROMETHEUS_URL = os.environ.get("PROMETHEUS_URL", "http://prometheus:9090")


def enrich_alert(alert):
    annotations = alert.get("annotations", {})
    query = annotations.get("promql_query")
    if not query:
        return "No promql_query annotation on alert rule."
    try:
        resp = requests.get(
            f"{PROMETHEUS_URL}/api/v1/query",
            params={"query": query},
            timeout=10,
        )
        data = resp.json()
        if data.get("status") == "success" and data["data"]["result"]:
            return f"Current value: {data['data']['result'][0]['value'][1]}"
    except Exception as exc:
        logger.warning("Prometheus query failed: %s", exc)
    return "Unable to fetch current metrics."


def build_incident_blocks(alert):
    labels = alert.get("labels", {})
    annotations = alert.get("annotations", {})
    deployment = labels.get("deployment", labels.get("service", "unknown"))
    namespace = labels.get("namespace", "default")
    action_value = f"{namespace}/{deployment}"

    return [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": f"Incident: {labels.get('alertname', 'unknown')}",
            },
        },
        {
            "type": "section",
            "fields": [
                {"type": "mrkdwn", "text": f"*Severity:* {labels.get('severity', 'n/a')}"},
                {"type": "mrkdwn", "text": f"*Namespace:* {namespace}"},
                {"type": "mrkdwn", "text": f"*Deployment:* {deployment}"},
                {"type": "mrkdwn", "text": f"*Started:* {alert.get('startsAt', 'unknown')}"},
            ],
        },
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": annotations.get("description", "No description"),
            },
        },
        {
            "type": "section",
            "text": {"type": "mrkdwn", "text": f"*Context:*\n```{enrich_alert(alert)}```"},
        },
        {
            "type": "actions",
            "elements": [
                {
                    "type": "button",
                    "text": {"type": "plain_text", "text": "Runbook"},
                    "url": annotations.get("runbook_url", "https://runbooks.example.com"),
                },
                {
                    "type": "button",
                    "text": {"type": "plain_text", "text": "Restart deployment"},
                    "action_id": "restart_deployment",
                    "value": action_value,
                    "style": "danger",
                    "confirm": {
                        "title": {"type": "plain_text", "text": "Confirm restart"},
                        "text": {
                            "type": "plain_text",
                            "text": f"Restart {deployment} in {namespace}?",
                        },
                        "confirm": {"type": "plain_text", "text": "Restart"},
                        "deny": {"type": "plain_text", "text": "Cancel"},
                    },
                },
            ],
        },
    ]


@flask_app.post("/api/alertmanager")
def receive_alert():
    for alert in request.json.get("alerts", []):
        client.chat_postMessage(
            channel=SLACK_CHANNEL,
            blocks=build_incident_blocks(alert),
            text=f"Incident: {alert.get('labels', {}).get('alertname', 'unknown')}",
        )
    return {"status": "ok"}, 200


@slack_app.action("restart_deployment")
def restart_deployment(ack, body, client):
    ack()
    namespace, deployment = body["actions"][0]["value"].split("/", 1)
    user = body["user"]["id"]
    # Call an internal executor service with RBAC instead of shelling out from the bot process.
    result = requests.post(
        "http://remediation-executor/remediate",
        json={"action": "rollout_restart", "namespace": namespace, "deployment": deployment, "requested_by": user},
        timeout=30,
    )
    client.chat_postEphemeral(
        channel=body["channel"]["id"],
        user=user,
        text=f"Restart result: {result.text}",
    )


@flask_app.post("/slack/events")
def slack_events():
    return handler.handle(request)


@flask_app.get("/health")
def health():
    return {"status": "ok"}, 200

Деплой бота в Kubernetes с least-privilege RBAC

Запускайте бота в отдельном monitoring namespace с секретами для Slack bot token и signing secret. Пробуйте /health для probe и терминируйте TLS на ingress. Привязывайте Role, который разрешает только get и patch на Deployments в утверждённых namespace — никогда не выдавайте cluster-admin автоматизации, которую могут триггерить пользователи Slack. Отдельный remediation executor может держать kubectl credentials, чтобы публичный процесс бота оставался тонким.

YAML · Deployment и Role для ChatOps-бота
apiVersion: apps/v1
kind: Deployment
metadata:
  name: chatops-bot
  namespace: monitoring
spec:
  replicas: 2
  selector:
    matchLabels:
      app: chatops-bot
  template:
    metadata:
      labels:
        app: chatops-bot
    spec:
      serviceAccountName: chatops-bot
      containers:
        - name: bot
          image: registry.example.com/chatops-bot:latest
          ports:
            - containerPort: 8080
          envFrom:
            - secretRef:
                name: chatops-secrets
          env:
            - name: PROMETHEUS_URL
              value: http://prometheus.monitoring:9090
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: chatops-bot
  namespace: production
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "patch"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]

Операционные практики: безопасный и аудируемый ChatOps

Внедряйте поэтапно: первая неделя — read-only enrichment и ссылки на runbook; затем low-risk действия вроде acknowledge или deep link на логи; destructive remediation — в последнюю очередь. Для production-мутаций требуйте второго approver — публикуйте confirmation thread и выполняйте только после одобрения другим on-call в канале, а не только через Slack confirm dialog одного пользователя. Логируйте каждое действие в Loki, Elasticsearch или incident database с timestamp, user, alert name, target и result, чтобы post-incident review не зависел от retention Slack. Держите каждый инцидент в отдельном треде: при связанном алерте отвечайте в существующий тред, а не спамьте канал. ChatOps не исправит слабые алерты или отсутствие runbook'ов, но убирает фрагментацию инструментов — респондеры тратят время на диагностику и восстановление, а не на поиск вкладок.

ChatOps работает только при надёжном контексте алертов — сначала задайте сигналы и dashboard'ы по базовому гайду по observability для небольших платформенных команд.

Свяжите срочность remediation с политикой надёжности через практики SLO, SLI и error budget для платформенных команд.