Мы хотели сделать простую вещь: после деплоя отправлять уведомление в чат MAX из GitLab CI.

На бумаге задача выглядела почти тривиально:

  • есть MAX_BOT_TOKEN

  • есть MAX_NOTIFY_CHAT_ID

  • есть curl

  • есть POST https://platform-api.max.ru/messages?chat_id=...

Но на практике уведомления не приходили несколько дней. Мы меняли образы, переписывали скрипты, упрощали payload, добавляли диагностику. Результат был один: сообщение не доходило.

Настоящая причина оказалась совсем не там, где мы её искали.


Связанные материалы

  • annotation.md — короткая аннотация и формулировки для карточки статьи

  • intro.md — более отполированное вступление для публикации

  • code-snippets.md — готовые фрагменты кода для вставки в статью


Первые симптомы

Первый полезный лог выглядел так:

DEBUG: Response: {"code":"proto.payload","message":"Can't deserialize body"}
ERROR: не удалось отправить. Response: {"code":"proto.payload","message":"Can't deserialize body"}

Это давало ложное ощущение, что проблема в формировании JSON. Ведь MAX API явно отвечает — значит, запрос доходит. Значит, сеть в порядке. Значит, надо чинить payload.

Мы потратили несколько итераций именно на это.


Что мы пробовали (и что не работало)

Попытка 1: printf + временный файл

printf '{"text":"%s"}' "$MSG" > /tmp/payload.json
curl ... -d @/tmp/payload.json

Ответ: Can't deserialize body

Попытка 2: jq + временный файл

Переключились на alpine:3.20, установили jq:

jq -nc --arg t "$MSG" '{text:$t}' > /tmp/payload.json
curl ... -d @/tmp/payload.json

Ответ: всё равно Can't deserialize body

Попытка 3: переменная вместо файла

PAYLOAD=$(jq -nc --arg t "$MSG" '{text:$t}')
curl ... --data "${PAYLOAD}"

Ответ изменился: Empty request body

Это был прогресс — теперь тело вообще не доходило. Но apk add начал падать с exit code 2: раннер не мог тянуть пакеты из Alpine репозиториев.

Попытка 4: статичный хардкод

Убрали все переменные. Полностью статичный JSON в одинарных кавычках:

curl ... -d '{"text":"test ok"}'

Ответ: Empty request body


Ключевой эксперимент

На этом этапе стало очевидно: проблема не в JSON. Формат идеальный, проще некуда. Но тело не доходит.

Тогда мы проверили два сценария с одного и того же сервера:

С сервера напрямую:

ssh -p 666 kurganskii.a@tverdasoft.ru \
  "curl -s -X POST 'https://platform-api.max.ru/messages?chat_id=...' \
   -H 'Authorization: ...' \
   -H 'Content-Type: application/json' \
   -d '{\"text\":\"test from server\"}'"

Результат: сообщение пришло, HTTP 200, message_id в ответе.

Из Docker-контейнера на том же сервере:

ssh -p 666 kurganskii.a@tverdasoft.ru  \
  "docker run --rm curlimages/curl:8.7.1 curl -s \
   -X POST 'https://platform-api.max.ru/messages?chat_id=...' \
   -H 'Authorization: ...' \
   -d '{\"text\":\"test from docker\"}'"

Результат: exit code 6curl: (6) Could not resolve host: platform-api.max.ru

Вот она, настоящая причина.


Настоящая проблема: Docker DNS

Docker-контейнеры на этом сервере не могли резолвить внешние хосты. Хост работал нормально, а контейнеры — нет.

Проверка с явным DNS подтвердила диагноз:

docker run --rm --dns 8.8.8.8 curlimages/curl:8.7.1 curl -s \
  -X POST 'https://platform-api.max.ru/messages?chat_id=...' \
  -H 'Authorization: ...' \
  -d '{"text":"test from docker dns8"}'

Результат: сообщение пришло мгновенно.


Почему мы так долго не видели DNS

Это важный момент. Мы видели Can't deserialize body, а не Could not resolve host.

Ошибка DNS вернула бы curl exit code 6 и полное молчание. Но мы получали HTTP-ответ от MAX с осмысленным JSON. Значит, соединение устанавливалось.

Скорее всего, GitLab runner использует собственную DNS-конфигурацию при запуске контейнеров джобов, которая отличается от стандартного docker run. Это создавало частичную резолвацию: соединение иногда устанавливалось, но нестабильно, что приводило к битым или пустым телам запросов.

Ошибка proto.payload от MAX в таком случае — это симптом получения пустого или обрезанного тела, а не проблема с форматом JSON.


Исправление: DNS в конфиге раннера

Фикс элементарный — добавить DNS в config.toml GitLab раннера:

# /etc/gitlab-runner/config.toml
[[runners]]
  ...
  [runners.docker]
    dns = ["8.8.8.8", "8.8.4.4"]   # ← эта строка решила проблему

После этого:

sudo gitlab-runner restart

Альтернативный вариант — прописать DNS на уровне Docker daemon:

// /etc/docker/daemon.json
{
  "dns": ["8.8.8.8", "8.8.4.4"]
}
sudo systemctl restart docker

После рестарта раннера первый же запрос с хардкоженым {"text":"test ok"} отработал с HTTP 200.


Что оказалось рабочим шаблоном

После того как DNS заработал, базовый паттерн для GitLab CI стал выглядеть так:

