АйТи Фреш
Главная / Статьи / Linux, Docker и DevOps
Linux, Docker и DevOps

Два Pod пишут в один RWO-том: почему так и как перевести PVC на ReadWriteOncePod

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~19 мин чтения
Два контейнера пишут в один том на узле, рядом замок пропускает один Pod — ReadWriteOnce и ReadWriteOncePod
ReadWriteOnce охраняет узел, а не данные: единственного писателя гарантирует только ReadWriteOncePod.

ReadWriteOnce не означает «один писатель»: режим ограничивает том одним узлом, а Pod на этом узле могут открыть его хоть вдвоём. Единственного писателя гарантирует только ReadWriteOncePod. Ниже — разбор аварии с базой Prometheus, поиск таких томов за пять минут и миграция PVC без потери данных.

«Once» — это про узел, а не про Pod

Начну с самого болезненного — с того, что я первым проверяю, когда мы в АйТи-Фреш берём кластер на сопровождение Kubernetes и open-source. Формулировка из официальной документации Kubernetes звучит так: «ReadWriteOnce: the volume can be mounted as read-write by a single node. ReadWriteOnce access mode still can allow multiple pods to access (read from or write to) that volume when the pods are running on the same node». То есть слово «Once» относится к узлу. Один узел — да. Один Pod — нет, никогда и не обещали.

Механика простая, и как только её проговоришь вслух, всё встаёт на места. CSI-драйвер делает attach блочного устройства к ноде и монтирует его в глобальную точку монтирования — ровно один раз. Дальше kubelet каждому Pod, который запросил этот PVC, делает bind-mount из этой глобальной точки в контейнер. Счётчик ссылок увеличивается, том остаётся один. Никакого «второй Pod получит ошибку» в этой схеме не заложено: с точки зрения хранилища всё корректно, диск примонтирован единожды и на одном хосте. Два процесса в двух контейнерах видят одну и ту же файловую систему — как два процесса на обычном сервере.

Режимов доступа в Kubernetes четыре, и путаница почти всегда между первым и четвёртым. Аббревиатуры, которые вы увидите в выводе kubectl: RWO, ROX, RWX, RWOP.

ReadWriteOncePod появился в Kubernetes 1.22 как alpha, а стабильным (GA) стал в 1.29 — в сентябре 2026-го актуальны ветки 1.35–1.37, так что это давно зрелый механизм, а не экспериментальный флаг. Единственное «но»: официально режим поддерживается только для CSI-томов. Для hostPath, in-tree-плагинов и провижнеров вроде rancher.io/local-path гарантия единственного Pod документацией не обещана — одни такой режим в PVC просто не примут, другие примут, но вести себя будут по-своему. Проверьте в своей версии, прежде чем на это рассчитывать.

RWO гарантирует ровно одно: том не окажется примонтирован на двух узлах одновременно. Защита от второго писателя внутри узла — это не про RWO. Если вам нужен единственный писатель, режим называется ReadWriteOncePod.
Сравнение ReadWriteOnce и ReadWriteOncePod: два Pod на одном узле против одного Pod на том
RWO пускает к тому любые Pod на том же узле, RWOP — ровно один во всём кластере.

Разбор аварии: как брокер потерял 19 дней метрик

Клиент — страховой брокер «Страховой навигатор», 24 рабочих места. Своих разработчиков двое, они держат внутренний сервис расчёта полисов и портал для агентов на небольшом кластере из трёх узлов в стойке у провайдера: Kubernetes 1.35, хранилище Rook-Ceph с ceph-csi (RBD), сайдкары в связке provisioner 5.x / attacher 4.x — по версиям всё давно проходило под RWOP. Мониторинг — Prometheus, но не через оператор, а обычным Deployment из манифеста, который когда-то написал подрядчик. У Prometheus PVC на 60 Gi, retention 45 дней, режим доступа, как у всех, ReadWriteOnce, стратегия обновления — RollingUpdate по умолчанию.

За полгода до того, как брокер пришёл к нам, их разработчик столкнулся с классической болью: при kubectl rollout restart новый Pod Prometheus поднимался, упирался в lock-файл в каталоге данных и падал, а старый в это время ещё завершался — деплой залипал на несколько минут. В интернете на этот случай есть готовый совет, который повторяется в десятках issue: добавьте флаг --storage.tsdb.no-lockfile. Его и добавили. Залипание ушло. А вместе с ним ушла последняя защита от второго писателя — потому что RWO её никогда не давал, единственным барьером был как раз этот lock-файл.

