Можно ли подключить сетевой диск к железному серверу за минуту, при этом не выключая его, сохраняя отказоустойчивость и не привлекая инженеров?

Я Беляков Алексей — Go-разработчик в Cloud.ru, в статье расскажу, как нам удалось это сделать. Сначала поделюсь кейсами, которые натолкнули на создание такой фичи, затем расскажу, как мы реализовали ее интеграцию со стороны сервиса Bare Metal, а в конце покажу, как всего за минуту можно расширить дисковое пространство физического сервера.

Когда локальных дисков недостаточно: три кейса

В каких случаях выручает наше решение? Приведу примеры типовых ситуаций из практики.

Кейс 1. Место кончается, но нельзя выключать сервер. Например, у вас сервер Bare Metal на котором развернуты Jira, Confluence, СУБД (PostgreSQL, MySQL, Cassandra) или распределенное приложение (Kafka, Elasticsearch). И вот стало заканчиваться место. Что это значит? Нужно мигрировать, что-то выводить из прода, связываться с клиентами и объяснять ситуацию, о чем-то просить.

Наш способ → динамически добавлять диски без физического вмешательства. Не нужно отключать сервера, можно быстро подключить дополнительный диск сохраняя высокую скорость. Пример: кластер баз данных, где нужно увеличить хранилище без остановки сервиса.

Кейс 2. Настройка резервного копирования без доработок. При интеграции резервное копирование выносится за пределы самой инфраструктуры, где происходит это резервное копирование, например, в S3 или FTP. В этом случае нужны доработки.

Наш способ → подключить сетевой диск, который ещё и расширяется до бесконечности (ну, или почти ?). Можно динамически наращивать объемы хранилища без замены оборудования, подстраиваясь под меняющиеся потребности и легко интегрируясь через iSCSI. Пример: дублирование критически важных данных на кластерное SDS-хранилище для Bare Metal.

Кейс 3. Разделяемое хранилище для кластерных решений. Когда нужна поддержка функции для работы разных серверов Bare Metal с одними и теми же данными, например, Kubernetes c Persistent Volumes, OpenStack Cinder.

Наш способ → разворачивать приватные облака поверх параметральных виртуалок, при этом сохраняя консистентность данных поверх SDS-дисков. При этом SDS-диски — это не Ceph, Software and Define Storage или NetApp, а наша собственная разработка. Пример: приватная виртуализация поверх Bare Metal, где диски ВМ хранятся специальным способом для удобного управления. 

Архитектура

Есть клиент, ПО, физический сервер и SDS-кластер (рассказ о том, как строился SDS можно почитать в статье нашего коллеги). Подключение сервера к клиентской сети реализовано через MC-LAG (Multi-chassis link aggregation group) — два сетевых интерфейса подключены к двум коммутаторам и объединены в один логический. Все это смотрит в одну и ту же VPC, в одну и ту же сеть, которая по-прежнему остается 25 Гбит/с, но обеспечивает дополнительную отказоустойчивость. 

Подключение к SAN сделали по тому же принципу, поэтому в каждом физическом сервере у нас четыре интерфейса: два смотрят в client-plane (клиентскую зону сети) и вторые два в SAN (или storage-сеть). 

Архитектура предусматривает четыре интерфейса
Архитектура предусматривает четыре интерфейса

Но дальше возникает вопрос — а как контролировать трафик между СХД? Просто поставив коммутаторы, пусть даже на которых есть какие-нибудь ACL-группы на L3-уровне, мы не сможем полностью обеспечить балансировку и контроль iSCSI-трафика, а также отказоустойчивость решения. По этим причинам мы сделали SDS Gateway, который является неким прокси iSCSI-трафика.

Причем их два, а местами может быть и больше. А отказоустойчивость нам помогает сохранять протокол ALUA — при выходе из строя одного из SDS Gateway мы автоматически переключаемся на другой. И даже если он еще не вышел из строя, а просто немного перегружен, мы уже балансируем нагрузку.

Протокол ALUA помогает балансировать и сохранять отказоустойчивость
Протокол ALUA помогает балансировать и сохранять отказоустойчивость

Подключаем диск к серверу, не привлекая инженера