if [ -z "$MAX_BOT_TOKEN" ] || [ -z "$MAX_NOTIFY_CHAT_ID" ]; then
  echo "WARN: MAX_BOT_TOKEN или MAX_NOTIFY_CHAT_ID не настроены"
  exit 0
fi

TOKEN="$(printf '%s' "$MAX_BOT_TOKEN" | tr -d '\r\n')"
CHAT_ID="$(printf '%s' "$MAX_NOTIFY_CHAT_ID" | tr -d '\r\n[:space:]')"
BODY_FILE="$(mktemp)"
RESPONSE_FILE="$(mktemp)"

printf '{"text":"%s"}' "${MSG}" > "${BODY_FILE}"

BODY_SIZE="$(wc -c < "${BODY_FILE}" | tr -d '[:space:]')"
echo "DEBUG: CHAT_ID=${CHAT_ID}"
echo "DEBUG: BODY_SIZE=${BODY_SIZE}"
echo "DEBUG: BODY_TEXT=$(cat "${BODY_FILE}")"

CURL_STATUS=0
HTTP_CODE=$(curl -sS --max-time 15 \
  -o "${RESPONSE_FILE}" \
  -w "%{http_code}" \
  -X POST "https://platform-api.max.ru/messages?chat_id=${CHAT_ID}" \
  -H "Authorization: ${TOKEN}" \
  -H "Content-Type: application/json" \
  --data @"${BODY_FILE}") || CURL_STATUS=$?

RESPONSE="$(cat "${RESPONSE_FILE}" 2>/dev/null || true)"
rm -f "${BODY_FILE}" "${RESPONSE_FILE}"

echo "DEBUG: HTTP_CODE=${HTTP_CODE} CURL_STATUS=${CURL_STATUS}"
echo "DEBUG: Response: ${RESPONSE}"

if [ "${CURL_STATUS}" -ne 0 ] || [ "${HTTP_CODE}" != "200" ]; then
  echo "ERROR: не удалось отправить. Response: ${RESPONSE}"
  exit 1
fi

Финальный вид уведомления

После того как базовое отправление заработало, мы добавили полезный контекст. Вот финальный формат, который используется в проекте:

✅ Успешный деплой на DEV

📦 Проект: u.clinic
🌍 Окружение: DEV
🌿 Ветка: dev
🔖 Версия: f7a249d0 (https://git.tverdasoft.ru/.../commit/f7a249d0...)
💬 Commit: fix(ci): улучшить обработку уведомлений MAX
👤 Автор: Anton Kurganskii
⏰ Время (МСК): 2026-04-08 21:29:07
⏱️ Длительность pipeline: 9 мин 52 сек

🔗 Pipeline: https://git.tverdasoft.ru/.../pipelines/809

✓ Деплой завершен успешно

Все поля берутся из стандартных переменных GitLab CI: CI_PROJECT_NAME, CI_COMMIT_BRANCH, CI_COMMIT_SHORT_SHA, CI_COMMIT_SHA, CI_PROJECT_URL, CI_COMMIT_TITLE, CI_COMMIT_AUTHOR, CI_PIPELINE_CREATED_AT, CI_PIPELINE_URL.

Время МСК считается через смещение UTC+3 средствами BusyBox date. Автор извлекается из CI_COMMIT_AUTHOR с отрезанием email через sed.

Полный сниппет — в code-snippets.md.


Почему curlimages/curl, а не Alpine с jq

Мы рассматривали вариант с alpine:3.20 + apk add curl jq, чтобы правильно экранировать JSON через jq. Но раннер не имел доступа к Alpine репозиториям — apk add падал с exit code 2.

curlimages/curl устанавливается из локального кеша (pull_policy: if-not-present) и не требует сети на этапе подготовки образа. Поэтому оставили его.

Для экранирования спецсимволов в commit message достаточно sed:

SAFE_TITLE="$(printf '%s' "${CI_COMMIT_TITLE}" | sed 's/\\/\\\\/g; s/"/\\"/g')"

Правила диагностики, которые реально сработали

Если MAX Messenger не принимает сообщение из GitLab CI:

  1. Проверьте DNS в Docker — запустите docker run --rm curlimages/curl curl ... platform-api.max.ru вручную на сервере раннера. Если exit code 6 — вся дальнейшая отладка payload бессмысленна

  2. Проверьте с сервера напрямую — если с хоста работает, а из контейнера нет, проблема в сетевой конфигурации Docker

  3. Добавьте DNS в config.tomldns = ["8.8.8.8", "8.8.4.4"] в [runners.docker]

  4. Только потом разбирайтесь с payloadBODY_SIZE, BODY_TEXT, HTTP_CODE, полный Response

  5. Начинайте с минимального ASCII payload{"text":"test ok"}, без кириллицы и форматирования

  6. Очищайте переменные от \r\n через tr -d '\r\n'

  7. Используйте chat_id для групп, user_id для личных — негативный ID всегда chat_id


Вывод

Снаружи это выглядело как “уведомления не работают из GitLab CI”. По ощущениям всё время хотелось подозревать JSON, кодировку или API.

На самом деле:

  • хост резолвил platform-api.max.ru нормально

  • Docker-контейнеры — нет

  • GitLab runner запускал notify-джобы в контейнерах

  • proto.payload — это следствие, не причина

Один параметр в config.toml раннера (dns = ["8.8.8.8"]) решил недельную проблему.

Самый полезный вывод: если curl exit code 6 из контейнера при работающей сети на хосте — сначала DNS, потом всё остальное.