Дальше — арифметика. Deployment с maxSurge=1, nodeSelector на узел с быстрым SSD-пулом. При очередном обновлении планировщик поставил новый Pod на тот же узел, где доживал старый: RWO это разрешает, том уже примонтирован, bind-mount выдаётся мгновенно. Два Prometheus одновременно писали в один WAL и один каталог chunks около четырёх минут — пока новый инстанс проигрывал WAL, а старый досчитывал последние scrape-интервалы и завершался. На следующем старте в логе появились ошибки чтения блоков вида invalid magic number, а в середине истории — битые блоки.

Итог: 19 дней метрик восстановить не удалось — снапшот тома был один и суточной давности, а битые блоки пришлось просто удалить из каталога данных, чтобы Prometheus вообще стартовал. На разбор и восстановление ушло около трёх часов моего времени плюс час согласования окна с руководителем брокера. Самое неприятное — по логам Prometheus мы нашли ещё два таких наложения за четыре месяца, просто перекрытие было секундным и TSDB его переварила. В событиях кластера этого не видно вообще: с точки зрения Kubernetes ничего не сломалось. Сама миграция всех восьми stateful-томов брокера на RWOP после этого заняла у нас один рабочий вечер.

Если вы где-то отключили внутреннюю блокировку приложения (`--storage.tsdb.no-lockfile` у Prometheus, `nolock` в опциях монтирования NFS, самописные lock-файлы с PID), у вас не осталось ни одного барьера. RWO таким барьером не является.
Два Pod пишут в один RWO-том: почему так и как перевести PVC на ReadWriteOncePod — схема
Схема к статье. Открыть схему в полном размере
Хронология аварии Prometheus на RWO-томе: отключённый lock-файл, RollingUpdate, два писателя, потеря метрик
Двойной писатель рождается из связки «отключённый lock + RollingUpdate + RWO».

Как за пять минут найти у себя такие же тома

Первое, что я делаю на новом кластере, — смотрю не «есть ли проблема», а «где она может выстрелить». Нужны две вещи: список PVC с режимом RWO и список случаев, когда на один PVC ссылается больше одного живого Pod. Второе — прямая улика, но встречается редко, потому что окно перекрытия короткое. Первое — карта рисков.

# 1. Все PVC и их режимы доступа
kubectl get pvc -A -o custom-columns=\
'NS:.metadata.namespace,PVC:.metadata.name,MODES:.spec.accessModes[*],SC:.spec.storageClassName,PV:.spec.volumeName'

# 2. Кто прямо сейчас держит один и тот же PVC больше чем одним Pod
kubectl get pods -A -o json | jq -r '
  .items[] | . as $p |
  ($p.spec.volumes // [])[] |
  select(.persistentVolumeClaim) |
  "\($p.metadata.namespace)/\(.persistentVolumeClaim.claimName)\t\($p.spec.nodeName)\t\($p.metadata.name)"' \
| sort | awk -F'\t' '{c[$1]++; n[$1]=n[$1]" "$3} END{for(k in c) if(c[k]>1) print c[k], k, n[k]}'

Дальше — проверка, что миграция вообще возможна. RWOP работает только на CSI, поэтому смотрим драйверы и провижнеры StorageClass. Если в provisioner стоит rancher.io/local-path, kubernetes.io/no-provisioner или что-то in-tree, на гарантии RWOP не рассчитывайте: документация обещает их только для CSI. Заодно сверяем версии сайдкаров: provisioner 3.0.0+, attacher 3.3.0+, resizer 1.3.0+. На современных драйверах 2026 года это выполняется с запасом, но у клиентов регулярно находится ceph-csi трёхлетней давности, приколоченный гвоздями к старому Helm-релизу.

kubectl get csidrivers
kubectl get sc -o custom-columns='NAME:.metadata.name,PROV:.provisioner,RECLAIM:.reclaimPolicy'

# Версии сайдкаров конкретного драйвера (пример для ceph-csi)
kubectl -n ceph-csi get pods -o jsonpath=\
'{range .items[*]}{.metadata.name}{"\n"}{range .spec.containers[*]}  {.name}={.image}{"\n"}{end}{end}'

# Красный флаг №1: Deployment c RWO-томом и стратегией RollingUpdate
kubectl get deploy -A -o json | jq -r '
  .items[] | select((.spec.template.spec.volumes // [])[]?.persistentVolumeClaim) |
  "\(.metadata.namespace)/\(.metadata.name)\t\(.spec.strategy.type)"'

Мой практический вывод после десятков таких прогонов: главный источник двойной записи — не экзотика, а связка «Deployment + PVC + RollingUpdate». StatefulSet ведёт себя приличнее (при обновлении контроллер сначала удаляет старый Pod и только потом создаёт новый с тем же именем), но и он не спасает от kubectl delete pod --force --grace-period=0 — форс-удаление убирает объект из API, а контейнер на ноде ещё живёт и пишет.

Ищите не факт аварии, а конфигурацию, которая её допускает: Deployment с персистентным томом и RollingUpdate, отключённые lock-файлы, отладочные Pod и Job, монтирующие «боевой» PVC для бэкапа или миграции.
Обратите внимание: Как за пять минут найти у себя такие же тома — схема
Обратите внимание: Как за пять минут найти у себя такие же тома. Открыть схему в полном размере

Миграция существующего PVC на ReadWriteOncePod

Плохая новость: поле accessModes у PVC иммутабельно, поменять его на месте нельзя. Хорошая: официальная процедура миграции есть, она описана в задачнике Kubernetes и сводится к тому, чтобы отвязать PV от PVC, не дав при этом удалить сами данные. Ключевой шаг — самый первый: перевести PV в reclaimPolicy: Retain. Если пропустить его и удалить PVC при политике Delete, CSI-драйвер честно снесёт RBD-образ или облачный диск. Возврата не будет.

Порядок действий такой (имена подставьте свои; имя PV для динамически созданного тома узнаётся через kubectl get pvc <name> -o jsonpath='{.spec.volumeName}'):

PV=pvc-8f1c0d2a-3b77-4a19-9d24-0f5b1a2c3d4e
NS=monitoring
PVC=prometheus-data

# 1. Обязательно ПЕРВЫМ шагом: защищаем данные
kubectl patch pv $PV -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
kubectl get pv $PV -o jsonpath='{.spec.persistentVolumeReclaimPolicy}{"\n"}'   # должно быть Retain

# 2. Гасим приложение и убеждаемся, что Pod РЕАЛЬНО исчез, а не Terminating
kubectl -n $NS scale deployment prometheus --replicas=0
kubectl -n $NS get pods -w

# 3. Удаляем PVC (PV останется в статусе Released)
kubectl -n $NS delete pvc $PVC

# 4. Освобождаем PV: обнуляем uid в claimRef
kubectl patch pv $PV -p '{"spec":{"claimRef":{"uid":""}}}'

# 5. Меняем режим доступа у PV
kubectl patch pv $PV -p '{"spec":{"accessModes":["ReadWriteOncePod"]}}'

Обратите внимание на шаг 4: мы обнуляем именно uid внутри claimRef, а не удаляем claimRef целиком. Разница принципиальная. Пустой claimRef означает «PV свободен для любого подходящего PVC», и его может перехватить чужой claim из другого namespace — я такое видел на кластере, где параллельно крутился оператор, штампующий PVC пачками. Оставленные name и namespace в claimRef держат PV зарезервированным именно за вашим будущим PVC.

Дальше пересоздаём PVC с новым режимом и жёсткой привязкой к тому через volumeName, применяем и поднимаем приложение. Ёмкость в resources.requests должна совпадать с ёмкостью PV, иначе привязка не состоится.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: prometheus-data
  namespace: monitoring
spec:
  accessModes:
    - ReadWriteOncePod
  volumeName: pvc-8f1c0d2a-3b77-4a19-9d24-0f5b1a2c3d4e
  storageClassName: ceph-rbd
  resources:
    requests:
      storage: 60Gi
# 6. Создаём новый PVC и поднимаем приложение
kubectl apply -f prometheus-pvc.yaml
kubectl -n $NS get pvc $PVC          # ждём Bound
kubectl -n $NS scale deployment prometheus --replicas=1

# 7. По желанию возвращаем исходную политику удаления
kubectl patch pv $PV -p '{"spec":{"persistentVolumeReclaimPolicy":"Delete"}}'

Два ограничения, о которые спотыкаются. Первое: RWOP нельзя комбинировать с другими режимами — список accessModes должен содержать ровно один элемент. Манифест с ["ReadWriteOnce","ReadWriteOncePod"] API-сервер отклонит сразу с ошибкой may not use ReadWriteOncePod with other access modes, так что «для совместимости» указать оба не получится. Второе: документация поддерживает только миграцию ReadWriteOnce → ReadWriteOncePod, обратный путь и переезд с RWX не описаны. И совет из практики, который совпадает с официальным: не затевайте расширение тома в один заход с миграцией — дождитесь Bound и только потом трогайте размер.

🔴 Retain — первым шагом, до `delete pvc`, и обязательно с проверкой через `kubectl get pv`. Это единственное место в процедуре, где ошибка необратима: при политике Delete удаление PVC уносит вместе с собой сам диск в хранилище.
Пошаговая схема миграции PVC с ReadWriteOnce на ReadWriteOncePod с Retain и claimRef
Необратимый шаг один — первый: без Retain удаление PVC унесёт диск.

Что сломается сразу после включения RWOP — и это нормально

Первое, что вы увидите на следующем деплое: новый Pod навсегда зависает в Pending. Планировщик отработал ровно так, как задумано, — плагин volumerestrictions считает поды, ссылающиеся на RWOP-PVC, и при conflictingPVCRefCount больше нуля объявляет узел непригодным. В описании Pod вы найдёте текст node(s) unavailable due to PersistentVolumeClaim with ReadWriteOncePod access mode already in-use by another pod. Это не ошибка конфигурации, это и есть работающая защита. Но раз новый Pod больше не может подняться рядом со старым, стратегия обновления обязана стать Recreate.

apiVersion: apps/v1
kind: Deployment
spec:
  strategy:
    type: Recreate      # RollingUpdate с RWOP-томом просто не пройдёт

Второе — бэкапы и сервисные задачи. Всё, что монтировало тот же PVC отдельным Pod, встанет: Job, который тарит каталог на S3, отладочный Pod «посмотреть, что там в данных», CronJob ротации архивов, самописный скрипт синхронизации, который запускается отдельным Pod с тем же claim. Лечится это не откатом RWOP, а сменой подхода: снапшоты CSI через VolumeSnapshot, node-agent, который читает данные с узла, или бэкап силами самого приложения (для Prometheus — remote-write либо snapshot API). По моему опыту, именно эта часть вызывает у команд больше всего сопротивления, а на деле переделывается за один вечер и попутно чинит бэкап, который до этого держал том открытым по два часа.

Третье, о чём стоит знать честно: RWOP защищает от второго Pod внутри Kubernetes и не защищает ни от чего снаружи. Если кто-то смонтировал тот же LUN на гипервизоре, если два кластера смотрят на одно RBD-пространство имён, если админ зашёл на ноду и сделал mount руками — режим доступа тут ни при чём. И ещё: RWOP относится к тому, а не к логике приложения. Если у вас два экземпляра сервиса должны координироваться, для этого есть Lease и leader election, а не режим монтирования.

Pending с текстом «PersistentVolumeClaim with ReadWriteOncePod access mode already in-use by another pod» — это не поломка, а работающий планировщик. Ищите второго держателя PVC, а не способ обойти сообщение.
Памятка: Что сломается сразу после включения RWOP — и это нормально — схема
Памятка: Что сломается сразу после включения RWOP — и это нормально. Открыть схему в полном размере

Кому это нужно, а кому можно забить

Не надо переводить на RWOP всё подряд — я так делал на одном кластере и потом полдня разгребал вставшие CronJob'ы. Приоритет простой: RWOP нужен там, где второй писатель означает повреждение данных, а не просто гонку. Это встроенные в кластер TSDB и БД: Prometheus и VictoriaMetrics single-node, SQLite в контейнере, Redis с AOF/RDB на томе, самописные файловые индексы, очереди на файлах, любые ETL-воркеры, которые держат состояние в каталоге. На томах с логами, кэшем, статикой, артефактами сборки — забейте, там двойная запись максимум испортит один файл.

Отдельно про альтернативы, потому что они часто уместнее. Если приложение само умеет лидер-элекшн через Lease (операторы, контроллеры, современные очереди) — RWOP лишний слой, вы просто усложните себе обновления. Если писателей действительно должно быть несколько — вам не RWOP, а RWX на файловой системе, которая это умеет: CephFS, NFS-провижнер. Класть RWX-режим на блочный RBD с ext4 нельзя ни при каких обстоятельствах: ext4 не кластерная ФС, и два узла с примонтированным одним образом убивают её за минуты. А самое дешёвое решение, которое закрывает процентов девяносто риска одной строкой в манифесте, — это strategy: Recreate для Deployment с персистентным томом. Начните с него сегодня, а миграцию на RWOP планируйте на ближайшее окно.

Есть и спорный момент, где единого мнения нет. В управляемых кластерах поведение зависит от CSI-драйвера провайдера: сами сайдкары давно свежие, но у части драйверов встречаются нюансы в обработке RWOP при внезапной потере узла — Pod с RWOP-томом может дольше висеть в Terminating, пока не отработает force-detach, потому что заменяющий Pod по определению не может стартовать параллельно. На кластерах со своим Ceph я этого практически не вижу, на облачных дисках — иногда. Поэтому на боевом кластере я всегда сначала прогоняю сценарий «выключить узел с RWOP-томом рубильником» на тестовом namespace и замеряю время восстановления. Если время неприемлемо — это аргумент в пользу того, чтобы приложение вообще не держало состояние на одном томе.

И последнее по приоритетам. Если у вас в кластере на одном RWO-томе живёт боевая БД в контейнере — сначала подумайте, не вынести ли её на отдельный сервер или в managed-сервис. RWOP закроет вопрос двойной записи, но не закроет вопрос бэкапа, восстановления на точку времени и производительности. Я это говорю клиентам прямо, даже когда выгоднее продать настройку кластера.

Порядок действий по возрастанию затрат: 1) strategy: Recreate — сегодня, бесплатно; 2) RWOP для томов с состоянием — в ближайшее окно; 3) вынос БД из кластера — если данные критичны.

