- PVSM.RU - https://www.pvsm.ru -
Мы хотели сделать простую вещь: после деплоя отправлять уведомление в чат 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.
Мы потратили несколько итераций именно на это.
printf '{"text":"%s"}' "$MSG" > /tmp/payload.json
curl ... -d @/tmp/payload.json
Ответ: Can't deserialize body
Переключились на alpine:3.20, установили jq:
jq -nc --arg t "$MSG" '{text:$t}' > /tmp/payload.json
curl ... -d @/tmp/payload.json
Ответ: всё равно Can't deserialize body
PAYLOAD=$(jq -nc --arg t "$MSG" '{text:$t}')
curl ... --data "${PAYLOAD}"
Ответ изменился: Empty request body
Это был прогресс — теперь тело вообще не доходило. Но apk add начал падать с exit code 2: раннер не мог тянуть пакеты из Alpine репозиториев.
Убрали все переменные. Полностью статичный 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 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"}'
Результат: сообщение пришло мгновенно.
Это важный момент. Мы видели 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 в 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 'rn')"
CHAT_ID="$(printf '%s' "$MAX_NOTIFY_CHAT_ID" | tr -d 'rn[: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.
Мы рассматривали вариант с 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"}, без кириллицы и форматирования
Очищайте переменные от rn через tr -d 'rn'
Используйте 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, потом всё остальное.
Автор: Kurganskyi
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/bash/449436
Ссылки в тексте:
[1] Источник: https://habr.com/ru/articles/1022420/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1022420
Нажмите здесь для печати.