Как же не привлекать инженера к этой активности? Вот один из вариантов:

  1. Создаем SDS-диск в личном кабинете. 

  2. Создаем дополнительную сеть для диска. 

  3. Привязываем диск к серверу в личном кабинете.

  4. Затем мы подключаемся к самому серверу по SSH или KVM.

  5. Настраиваем сеть SAN, используя NetPlan, и дополнительно настраиваем на нашем сервере.

  6. Создаем интерфейсы iSCSI и привязываем к сетевым интерфейсам, которые только что создали. 

  7. Настраиваем аутентификацию CHAP и указываем IQN, iSCSI таргета и авторизовываемся.

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

Кажется, как-то много шагов, как считаете? Мы так, конечно, не сделали ?

Проектирование

Поскольку мы хотели свести ручную настройку к минимуму, перед нами встали следующие задачи и цели:

1. Хостами SDS Gateway должна управлять автоматика.

2. Продумать вопросы масштабирования и отказоустойчивости.

3. Хранить информацию о сущностях, которые создают/удаляют клиенты.

4. Облегчить подключение дисков на самих серверах.

Для решения первого вопроса мы пошли по пути минимализма и применили подход Reconciliation loop. Суть его заключается в том, что мы получаем желаемое состояние системы, сравниваем его с текущим и выполняем действия, которые устраняют эту разницу.

Да, получается, что мы постоянно нагружаем опросами Control Plane (зато знаем ожидаемый уровень нагрузки — всегда максимальный) и приводим SDS Gateway в желаемое состояние не моментально, а только тогда, когда начнет работать очередная итерация цикла, в которой уже будут известны изменения. Зато имеем простую реализацию, которая обеспечивает хостам возможность самовосстановления и не допускает промежуточных состояний.

Решение второго вопроса частично решили закрыть с помощью Reconciliation loop («больные» хосты могут сами себя восстановить, а введение новых хостов по сути и есть цикл приведения к ожидаемому состоянию), а также с помощью планировщика, который выводит хосты, которые давно не запрашивали актуальную конфигурацию, из состояния активных и мигрирует их TPG на «здоровые» хосты.

Третий вопрос планировали закрывать с помощью баз данных, ну а про четвертый поговорим подробнее чуть позже.

По итогу верхнеуровневая схема, в которую мы метили, выглядела так:

Решение

Начнем издалека. SDS-кластер предоставляет интерфейс iSCSI target, для взаимодействия с которым существует утилита targetcli. С ее помощью можно создавать тома, привязывать к этим томам LUN, настраивать ACL и многое другое. Получается, нам нужно было написать обертку над targetcli, которая бы выполняла нужную нам логику. Такой оберткой стал сервис target-agent, фундамент которого — функция вида:

func execTargetCli(ctx context.Context, args ...string) (*exec.Cmd, *bytes.Buffer, error) {
    cmd := exec.CommandContext(ctx, "/usr/libexec/storage-target-agentd/targetcli", args...)

    var stderr bytes.Buffer
    cmd.Stdout = &stderr
    cmd.Stderr = &stderr

    if err := cmd.Run(); err != nil {
        return cmd, &stderr, err
    }

    return cmd, &stderr, nil
}

Затем над ней строится нужная нам логика. Например, отключение LUN выглядит так:

func (c *composer) DetachLun(ctx context.Context, target *model.Target, lun *model.Lun) error {
    path := getTargetPath(target)
    lunID := "lun" + lun.ID

    lunPath := path.Join(target.Path, "luns", lunID)
    if !c.executor.IsExist(ctx, lunPath) {
        return nil
    }

    if err := c.executor.ExecTargetCli(ctx, target.Path +"/luns", "delete", lunID); err != nil {
        return err
    }

    return nil
}

За хранение актуальных конфигураций sds gateway хостов отвечает другой сервис — sds-connector. Именно к нему и ходит c определенной периодичностью target-agent, запрашивая актуальное состояние и сравнивая его со своим текущим. В случае, если они отличаются, применяются diff-ы:

func (s *service) FetchAndApplyConfiguration(ctx context.Context) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    targetState, targetVersion, err := s.deps.Fetcher.Fetch(ctx, s.version)

    defer func() {
        s.state.Error = err
        if err != nil {
            s.state.HostStatus = model.HostStatusFailed
        }
    }()

    switch {
    case err != nil:
        return fmt.Errorf("failed to fetch configuration: %w", err)
    case targetVersion == s.version:
        // Нет изменений
        return nil
    }

    diff := s.deps.Differ.ComputeDiff(s.state, targetState)

    if err = s.deps.Applier.ApplyDiff(ctx, diff); err != nil {
        return fmt.Errorf("failed to apply configuration: %w", err)
    }

    s.version = targetVersion

    return nil
}

