Представим форму «Запросить расчёт»: имя, почта, комментарий и один PDF с техзаданием. Браузер отправляет её на адрес вида /.netlify/functions/request. Функция принимает запрос, достаёт файл, куда-то его кладёт, отправляет письмо менеджеру и отвечает JSON-ом, по которому фронтенд показывает «Спасибо». Для посетителя это один жест. Для кода это как минимум четыре действия: разобрать тело, сохранить файл, сохранить заявку, уведомить человека. На Netlify они живут в одном файле и выглядят как одно целое. На собственном VPS это целое придётся собрать заново, и важно собрать его с теми же видимыми гарантиями: пользователь не получает ложного «успеха», заявка не пропадает, а лишний клик не плодит дубли.
Функции Netlify исполняются в управляемой среде по запросу или событию, а их код собирается, версионируется и разворачивается вместе с сайтом. Отсюда следует неприятная для переезда вещь: многое, что вы считали «просто кодом формы», на самом деле принадлежало окружению. Сборка, переменные, маршрут, лимиты, логи, повторные запуски — всё это нужно будет либо воспроизвести, либо осознанно отказаться от него. Поэтому начнём не с nginx и не с языка программирования, а с описания того, что именно обещает ваша форма снаружи.
Контракт, который браузер уже выучил
Контракт формы — это всё, на что опирается фронтенд и что он не проверяет явно. Выпишите его по реальному запросу из вкладки Network, а не по памяти. Для нашей формы получится примерно такая таблица.
| Что | Как сейчас | Что проверить на VPS |
|---|---|---|
| Метод и путь | POST на путь функции | Старый путь остаётся рабочим на время переключения |
| Тело | multipart/form-data, поля и один файл | Имена полей, регистр, пустые значения, порядок частей |
| Файл | PDF, ограничение по размеру в браузере | Те же ограничения на сервере, а не только в JavaScript |
| Успех | Код 200 и JSON с флагом, который читает фронтенд | Тот же код и та же форма JSON |
| Ошибка валидации | Код 4xx и список полей с ошибками | Совпадение ключей, чтобы подсветка полей не сломалась |
| CORS | Не нужен, если форма и функция на одном домене | Нужен, если API переедет на поддомен |
| Повтор | Не определён: каждый клик — новый запрос | Здесь появляется новое поведение, и его надо задать явно |
Последняя строка важнее остальных. Старая функция, скорее всего, повторов не замечала: второй клик создавал вторую заявку и второе письмо. Пока всё работало быстро, этого никто не видел. На новом месте ответ может прийти на полсекунды позже, и вероятность двойных кликов вырастет. Перенос — подходящий момент перестать полагаться на удачу.
Перенос адреса — ещё не перенос функции
Соблазнительно считать задачу решённой, когда запрос на старый путь доходит до нового сервера. Но это только маршрут. У обработчика есть собственные ограничения, секреты и зависимости, и для этой формы их стоит перечислить письменно до первой строки кода.
- Ограничения среды. Максимальное время выполнения, предел размера тела запроса, доступная память. Откройте настройки и логи вашей функции и выпишите реальные значения, под которые форма была написана. Если файлы до сих пор укладывались в лимит платформы, это не значит, что на VPS лимит «бесконечен»: он просто станет вашим собственным решением в настройках прокси и приложения.
- Секреты. Пароль или ключ почтового сервиса, адрес получателя, токен капчи, если она есть. Составьте список имён переменных и того, кто их использует. Значения не копируйте в чат, в репозиторий или в историю команд; на сервер они попадают отдельным закрытым файлом окружения с правами только для служебного пользователя.
- Зависимости. Библиотека разбора multipart, клиент почты, определение типа файла, антивирусная проверка, если была. Зафиксируйте версии из lock-файла: на VPS вы будете собирать то же самое, а не «примерно похожее».
- Побочные эффекты. Куда функция писала помимо письма: в таблицу, в стороннюю CRM, в хранилище файлов, в уведомления в мессенджер. Каждый такой пункт — отдельная связь, которая должна пережить переезд.
- Скрытые допущения. Например, что временный файл лежал в файловой системе, которая исчезала после запроса. На VPS диск постоянный, и всё недописанное останется лежать, пока вы сами его не уберёте. К этому мы вернёмся.
Отдельно оговорим, что Netlify Forms и Netlify Functions — разные сервисы. Если ваша форма работала через управляемые Forms, то у неё были их собственные механизмы приёма, и переносить придётся их роль, а не код функции. В нашей статье форма обрабатывается именно функцией, поэтому всё, что делает обработчик, мы должны увидеть в его коде. Если чего-то там нет — например, защиты от спама или хранения заявок «где-то в панели», — значит, этого нет и в контракте, который надо воспроизвести, либо оно жило в другом сервисе и требует отдельного решения.
Отдельный API: приём запроса на VPS
На сервере появляется небольшое приложение с единственным маршрутом приёма и вспомогательным маршрутом проверки состояния. Перед ним стоит nginx, который терминирует TLS и пересылает запросы приложению. Язык и фреймворк не принципиальны; принципиально, чтобы разбор тела, проверка и сохранение были отдельными шагами, а не одной функцией на сто строк.
Ресурсы здесь скромные по меркам любого сервера: форма с файлом — это не вычислительная задача, а задача на аккуратность. Для старта подойдёт небольшой тариф VPS с NVMe-диском и root-доступом вроде Ba или Uab из публичного списка MAATRIX; root нужен, чтобы настроить nginx, службу приложения и расписание очистки. Ресурсы тарифа не заменяют замера: нагрузочную пробу с вашими реальными файлами стоит сделать на выбранной конфигурации, а не принимать цифры из таблицы за обещание ёмкости.
Порядок проверки внутри запроса
Порядок шагов определяет, сколько ненужной работы вы делаете и насколько безопасно. Рабочая последовательность такая.
- Ограничить размер тела ещё на nginx (директива
client_max_body_size) значением чуть выше допустимого размера файла плюс поля. Приложение не должно читать гигабайты, чтобы потом отказать. - Прочитать заголовок
Content-Typeи убедиться, что это multipart. Иначе сразу вернуть 415 с понятным телом. - Разобрать поля потоково; файл писать во временный каталог, а не держать в памяти целиком.
- Проверить поля: обязательность, длину, формат почты. Ошибки собрать списком и вернуть в том же формате, что отдавала функция.
- Проверить файл по содержимому, а не по имени: расширение и заявленный тип легко подделать, поэтому начало файла должно соответствовать PDF. Размер сверить с реальным числом записанных байтов.
- Только после этого переходить к постоянному хранению.
Если фронтенд или промежуточный слой подписывает запрос, например HMAC-подписью тела, порядок меняется: подпись проверяется до любого разбора. Хорошую иллюстрацию даёт описание вебхуков Stripe: подпись считается по исходному телу запроса, и любое его изменение до проверки делает проверку некорректной. К форме расчёта Stripe отношения не имеет, но принцип переносится: если у вас есть подпись, прокси и приложение не должны переписывать тело и разбирать его заранее. Для обычной формы без подписи этот пункт можно пропустить.
Что nginx делает с ответом и почему это важно меньше, чем кажется
В документации модуля proxy описаны две настройки, о которых часто спорят при переездах: буферизация ответа и таймаут чтения. proxy_buffering управляет буферизацией ответов от приложения; при отключении данные уходят клиенту по мере получения. Для потоковых ответов это существенно. Наша форма отвечает коротким JSON-ом, и разница между режимами для неё практически незаметна, поэтому трогать этот параметр без причины не нужно. Заголовок ответа X-Accel-Buffering умеет менять буферизацию для конкретного ответа, но только если конфигурация это не запрещает; нам он не потребуется.
Куда интереснее proxy_read_timeout. Он отсчитывает интервал между двумя последовательными чтениями ответа от приложения, а не общую длительность запроса. Для формы это означает, что если приложение молчит дольше установленного интервала, например зависло на отправке письма внутри обработчика, nginx оборвёт соединение и браузер получит ошибку шлюза, хотя приложение, возможно, ещё продолжит работу и даже закончит её. Это и есть источник «неопределённого ответа»: клиент не знает, дошла ли заявка. Лучший способ не попадать в эту ситуацию — не делать в обработчике ничего медленного. Так мы и поступим.
Сначала заявка, потом письмо
В старой функции, вероятно, последовательность была такой: принять, отправить письмо, ответить. Если почтовый сервис отвечал медленно или с ошибкой, пользователь видел сбой, а заявка существовала только в этом письме — то есть нигде. На VPS мы перевернём порядок: заявка должна стать долговечной раньше, чем кто-либо о ней узнает. Письмо превращается из условия успеха в следствие.
Для этого нужны две таблицы в любой надёжной базе, будь то PostgreSQL или SQLite с правильной настройкой. Первая — сами заявки, вторая — очередь уведомлений. Схема в упрощённом виде:
CREATE TABLE submissions (
id BIGSERIAL PRIMARY KEY,
submit_key TEXT NOT NULL UNIQUE,
payload_hash TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT,
file_path TEXT,
file_sha256 TEXT,
file_size BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
submission_id BIGINT NOT NULL REFERENCES submissions(id),
status TEXT NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
next_try_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Запись заявки и запись уведомления делаются в одной транзакции вашей собственной базы: либо есть обе строки, либо ни одной. Это единственное место, где атомарность действительно у вас в руках. Внешний почтовый вызов в эту транзакцию не входит, и считать иначе нельзя: письмо уходит отдельным шагом, и между «база подтвердила» и «почта приняла» всегда есть окно, в которое может попасть сбой.
Файл: временное имя, проверка, переименование
С файлом схема похожа. Он записывается в каталог загрузок под временным именем, например с суффиксом .part. По окончании записи считается размер и контрольная сумма SHA-256, затем файл переименовывается в постоянное имя, построенное из идентификатора заявки или хэша, а не из пользовательского имени. Пользовательское имя хранится как метаданные и никогда не участвует в пути на диске: так исчезают и подмена каталогов, и коллизии одинаковых «ТЗ.pdf».
Переименование внутри одной файловой системы выполняется как одна операция, и это даёт полезное свойство: в постоянном каталоге либо лежит целый файл, либо его нет. Каталог временных файлов и каталог постоянных лучше держать на одном разделе именно ради этого. Только после успешного переименования строка заявки записывается в базу со ссылкой на файл. Если сервис упадёт между переименованием и записью, останется файл-сирота без строки; это безвредно и убирается плановой чисткой, о которой скажем ниже. Обратная ситуация — строка без файла — недопустима, и порядок шагов её исключает.
Рабочий процесс уведомлений
Отдельный процесс раз в несколько секунд выбирает из notifications записи со статусом pending, у которых наступило next_try_at, и пытается отправить письмо. Удалось — статус sent. Не удалось — счётчик попыток растёт, а время следующей попытки сдвигается с нарастающей паузой. После заданного числа неудач запись получает статус failed, и именно здесь должен сработать независимый сигнал: строка в журнале, которую видит мониторинг, или сообщение в служебный канал. Тихо накапливающийся failed — это та же потерянная заявка, только с лучшей бухгалтерией.
Теперь честно о границе гарантий. Если процесс отправил письмо, а затем упал до обновления статуса, то после перезапуска он отправит письмо снова. Ровно один раз без дополнительных механизмов внешний вызов не гарантируется, поэтому мы выбираем сторону «лучше два письма, чем ни одного» и делаем дубль безвредным: в каждом письме указан номер заявки, а менеджеру видно, что это та же самая. Так же устроен подход, описанный в вебхуках Stripe: долгую обработку отделяют от быстрого успешного ответа endpoint, а повторные доставки допускают как норму. Мы применяем ту же мысль к собственному обработчику: быстрый ответ клиенту после сохранения, работа с почтой в фоне.
Вложение в письмо тоже можно не прикладывать. Если ссылка ведёт на файл в защищённом разделе, где менеджер входит под своей учётной записью, письмо остаётся лёгким, не упирается в лимиты почтового провайдера и не расползается по ящикам. Это решение зависит от процессов вашей команды; если менеджерам привычнее получать файл письмом, приложите его, но берите байты из постоянного хранилища, а не из памяти запроса.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Повтор после неопределённого ответа
Вернёмся к окну неопределённости. Клиент отправил форму и не получил ответа: сеть моргнула, вкладку переключили, nginx оборвал соединение. Заявка могла дойти, а могла не дойти. Пользователь, не зная этого, нажимает кнопку ещё раз. Хороший обработчик обязан сделать так, чтобы результат повторного нажатия не зависел от того, что произошло с первым.
Ключ отправки живёт в браузере, а не на сервере
Идея проста. Когда пользователь открывает форму или начинает её заполнять, фронтенд создаёт случайную строку, например UUID, и отправляет её вместе с полями, скажем в заголовке Idempotency-Key или в служебном поле. Ключ хранится в состоянии формы и не меняется при повторных кликах. Он обновляется лишь после подтверждённого успеха или когда пользователь осознанно начал новую заявку. Если генерировать ключ заново на каждый клик, смысл теряется: каждая попытка будет выглядеть сервером как новая заявка. Сам по себе новый идентификатор попытки ничего не делает идемпотентным; идемпотентность появляется оттого, что один и тот же ключ приходит несколько раз, а сервер умеет его узнавать.
Сервер распознаёт повторы с помощью ограничения UNIQUE на колонку submit_key. Проверка «есть ли уже такая строка» перед вставкой не годится: два одновременных запроса обе увидят «нет» и обе вставят. Правильно — пытаться вставить и обрабатывать конфликт. Например, в PostgreSQL:
INSERT INTO submissions (submit_key, payload_hash, name, email, message, file_path, file_sha256, file_size)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (submit_key) DO NOTHING
RETURNING id;Если вернулась строка, мы первые, и дальше создаём уведомление. Если пусто, заявка с этим ключом уже существует, и мы читаем её, сравниваем payload_hash с хэшем текущего запроса и отвечаем по результату сравнения.
- Тот же ключ, то же содержимое. Это честный повтор. Возвращаем тот же успешный ответ с тем же идентификатором, что получил бы первый запрос. Новых файлов и писем не создаём.
- Тот же ключ, другое содержимое. Это уже не повтор: либо ошибка клиента, либо чужая попытка подобрать ключ. Отвечаем 409 с кратким описанием и ничего не перезаписываем.
- Тот же ключ, первый запрос ещё выполняется. Редкий, но возможный случай при двух параллельных запросах: ограничение уникальности заставит второй дождаться исхода первого или получить конфликт. Ответ — тот же, что и в первой строке, как только заявка зафиксирована.
Хэш содержимого считается по полям и контрольной сумме файла, а не по сырым байтам запроса: границы multipart-частей у двух отправок могут отличаться, хотя смысл тот же.
Куда девать загруженный дважды файл
Повтор приходит вместе с файлом, и тот уже записан во временный каталог. Логика должна выяснить, что ключ известен, до переименования в постоянное имя или сразу после, и удалить временный файл. Проще всего считать контрольную сумму, попытаться вставить строку и, если случился конфликт, удалить только что записанный .part. Постоянный файл первой заявки при этом не затрагивается, потому что его имя построено из идентификатора заявки, а не из имени, пришедшего в повторе.
Неполное вложение: обрыв на полпути
Отдельный сценарий — клиент оборвал соединение во время загрузки. Приложение получило часть файла, и поток закрылся. Хорошая новость: файл лежит под временным именем, в базе записей нет, пользователь не получил подтверждения. Плохая новость: временный файл остался на диске. Среда Netlify подчищала такие хвосты сама, а на VPS это ваша обязанность.
Есть два уровня защиты. Первый — немедленная реакция: обработчик перехватывает событие закрытия соединения и ошибку потока и удаляет .part. Второй — страховка на случай, если процесс убили или сервер перезагрузили: плановая задача, которая раз в час удаляет из каталога временных файлов всё старше, например, двух часов. Значение выбирайте с запасом относительно самой долгой допустимой загрузки; цифра здесь условная и подбирается под ваши размеры файлов и скорость каналов. Такая же задача находит файлы-сироты в постоянном каталоге, у которых нет строки в базе, но удалять их лучше не сразу, а сначала переносить в карантинную папку на несколько суток: ошибки в такой чистке дорого стоят.
Пользователю, чья загрузка оборвалась, фронтенд предлагает нажать кнопку ещё раз. Ключ отправки тот же, база заявку не видела, значит, сервер примет её как первую. Если же оборвался не запрос, а ответ, то повтор вернёт уже существующий результат. В обоих случаях человеку не нужно знать, какой из вариантов произошёл.
Репетиция до переключения
Всё описанное превращается в проверяемый набор сценариев. Репетицию удобно проводить на стенде с тем же nginx и приложением, куда фронтенд направлен через переменную с адресом API. Тестовые заявки отмечайте служебной почтой или специальным значением поля, чтобы отличать их от настоящих.
| Сценарий | Как воспроизвести | Ожидаемый результат |
|---|---|---|
| Обычная отправка | Форма с PDF допустимого размера | Ответ 200, одна строка в заявках, один файл, одно уведомление |
| Серия кликов | Пять одинаковых запросов с одним ключом подряд и параллельно | Одна заявка, один файл, одно письмо; остальные ответы совпадают с первым |
| Ключ с другим содержимым | Те же ключ, но другой комментарий | Ответ 409, исходная заявка не изменилась |
| Обрыв загрузки | Прервать curl на половине файла | Записи в базе нет, временный файл удалён сразу или плановой задачей |
| Потерянный ответ | Остановить приложение после записи в базу, до ответа клиенту, затем повторить запрос | Повтор возвращает успех без новой заявки |
| Сбой почты | Указать недоступный адрес почтового сервера | Ответ клиенту всё равно успешный, уведомление в pending с растущим числом попыток |
| Слишком большой файл | Файл выше лимита | Отказ на уровне nginx с кодом 413 и понятным сообщением на фронтенде |
| Чужой тип файла | Исполняемый файл, переименованный в .pdf | Отказ с ошибкой валидации по полю файла |
Серия кликов проверяется командой, которую можно запускать на любой машине с curl:
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Idempotency-Key: rehearsal-0001' \
-F 'name=Тест' -F 'email=test@example.com' \
-F 'message=репетиция' -F 'file=@spec.pdf;type=application/pdf' \
https://staging.example.com/api/request &
done; waitКлюч и адрес здесь условные. После запуска откройте базу и посчитайте строки с этим ключом, а в каталоге загрузок — файлы. Один из пяти ответов может прийти чуть позже других, потому что остальные ждали исхода первого; код у всех должен быть успешным.
Сценарий с остановкой приложения особенно поучителен. Он показывает то, что в логах не видно: клиент остался без ответа, но система находится в корректном состоянии. Если после повтора вы видите две строки или два файла, значит, ключ не доходит до базы или ограничение уникальности не создано, и переключать фронтенд рано.
Переключение фронтенда и наблюдение за старым маршрутом
Переключение — это изменение одного адреса в клиентском коде, но последствия у него не одномоментные. Часть пользователей будет держать открытыми вкладки со старой версией страницы, и их форма продолжит стучаться в функцию Netlify. Закрывать её на следующий день после выкладки нельзя.
Практичная последовательность выглядит так.
- Адрес API берётся из конфигурации. Фронтенд читает его из одной настройки сборки, а не из десятка мест. Откат — возврат значения и новая выкладка.
- Малый процент трафика. Если у вас есть способ направить часть посетителей на новый адрес, начните с небольшой доли. Если нет, выложите новый адрес в период, когда заявок обычно мало, и внимательно следите за первыми.
- Контрольная заявка. Сразу после выкладки отправьте настоящую заявку с боевой страницы и проследите её путь: ответ, строка в базе, файл на диске, письмо у менеджера.
- Старый маршрут не отключается, а наблюдается. Оставьте функцию Netlify работающей и смотрите её журнал вызовов. Падающая к нулю частота запросов — признак того, что старые вкладки закрылись и кэш обновился. Один неожиданный вызов через неделю означает, что есть источник, о котором вы забыли: другая страница, письмо со ссылкой, партнёрская интеграция.
- Заявки старого пути не терять. Пока функция жива, она продолжает присылать письма как раньше. Договоритесь, где менеджеры ищут заявки в переходный период, и на время сведите их в один список вручную или временным импортом.
Критерии возврата заранее определите письменно и придерживайтесь их без споров. Например: возвращаемся на Netlify, если на новом обработчике статус failed в уведомлениях появился хотя бы раз без понятной причины; если число заявок существенно ниже обычного для этого часа по сравнению с тем же днём недели; если в базе обнаружены дубли с разными ключами и одинаковым содержимым, указывающие на сбой на стороне клиента. Конкретные пороги зависят от вашего потока заявок, а не от универсальных цифр.
Параллельно с этим следите за самим сервером: свободное место на диске с загрузками, возраст самого старого pending-уведомления, размер каталога временных файлов. Три числа, которые легко вывести в простую страницу состояния, расскажут о здоровье формы лучше любого абстрактного показателя нагрузки. Если по мере роста вложений каталог загрузок начнёт занимать ощутимую часть диска, это повод увеличить том или вынести файлы в отдельное хранилище, а не повод подозревать код. Резервные копии базы и каталога файлов должны быть настроены вами; наличие автоматических копий на уровне тарифа в публичных материалах не подтверждено, поэтому закладывайте собственное расписание и проверяйте восстановление хотя бы на тестовой машине.
Если вы переносите не одну форму, а весь сайт, остальные части — редиректы, заголовки, другие функции — удобнее разбирать по общей схеме; её описывает материал про перенос форм, функций и перенаправлений с Netlify на VPS. Здесь же мы занимались одной функцией и её контрактом, потому что именно в нём прячутся мелкие обещания, которые легко потерять.
Одна заявка, пять кликов
Вернёмся к форме расчёта. Посетитель выбирает PDF, нажимает «Отправить», страница задумывается, он нажимает ещё раз, потом ещё. Каждый клик несёт один и тот же ключ, и ни один не создаёт нового файла. Первый запрос записывает файл во временное имя, считает сумму, переименовывает, вставляет строку и уведомление. Остальные натыкаются на ограничение уникальности и получают тот же успешный ответ. Даже если в этот момент оборвётся соединение или почта откажется отвечать, заявка уже лежит в системе, а письмо уйдёт, как только сервис оживёт.
Проверить это можно одним запросом к базе после репетиции:
SELECT s.id, s.submit_key, s.file_size, count(n.id) AS notifications
FROM submissions s
LEFT JOIN notifications n ON n.submission_id = s.id
WHERE s.submit_key = 'rehearsal-0001'
GROUP BY s.id;Ожидаемый ответ — одна строка: один идентификатор, один размер файла, одно уведомление. А в каталоге загрузок — один файл с именем, построенным из этого идентификатора, и ни одного хвоста с суффиксом .part. Пять нажатий, одна заявка, одно вложение. Если картина именно такая, обработчик перенесён не как адрес, а как набор обещаний, которые видел пользователь.