Мы хотели сделать простую вещь: после деплоя отправлять уведомление в чат MAX из GitLab CI.
На бумаге задача выглядела почти тривиально:
есть
MAX_BOT_TOKENесть
MAX_NOTIFY_CHAT_IDесть
curlесть
POST https://platform-api.max.ru/messages?chat_id=...
Но на практике уведомления не приходили несколько дней. Мы меняли образы, переписывали скрипты, упрощали payload, добавляли диагностику. Результат был один: сообщение не доходило.
Настоящая причина оказалась совсем не там, где мы её искали.
Первые симптомы
Первый полезный лог выглядел так:
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 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 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:
Проверьте 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, потом всё остальное.
Комментарии (20)

msergein
12.04.2026 08:04Ставят минусы потому что интеграция с Максом, детский сад

R0uT3r
12.04.2026 08:04Ставят минусы, потому что весь этот треш с блокировкам удобных инструментов (vpn в том числе) начался с MAX. Поддерживать это поделие нет желания ни в коем виде.

aamonster
12.04.2026 08:04Скорей за нейрослоп. Обрати внимание на раздел "вспомогательные материалы": его явно оставили для нейронки, а та пропустила насквозь. Ну и "автор" статью не вычитывал. Если у них и с кодом так же – не удивительно, что интеграция не работала.

ma1uta
12.04.2026 08:04А на Хабре есть правило, чтобы перед публикацией статьи, автор должен прочитать минимум один раз то, что сгенерировала нейронка?

n_bogdanov
12.04.2026 08:04А можно в маске получить бота на физлицо?

Kurganskyi Автор
12.04.2026 08:04На текущий момент регистрация открыта только для юр. лиц.
Кстати сделал регистрацию за 5 мин, подтвердил через т.банк реквизиты и дали доступ.
n_bogdanov
12.04.2026 08:04Ну то есть вы серьезно не видите проблематики в том, что базовый функционал соцсети у вас доступен только для ИП и т.д. Физлицо лишено такого удовольствия.
Ну вот у меня есть свой гитлаб, свой гитхаб. А ИП у меня нету. Ради Макса открывать ИП?

Kurganskyi Автор
12.04.2026 08:04В нашей стране, стать самозанятым можно в два клика и я вижу проблематику только в умах людей, которые строят невидимые стены и входят в бесползеную оппозицию с системой. Для меня эта трата энергии и сил. Мир меняется очень быстро и новые реалии диктую новую парадигму поведения в социальных сетях. Естественный отбор уберет тех кто сопротивляется. Есть новая реальность и я принял её.

n_bogdanov
12.04.2026 08:04Естественный отбор подсказывает, что лучше использовать не Макс, а СМС.
Ну и на самозанятого токен получить нельзя. Регистрировать ИП ради Макса - боже упаси. Токены от рабочего аккаунта в личных проектах использовать немного не вписывается в NDA.
Резюмируя - сырая штука, которая не решит моих задач, придется тратить время и деньги на модернизацию monitoring pipeline, встраивая heartbeat и имея дублирующие каналы связи - телеграмм, ВК, смс.

GoldArr
12.04.2026 08:04Мы достигли апогея технологического прогресса, чтобы вернутся к традиционным СМС

Inlore
12.04.2026 08:04Шутка смешная в текущих реалиях, но тут всё-таки человек говорил именно про дублирование. И как бы смс вполне себе используют как второй канал доставки, если через интернет не удалось отправить

n_bogdanov
12.04.2026 08:04Чтобы pipeline усложнился и в нем появились сразу n каналов с heartbeat. А в ботах появились heartbeat и кнопки acknowledge
Как итог - мой monitoring pipeline из хука alertmanager в tg API перерос в alertmanager+goalert + мультиканальный бот с heartbeat, кнопками в ботах, интеграцией с Jira и email , возможность интеграции с smsd
Спасибо Макс, ты толкнул эволюцию

thunderspb
12.04.2026 08:04
Т.е теперь настолько не палятся при использовании ии для статей?

n_bogdanov
12.04.2026 08:04Потому что нейроаналитику лень даже вычитать. Nuff said

Kurganskyi Автор
12.04.2026 08:04Спасибо большое за такое количество внимания и комментариев к моей первой статье. Меня поразило такое большое количество охватов и просмотров.
Больше всего замотивировали писать дальше 7 человек, которые сохранили статью себе и более 100, которые перечитали её несколько раз.
Подписывайтесь, дальше будет больше про макс. Расскажу как сделали первого чат-бота для мфц и проект был выбран пилотным для региона, который мы курируем.
Так же поделюсь, как используя современные ии выстраивать процессы внутри команды.
ps дальше больше
rSedoy
Аналитик, ну сделал бы сначала анализ отношения пользователей хабра к Максу.
Kurganskyi Автор
Спасибо за комментарий, буду теперь знать, что не очень любят)