Вычисление разницы происходит за счет многочисленных сравнений текущего и целевого состояний с добавлением в итоговый слайс функций, выполнение которых сконфигурирует sds gateway как нам нужно. По итогу мы избавляемся от ручной настройки таргетов — за нас это делает автоматика.

Хорошо, теперь главный вопрос — а как так сделать, чтобы пользователь нажал кнопку «подключить диск», а мы бы выбрали наиболее свободные sds gateway хосты и сконфигурировали их? Здесь как раз и приходит на помощь сервис sds-connector. С выбором хоста помогает сортировка и выбор наименее занятого sds gateway:

// LeastCount проверяет, какие серверы имеют наименьшее количество сущностей в данный момент, и
// отправляет новые сущности на эти серверы.
// Это предполагает, что все сущности требуют примерно одинаковой вычислительной мощности.
type LeastCount struct {
    countByHost map[id.HostID]int
    ids         []id.HostID
}

func (lc *LeastCount) SelectFrom(ids []id.HostID) id.HostID {
    slices.SortStableFunc(ids, func(i, j id.HostID) int {
        return lc.countByHost[i] - lc.countByHost[j]
    })

    selected := ids[0]
    lc.countByHost[selected]++

    return selected
}

А что, если хост перестал опрашивать sds-connector? Тогда его переводит в статус «Unhealthy» специальная cron job, и сразу же запускается миграция Target Portal Group:

type alua struct {
    ActiveHost target_port_groups.TargetPortGroupWithLun
    OtherHosts map[id.HostID]target_port_groups.TargetPortGroupWithLun
}

func (m *migrator) MigrateActiveTPG(ctx context.Context, tx *gorm.DB, hostID id.HostID) error {
    // Сбор всех TargetPortGroup хоста в состоянии Optimized
    activeTPGs, err := m.storages.TargetPortGroups.
        WithTx(tx).
        FindForHostAndState(ctx, hostID, model.AluaAccessStateOptimized)
    if err != nil {
        return fmt.Errorf("find active TPGs: %w", err)
    }

    // Стратегия для выбора размещения активной TPG
    balancingStrategy := m.balancer.ActiveTPG()
    aluaByVolume := make(map[volumes.VolumeID]*alua)

    for _, active := range activeTPGs {
        // Сбор хостов, которые имеют путь до volume из мигрируемой TPG
        state, err := m.collectHostsForTPG(ctx, tx, active, balancingStrategy)
        if err != nil {
            return fmt.Errorf("collect hosts for TPG %s: %w", active.TargetPortGroup.Uuid, err)
        }

        aluaByVolume[active.Lun.VolumeID] = &alua{
            ActiveHost: active,
            OtherHosts: state,
        }
    }

    for _, state := range aluaByVolume {
        // Перевод TPG мигрируемого хоста в Standby и перевод TPG свободного хоста в Optimized
        if err := m.migrateTPG(ctx, tx, state, balancingStrategy); err != nil {
            return fmt.Errorf("migrate TPG %s: %w", state.ActiveHost.TargetPortGroup.Uuid, err)
        }
    }

    return nil
}

Если же какой-то из Unhealthy-хостов начал вновь запрашивать конфигурацию, то cron job переведет его в состояние Active.

Теперь у нас есть балансировка, автоматическая настройка таргетов и sds gateway. Получается, пользователю достаточно подключить диск в UI нашего сервиса, зайти на сервер и ввести команды вида:

iscsiadm -m discovery -t st -p $ISCSI_TARGET
iscsiadm -m node -T $WWN -o update -n node.session.auth.authmethod -v CHAP
iscsiadm -m node -T $WWN -o update -n node.session.auth.username -v $USERNAME
iscsiadm -m node -T $WWN -o update -n node.session.auth.password -v $PASSWORD
iscsiadm -m node -T $WWN -o update -n node.session.auth.username_in -v $MUTUAL_USERNAME
iscsiadm -m node -T $WWN -o update -n node.session.auth.password_in -v $MUTUAL_PASSWORD
iscsiadm -m node -T $WWN --login
iscsiadm -m session --rescan

Желая облегчить клиентский путь, мы продумали автоматическое создание скриптов для настройки клиентского сервера как iSCSI-инициатора. Поскольку действия по подключению таргетов шаблонны, мы использовали стандартный шаблонизатор Go, который позволяет генерировать shell-скрипт с командами iscsiadm:

var attachScriptTemplate = template.Must(
    template.New("attach-volumes.sh").
        Parse(`{{range .Portals -}}
{{if .Targets -}}
iscsiadm -m discovery -t st -p {{.Address}}
{{range .Targets -}}
iscsiadm -m node -T {{.WWN}} -o update -n node.session.auth.authmethod -v CHAP
iscsiadm -m node -T {{.WWN}} -o update -n node.session.auth.username -v {{.UserCredentials.RawUser}}
iscsiadm -m node -T {{.WWN}} -o update -n node.session.auth.password -v {{.UserCredentials.RawPassword}}
iscsiadm -m node -T {{.WWN}} -o update -n node.session.auth.username_in -v {{.MutualUserCredentials.RawUser}}
iscsiadm -m node -T {{.WWN}} -o update -n node.session.auth.password_in -v {{.MutualUserCredentials.RawPassword}}
iscsiadm -m node -T {{.WWN}} --login
{{end -}}
{{end -}}
{{end -}}
iscsiadm -m session --rescan`),
)

В чистом виде кредлы у себя мы, конечно, не храним — всё зашифровано. Но даже этот шаг мы облегчили — теперь запускается команда, которая с помощью curl подтягивает по временной ссылке скрипт для подключения дисков и исполняет его. 

Поэтому подключить сетевой диск будет не сложнее, чем приготовить утренний кофе:

1. Создаем SDS-диск в личном кабинете. 

2. Привязываем диск к арендованному серверу. 

3. Подключаемся по SSH или VNC и выполняем единственную команду.

Подключаем диск за минуту (ну, почти)

1. Создаем диск в той же зоне доступности, что и сервер:

2. В карточке сервера в разделе «Диски» добавляем созданный диск:

3. Затем нажимаем кнопку «Скрипт подключения»:

4. Копируем полученную команду:

5. Через предоставляемый интерфейсом буфер обмена вставляем команду:

6. Вводим lsblk-S и видим, что диск успешно добавился:

Если у вас встает вопрос, а почему дисков два? Ответ простой — это ALUA, который гарантирует отказоустойчивость. В этом можно убедиться, прописав multipath -ll:

Примерно за 50 секунд мы получили подключенный диск. Поскольку еще осталось время, с помощью mkfs создадим на нем файловую систему, например, XFS, а также примонтируем ее к каталогу:

Таким образом нам удалось уложиться в минуту и шестнадцать секунд. Конечно, если не торопиться, то это может занять чуть больше времени, но всё равно не критично.

Подробнее про настройку мы рассказывали на конференции GoCloud 2025, если интересно, то смотрите доклад в записи.

Производительность  

Цифры ниже — это то, что мы гарантируем благодаря нашему собственному блочному хранилищу: 

Пропускная способность для 100 ГБ по чтению и записи при IOPS = 128
Пропускная способность для 100 ГБ по чтению и записи при IOPS = 128
Пропускная способность при IOPS = 10 000
Пропускная способность при IOPS = 10 000
Такие цифры гарантируем в зависимости от размера диска
Такие цифры гарантируем в зависимости от размера диска

Итоги

Команда Evolution Bare Metal сделала прекрасное решение, благодаря которому теперь можно расширить дисковое пространство серверов прямо во время их работы. 

Что мы сделали:

  • интегрировали SDS (Software-Defined Storage) через iSCSI и реализовали дополнительные сервисы, которые обеспечивают прозрачную интеграцию Bare Metal и SDS-хранилища. Можем как переключать диски между виртуальными машинами и бареметальными, а скоро сможем шерить их между собой.

  • Уделили большое внимание целостности данных пользователей. Поэтому подключение поддерживает ALUA (Asymmetric Logical Units Access) для повышения отказоустойчивости.

Мы старались обеспечить максимально удобный клиентский путь и надеемся, что нам это удалось: получился отказоустойчивый сетевой SSD с удобной и простой настройкой, которая занимает около минуты.

А про другие наши решения будем рассказывать 3 сентября на IT-конференции GoCloud Tech 2025. Приходите, будем рады видеть всех ?

Комментарии (0)