Привет, Хабр!

Задумывались ли вы при написании программы о том, что будет, если на диске закончится место или при чтении данных из сектора возникнет ошибка? Обрабатывается ли это?

Для обеспечения надежности системы важно проводить тестирование ее поведения в различных ситуациях, в том числе при сбоях файловых систем.

Многие программы так или иначе взаимодействуют с файловыми системами, а для критически важных приложений, таких как СУБД, надежность работы с диском особенно важна.

Поэтому хотелось бы иметь инструмент в Linux для тестирования, который позволил генерировать ошибки при попытке считать файл, записать в файл и в прочих вызовах, чтобы посмотреть как реагирует СУБД. В качестве примера был взят PostgreSQL

FUSE

В Linux для этого существует модуль ядра FUSE, который позволяет написать свою файловую систему как обычную программу.

Все решения работают через FUSE. Этот модуль позволяет писать виртуальную файловую систему, которую можно смонтировать в папку(mntpoint) и указать откуда брать изначальные данные(basedir). Тогда если мы заходим создать файл или посмотреть содержимое mntpoint, то система увидит что это папка смонтирована в нашу файловую систему и передаст управление нашей программе.

Операционная система(Kernel) смотрит, что в папку смонтирована наша файловая система и передает управление ей
Операционная система(Kernel) смотрит, что в папку смонтирована наша файловая система
и передает управление ей

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

Обзор существующих инструментов

Название

Комментарий

CharybdeFS

Старые зависимости, не расширяемый

PetardFS

Старые зависимости, обновлялся последний раз 10 лет назад, не расширяемый

libeatmydata

Хорошо работает, но функционал ограничен(не расширяемый)

UnreliableFS

Ограничен набор конфигов

Ограниченность или не расширяемость инструмента значит, что нельзя написать свое поведения сбоя, а можно выбрать лишь из заложенных автором инструмента.

Ничего по итогу мне не понравилось, поэтому захотелось написать свой велосипед.

Легкость и простота написания файловых систем при помощи 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 из исходников, настраивает физическую реплику, стартует на моей ФС.

Директории, которые создает bash скрипт
Директории, которые создает bash скрипт
Реплика работает на порту 5433
Реплика работает на порту 5433

Сами эксперименты

Подготовка

Для проведения экспериментов нам достаточно менять /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
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

Транзакция отменилась (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)


  1. outlingo
    25.06.2024 10:19

    У вас "немного странные" представления о работе ФС, потому как сценарий "При попытке записи буфера в файл, вставляется заполненный нулями буфер такого же размера" является невозможным.


    1. slonpts
      25.06.2024 10:19

      Видимо, вы разбираетесь в теме? Можете написать, какие сценарии бывают, какие являются наиболее вероятными, а какие менее?


      1. outlingo
        25.06.2024 10:19

        Есть несколько сценариев базовых интересных:

        При записи произошла ошибка но часть данных записалась а часть нет (усложненный кейс - вторая половина данных записалась а первая нет)

        При сбросе буферов (при синке) произошла ошибка и часть кэша синкнулась а часть нет (усложненный случай - данные записанные позже чиркнули б а записанные раньше нет, бросив ошибку синка)

        Ошибки чтения неинтересны


    1. snpgg Автор
      25.06.2024 10:19

      Спасибо за фидбэк! Да я понимаю, забыл подчеркнуть, что пример игрушечный. К сожалению интересных результатов с более серьезными экспериментами пока не нашел, поэтому показал этот.


    1. myxo
      25.06.2024 10:19

      Вообще возможен. Бывают ситуации когда контроллер диска багнутый или просто начинает врать что сделал fsync, хотя данные не записались (а потом машина рестартует и все теряется). Ну и конечно диск может побиться и возвращать любой мусор.


      По идее серьезные распределенные базы должны быть готовы к такому сценарию и, например, реплицировать данные из памяти, а не с диска


      1. outlingo
        25.06.2024 10:19
        +1

        Эти кейсы относятся уже к другой категории, не к реакциям на ошибки, а по сути к методам восстановления данных. Если говорить про БД, то, например, у того же Oracle его redo logs (аналог PG WAL) можно писать в несколько мест параллельно, как раз чтобы нивелировать такие риски


  1. efi
    25.06.2024 10:19

    А прикольная статья, мне понравилась. Люблю всякие такие эксперименты, попробую поиграться дома. Спасибо!