Мы хотели сделать простую вещь: после деплоя отправлять уведомление в чат 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.jsoncurl ... -d @/tmp/payload.json
Ответ: Can't deserialize body
Попытка 2: jq + временный файл
Переключились на alpine:3.20, установили jq:
jq -nc --arg t "$MSG" '{text:$t}' > /tmp/payload.jsoncurl ... -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 6 — curl: (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 0fiTOKEN="$(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=0HTTP_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 1fi
Финальный вид уведомления
После того как базовое отправление заработало, мы добавили полезный контекст. Вот финальный формат, который используется в проекте:
✅ Успешный деплой на 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:
-
Проверьте DNS в Docker — запустите
docker run --rm curlimages/curl curl ... platform-api.max.ruвручную на сервере раннера. Если exit code 6 — вся дальнейшая отладка payload бессмысленна -
Проверьте с сервера напрямую — если с хоста работает, а из контейнера нет, проблема в сетевой конфигурации Docker
-
Добавьте DNS в config.toml —
dns = ["8.8.8.8", "8.8.4.4"]в[runners.docker] -
Только потом разбирайтесь с payload —
BODY_SIZE,BODY_TEXT,HTTP_CODE, полный Response -
Начинайте с минимального ASCII payload —
{"text":"test ok"}, без кириллицы и форматирования -
Очищайте переменные от
\r\nчерезtr -d '\r\n' -
Используйте
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, потом всё остальное.
ссылка на оригинал статьи https://habr.com/ru/articles/1022420/