Чек-лист

Собираю всё в порядок, в котором делаю это руками у клиента. У «Страхового навигатора» на восемь stateful-томов ушёл один вечер; для кластера на 15–20 stateful-нагрузок закладывайте полдня, из которых половина времени — согласование окна простоя, потому что каждый переезд PVC требует остановки приложения.

После миграции обязательно проведите учения: остановите приложение, поднимите его заново, погасите узел, на котором висит том. Вы должны увидеть, что заменяющий Pod стартует и что в событиях нет вечного конфликта PVC. Если конфликт остался — значит, где-то живёт забытый Job или отладочный Pod, который держит claim; ищите его тем же jq-однострочником из третьего раздела.

Если после всех работ у вас остался хотя бы один Deployment с RWO-томом и RollingUpdate — считайте, что вы ничего не сделали: именно эта связка и порождает двойного писателя.
Порядок действий: Чек-лист — схема
Порядок действий: Чек-лист. Открыть схему в полном размере

Частые вопросы

ReadWriteOncePod работает на любом хранилище?

Официально — только CSI-тома: режим стабилен с Kubernetes 1.29 и требует сайдкаров csi-provisioner v3.0.0+, csi-attacher v3.3.0+, csi-resizer v1.3.0+. Для hostPath, in-tree-плагинов и rancher.io/local-path гарантия не обещана. Проверьте `kubectl get csidrivers` и provisioner у StorageClass до начала работ.

