Два сервера писали в один NFS-раздел и получили битые файлы без ошибок
Ночной экспорт отработал на обоих серверах без единой строчки ошибки в логе, оба процесса рапортовали «готово», а утром бухгалтерия открыла итоговый файл и увидела мешанину из чужих строк вперемешку со своими. Ни один exit-код не был ненулевым, ни одна попытка записи не вернула ошибку — а файл на общем NFS-разделе оказался испорчен. Разбираем, почему сетевая файловая система промолчала там, где локальная обычно кричит, и что на самом деле пошло не так.
Содержание
Хронология: два «успеха» и один битый файл
Сцена такая: биллинговый сервис крутится на двух прикладных серверах, app1 и app2, за балансировщиком. Раз в сутки в 00:00 по времени серверов cron-задача формирует сводный CSV-файл резервов и кладёт его в общий каталог на NFS-шаре, откуда его забирает бухгалтерская система. Штатно экспорт запускался только на app1 — на app2 эту cron-задачу включили несколькими днями раньше при расширении инфраструктуры, по шаблону конфигурации, скопированному с первого сервера, и никто не подумал, что оба скрипта пишут в один и тот же путь.
Хронология по логам после того, как разбор дошёл до сути:
| Время (локальное) | Событие |
|---|---|
| 00:00:00 | Cron стартовал export_daily.sh на app1 |
| 00:00:00 | Cron стартовал export_daily.sh на app2 (задача включена по ошибке) |
| 00:00:02 | app1: открыт /mnt/billing-export/reserves_20260904.csv на запись (O_CREAT|O_TRUNC) |
| 00:00:02 | app2: открыт тот же файл на запись (O_CREAT|O_TRUNC) |
| 00:00:03–00:00:07 | Оба процесса пишут данные буферами по мере генерации строк |
| 00:00:08 | app1: exit code 0, лог «export completed, 41 208 rows» |
| 00:00:08 | app2: exit code 0, лог «export completed, 41 208 rows» |
| 09:15 | Бухгалтерская система не смогла распарсить файл — обрыв структуры CSV на середине |
| 09:40 | Начато расследование |
Оба сообщения об успехе технически были правдой — каждый процесс действительно записал все свои строки и получил подтверждение от файловой системы на каждую операцию записи. Проблема не в том, что кто-то из них соврал в логе, а в том, что они писали в один и тот же файл одновременно, ничего друг о друге не зная.
Первая версия: ищем баг не там
Первая гипотеза при таком симптоме звучит логично: раз файл битый, а ошибок нет — значит, баг в самом скрипте генерации, что-то вроде неправильного экранирования кавычек в CSV или обрыва потока при генерации последней строки. Полчаса ушло на прогон export_daily.sh вручную на обоих серверах по отдельности — оба раза файл получался идеально валидным, с ровно 41 208 строками, без единого артефакта.
Это и должно было сразу натолкнуть на мысль: если скрипт стабильно работает правильно при одиночном запуске, проблема не в его внутренней логике, а в том, что происходит, когда работают два экземпляра сразу. Ловушка в том, что интуитивно ожидаешь явную ошибку при конфликте — «файл занят», «отказано в доступе», хоть что-нибудь. Отсутствие ошибки подсознательно снимало версию «два писателя» с рассмотрения дольше, чем следовало.
Переломный момент — сверка cron-логов на обоих хостах бок о бок по метке времени. Оба показали запуск export_daily.sh в 00:00:00 день в день. Дальше разбор пошёл в правильном направлении: не «что не так со скриптом», а «что происходит, когда два процесса на двух разных машинах пишут в один и тот же файл на общем хранилище одновременно».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверЧто было внутри битого файла на самом деле
Ключевую улику дал не лог, а содержимое самого повреждённого файла. Он не был обрублен и не был пустым — он был структурно нормальным CSV почти на всей длине, с корректными заголовками и корректными строками, но где-то в середине шла резкая смена: набор ID клиентов сменялся на другой диапазон, не пересекающийся с первым, а количество строк в итоговом файле — 38 950 — не совпадало ни с 41 208, залогированными app1, ни с 41 208, залогированными app2. Часть строк повторялась дважды, часть отсутствовала вовсе.
$ wc -l /mnt/billing-export/reserves_20260904.csv
38950 /mnt/billing-export/reserves_20260904.csv
$ awk -F, '{print $1}' reserves_20260904.csv | sort | uniq -c | sort -rn | head -3
2 CUST-10042
2 CUST-10043
2 CUST-10044
Это ровно тот отпечаток, который оставляет не логическая ошибка в одном процессе, а буквальное чередование кусков двух разных записей на уровне файла: оба процесса открыли файл с флагом усечения (O_TRUNC), оба вели собственный буфер и писали данные пачками по мере генерации, и в какой-то момент запись от app2 обнулила файл или перезаписала уже написанный app1 кусок, после чего оба процесса продолжили писать каждый свою последовательность байт в свои смещения, ничего не подозревая о существовании другого. Результат — файл-мозаика из фрагментов двух независимых прогонов, а не файл с логической ошибкой внутри одного прогона. Это тот самый случай тихой порчи данных без явной ошибки чтения, который подробно разбирался в статье про обнаружение тихой порчи файлов: проверка контрольной суммы или количества строк перед тем, как файл уходит потребителю, поймала бы проблему в момент возникновения, а не через девять часов в бухгалтерии.
Почему NFS не защищает от одновременной записи в один файл
Ключевая ошибка ожиданий здесь — перенос интуиции из мира «один процесс, одна локальная файловая система» на мир «два независимых клиента, общее сетевое хранилище». На локальной машине один процесс, кажется, «просто пишет в файл», и разработчики привыкают не думать о конкурентной записи вообще, потому что типичное приложение спроектировано так, что в конкретный файл в конкретный момент пишет ровно один процесс — конкуренции физически не возникает, вопрос атомарности не всплывает.
NFS меняет картину, но незаметно для того, кто не думал об этом специально:
- Запись файла с диска клиента на NFS-сервере физически превращается в последовательность отдельных сетевых RPC-вызовов
WRITE— большой буфер, который скрипт «пишет одним куском» на уровне кода, на уровне протокола режется на несколько сетевых операций. Если два клиента пишут в один файл одновременно, их RPC-вызовыWRITEперемежаются на сервере в том порядке, в котором до него дошли по сети — а не в том порядке, который предполагал каждый из клиентов. - NFS (особенно классическая NFSv3-семантика) не даёт автоматической взаимоисключающей блокировки файла при открытии на запись несколькими клиентами. Открыть один и тот же путь на запись с двух хостов одновременно — вполне легальная с точки зрения протокола операция, которая просто выполнится для обоих.
- Блокировки в NFS-мире (протокол NLM — Network Lock Manager,
flock/fcntlповерх сети) — advisory, то есть добровольные: они работают, только если приложение явно их запрашивает и явно их уважает. Если ни один из двух скриптов не вызываетflock(), для файловой системы это два равноправных клиента, которые оба имеют право писать. - Каждый отдельный
WRITE-вызов клиент получает подтверждение от сервера сразу после того, как сервер его выполнил — и с точки зрения этого клиента запись прошла успешно, потому что так оно и есть: его данные действительно были записаны на диск сервера. О том, что через секунду другой клиент перезапишет тот же диапазон байт своими данными, первый клиент никогда не узнает — это происходит уже после того, как его собственный вызов вернул успех.
Отдельно оказалось, что монтирование шары на обоих серверах было выполнено с опцией nolock:
$ mount | grep billing-export
nfs-storage:/export/billing on /mnt/billing-export type nfs (rw,nolock,vers=3)
Опцию когда-то добавили, чтобы обойти проблемы с rpc.statd при первоначальной настройке — и благополучно забыли снять. С nolock даже если бы разработчик позже добавил в скрипт вызов flock(), это не дало бы ничего: блокировка осталась бы чисто локальной для процессов на одном хосте и не создавала бы координации между app1 и app2. Ни ядро, ни NFS-клиент не выдают при этом предупреждения — flock() в такой конфигурации либо тихо не блокирует ничего между хостами, либо может вернуть успех, не гарантируя реальной эксклюзивности. Это и есть та грань между локальной файловой системой с одним писателем и сетевым хранилищем с несколькими независимыми клиентами, о которую разбился этот кейс. Более общее сравнение поведения сетевых протоколов доступа к файлам разбиралось в статье NFS против SMB для Linux-сервера.
Корневая причина
Технический механизм — конкурентная запись двух независимых клиентов в один файл на NFS без единого механизма блокировки, усугублённая опцией монтирования nolock, которая обнулила бы даже потенциальную защиту через flock().
Но техническая причина здесь — не главная. Архитектурная причина глубже: система изначально не была спроектирована с явным ответом на вопрос «кто из процессов имеет право писать в этот файл». Пока export_daily.sh существовал только на app1, вопрос не вставал — по умолчанию был ровно один писатель, что создавало иллюзию, будто «NFS просто работает как обычная файловая система». Когда добавили второй сервер с той же задачей, никто явно не спроектировал, что должно произойти при пересечении: не было ни блокировки, ни распределения по разным именам файлов — просто скопировали cron-задачу и понадеялись, что файловая система сама разберётся. Формулировка причины: несколько независимых процессов писали в один файл на общем сетевом хранилище без явного механизма координации.
Похожая логика — не полагаться на то, что общий ресурс сам разрулит конкуренцию, а явно спроектировать, кто и когда получает доступ, — разбиралась и для конкуренции внутри одной базы данных в статье база данных встала на ровном месте: разбор блокировки: там источник неожиданного поведения тоже был не в отдельно взятом запросе, а в том, что несколько процессов претендовали на один и тот же ресурс без явно продуманной модели доступа.
Что изменили: один файл — один писатель
Первым делом отключили cron-задачу экспорта на app2 — это устранило симптом за пять минут, но не решило архитектурную проблему: при следующем расширении инфраструктуры кто-нибудь снова мог по аналогии включить ту же задачу на новом сервере. Дальше работали над тем, чтобы конкурентная запись стала архитектурно невозможной.
Принцип «один файл — один писатель» — там, где это осуществимо, лучшее решение не бороться с конкурентной записью, а спроектировать так, чтобы её физически не было:
- Каждый прикладной сервер, если ему действительно нужно что-то писать самостоятельно, пишет в файл с уникальным именем, включающим идентификатор источника:
reserves_app1_20260904.csv,reserves_app2_20260904.csv, а не в общий путь. Конкуренции за один и тот же файл в такой схеме нет в принципе, независимо от того, сколько писателей и как именно они синхронизированы по времени. - Если в итоге всё равно нужен один сводный файл — его собирает отдельный, единственный процесс-агрегатор, который читает готовые файлы каждого источника и объединяет их в общий результат. Это классическое разделение producer/consumer: несколько производителей пишут каждый своё, ровно один потребитель читает всё и производит финальный артефакт. У финального файла по-прежнему остаётся ровно один писатель.
- В нашем случае решение свелось именно к этому:
app1иapp2пишут каждый в свой файл с суффиксом хоста, а третий, отдельный cron на выделенном сервере агрегации в 00:15 объединяет оба файла, проверяет контрольную сумму и итоговое число строк, и только после успешной проверки кладёт результат в каталог, который читает бухгалтерская система.
Там, где архитектурно действительно необходима конкурентная запись в общий ресурс несколькими независимыми процессами — например, несколько воркеров реально должны писать в один общий журнал одновременно — решение не в том, чтобы понадеяться на файловую систему, а в явном механизме координации:
- Явная блокировка файла с осознанным использованием NFS-locking: убрать
nolockиз опций монтирования, убедиться, чтоrpc.statd/rpc.lockdреально работают на сервере и на клиентах, и обязательно вызыватьflock()/fcntl()в самом коде — файловая система не расставит блокировки за приложение, она только предоставляет механизм, которым приложение обязано явно воспользоваться. - Там, где важна настоящая атомарность записи, честнее не изобретать координацию поверх файловой системы, а взять инструмент, который для этого создан — базу данных с транзакциями: несколько писателей пишут строки в таблицу в рамках отдельных транзакций, СУБД берёт на себя изоляцию и атомарность, а итоговый файл генерирует один отдельный процесс экспорта из уже согласованных данных.
- Обязательный пункт на будущее — явное тестирование сценария конкурентной записи при проектировании системы с общим сетевым хранилищем: два экземпляра пишущего процесса запускаются одновременно против реальной NFS-шары, после чего результат проверяется побайтово и по числу строк.
Для тех, кто ещё выбирает архитектуру общего хранилища с нуля, стоит заранее продумать, действительно ли NFS — правильный инструмент под сценарий с несколькими писателями, или лучше сразу закладывать другую модель доступа; сравнение вариантов общего хранилища для похожих сценариев разбиралось в статье общее хранилище для кластера: NFS, iSCSI или Ceph.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему ни один из процессов не получил ошибку при записи?
Каждый отдельный вызов записи NFS реально выполнила и подтвердила — с точки зрения конкретного клиента его данные были успешно записаны. О параллельной записи другого клиента в тот же файл ни один из процессов не знает, если оба не используют явную блокировку.
Разве open() с O_TRUNC не должен был бы конфликтовать между двумя клиентами?
Нет — усечение файла на NFS выполняется как обычная операция, которую сервер применяет по мере поступления запросов; ничто в протоколе не мешает второму клиенту усечь файл сразу после того, как первый его открыл и начал писать, если оба процесса не координируются явно.
Помогло бы, если бы серверы писали не в одну секунду?
Снизило бы вероятность попадания в узкое окно конкуренции в конкретный день, но не убрало бы саму архитектурную уязвимость — рано или поздно окна снова совпали бы. Полагаться на то, что два независимых крон-расписания никогда не пересекутся по времени, — не решение, а отсрочка повторного проявления.
Достаточно ли добавить flock() в скрипт?
Только если блокировка реально работает между хостами — NFS смонтирована без nolock, а служба блокировок (NLM) действительно поднята на сервере и клиентах. Добавить flock() поверх шары с nolock — не даст защиты, но создаст ложное ощущение, что проблема решена.
Как проверить, что похожая проблема не тлеет в другой системе?
Пройтись по cron-задачам на всех серверах, которые пишут в общие пути на сетевых хранилищах, и явно ответить: «кто ещё, кроме этого процесса, может писать в этот же путь» — если ответ не «никто, это гарантировано архитектурно», стоит разобраться заранее.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →