Привет, Хабр!
Задумывались ли вы при написании программы о том, что будет, если на диске закончится место или при чтении данных из сектора возникнет ошибка? Обрабатывается ли это?
Для обеспечения надежности системы важно проводить тестирование ее поведения в различных ситуациях, в том числе при сбоях файловых систем.
Многие программы так или иначе взаимодействуют с файловыми системами, а для критически важных приложений, таких как СУБД, надежность работы с диском особенно важна.
Поэтому хотелось бы иметь инструмент в Linux для тестирования, который позволил генерировать ошибки при попытке считать файл, записать в файл и в прочих вызовах, чтобы посмотреть как реагирует СУБД. В качестве примера был взят PostgreSQL
FUSE
В Linux для этого существует модуль ядра FUSE, который позволяет написать свою файловую систему как обычную программу.
Все решения работают через FUSE. Этот модуль позволяет писать виртуальную файловую систему, которую можно смонтировать в папку(mntpoint
) и указать откуда брать изначальные данные(basedir
). Тогда если мы заходим создать файл или посмотреть содержимое mntpoint
, то система увидит что это папка смонтирована в нашу файловую систему и передаст управление нашей программе.
Fuse вообще кто-то использует?
Существует несколько FUSE, которые способны принести тем, кто их применяет, реальную пользу. Вот некоторые из:
sshfs — монтирует удалённую файловую систему, используя лишь ssh-доступ.
fuse-zip — позволяет монтировать zip-файлы в виде файловых систем.
gitfs — позволяет монтировать в виде файловых систем git-репозитории.
geese-fs — монтирует S3 storage, используется Яндексом
Компания VK(Mail.ru)
А как же другие операционные системы?
В Windows аналогичную функциональность предоставляет WinFsp, включая слои совместимости с FUSE для упрощения портирования существующего FUSE-кода.
Другой вариант для Windows — проект Dokan, предлагающий интерфейсы к своему
API для FUSE, хотя по результатам бенчмарков WinFsp обеспечивает более высокую производительность.
Есть даже для Mac OS
Обзор существующих инструментов
Название |
Комментарий |
Старые зависимости, не расширяемый |
|
Старые зависимости, обновлялся последний раз 10 лет назад, не расширяемый |
|
Хорошо работает, но функционал ограничен(не расширяемый) |
|
Ограничен набор конфигов |
Ограниченность или не расширяемость инструмента значит, что нельзя написать свое поведения сбоя, а можно выбрать лишь из заложенных автором инструмента.
Ничего по итогу мне не понравилось, поэтому захотелось написать свой велосипед.
Легкость и простота написания файловых систем при помощи FUSE
Решил написать простую файловую систему при помощи FUSE на Python. Оказалось это можно сделать буквально в 30 строчек кода.
import os
import sys
import random
from fuse import FUSE, FuseOSError, Operations
class SimpleFS(Operations):
def __init__(self, basedir):
self.basedir = basedir
def read(self, path, size, offset, fh):
full_path = self._full_path(path)
try:
with open(full_path, 'rb') as f:
data = f.read()
# 50% probability of no-op
if random.random() < 0.5:
return b'' # Return empty data as a no-op
return data[offset:offset + size]
except FileNotFoundError:
raise FuseOSError(2)
def _full_path(self, partial):
if partial.startswith("/"):
partial = partial[1:]
path = os.path.join(self.basedir, partial)
return path
if __name__ == '__main__':
if len(sys.argv) != 3:
print("Usage: {} mountpoint basedir".format(sys.argv[0]))
sys.exit(1)
mountpoint = sys.argv[1]
basedir = sys.argv[2]
fuse = FUSE(SimpleFS(basedir), mountpoint, foreground=True)
В данном коде я использую библиотеку pyfuse, которая позволяет писать свою файловую систему, используя FUSE. Чтобы написать свою ФС, я переопределил методы у класса Operations
(в данном случае я переопределил read
, который с вероятностью 50% вместо стандартного поведения возвращает пустые данные (имитирует "no-op"))full_path
вспомогательная функция, которая позволяет обращаться к файлам из директории basedir
путь к mntpoint
, basedir
принимаю в аргументах при запуске
Чтобы ее запустить достаточно иметь две папки: одну пустую(mntpoint
), другую - нет, откуда будут браться исходные данные(basedir
)
Заполним basedir. При запуске в mntpoint
будут видны все файлы, которые есть в basedir
ᐅ mkdir mntpoint basedir
ᐅ echo "hello world" > basedir/1.txt
Вот как примерно это работает:
В одном окне терминала запускаем файловую систему(здесь же будем смотреть логи)
ᐅ python3 fs.py mntpoint basedir
- в качестве аргументов передаем путь к mntpoint
и basedir
В другом окне терминала будем пытаться читать файл:
ᐅ cat mntpoint/1.txt
ᐅ cat mntpoint/1.txt
hello 1
ᐅ cat mntpoint/1.txt
ᐅ
Работает! Видим, что при некоторых обращениях при попытке прочитать файл - нам возвращается ничего.
Пишем свою FS. Структура конфигурации
Чтобы проводить разнообразные эксперименты, необходимо иметь возможность конфигурировать сбои, добавляя лишь код сбоя, не перезагружая файловую систему.
Конфигурация имеет 3 уровня вложенности:
1. Путь. Здесь с помощью регулярного выражения описывается, для каких именно файлов мы хотим применить наши сбои.
2. Описываются какие вызовы (например: write
, read)
мы хотим подменить.
3. Из какого .py
файла(module
) и какая конкретно функция(function
) будет вызвана вместо исходной.
Как запускать инструмент для сбоев?
Склонировать репозиторий
ᐅ mkdir mntpoint basedir
ᐅ echo "hello world" > basedir/1.txt
ᐅ echo "hello 2 world" > basedir/2.txt
ᐅ echo "{}" > config.json # Создаем пустой конфиг
Создаем свой файл со сбоем. С 50% шансом при чтении возвращаем пустой набор байт.
custom_module.py
import errno
import os
import random
import time
from errno import ENOENT
from fuse import FuseOSError
def read_with_random_empty(path, size, offset, fh=None):
random.seed(time.time())
print(f"custom read method for {path}")
try:
with open(path, 'rb') as f:
data = f.read()
res = data[offset:offset + size]
if random.random() <= 0.5:
print(f"We not get this data to user -> {res[:10]}")
return b''
return res
except FileNotFoundError:
raise FuseOSError(2)
ᐅ python3 -m venv myvenv
ᐅ source myvenv/bin/activate
(myvenv) ᐅ pip install -r requirements.txt
Collecting fusepy==3.0.1
Using cached fusepy-3.0.1-py3-none-any.whl
Collecting print-color==0.4.6
Using cached print_color-0.4.6-py3-none-any.whl (7.7 kB)
Installing collected packages: fusepy, print-color
Successfully installed fusepy-3.0.1 print-color-0.4.6
ᐅ python3 main.py mntpoint basedir config.json
Namespace(mount_point='mntpoint', base_dir='basedir', config_file='config.json')
ᐅ ls mntpoint
1.txt 2.txt
Файловая система смонтировалась в mntpoint
, и теперь при обращении в mntpoint
, вызовы будут перехватываться нашей файловой системой, но поведение пока будет стандартным, потому что конфиг пустой.
Поменяем содержимое конфига(config.json):
{
"/1*.\\.txt": {
"read": {
"module": "custom_module",
"function": "read_with_random_empty"
}
}
}
Здесь мы определяем сбои на все файлы, которые начинаются с 1
и имеют расширение txt
Проверим!
ᐅ cat mntpoint/1.txt
ᐅ cat mntpoint/1.txt
hello world
Посмотрим логи:
custom read method for basedir/1.txt
We not get this data to user -> b'hello worl'
custom read method for basedir/1.txt
Подготовка для экспериментов PostgreSQL
Мною был написан bash-скрипт, который ставит в /tmp
PostgreSQL из исходников, настраивает физическую реплику, стартует на моей ФС.
Сами эксперименты
Подготовка
Для проведения экспериментов нам достаточно менять /tmp/fuse/unreliable-fs/config.json
Во всех экспериментах мы будем использовать SQL-скрипт, лежащий в директории /tmp/fuse/unreliable-fs/queries/1.sql
созданной bash-скриптом.
-- 1.sql
CREATE TABLE IF NOT EXISTS RandomText (
id SERIAL PRIMARY KEY,
text_column TEXT
);
-- Generate and insert random text into the table
INSERT INTO RandomText (text_column)
SELECT md5(random()::text)
FROM generate_series(1, 100000) AS gs;
Скрипт создает таблицу, если она еще не была создана, и вставляет туда 100000 строчек со случайными значениями единственного поля text_column
.
Эксперимент "Недостаточно места при записи на диск"
С 50% вероятностью кидаем ошибку, что недостаточно места, при попытке записи буфера в файл.
Для этого в custom_module.py
добавим реализацию функции сбоя.
def write_with_random_left_space(path, buf, offset, fh):
random.seed(time.time())
print(f"custom write method for {path}", color="green")
if random.random() <= 0.5:
print(f"Left space", color="red")
raise FuseOSError(errno.ENOSPC)
os.lseek(fh, offset, os.SEEK_SET)
return os.write(fh, buf)
Запускаем наш скрипт первый раз с пустым конфигом(он лежит в /tmp/fuse/unreliable-fs/config.json
)
ᐅ /tmp/fuse/pg/bin/psql -d postgres -f /tmp/fuse/unreliable-fs/queries/1.sql
CREATE TABLE
INSERT 0 100000
Смотрим что данные вставились на мастер-узел:ᐅ /tmp/fuse/pg/bin/psql -d postgres -p 5432 -c "SELECT COUNT(*) FROM RandomText;"
count
-------------
100000
Смотрим что данные вставились на реплику:ᐅ /tmp/fuse/pg/bin/psql -d postgres -p 5433 -c "SELECT COUNT(*) FROM RandomText;"
count
-------------
100000
Теперь включим сбой. Для этого поменяем /tmp/fuse/unreliable-fs/config.json
{
"/data/*": {
"write": {
"module": "custom_module",
"function": "write_with_random_left_space"
}
}
}
ᐅ /tmp/fuse/pg/bin/psql -d postgres -f /tmp/fuse/unreliable-fs/queries/1.sql
Транзакция отменилась (100000 строк не вставилось). Мастер-узел и реплика работают.
psql:/tmp/fuse/unreliable-fs/queries/1.sql:4: NOTICE: relation "randomtext" already exists, skipping
CREATE TABLE
psql:/tmp/fuse/unreliable-fs/queries/1.sql:9: ERROR: could not write to file "base/pgsql_tmp/pgsql_tmp85936.0":
На устройстве не осталось свободного места
ᐅ /tmp/fuse/pg/bin/psql -d postgres -p 5432 -c "SELECT COUNT(*) FROM RandomText;"
count
-------------
100000
ᐅ /tmp/fuse/pg/bin/psql -d postgres -p 5432 -c "SELECT COUNT(*) FROM RandomText;"
count
-------------
100000
Эксперимент "Запись нулей"
При попытке записи буфера в файл, вставляется заполненный нулями буфер такого же размера.
Для этого в custom_module.py
добавим реализацию функции сбоя:
def write_zeros(path, buf, offset, fh):
print(f"custom write zeros method for {path} with size {len(buf)}", color="green")
os.lseek(fh, offset, os.SEEK_SET)
size = len(buf)
zeros = b'\x00' * size
os.write(fh, zeros)
return size
Теперь включим сбой. Для этого поменяем /tmp/fuse/unreliable-fs/config.json:
{
"/data/*": {
"write": {
"module": "custom_module",
"function": "write_zeros"
}
}
}
ᐅ /tmp/fuse/pg/bin/psql -d postgres -f /tmp/fuse/unreliable-fs/queries/1.sql
psql:/tmp/fuse/pglab/queries/1.sql:4: NOTICE: relation "randomtext" already exists, skipping
CREATE TABLE
INSERT 0 99767
Вставляли 100000
строк, а вставилось 99767
ᐅ /tmp/fuse/pg/bin/psql -d postgres -p 5432 -c "SELECT COUNT(*) FROM RandomText;"
count
-------------
199767
На реплику вообще данные не пришли, поскольку WAL_KEEPSIZE=0, логи нам об этом сигнализируют
LOG: "started streaming WAL from primary at 0/4000000 on timeline 1"
ERROR: "could not receive data from WAL stream: ERROR: requested WAL segment 000000010000000000000004 has already been removed"
ERROR: requested WAL segment 000000010000000000000004 has already been removed
LOG: waiting for WAL to become available at 0/4000018
ᐅ /tmp/fuse/pg/bin/psql -d postgres -p 5433 -c "SELECT COUNT(*) FROM RandomText;"
count
-------------
100000
Атомарность транзакции нарушилась. Если мы отключим теперь сбой и попробуем вставить данные на мастер-узел, то данные на реплику не вставятся, реплика больше не видет мастера.
Если поставить wal_keep_size = 1024
, то и на реплики и на мастере окажутся 199767
строк, но 233 строчки куда-то делись.
Как я поставил wal_keep_size
Я отредактировал баш скрипт для проведения экспериментов и добавил в конец функции create_physical_replica такую строчку:
echo "wal_keep_size = 1024MB" >> "${REPLICA_DATA_DIR}/postgresql.conf"
Итог
Fuse позволил просто и быстро написать файловую систему, которая решила мою задачу: а именно я могу создавать различные сбои и смотреть как поведёт себя та или иная программа. Были проведены эксперименты с кластером PostgreSQL, состоящим из двух узлов: мастер и реплика. В ходе одного из экспериментов был получен интересный результат, а именно транзакция выполнилась не атомарно, реплика навсегда потеряла связь с мастером.
Комментарии (7)
efi
25.06.2024 10:19А прикольная статья, мне понравилась. Люблю всякие такие эксперименты, попробую поиграться дома. Спасибо!
outlingo
У вас "немного странные" представления о работе ФС, потому как сценарий "При попытке записи буфера в файл, вставляется заполненный нулями буфер такого же размера" является невозможным.
slonpts
Видимо, вы разбираетесь в теме? Можете написать, какие сценарии бывают, какие являются наиболее вероятными, а какие менее?
outlingo
Есть несколько сценариев базовых интересных:
При записи произошла ошибка но часть данных записалась а часть нет (усложненный кейс - вторая половина данных записалась а первая нет)
При сбросе буферов (при синке) произошла ошибка и часть кэша синкнулась а часть нет (усложненный случай - данные записанные позже чиркнули б а записанные раньше нет, бросив ошибку синка)
Ошибки чтения неинтересны
snpgg Автор
Спасибо за фидбэк! Да я понимаю, забыл подчеркнуть, что пример игрушечный. К сожалению интересных результатов с более серьезными экспериментами пока не нашел, поэтому показал этот.
myxo
Вообще возможен. Бывают ситуации когда контроллер диска багнутый или просто начинает врать что сделал fsync, хотя данные не записались (а потом машина рестартует и все теряется). Ну и конечно диск может побиться и возвращать любой мусор.
По идее серьезные распределенные базы должны быть готовы к такому сценарию и, например, реплицировать данные из памяти, а не с диска
outlingo
Эти кейсы относятся уже к другой категории, не к реакциям на ошибки, а по сути к методам восстановления данных. Если говорить про БД, то, например, у того же Oracle его redo logs (аналог PG WAL) можно писать в несколько мест параллельно, как раз чтобы нивелировать такие риски