Как стать автором
Обновить

HA Web кластер на Hetzner

Итак, существует задача построения решения для отказоустойчивости серверов на площадке хостинг провайдера Hetzner.

Ограничения:
1.После построения решения сервера передаются заказчику в его IT отдел где нет по сути Unix администраторов.
2.Решение должно быть как можно более бюджетным, простым и надёжным.

Видимые варианты решения:
1.Кластер виртуализации на proxmox
2.Docker
3.Настроить весь софт сразу на железе по старинке.



Стадия выбора варианта:
1. Кластер виртуализации — всё хорошо, всё классно, есть снапшоты, есть бэкапы виртуалок из коробки, есть визуальная система управления (Proxmox), но Hetzner не поддерживает Multicast из-за чего HA кластер с миграцией виртуалок не построишь.
2. Docker — кажется отличным решением, но сталкиваемся с трудностями администрирования, чтобы понять докер, надо его достаточно много попользовать со всех сторон, что даёт очевидную сложность в поддержке IT отделом заказчика, который сразу здесь и сейчас с помощью гугла за полчаса должен всё восстановить по мануалу. Но так как докер достаточно сырой ещё(ИМХО) от него бывает проблем получаешь очень много, т.е. вопрос о сопровождении и решении проблем силами IT отдела становится всё более призрачным.
3.Настроить весь софт на железе — просто, легко, но очень большая работа по восстановлению нод. В случае сбоя одной надо делать много действий чтобы настроить сломанную ноду заново.

По итогам раздумий, проб и ошибок, был выбран третий вариант.
Для быстрого же восстановления ноды в случае поломки было принято решение просто поставить разделённую систему бэкапирования с веб-интерфейсом.
(Bacula настройка Bacula не рассматривается в данной статье)

Для самого же кластера выбираем простую проверенную схему
master(active) — master(passive)
т.е. в один момент времени работает одна нода, вторая работает в резерве и забирает изменения, включается в случае отказа активной.

Итак, приступим к простому и бюджетному варианту создания failover кластера.
Что нам нужно.
1. Два выделенных сервера в разных ДЦ, желательно одинаковой конфигурации
2. Заказанная услуга failover IP.

Все настройки производятся на CentOS 6.7 (в связи в религиозными предпочтениями автора)
Для начала настраиваем одну ноду для работы с приложением.
Приложения требует LNMP.

Приведу пример что требовалось поставить мне.

Ставим PHP 7.0
yum -y update

rpm -Uvh https://dl.fedoraproject.org/pub/epel/epel-release-latest-6.noarch.rpm
rpm -Uvh https://mirror.webtatic.com/yum/el6/latest.rpm
yum install -y php70w-fpm php70w-opcache
yum install -y php70w-cli php70w-dba php70w-gd php70w-intl php70w-mbstring php70w-mysql php70w-pdo php70w-soap php70w-xml



Ставим MySQL

yum -y install mysql mysql-server



Чтобы поставить nginx, создаём файлик
/etc/yum.repos.d/nginx.repo

Со следующим содержимым

[nginx]
name=nginx repo
baseurl=http://nginx.org/packages/centos/\$releasever/\$basearch/
gpgcheck=0
enabled=1



После чего устанавливаем NGINX

yum -y install nginx



Настройки nginx и php приводит не буду, тут каждый делает как нужно, единственное что, я вынес настройки сайтов NGINX в отдельную папку для того чтобы синхронизировать их между нодами, и поменял расположение хранения сессий в PHP.
И для простоты добавил запуск NGINX и PHP от пользователя nodeowner чтобы не мучится с правами при синхронизации исходного кода PHP,
плюс сменил владельца на папку сессий PHP

Далее устанавливаем утилиту lsyncd для синхронизации файлов между нодами

    yum install -y lsyncd lua lua-devel


Lsyncd является надстройкой над rsync которая по сути просто по истечении какого-то времени синхронизирует изменения между двумя серверами.
Для работы lsyncd следует создать пользователя на каждой ноде с авторизацией через ключ.
Т.е. от root первой ноды должна быть доступна без парольная аутентификация на пользователя nodeowner второй ноды.
от root второй ноды должна быть доступна без парольная аутентификация на пользователя nodeowner первой ноды,
так как демон запускается от root, а синхронизировать мы хотим только те папки к которым есть доступ у nodeowner ( как раз для этого nginx и php-fpm запускаются от этого пользователя)

Далее приведу простой пример генерирования ключа.
Повторяется на каждой ноде

#Ssh-keygen от root
ssh-keygen
# На всё просто нажимаем enter
cat ~/.ssh/id_rsa.pub
# Копируем в буфер обмена всё что вывелось в данном файле


#Ssh-keygen от root на первой ноде
ssh-keygen
# На всё просто нажимаем enter
cat ~/.ssh/id_rsa.pub
# Копируем в буфер обмена всё что вывелось в данном файле


#На второй ноде
nano ~/.ssh/authorized_keys
# Вставляем публичный ключ
chown -R nodeowner:nodeowner ~/.ssh/authorized_keys
chmod 0600 ~/.ssh/authorized_keys


Открываем настройки ssh, и смотрим, что там разрешена авторизация через ключи (Должны быть включены следующие директивы)

HostKey /etc/ssh/ssh_host_rsa_key
RSAAuthentication yes
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys


service sshd restart


Далее настраиваем Lsyncd конфиг пишется на языке Lua

!!! {nodeIp} следует заменить на IP адрес второй ноды, ну или на hostname

----
-- User configuration file for lsyncd.
--
-- Simple example for default rsync, but executing moves through on the target.
--
-- For more examples, see /usr/share/doc/lsyncd*/examples/
--
-- sync{default.rsyncssh, source="/var/www/html", host="localhost", targetdir="/tmp/htmlcopy/"}
 
settings {
  logfile    = "/var/log/lsyncd/lsyncd.log",
  statusFile = "/var/log/lsyncd/lsyncd.status",
  nodaemon   = true--<== лучше оставить для дебага. потом выключите. (false)
}
 -- Синхронизируем файлы на нодах исключая логи (лучше чтобы на каждой ноде они всё-таки были свои)
sync {
  default.rsync,
  source="/var/www/application",
  target="nodeowner@{nodeIp}:/var/www/application",
  exclude = "*.log",
  rsync = {
        compress = true,
        acls = true,
        verbose = true,
        rsh = "/usr/bin/ssh -p 22 -o StrictHostKeyChecking=no"
  },
  delay=10
}
 -- Синхронизируем настройки Vhosts в nginx
sync {
  default.rsync,
  source="/etc/nginx/sites-enabled", --<== Заменить на место где хранятся настройки Vhosts в nginx
  target="nodeowner@{nodeIp}:/etc/nginx/sites-enabled",  --<== Заменить на место где хранятся настройки Vhosts в nginx
  rsync = {
        compress = true,
        acls = true,
        verbose = true,
        rsh = "/usr/bin/ssh -p 22 -o StrictHostKeyChecking=no"
  },
 delay=20
}
 -- Синхронизируем сессии php, дабы не терять авторизацию при переходе на другую ноду
sync {
  default.rsync,
  source="/var/lib/php/session", -- тут путь не по дефолту, надо настраивать или смотреть в настройках php-fpm
  target="nodeowner@{nodeIp}:/var/lib/php/session", -- тут путь не по дефолту, надо настраивать или смотреть в настройках php-fpm
  rsync = {
    compress = true,
    acls = true,
    verbose = true,
    rsh = "/usr/bin/ssh -p 22 -o StrictHostKeyChecking=no"
  },
  delay = 10,
}


После всего этого, нам осталось только настроить репликацию MySQL мастер мастер.
Так как таковой по сути не существует то делаем «двойной слейв»

Создаём базу данных для репликации, пользователя для доступа к базе и пользователя для репликации

Первая нода
grant replication slave on *.* to replication@'[IP адрес второй ноды]' identified by '[пароль для репликации]';
flush privileges;
exit


Вторая нода
grant replication slave on *.* to replication@'[IP адрес первой ноды]' identified by '[пароль для репликации]';
flush privileges;
exit


Добавляем папки для бинарных логов (на двух нодах)

mkdir /var/log/mysql
mkdir /var/log/mysql/var
chown -R mysql:mysql /var/log/mysql


my.cnf первой ноды

[mysqld]
datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock
user=mysql
# Disabling symbolic-links is recommended to prevent assorted security risks
symbolic-links=0
 
key_buffer_size = 32M
innodb_flush_method = O_DSYNC
innodb_buffer_pool_size = 1G
innodb_log_file_size = 64M
innodb_flush_log_at_trx_commit = 2
table_cache = 1024
query_cache_limit = 2M
query_cache_size = 64M
thread_cache_size = 8  
 
bind-address = 0.0.0.0
server-id = 1
log-bin = /var/log/mysql/var/bin.log
log-slave-updates
log-bin-index = /var/log/mysql/log-bin.index
log-error = /var/log/mysql/error.log
relay-log = /var/log/mysql/relay.log
relay-log-info-file = /var/log/mysql/relay-log.info
relay-log-index = /var/log/mysql/relay-log.index
auto_increment_increment = 10
auto_increment_offset = 1
master-host = [IP адрес второй ноды]
master-user = replication
master-password = [пароль для репликации]
replicate-do-db = [база для репликации]
  
[mysqld_safe]
log-error=/var/log/mysqld.log
pid-file=/var/run/mysqld/mysqld.pid


my.cnf второй ноды

[mysqld]
datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock
user=mysql
# Disabling symbolic-links is recommended to prevent assorted security risks
symbolic-links=0
 
key_buffer_size = 32M
innodb_flush_method = O_DSYNC
innodb_buffer_pool_size = 1G
innodb_log_file_size = 64M
innodb_flush_log_at_trx_commit = 2
table_cache = 1024
query_cache_limit = 2M
query_cache_size = 64M
thread_cache_size = 8  
 
 
bind-address = 0.0.0.0
server-id = 2
log-bin = /var/log/mysql/bin.log
log-slave-updates
log-bin-index = /var/log/mysql/log-bin.index
log-error = /var/log/mysql/error.log
relay-log = /var/log/mysql/relay.log
relay-log-info-file = /var/log/mysql/relay-log.info
relay-log-index = /var/log/mysql/relay-log.index
auto_increment_increment = 10
auto_increment_offset = 2
master-host = [IP адрес первой ноды]
master-user = replication
master-password = [пароль для репликации]
replicate-do-db = [база для репликации]
  
[mysqld_safe]
log-error=/var/log/mysqld.log
pid-file=/var/run/mysqld/mysqld.pid


После чего рестартуем Mysqld на двух нодах.

Для большей секюрности советую настроить iptables для ограничению по порту 3306.
Чтобы на этот порт не ломился кто не попадя.

Ну и последним штрихом ставим на двух нодах скрипт для смены FailoverIP на cron с интервалом в минуту.
!!! Скрипт работает так, что если вы вызываете сайт по ip адресу должна открываться страница с кодом 200 (т.е. само приложение должно отдавать правильные http заголовки, и быть доступно напрямую по ip).

Failover IP поднимается на виртуальном интерфейсе eth0:0

Для другого варианта использования, нужно будет править скрипт.

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

<?php
  
$config = include('config.php');
  
define('HETZNER_IP','https://robot-ws.your-server.de');
   
$failoverIp = $config['failover_ip'];
  
$currentServerId = $config['node']['current']['ip'];
$failoverServerIp = $config['node']['failover']['ip'];
   
$user = $config['hetzner_user'];
$password = $config['hetzner_password'];
  
if( !isCurrentServerActive( $user, $password, $currentServerId, $failoverIp ) ){
    $code = getCode($failoverServerIp);
    if( $code != 200 ){
        changeHetznerServerIp( $user, $password, $currentServerId, $failoverIp );
        notifyAfterChangeIp( $config );
    }
}
   
function notifyAfterChangeIp( $config )
{
    $to = implode(',', $config['mail']['To']);
    $headers = [];
    $headers[] = "MIME-Version: 1.0";
    $headers[] = "Content-type: text/plain; charset=utf-8";
    $headers[] = "From: Failover Cluster {$config['node']['current']['name']} <noreply@{$config['node']['current']['name']}.example.com>";
    $headers[] = "Cc: " . implode(', ', $config['mail']['Cc'] );
    $headers[] = "Subject: {$config['mail']['subject']}";
      
      
      
    $message = strtr($config['mail']['message'],
        [
            '{{currentNodeName}}' => $config['node']['current']['name'] . " ({$config['node']['current']['ip']})",
            '{{failoverNodeName}}' => $config['node']['failover']['name'] . " ({$config['node']['failover']['ip']})",
        ]
    );
      
    mail($to, $config['mail']['subject'], $message, implode("\r\n",$headers) );
}
   
function getCode( $url )
{
    $ch = curl_init($url);
    curl_setopt($ch,CURLOPT_TIMEOUT,10);
    curl_setopt($ch, CURLOPT_FILETIME, true);
    curl_setopt($ch, CURLOPT_NOBODY, true);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HEADER, true);
    $output = curl_exec($ch);
    $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
   
    return $httpcode;
}
   
function isCurrentServerActive($user, $password, $currentIp, $failoverIp)
{
    $ch = curl_init( HETZNER_IP . "/failover/{$failoverIp}" );
    curl_setopt( $ch, CURLOPT_USERPWD, "{$user}:{$password}");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch,CURLOPT_TIMEOUT,10);
    $output = curl_exec($ch);
    $output = json_decode( $output, true );
    curl_close( $ch );
    if( trim($output['failover']['active_server_ip']) == $currentIp ){
        return true;
    }else{
        return false;
    }
       
}
   
function changeHetznerServerIp( $user, $password, $changeTo, $failoverIp )
{
    $ch = curl_init( HETZNER_IP . "/failover/{$failoverIp}" );
    curl_setopt($ch, CURLOPT_USERPWD, "{$user}:{$password}");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch,CURLOPT_TIMEOUT,50);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, "active_server_ip={$changeTo}");
    $output = curl_exec($ch);
    curl_close( $ch );
       
    shell_exec("ifconfig eth0:0 {$failoverIp} netmask 255.255.255.255 {$failoverIp}");
}


Файл конфигурации
return [
    // Hezner account credentials
    'hetzner_user' => '',
    'hetzner_password' => '',
    //Failover ip
    'failover_ip' => '',
    // List of nodes
    'node' => [
        'current' => [
            // Node name
            'name' => 'node_one',
            // Current node physical ip (eth0)
            'ip' => '',
        ],
        'failover' => [
            'name' => 'node_two',
            //Remote node physical ip (eth0)
            'ip' => '',
        ],
    ],
    //Mail configuration for send emails after change failover Ip Node
    'mail' => [
        'To' => [
            //Emails for notify
        ],
        'Cc' => [
              
        ],
        'subject' => 'Change master node on sites cluster',
        'message' => 'Something went wrong with master node {{failoverNodeName}}, set master node: {{currentNodeName}}',
    ]
];


Дальше настраиваем мониторинги, бэкапы и прочие полезности.

Решение не идеально, и требуется допиливать под нужды, но основная мысль я надеюсь ясна и будет кому-нибудь полезна =)
Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.