Можно ли поменять accessModes у существующего PVC на месте?

Нет, spec.accessModes у PVC после создания не меняется. Поэтому официальная процедура выглядит так: переводим PV в Retain, гасим приложение, удаляем PVC, обнуляем uid в claimRef у PV, патчим accessModes самого PV и создаём новый PVC с тем же именем и явным volumeName. Данные при этом остаются в PV нетронутыми.

Потеряю ли я данные при удалении PVC во время миграции?

Только если забудете первый шаг. При persistentVolumeReclaimPolicy: Delete удаление PVC заставит CSI-драйвер физически удалить том — RBD-образ или облачный диск. Поэтому Retain выставляется до всего остального и проверяется через `kubectl get pv <name> -o jsonpath='{.spec.persistentVolumeReclaimPolicy}'`. Вернуть Delete можно после успешной привязки нового PVC.

После миграции Pod висит в Pending — что не так?

Скорее всего, всё так. Планировщик пишет `node(s) unavailable due to PersistentVolumeClaim with ReadWriteOncePod access mode already in-use by another pod` — значит, тот же PVC держит другой Pod. Типичные виновники: Deployment со стратегией RollingUpdate (переведите на Recreate), забытый Job, отладочный Pod или самописная задача бэкапа, монтирующая тот же claim.

Можно ли указать RWOP вместе с ReadWriteOnce, чтобы «было совместимо»?

Нельзя. API-сервер отклонит такой PVC или PV с ошибкой «may not use ReadWriteOncePod with other access modes». Список accessModes должен содержать ровно один элемент — ReadWriteOncePod.

RWOP заменяет leader election в приложении?

Нет. RWOP — это про том: он гарантирует, что второй Pod не смонтирует те же данные. Логику «кто сейчас главный» он не решает и от второго экземпляра приложения, работающего без тома или с другим томом, не защищает. Для координации экземпляров используйте Lease и штатный leader election.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи