Fail2ban — утилита чрезвычайно полезная во многих случаях. Думаю, многие используют её для того, чтобы в автоматическом режиме блокировать особенно назойливых «посетителей». К сожалению, если входящий поток становится слишком большим, fail2ban теряет все свои полезные свойства, потому что разбор лога безнадёжно отстаёт от реальности.

Вот, например, лог nginx из 100 тысяч строчек fail2ban при самых простых настройках (failregex='^<ADDR>') разбирает порядка 45 секунд:

$ fail2ban-regex nginx.log '^<ADDR>'

Running tests
=============

Use   failregex line : ^<ADDR>
Use         log file : nginx.log
Use         encoding : UTF-8


Results
=======

Failregex: 100000 total
|-  #) [# of hits] regular expression
|   1) [100000] ^<ADDR>
`-

Ignoreregex: 0 total

Date template hits:
|- [# of hits] date format
|  [100000] Day(?P<_sep>[-/])MON(?P=_sep)ExYear[ :]?24hour:Minute:Second(?:\.Microseconds)?(?: Zone offset)?
`-

Lines: 100000 lines, 0 ignored, 100000 matched, 0 missed
[processed in 44.48 sec]

Честно разделив одно на другое, получаем производительность порядка 2250 строк в секунду. Возьмём эту цифру за основу.

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

Теперь наступает пора оптимизации. Для начала попробуем остаться вместе с fail2ban и посмотреть, на что же он тратит время. Поскольку fail2ban написан на Python, мы можем просто подключить cProfile без всяких модификаций кода:

$ python -m cProfile -s cumtime /usr/bin/fail2ban-regex nginx.log '^<ADDR>'

...

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.001    0.001   52.734   52.734 fail2ban-regex:26(<module>)
        1    0.000    0.000   52.640   52.640 fail2banregex.py:784(exec_command_line)
        1    0.000    0.000   52.638   52.638 fail2banregex.py:719(start)
        1    0.411    0.411   52.636   52.636 fail2banregex.py:571(process)
   100000    0.789    0.000   50.909    0.001 fail2banregex.py:448(testRegex)
   100000    0.689    0.000   49.839    0.000 filter.py:601(processLine)
   100000    3.741    0.000   39.767    0.000 datedetector.py:321(matchTime)
  1595350    0.945    0.000   34.731    0.000 datetemplate.py:157(matchDate)
  1695573   33.933    0.000   33.933    0.000 {method 'search' of '_sre.SRE_Pattern' objects}
   100000    0.205    0.000    5.583    0.000 datedetector.py:469(getTime)
   100000    0.187    0.000    5.305    0.000 datetemplate.py:323(getDate)
   100000    1.826    0.000    4.858    0.000 strptime.py:172(reGroupDictStrptime)
   100000    1.284    0.000    3.519    0.000 filter.py:811(findFailure)
  1400000    0.564    0.000    1.479    0.000 utf_8.py:15(decode)
200000/100000    0.615    0.000    1.303    0.000 strptime.py:143(zone2offset)
   100000    0.154    0.000    1.100    0.000 strptime.py:124(validateTimeZone)
   100000    0.422    0.000    0.965    0.000 {method 'index' of 'list' objects

и видим неутешительную картину. Большую часть времени fail2ban тратит на то, чтобы разобраться с форматом даты. К счастью, ему в этом можно помочь — нужно специфицировать, как именно эту самую дату разбирать. Сделать это можно с помощью параметра datepattern в файлах filter.d/*.conf, а для fail2ban-regex достаточно указать параметр -d.

Вывод fail2ban-regex
$ fail2ban-regex nginx.log -d '%d/%B/%Y:%H:%M:%S' '^<ADDR> - [^ ]+ '

Running tests
=============

Use      datepattern : %d/%B/%Y:%H:%M:%S : Day/MONTH/Year:24hour:Minute:Second
Use   failregex line : ^<ADDR> - [^ ]+
Use         log file : nginx.log
Use         encoding : UTF-8


Results
=======

Failregex: 100000 total
|-  #) [# of hits] regular expression
|   1) [100000] ^<ADDR> - [^ ]+
`-

Ignoreregex: 0 total

Date template hits:
|- [# of hits] date format
|  [100000] Day/MONTH/Year:24hour:Minute:Second
`-

Lines: 100000 lines, 0 ignored, 100000 matched, 0 missed
[processed in 8.07 sec]

Итого скорость разбора выросла почти в шесть раз — до почти 12,5 тысяч строк в секунду. К сожалению, такая скорость достижима только при разборе с помощью fail2ban-regex: как уже было сказано выше, в боевом режиме при непрерывном чтении логов это происходит несколько медленнее.

Перепишем на чём-то компилируемом

Поскольку на этом возможности fail2ban исчерпались, мне пришлось искать альтернативные способы. В последнее время я начал изучать Rust и пребываю в полном восторге от этого языка, поэтому выбрал его.

Для того, чтобы эффективно банить нарушителей, нужно хранить последние N запросов (или меньше) для каждого IP-адреса и проверять, не слишком ли часто они приходят. Здесь хорошо подходит кольцевой буфер с несложной логикой: добавляем таймстампы запросов по кругу, и когда накопим ring_size значений, то при добавлении возвращаем разницу времени вновь добавленного запроса и N запросов назад.

Код кольцевого буфера
struct RingBanBuffer {
    timestamps: Vec<Option<i64>>,
    last_index: usize,
}

impl RingBanBuffer {
    fn new(ring_size: usize) -> RingBanBuffer {
        RingBanBuffer {
            timestamps: vec![None; ring_size],
            last_index: 0
        }
    }

    fn add_query(&mut self, ts: i64) -> Option<i64> {
        self.timestamps[self.last_index] = Some(ts);
        self.last_index = (self.last_index + 1) % self.timestamps.len();

        self.timestamps[self.last_index].map(|prev| ts - prev)
    }
}

Собственно парсер логов тоже не слишком сложно написать: сначала выдираем интересующие нас поля (IP и дату) с помощью регулярного выражения, потом разбираем их. Если что-то не то, то возвращаем None.

Код парсера
use chrono::*;
use regex::Regex;
use std::net::IpAddr;

struct ParseResult {
    ip: IpAddr,
    timestamp: i64,
}

struct RegexParser {
    regex: Regex,
    date_format: String,
}

impl RegexParser {
    fn new(regex: &str, date_format: &str) -> Self {
        let re = Regex::new(regex).unwrap();
        RegexParser {
            regex: re,
            date_format: date_format.to_string(),
        }
    }

    fn parse_line(&self, line: &str) -> Option<ParseResult> {
        let caps = self.regex.captures(line)?;
        let timestamp = DateTime::parse_from_str(&caps["DT"], &self.date_format).ok()?.timestamp();
        let ip: IpAddr = caps["ip"].parse().ok()?;
        Some(ParseResult { ip, timestamp })
    }
}

Для парсинга даты понадобится крейт chrono.

Осталось всё соединить воедино. Заведём HashMap, сопоставляющий IP-адреса с кольцевыми буферами и состоянием забаненности, читаем файл по строчкам и наполняем структуру данных. Когда весь файл прочитан, то печатаем список забаненных в stdout и статистику в stderr.

Код main
fn main() {
    let reader = BufReader::new(File::open("nginx.log").unwrap());
    let parser = RegexParser::new(
        r"^(?P<ip>[\d.]+) - [^ ]+ \[(?P<DT>[^\]]+)\]",
        "%d/%B/%Y:%H:%M:%S %z",
    );

    let mut requests: HashMap<IpAddr, (RingBanBuffer, bool)> = HashMap::new();

    let mut line_count = 0;
    let start = std::time::Instant::now();
    for line in reader.lines() {
        line_count += 1;
        if let Some(ParseResult { ip, timestamp }) = line.ok().and_then(|l| parser.parse_line(&l) ) {
            let entry = requests.entry(ip).or_insert((RingBanBuffer::new(30), false));
            if let Some(delta) = entry.0.add_query(timestamp) {
                if delta < 30 {
                    entry.1 = true;
                }
            };
        }
    }

    let elapsed = start.elapsed();

    let banned_ips: Vec<&IpAddr> = requests.iter()
        .filter(|(_, (_, banned))| *banned)
        .map(|(k, _)| k)
        .collect();

    for ip in banned_ips.iter() {
        println!("{}", ip);
    }

    eprintln!(
        "elapsed {} ms, {} lines parsed, {} lines/s, banned = {}/{}",
        (elapsed.as_micros() as f64 / 1e3),
        line_count,
        line_count as f64 / (elapsed.as_micros() as f64 / 1e6),
        banned_ips.len(),
        requests.len()
    );
}

Запускаем, проверяем...

elapsed 208.154 ms, 100000 lines parsed, 480413.5399752107 lines/s, banned = 565/3506

480 тысяч строк в секунду, ускорение приблизительно в 38 раз, вау. Необходимую на практике скорость этот вариант уже перекрывает, но тут мне стало интересно, а насколько ещё это можно ускорить.

Разбор за линейное время

Прежде чем заниматься оптимизацией производительности, хорошо бы выяснить, а что же, собственно говоря, тормозит. Для профилирования кода можно воспользоваться прекрасным профайлером perf (у него есть целая wiki с документацией, а на Хабре есть введение в картинках).

$ perf record target/release/fast2ban > /dev/null
elapsed 206.889 ms, 100000 lines parsed, 483350.97564394434 lines/s, banned = 565/3506
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.031 MB perf.data (789 samples) ]
$ perf report

Видим вполне ожидаемую картину: большую часть времени занимает исполнение регулярного выражения, а именно — бэктрекинг.

perf report в цвете выглядит лучше
perf report в цвете выглядит лучше

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

После некоторых поисков я выяснил, что преобразование регулярных выражений в конечные автоматы, конечно, возможно: это в некоторых случаях умеет и крейт regex. Есть специальный крейт regex-automata, но для регулярных выражений, которые содержат capturing groups, оно не работает.

Кроме этого, существует расширение конечных автоматов под названием Tagged DFA, и готовые решения на его основе, например, RE2C (оно даже может генерировать код и на Rust). Регулярное выражение здесь достаточно простое, поэтому нечто аналогичное здесь несложно написать и своими руками.

Заодно обратим внимание на бесполезный вызов decode_utf8 — nginx по умолчанию пишет логи в ASCII. Линейный разбор можно сделать и на сыром массиве байтов, преобразуя их в строки только для компилятора с помощью unsafe-функции from_utf8_unchecked, которая по факту делает ничего.

Всё содержимое строки до первого пробела будем считать IP-адресом, потом пропустим три байта, затем до следующего пробела, затем до [, а затем до ]. Код при этом можно писать достаточно высокоуровнево, пользуясь итераторами: компилятор эффективно их оптимизирует. Получается разбор «в лоб»:

parse_line_automata
use std::str::from_utf8_unchecked;

fn parse_line_automata(line: &[u8], date_format: &str) -> Option<ParseResult> {
    let mut iter = line.iter().enumerate();
    let ip_end = iter.position(|(_, &c)| c == b' ')?;

    let ip_str = unsafe { from_utf8_unchecked(&line[..ip_end]) };
    let ip: IpAddr = ip_str.parse().ok()?;

    let mut iter = iter
        .skip(3)
        .skip_while(|&(_, &c)| c != b' ')
        .skip_while(|&(_, &c) | c != b'[');

    let (date_start, _) = iter.next()?;
    let date_end = iter.position(|(_, &c)| c == b']')?;

    let date = unsafe { from_utf8_unchecked(&line[date_start+1..date_start+date_end+1]) };

    let timestamp = DateTime::parse_from_str(date, date_format)
        .ok()?
        .timestamp();

    Some(ParseResult { ip, timestamp })
}

Единственное необходимое изменение в main: строки нужно получать теперь не с помощью lines(), а с помощью split(b'\n'), чтобы на выходе были не String , а [u8].

Увеличиваем тестовый файл до одного миллиона строк, запускаем...

elapsed 1269.688 ms, 1000000 lines parsed, 787595.0627240709 lines/s, banned = 1375/3506

Получили дополнительный выигрыш чуть больше, чем в полтора раза. Интересно, что там в профиле?

Теперь больше всего времени занимает разбор времени
Теперь больше всего времени занимает разбор времени

Теперь мы упёрлись в strftime, который, в общем-то, нам не нужен. Более того, для того, чтобы банить нарушителей вообще не нужно разбирать дату, а только время. При необходимости можно добавлять к отрицательным разницам 86400, но и без этого окажется потерянной максимум одна минута вокруг полуночи.

Для этого достаточно после второго пробела пропускать символы аж до :, и не нужно искать закрывающую скобку, поскольку длина времени всегда составляет восемь символов.

Разбор только времени
fn parse_line_automata_time_only(line: &[u8], time_format: &str) -> Option<ParseResult> {
    let mut iter = line.iter().enumerate();
    let ip_end = iter.position(|(_, &c)| c == b' ')?;

    let ip_str = unsafe { from_utf8_unchecked(&line[..ip_end]) };
    let ip: IpAddr = ip_str.parse().ok()?;

    let mut iter = iter
        .skip(3)
        .skip_while(|&(_, &c)| c != b' ')
        .skip_while(|&(_, &c)| c != b':');

    let (date_start, _) = iter.next()?;

    let time = unsafe { from_utf8_unchecked(&line[date_start + 1..date_start + 9]) };

    let timestamp = NaiveTime::parse_from_str(time, time_format)
        .ok()?
        .num_seconds_from_midnight() as i64;

    Some(ParseResult { ip, timestamp })
}

Результаты запуска радуют:

elapsed 699.125 ms, 1000000 lines parsed, 1430359.3777936706 lines/s, banned = 1375/3506

Ускорение ещё почти вдвое, но разбор времени всё ещё занимает почти 20% времени:

NB: время на вызовы функций из chrono:: нужно суммировать
NB: время на вызовы функций из chrono:: нужно суммировать

Разбор и парсинг за один проход

Если внимательно подумать над тем, что происходит в предыдущем варианте, то окажется, что при разборе строки часть с IP-адресом строки читается дважды: сначала при поиске его конца, а затем — ещё раз — при парсинге в IpAddr. Поскольку формат строки уже довольно плотно прибит к функции парсинга, почему бы не разбирать адрес и время сразу по мере просмотра строки?

Скучный код парсинга
fn parse_line_v2(line: &[u8]) -> Option<ParseResult> {
    let mut ip = 0u32;
    let mut cur_grp = 0u32;
    let mut timestamp = 0i64;
    let mut cur_time = 0i64;

    let mut iter = line.iter();
    for c in iter.by_ref() {
        if *c == b'.' {
            ip = ip * 256 + cur_grp;
            cur_grp = 0;
            continue;
        }
        if *c == b' ' {
            break;
        }
        cur_grp = cur_grp * 10 + (*c - b'0') as u32;
    }
    ip = ip * 256 + cur_grp;

    let iter = iter.skip(3).skip_while(|c| **c != b' ').skip_while(|c| **c != b':');
    for c in iter {
        if *c == b':' {
            timestamp = timestamp * 60 + cur_time;
            cur_time = 0;
            continue;
        }
        if *c == b' ' {
            break;
        }
        cur_time = cur_time * 10 + (*c - b'0') as i64;
    }
    timestamp = timestamp * 60 + cur_time;
    let ip: IpAddr = IpAddr::V4(Ipv4Addr::from(ip));
    Some(ParseResult { ip, timestamp })
}

Кода получилось, конечно, побольше, но что насчёт эффективности?

elapsed 552.038 ms, 1000000 lines parsed, 1811469.5002880236 lines/s, banned = 1375/3506
Всё больше libc в верхних строчках профиля
Всё больше libc в верхних строчках профиля

Примерно ещё на четверть быстрее. В профиле начинает заметное время занимать функция __memcpy_ssse3_back из libc, которая вызывается при чтении файла, и... подождите, мы же можем использовать SIMD для разбора!

Разбор IP и времени из строки с помощью SIMD

Решение, которое я использую, основано на ответе на StackOverflow, и, если верить совпадению никнеймов, принадлежит @stgatilov. Оно основано на том, что IP-адрес в виде строки целиком всегда помещается в 128-битный регистр, в который влезает 16 символов, а максимальная длина IP-адреса — 15 символов (12 цифр и 3 точки).

Автор достаточно подробно объяснил в комментариях механизм работы этого алгоритма, я переведу его с некоторыми сокращениями:

Сначала мы загружаем 16 байт с невыровненного адреса с помощью инструкции lddqu. <...> Последующий код будет правильно работать вне зависимости от байтов после окончания адреса. В любом случае, вам лучше убедиться, что каждый IP-адрес занимает не менее 16 байт памяти.

Затем мы вычитаем '0' из всех символов. После этого '.' превращается в -2, а все цифры остаются неотрицательными. Теперь мы вычисляем битовую маску знаков всех байтов с помощью _mm_movemask_epi8.

В зависимости от значения этой маски мы извлекаем нетривиальную 16-байтовую маску перестановки из таблицы shuffleTable. Таблица весит целый мегабайт, и для предварительного вычисления требуется довольно много времени <...>.

Интринсик _mm_shuffle_epi8 позволяет нам изменять порядок байтов в XMM-регистре с помощью маски перестановки. В результате регистр XMM содержит четыре 4-байтовых блока, каждый блок содержит цифры (в little-endian). Мы преобразуем каждый блок в 16-битное число с помощью _mm_maddubs_epi16, за которым следует _mm_hadd_epi16. Затем мы переупорядочиваем байты регистра так, чтобы весь IP-адрес занимал нижние 4 байта.

Наконец, мы извлекаем младшие 4 байта из XMM-регистра с помощью _mm_extract_epi32. Если у вас его нет, можно воспользоваться_mm_extract_epi16, но это будет работать немного медленнее.

Код разбора IP
use std::arch::x86_64::*;

fn parse_ip_simd(addr: &[u8]) -> IpAddr {
    let result: u32;
    unsafe {
        let input = _mm_lddqu_si128(addr.as_ptr() as *const __m128i); 
        let input = _mm_sub_epi8(input, _mm_set1_epi8(b'0' as i8));
        let cmp = input;
        let mask = _mm_movemask_epi8(cmp); 
        let shuf = SHUFFLE_TABLE[mask as usize];
        let arr = _mm_shuffle_epi8(input, shuf); 
        let coeffs = _mm_set_epi8(0, 100, 10, 1, 0, 100, 10, 1, 0, 100, 10, 1, 0, 100, 10, 1);
        let prod = _mm_maddubs_epi16(coeffs, arr);
        let prod = _mm_hadd_epi16(prod, prod); 
        let imm = _mm_set_epi8(-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 6, 4, 2, 0);
        let prod = _mm_shuffle_epi8(prod, imm);
        result = transmute(_mm_extract_epi32::<0>(prod))
    }
    IpAddr::V4(Ipv4Addr::from(result))
}

Код для подготовки таблицы перестановок
fn init_shuffle_table() {
    for len0 in 1..4 {
        for len1 in 1..4 {
            for len2 in 1..4 {
                for len3 in 1..4 {
                    let slen = len0 + len1 + len2 + len3 + 4;
                    let lens = [&len0, &len1, &len2, &len3];
                    let rem = 16 - slen;
                    for rmask in 0..(1 << rem) {
                        let mut mask = 0;
                        let mut shuf: [i8; 16] = [-1; 16];
                        let mut pos = 0;
                        for i in 0..4 {
                            for j in 0..*lens[i] {
                                shuf[((3 - i) * 4 + (lens[i] - 1 - j))] = pos;
                                pos += 1;
                            }
                            mask ^= (1) << pos;
                            pos += 1;
                        }
                        mask ^= rmask << slen;
                        unsafe {
                            _mm_store_si128(
                                &mut SHUFFLE_TABLE[mask],
                                _mm_loadu_si128(&shuf as *const i8 as *const __m128i),
                            );
                        }
                    }
                }
            }
        }
    }
}

Аналогичным образом можно разбирать и время с помощью SIMD-инструкций. Это значительно проще, поскольку минуты, секунды и часы всегда занимают ровно по два символа, и таблица с масками перестановок здесь не нужна.

Секунды и минуты располагаются в первых двух 16-битных словах (и умножаются на 1 и 60, соответственно), а часы — в пятом (и умножаются на 3600, т. е. 14*256+16). Выполняем умножение с горизонтальным сложением и извлекаем два двойных слова результата, которые остаётся просто сложить.

Разбор времени с помощью SIMD
fn parse_time_simd(x: &[u8]) -> u32 {
    unsafe {
        let input = _mm_loadu_si64(x.as_ptr() as *const _);
        let input = _mm_sub_epi8(input, _mm_set1_epi8(b'0' as i8));
        let input = _mm_shuffle_epi8(
            input,
            _mm_set_epi8(7, 6, 4, 3, -1, -1, -1, -1, 1, 0, -1, -1, -1, -1, -1, -1),
        );
        let coeffs = _mm_set_epi8(1, 10, 1, 10, 1, 10, 0, 0, 1, 10, 0, 0, 0, 0, 0, 0);
        let prod = _mm_maddubs_epi16(coeffs, input);
        let prod2 = _mm_madd_epi16(
            prod,
            _mm_set_epi8(0, 1, 0, 60, 0, 0, 0, 0, 14, 16, 0, 0, 0, 0, 0, 0),
        );
        let ms: u32 = std::mem::transmute(_mm_extract_epi32::<1>(prod2));
        let h: u32 = std::mem::transmute(_mm_extract_epi32::<3>(prod2));
        ms + h
    }
}

Заодно для поиска разделителей в строке можно использовать memchr, но не из стандартной библиотеки, а из крейта memchr который имеет оптимизированные для разных процессоров версии. Заодно и код стал короче:

parse_line_simd с memchr
fn parse_line_simd(line: &[u8]) -> Option<ParseResult> {
    let ip = parse_ip_simd(&line[..16]);

    let first_space = memchr(b' ', &line[7..])? + 7;
    let second_space = memchr(b' ', &line[(first_space + 3)..])? + first_space + 3;
    let time_begin = memchr(b':', &line[second_space..])? + second_space + 1;
    let timestamp = parse_time_simd(&line[time_begin..time_begin + 8]) as i64;

    Some(ParseResult { ip, timestamp })
}

Результаты выглядят неплохо, добавилось ещё ~10%:

elapsed 471.925 ms, 1000000 lines parsed, 2118980.77024951 lines/s, banned = 1375/3506
Профиль выглядит примерно так же, как и предыдущий
Профиль выглядит примерно так же, как и предыдущий

Осталось избавиться от копирования памяти при чтении файла (__memcpy_ssse3_back ), и для этого можно использовать mmap. Да, это накладывает определённые ограничения, но в погоне за скоростью сложно остановиться. Итак, берём крейт memmap и несколько переписываем код парсинга, чтобы он заодно возвращал, сколько байт он прочитал:

Парсинг, возвращающий количество обработанных символов
fn parse_line_simd(line: &[u8]) -> Option<(ParseResult, usize)> {
    let ip = parse_ip_simd(&line[..16]);

    let first_space = memchr(b' ', &line[7..])? + 7;
    let second_space = memchr(b' ', &line[(first_space + 3)..])? + first_space + 3;
    let time_begin = memchr(b':', &line[second_space..])? + second_space + 1;
    let timestamp = parse_time_simd(&line[time_begin..time_begin + 8]) as i64;

    Some((ParseResult { ip, timestamp }, time_begin + 8))
}

В главном цикле, соответственно, будем искать следующий символ переноса строки с учётом этого значения, проверяя, что до конца файла осталось больше 16 байт (потому что именно столько прочитает разбор IP).

Переписанные фрагменты main
fn main() {
    
    // ....

    let reader = unsafe { MmapOptions::new().map(&File::open("nginx.log").unwrap()).unwrap() };
    let mut start_pos = 0;
    let mut remains = reader.len();
    loop {
        let line = &reader[start_pos..start_pos + min(remains, 512)];
        if let Some((ParseResult { ip, timestamp }, shift)) = parse_line_simd(line) {
            start_pos += shift;
            remains -= shift;
            let entry = requests
                .entry(ip)
                .or_insert((RingBanBuffer::new(30), false));
            if let Some(delta) = entry.0.add_query(timestamp) {
                if delta < 30 {
                    entry.1 = true;
                }
            };
        }
        let next_line = memchr(b'\n', &reader[start_pos..]);
        if next_line.is_none() {
            break;
        }
        let shift = next_line.unwrap() + 1;
        start_pos += shift;
        remains -= shift;
        line_count += 1;

        if remains < 16 {
            break;
        }
    }

Выигрыш от использования mmap может быть незаметен в том случае, если данные читаются с диска недостаточно быстро: в этом случае копирование памяти не становится узким местом. Файл для тестирования содержит миллион строк и занимает 260 мегабайт, таким образом, на скорости порядка 1500 мб/с чтение файла займёт приблизительно 175 мс. Но если файл уже лежит в файловом кеше системы, то использование mmap даёт заметный выигрыш:

elapsed 373.291 ms, 1000000 lines parsed, 2678875.1938835923 lines/s, banned = 1375/3506
В этом запуске треть времени занимает поиск следующего разделителя в файле
В этом запуске треть времени занимает поиск следующего разделителя в файле

Чтобы увидеть, сколько на самом деле занимает чтение файла, нужно запустить perf от рута с ключом -a:

# perf record -a target/release/fast2ban > /dev/null
elapsed 370.284 ms, 1000000 lines parsed, 2700629.786866297 lines/s, banned = 1375/3506
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.786 MB perf.data (2473 samples) ]
# perf report

Нормальные замеры и итоги

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

lscpu виртуалки для тестов
Architecture:          x86_64
CPU op-mode(s):        32-bit, 64-bit
Byte Order:            Little Endian
CPU(s):                2
On-line CPU(s) list:   0,1
Thread(s) per core:    1
Core(s) per socket:    2
Socket(s):             1
NUMA node(s):          1
Vendor ID:             GenuineIntel
CPU family:            6
Model:                 85
Model name:            Intel Xeon Processor (Skylake, IBRS)
Stepping:              4
CPU MHz:               2294.608
BogoMIPS:              4589.21
Hypervisor vendor:     KVM
Virtualization type:   full
L1d cache:             32K
L1i cache:             32K
L2 cache:              4096K
L3 cache:              16384K
NUMA node0 CPU(s):     0,1
Flags:                 fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl xtopology eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch invpcid_single ssbd ibrs ibpb fsgsbase bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx avx512f avx512dq rdseed adx smap clwb avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 arat md_clear spec_ctrl

Правда, fail2ban-regex слишком медленный и я запускал его только по 10 раз. Кроме того, код на Rust разбирал по одному миллиону строк, а fail2ban-regex — по 100 тысяч. Сам fail2ban выводит замеренное время с точностью до 10мс, поэтому результаты для fail2ban округлены до 100 нс.

Вариант

ns/line

lines/s

Ускорение

Относительно предыдущего

fail2ban изначальный

488 600 ± 9 500

2 047

fail2ban с датой

79 900 ± 2 800

12 514

6,11x

6,11x

Наивная реализация

2 337,55 ± 96,66

427 799

209,04x

34,19x

Без регулярных выражений

1 143,58 ± 73,79

874 448

427,29x

2,04x

Разбор только времени

710,55 ± 43,93

1 407 366

687,69x

1,60x

Разбор в один проход

494,64 ± 32,65

2 021 688

987,87x

1,43x

SIMD

457,74 ± 30,96

2 184 669

1 067,51x

1,08x

SIMD+mmap

366,22 ± 23,56

2 730 612

1 334,28x

1,24x

Дополнительно я измерил просто итерирование по строкам различными методами:

Метод

ns/line

lines/s

Ускорение

.lines()

311,04 ± 12,89

3 214 981

.split(b'\n')

245,63 ± 10,82

4 071 210

1,27

mmap + memchr

173,55 ± 7,15

5 762 122

1,79

Исходники выложены на github, код разных вариантов из статьи находится в ветке habr разными коммитами. Пулл-реквесты, как всегда, приветствуются.

P. S. Внимательные читатели могли заметить, что само использование SIMD не дало большого выигрыша — всего 8%. Тем не менее, желтоватый заголовок статьи корректен: тысячекратное ускорение было достигнуто именно на этом шаге.

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


  1. amarao
    02.06.2022 12:14
    +10

    Начинается как админская байтка, но почти сразу же превращается в rusting. По админской байке, я бы начал с vector (утилита для процессинга логов на rust), но rusting требует совсем других попугаев, и байтка уже не про админские будни.

    Но интересно, спасибо!


  1. amarao
    02.06.2022 12:20
    +12

    Ах, да, ещё. Мне кажется, что если мы идём в сторону миллионов, то первая ошибка - это парсинг текстовых логов. Я поискал, nginx вроде, в protobuf или что-то такое не умеет, хотя задача интересная.


    1. m03r Автор
      02.06.2022 12:44
      +7

      Да, это точно. Я полагаю, что даже простое изменение формата лога (просто в виде IP/datetime) сильно бы помогло производительности. Но интересно было решать задачу именно с заданным форматом.


      1. amarao
        02.06.2022 13:47
        +3

        Я полез смотреть какие сервера умеют бинарный логгинг, и не нашёл. Что даже странно.

        Из моих тестов (я проверял производительность journald) я обнаружил, что он может до 30к сообщений в секунду (однопоточный). Мой (на коленке сляпанный с помощью https://docs.rs/journald/latest/journald/index.html) код на расте генерировал эти 30к сообщений с 50% утилизацией (тоже в одно ядро).

        Возможно, там все криворучки (меня включая), но я подозреваю, что ответ в другом - все эти itoa, datetime, sprintf'ы (и их эквиваленты в других языках) - очень тяжёлые функции с кучей аллокаций.

        .... может быть, нас ждёт новая революция с бинарными логами. (rkyv? zero cost serialization?)


        1. arheops
          02.06.2022 13:52
          +2

          Врядли. Большинству сервисов хватает обычного текста. Тоесть писать это некому.


          1. speshuric
            02.06.2022 14:09

            В "управляемых" облачных сервисах, наверное, имеет смысл. Если есть 1000 экземпляров сервиса и их логи, то почему бы, например, не сэкономить десяток серверов?


            1. BugM
              02.06.2022 21:10
              +6

              Логи нужны не чтобы их писать, а чтобы их читать. Глазами обычно. Удобный поиск с grep -C практически обязателен. И хочется чтобы он работал довольно быстро.

              А на самый чёрный день хочется иметь возможность зайти на прод и посмотреть лог глазами. Иногда спасает.

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


              1. gecube
                02.06.2022 21:47
                +2

                Это ваше частное мнение. Бинарные логи журналди прекрасны. Единственный их недостаток - их не открыть напрямую текстовым редактором. Но будем честны - лог файл на 1 гигабайт и более текстовым редактором вы открывать не будете. А запускать Grep или условный journalctl -u servicename | grep или напрямую условный journalgrep разницы нет. Это как для grpc сделали утилиту grpcurl и опять стало удобно отлаживаться


                1. BugM
                  02.06.2022 22:56
                  +2

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

                  Логи в нормальном режиме работы едут в некое хранилище логов и там уже они бинарные и пожатые. Текст живет только на сервере пишущем логи.

                  Хранилища логов и все пересылки с импортами проще конфигурить когда все логи одинаковые. Понятно что сделать можно все, но зачем эта лишняя сложность?

                  Экономия ЦПУ и локального диска от бинарных логов будет на уровне фонового шума. Было бы там процентов 10 все уже давно переехали бы.


                  1. gecube
                    03.06.2022 02:10

                    Это другой инструмент.

                    и что? Эластиксерч тоже другой инструмент и его ТОЖЕ надо учить отдельно, и готовить отдельно. И так с любым другим движком для хранения логов.

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

                    работай на опережение!

                    Логи в нормальном режиме работы едут в некое хранилище логов 

                    в этом случае как хранятся локальные логи вообще по барабану

                    Текст живет только на сервере пишущем логи.

                    или не текст. Или там вообще нет логов (потому что нефиг давать доступ на конечные сервера, или потому что они отротировались уже)

                    Хранилища логов и все пересылки с импортами проще конфигурить когда все логи одинаковые.

                    это вообще мимо. Нужно конфигурировать не на основе того - текстовые или нет логи - а на основе формата источника данных. И если у Вас десять приложений на сервере, то все равно придется писать десять конфигураций для сборщика логов. Или забить и парсить это на стороне хранилища (у того же ES есть возможность это делать на т.н. ингест ноде). Но это не столь важно.

                    Было бы там процентов 10 все уже давно переехали бы.

                    не переехали только лишь потому что проще всего писать в файл и не делать голову.


                    1. BugM
                      03.06.2022 02:49
                      -1

                      Ну мы все таки в век контейнеров живем. Можно всегда считать что есть одно приложение на сервер пишущее один лог в одно типовое место. Чаще всего stdout. И дальше все настраивать и прикидывать исходя из этого. Зоопарк тут починили, он меня тоже бесил.

                      не переехали только лишь потому что проще всего писать в файл и не делать голову.

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

                      Тот же grpc. На потоке данных от 10Гбит переезд на него окупает все затраты на более сложную поддержку, новые другие тулзы, сложность внутренней структуры и новые проблемы. А вот когда 100Мбит в прыжке поток хотят на него перевести у меня появляются вопросы. Расходы на http и прочие json настолько смешные что удобство и простота становятся важнее.

                      С логами тоже самое, только потоков данных таких нет. Логи занимают совсем смешной объем от общего потока данных и их оптимизация, пусть даже в два раза по любому из параметров, на общем потреблении железа никак не скажется. А типовые трудности бинарного протокола появляются сразу.


                      1. gecube
                        03.06.2022 10:39
                        +1

                        Ну мы все таки в век контейнеров живем. Можно всегда считать что есть одно приложение на сервер пишущее один лог в одно типовое место. Чаще всего stdout. И дальше все настраивать и прикидывать исходя из этого. Зоопарк тут починили, он меня тоже бесил.

                        12 факторов - это хорошие принципы, но они не бесспорные. С теми же логами - те кто попрошареннее - инструментируют свои приложения и шлют «логи» напрямую в эластик или тот же сентри, чтобы иметь нормальную агрегацию событий. А sentry это реально топ штука. Не разбирать же мультилайн, чтобы категорировать ошибки в софте? И внезапно выясняется, что особой ценности писать в stdout/stderr нет, это удобно только на тестовых стендах. А на проде - у разрабов все равно нет доступа к docker logs/kubectl logs. А ещё Вы явно не сталкивались с ограничением на длину лог строки, если кидать ее в stderr.

                        Короче - на самом деле тезис о том, что любые логи надо писать в виде текста в stderr/stdout совершенно не бесспорен.


          1. amarao
            02.06.2022 14:16
            +4

            ... Рано или поздно революция случится. Потому что всем нравится софт, который быстрее в 10 или в 100 раз предыдущего.

            Прокси-сервер, построенный из ebpf, io_uring и максимальном количестве zero copy (включая логгинг). Сказка? Миллион коннектов в секунду на одно ядро? (У nginx это сейчас 145k, и это при отключенном логгинге).


            1. edo1h
              03.06.2022 03:57

              революция случилась, https, так что нет ни 1кк rps, ни даже 145к


              1. amarao
                03.06.2022 12:07

                Во-первых есть куча случаев до/после ssl. Балансировка на транспортном уровне, балансировка после (тем же proxy protocol). Во-вторых, SSL ещё бывает с акселерацией, и там очень высокие показатели.


                1. edo1h
                  03.06.2022 14:57

                  Во-вторых, SSL ещё бывает с акселерацией, и там очень высокие показатели.

                  если речь про ktls, то ЕМНИП там хеншейк не оффлоадится.


        1. fougasse
          02.06.2022 14:12

          ясно, что itoa и, особенно,generic реализации sprintf сложновато обойтись без кучи аллокаций, но всегда можно подсунуть свой велосипед


          1. amarao
            02.06.2022 14:20
            +4

            На самом деле всё интереснее. Изначально я подумал про binary log в каком-нибудь protobuf'е, чтобы не возиться с записью IP в строки. Но это же раст! И есть условный rkyv, который позволяет получить сериализацию с нулевым оверхедом (на самом деле нет, потому что данные в памяти чуть менее удачно лежат, но всё равно круто). Если выбранный формат таков, что одновременно обеспечивает и быструю работу прокси, и быстрые логи (а быстрые логи - это быстрые waf'ы/ids'ы и т.д.), то это звучит как пролетарский кулак в лицо интерпретируемой мути (js/python/ruby/node).


    1. tuxi
      02.06.2022 15:00

      Nginx умеет /mirror, а там куда это приходит, уже можно делать что хочешь. Ну как вариант, чтобы отказаться от чтения логов.


  1. raamid
    02.06.2022 12:48
    +24

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


  1. 13werwolf13
    02.06.2022 13:39
    +5

    Оно основано на том, что IP-адрес в виде строки целиком всегда помещается в 128-битный регистр, в который влезает 16 символов, а максимальная длина IP-адреса — 15 символов (12 цифр и 3 точки).

    я так понимаю прекрастное будущее с ipv6 не наступило? :-)
    а какое решение предложите если наступит?

    а так статью читать приятно, хотя я у мамы не кодер а админ, но было очень интересно, спасибо.


  1. speshuric
    02.06.2022 13:56
    +1

    Пока читал, куча мыслей промелькнула: А nginx хотя бы теоретически может угнаться по скорости генерирования логов за таким разбором? А action (блокирование) как быстро станет узким местом? И (если блокировать по одному IP), то как быстро заблокируется /0? А еще стало интересно, получится ли эффективная работа fail2ban в ip6? И можно ли еще ускориться парсингом на GPU?


    1. amarao
      02.06.2022 14:21
      +1

      Скорость логгирования сложно сказать, но nginx даёт 145к коннектов в секунду на ядро (http, без логов). С логами будет кратно меньше.


      1. m03r Автор
        02.06.2022 14:27
        +3

        Кстати, логи можно собирать с нескольких серверов. nginx умеет логировать в rsyslog, а тот умеет пересылать логи по сети.

        В имеющейся схеме парсер логов запускается раз в минуту, и чем меньше времени (и, соответственно, ресурсов) он будет отъедать при разборе накопившегося — тем лучше.


  1. cepera_ang
    02.06.2022 14:08
    +1

    То есть итоговая скорость парсинга получается 0.25ГБ/0.373сек= 0.67ГБ/сек? Если судить по результатам других оптимизированных парсеров (simdjson, как пример), то ещё раз в десять ускорение должно быть возможно, а так как тут по сути только поиск двух значений в каждой строке идёт, то возможно и вообще пределом может быть скорость чтения из памяти.


    1. m03r Автор
      02.06.2022 15:02
      +3

      Если взять просто разделение строк по memchr, то производительность выходит порядка 0,26Гб/0,173 с = ~1,5 Гб/с.

      В оригинальной статье про simdjson указано, что на Skylake@3,4GHz парсинг происходил со скоростью 2,2Гб/с. На виртуалке тоже Skylake, но на частоте 2,3GHz, что при пересчёте даёт те же 1,5 Гб/с.

      Таким образом, если задействовать AVX, который использует memchr , и делить на строки одновременно с парсингом, то, думаю, схожей производительности можно и добиться. Как-нибудь надо будет попробовать...

      А 12 Гб/с simdjson достигает, судя по той же таблице на с. 17, на select, то есть выборке из уже разобранного дерева. Кстати, в этом он не чемпион, его обгоняет sajson.


      1. cepera_ang
        02.06.2022 23:27
        +3

        Оригинальной статье уже три года :)

        А вот тут его автор рассказывает как они на AVX-512 недавно ускорили и там поиск произвольных значений с нуля по 7Гбайт/сек шпарит или минификация (то есть поиск и удаление все пробельных символов, включая \n) на 12Гбайт/сек :)


  1. xenon
    02.06.2022 15:19
    +4

    Мне кажется, это очень частая ситуация, когда медленный скриптовый язык отлично подходит для реализации приложения, но тормозит в какой-то одной-двух маленьких деталюшках, которые бы, по-хорошему, надо реализовать на чем-то быстром (C, rust, go).

    Может быть как-то регексовый парсинг (может быть с упором именно на парсинг логов) можно вынести в отдельную быструю утилиту, так чтобы ее можно было легко соединить потом и с fail2ban и с любым другим скриптовым обработчиком логов. Чтобы писать логику на удобных скриптовых языках и при этом не тормозить на парсинге. (Примерно как ни один скриптовый язык не делает zip/gzip своими силами, это было бы фантастически медленно, все используют внешние либы или утилиты).


    1. gecube
      02.06.2022 17:08
      +2

       (Примерно как ни один скриптовый язык не делает zip/gzip своими силами, это было бы фантастически медленно, все используют внешние либы или утилиты).

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

      Может быть как-то регексовый парсинг (может быть с упором именно на парсинг логов) можно вынести в отдельную быструю утилиту

      это бред, потому что в корне надо менять подход... не пытаться парсить регулярные данные (еще и через промежуточный медиатор в виде файла, капец, эффективность уровня БОГ), а сразу фигачить в протобуф тот же...


      1. fshp
        03.06.2022 12:40
        +3

        Только не в протобаф а во flatbuffers. Протобаф гонится за компактностью, поэтому там varint используется. Flatbuffers же в приоритете имеет скорость и zero copy.


  1. gecube
    02.06.2022 15:19
    +12

    Что сказать.. Статья интересная. Спасибо автору за это. Хороший пример программирования.


    Но я категорически против использования fail2ban в проде.

    Во-первых, это лишний компонент. 

    Во-вторых, это глючный компонент. А что вы думали? Я уже рассказывал байку, как правильный конфиг f2b привел к взлому телефонии из-за бага в том, что он не матчил tcp/udp порты.

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

    В четвертых, f2b это попросту опасно. Пора привыкнуть к тому, что клиент далеко не определяется тупо айпи адресом. Запросто за одним айпи могут быть сотни пользователей. Это возможно как в мобильных сетях (привет мегафон с его натом), так и в случае корпоративных сетей (когда выход в инет делается с одного шлюза). И бан одного пользователя приводит к недоступности сервиса для всех. Повторяю. НЕЛЬЗЯ ИСПОЛЬЗОВАТЬ F2B для ПУБЛИЧНЫХ сервисов. Для частных - еще куда ни шло, но там он не нужен, потому что есть другие механизмы контроля

    В пятых, если вам нальют DDoS, f2b будет последним, кто вас спасет… Вот честно - у меня не было ситуации, когда f2b был полезным инструментом….


    @amarao какой бинарный логгинг, окстись. Тут энвой в ядро хотят затащить, чтобы быстрее работало, а ты journald разгонять пытаешься, который вообще НЕ ДЛЯ ЭТОЙ задачи.


    Далее поговорим о технических вещах. F2b работает через айпитейблз. Это зашквар. Потому что nftables и прочее. Как там с их поддержкой? Все ок? А еще это медленно. Если хотим быстро - надо фигачить ebpf. И, удивительно, решения для этого есть.


    Давайте попробуем подискутировать.


    1. randomsimplenumber
      02.06.2022 18:03

      Это да.. fail2ban отличный пример того, как решая одну проблему (клиент не может пройти авторизацию) можно придумать себе парочку других (вместо унылого подсчёта криптографии при установлении соединения процессор занят обработкой regexpов и заполнением таблиц в памяти ядра).

      Немного более прямо было бы писать логи в изначально машиночитаемой формате (sql?).

      А так, fail2ban нужен только затем чтобы админу было чем заняться ;)


      1. Semy
        03.06.2022 12:47

        sql - это язык запросов.

        Логи можно было бы писать в JSON. Это бы сохранило и читабельность (ну хотя бы относительную в отличии от бинарных логов) и более удобный парсинг через jq вместо grep.


        1. iig
          03.06.2022 12:57

          json - это ещё хуже (в смысле быстродействия).

          sql - это не только язык, но и изначально форматированные данные (не нужно парсить дату/время/ip) + возможность поиска/сортировки


          1. gecube
            03.06.2022 13:26

            sql - это не только язык, но и изначально форматированные данные (не нужно парсить дату/время/ip) + возможность поиска/сортировки

            sql - это не формат

            json - это ещё хуже (в смысле быстродействия). 

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

            еще добавлю

            @BugM подумайте еще вот о чем. Бинарный формат в journald обеспечивает надежное хранение и контроль целостности логов. В случае - если мы логи пишем просто в текстовые файлы - где гарантии того, что лог будет цельный? Что какой-то Васян или программа по ошибке его наполовину не затрет ? А с json, @Semy, так вообще песня. Положили мы его в файл. А лог по какой-то причине транкейтнулся. Результат очень простой - jq просто при парсинге этого лога возьмет и сломается... Не, такой подход не годится...


            1. iig
              03.06.2022 13:41

              sql - это не формат

              В смысле хранения данных - нет, не формат. В смысле доступа к данным - очень даже да. Данные парсить не нужно, они уже типизированные. Но я не настаиваю ;)


            1. Semy
              03.06.2022 14:53

              Если лог обрезался почему то не по границе строки, то text тоже будет поломан, а json упаковывается в строку. Поэтому я подразумеваю, что он "условно читаем". Визуально воспринимается он плохо, но хоть grep'нуть по нему можно. Одним словом -- компромисс.


    1. BigD
      02.06.2022 20:06
      +1

      Так, а чем заменить-то? Пример - веб-сервер, SSH надо защитить качественно, ключи не вариант (нужны пароли).


      1. gecube
        02.06.2022 20:22
        +1

        защитить от кого или от чего? Вы всегда должны думать в первую очередь - от чего защищаемся.

        ключи не вариант (нужны пароли).

        не нужны. Если это не так - Вы что-то делаете не так. Либо накидывайте конкретные кейсы, можно обсудить

        @ky0

        Понимаю, что такой вариант подходит не для всех случаев, но для бана активно долбящихся в какую-нибудь API — то, что надо. Nginx, ограничив спамера через limit_req, отправляет его айпишник в error_log, который, в свою очередь, просматривает f2b и в какой-то момент блокирует уже на сетевом уровне.

        я объяснил почему это не работает. Представьте себе, что Ваши клиенты сидят на каком-нибудь таймвебе, ECS или любом другом шареном хостинге и у них РАЗНЫЕ токены для доступа к Вашему АПИ, но айпишники общие. Вы реально будете их блочить на сетевом уровне, ау?


        1. BigD
          02.06.2022 20:30

          Ну ОК, подумал, какой-то менеджмент ключей можно сделать. Перебор/брутфорс больше не грозит? Можно не блокировать попытки подбора?


          1. randomsimplenumber
            02.06.2022 21:31

            А чем грозит попытка подбора ключа?


            1. BigD
              02.06.2022 22:37
              -1

              Успешным подбором? Перегрузкой файла с логами и места для логов?


              1. BugM
                02.06.2022 22:57
                +1

                Подобрать ключ? Вы это серьезно?

                Про ротацию логов по размеру вы ничего не слышали?


              1. gecube
                02.06.2022 22:57
                +1

                Перегрузкой файла с логами и места для логов?

                бредовый аргумент. Кладите логи на отдельную ФС и не блокируйте выполнение основного функционала, если логирование сломалось.

                Успешным подбором?

                здрасьте... если пароль еще можно подобрать (так как там энтропия низкая и пользователи обычно используют или словарные пароли, или пароли кочуют от сервиса к сервису), то подобрать ключ 4096 бит... ну удачи, как говорится

                Я гарантирую, что если злоумышленник вошел по ключу, то это означает не то, что он его подобрал, а то, что он его с*#^$*#$^* у владельца ключа... И это проблема гораздо более серьезная, чем подбор пароля (мало ли там еще остальные доступы этого пользователя в другие сервисы уже похеканы)...

                Да и вообще сделайте наконец-то OTP вход по SSH... И, да, это возможно...


              1. 13werwolf13
                03.06.2022 06:29

                подобрать ключ задача не реальная в текущих реалиях
                особенно если админ не наркоман и сделал конфиг sshd с таймаутами и безопасными ciphers.

                единственное чем грозит попытка подбора ключей (логинов и паролей) в таком случае это увеличенная нагрузка на проц и сеть, и от этого обычно спасает перевесить ssh с дефолтного порта на рандомный, а ещё лучше прикрыть его vpn и/или portknocking.

                и да, толку будет больше чем от f2b

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


                1. BigD
                  03.06.2022 07:00
                  -1

                  А где? Не нашёл


                  1. 13werwolf13
                    03.06.2022 07:12

                    что "где"?


                    1. BigD
                      03.06.2022 07:23
                      +1

                      вот теперь увидел «тут» :)


                1. randomsimplenumber
                  03.06.2022 07:46
                  +1

                  увеличенная нагрузка на проц и сеть

                  Fail2ban сам обеспечивает нагрузку на проц ;) С чем боролись, как говорится.


        1. AlexanderS
          03.06.2022 11:41
          +1

          не нужны. Если это не так — Вы что-то делаете не так. Либо накидывайте конкретные кейсы, можно обсудить

          Стоит домашний сервер, на котором крутится Nextcloud, к которому подключен мобильный клиент, который умеет авторизацию только по паролю. Используя f2b можно разобрать логи Nextcloud и сварганить защиту от перебора паролей. В принципе Nextcloud и сам умеет защищаться от брутфорса, но только таймаутом, а тут проблема убирается в корне сразу на целый день/неделю/месяц/год) Но со всеми вытекающими рисками блокировки сети за NAT. Однако если доступ к облаку на сервере осуществляется с определённых статичных IP, то это несущественно. А есть какая-то простая и незамороченная альтернатива в подобной ситуации?


          1. gecube
            03.06.2022 11:56

            1. перевесить nextcloud на нестандартный порт (не 80,443, а какой-нибуль 30200)

            2. сделать впн, вывешивать nextcloud на приватный ip, на телефоне зафигачить vpn клиента

            3. your choice...

            Используя f2b можно разобрать логи Nextcloud и сварганить защиту от перебора паролей. В принципе Nextcloud и сам умеет защищаться от брутфорса, но только таймаутом, а тут проблема убирается в корне сразу на целый день/неделю/месяц/год) 

            Вы сами дали ответ на свой вопрос. f2b тут нафиг не нужен. Рейт лимит и защита от брутфорса прекрасно делается на уровне приклада или reverse proxy перед ним (nginx).

            Я еще, кстати, надеюсь, что у Вас nextcloud по tls наружу торчит?


            1. AlexanderS
              03.06.2022 12:52

              Перевешивание сервисов на нестандартные порты, причём не «круглые» — это кажется первым пунктом вообще везде делать надо. Это закрывает кучу моментов, иначе боты одолеют.

              Сервер старый, стек LAMP в котором обычный HTTPS через самоподписной SSL сделан и прочая ламповая партизанщина. Я лет 5 назад цикл мануалов на хабре делал. С тех пор никакой эволюции не было ибо как-то не до этого. Хотя поддержка Debian 9 этим летом заканчивается. Да и для локалки нормально было, а сейчас в сети торчит только потому что там ничего важного нет)


          1. cepera_ang
            03.06.2022 12:19
            +1

            А есть какая-то простая и незамороченная альтернатива в подобной ситуации?

            Tailscale. Поставить на сервер, поставить на мобильник. 0 конфигурации, почти идеальная надёжность. Можно хоть вообще без пароля выставлять сервисы, уютная «локалка» для своих девайсов как в старые добрые доинтернетные времена.


            1. AlexanderS
              03.06.2022 12:54
              +1

              Это Mesh на основе WireGuard? Звучит очень привлекательно, спасибо за наводку.


      1. cepera_ang
        03.06.2022 00:12
        +1

        SSH посадить на менеджмент-интерфейс, доступный только из частной сети (с подключением по ключу, бгг). См. tailscale.


        1. BigD
          03.06.2022 00:23

          К сожалению, доступ к сайтам на сервере по SSH нужен куче агентств. Тут нужно что-то типа Teleport по хорошему, но он дико дорогой в Enterprise версии.


          1. gecube
            03.06.2022 00:26

            используйте бесплатную, она прекрасно работает.

            И, да, телепорт - это по сути ssh по сертификатам.

            Альтернативы:

            ssh по otp

            ssh по короткоживущим сертификатам - пример - https://smallstep.com/docs/tutorials/ssh-certificate-login

            Если есть какие-то вопросы - я собираю русскоязычное сообщество пользователей телепорта в телеграмме для обмена опытом. Уже пару ребят подсадил на это прекрасное решение и негатива в принципе нет

            https://t.me/ru_teleport


      1. edo1h
        03.06.2022 03:18

        ключи не вариант (нужны пароли).

        ну ладно, пароли так пароли, pwgen -Bs 16, тыкаем в любой.


        если же у вас пароль «год канонизации святого Доминика папой Григорием IХ», то fail2ban вас никак не спасёт, перебор паролей сегодня зачастую идёт распределённо.


    1. cepera_ang
      03.06.2022 00:10
      +2

      > В третьих, это медленно. Грепать или тейлить логи зашквар. Не понимаю, чем было бы хуже написать плагин на Lua для nginx и там трепать соединения.

      Да, по-настоящему оптимальный подход конечно не в том, чтобы сначала сохранять в файл (!) текстовый (!) лог через миллион системных вызовов и тонны оверхеда, а потом парсить его питоном (!), а реализовать эти счётчики прям в процессе принимающем соединения за пару десятков инструкций :)

      Это кстати интересный пример того, насколько отличаются понятия о производительности у «обычных» людей и тех, кто под большой нагрузкой выжимает реально последние проценты из _системы_, не отдельных программ или функций. В первом случае «мы перестали вызывать отдельную программу интерпретирующую скрипт с диска на каждое соединение» может быть оптимизацией закрывающей все проблемы (потому что современные железки — безумные звери), во втором случае «мы парсим первые байты пакета ещё в буфере на сетевой карте и принимаем решение за N-2 SIMD инструкции» — обычный вечер пятницы. Прост у первых тысяча коннектов в секунду (у-у-у! хайлоад!), а у вторых миллион :)


      1. gecube
        03.06.2022 16:00

        Да, по-настоящему оптимальный подход конечно не в том, чтобы сначала сохранять в файл (!) текстовый (!) лог через миллион системных вызовов и тонны оверхеда, а потом парсить его питоном (!), а реализовать эти счётчики прям в процессе принимающем соединения за пару десятков инструкций :)

        внезапно, лет 5 тому назад - я решал похожую задачу. Был некий лог, который тейлился скриптом на перле. Это было не очень поддерживаемо (перл вообще как ЯП уже вроде как умер, но в то время это было прям на острие технологий). Я его переписал на python. Чтобы Вы думали - производительность упала (!). Не то, чтобы сильно, но просела. Так что я прямо на практике убедился в том, что перл реально побыстрее пайтона работает. Другой вопрос, что в общем-то никто от этого не пострадал. И, да, с одной стороны я горжусь тем, что заскриптовал и это решение крутится в проде, с другой - не горжусь, потому что такие же костыли и лучше было придумать альтернативное решение с более вменяемой архитектурой... Но не было тогда ни времени, ни бюджета на это


        1. cepera_ang
          03.06.2022 16:52

          Все мы делаем решения в рамках возможного для нас. Главное стремиться к лучшему и расширять пространство возможного (вчера — скрипт парсящий лог на питоне, сегодня модуль на луа для nginx, завтра — модуль WASM на расте для envoy). Плохо было бы сидеть и защищать скрипт на питоне или спорить что лучше перл или питон :)


    1. radroxx
      03.06.2022 18:48
      -1

      fail2ban - отличный инструмент

      1. Это инструмент для конкретной задачи и он с ней справляется хорошо

      2. Если у вас кривые правила в фаерволе не какой f2b не поможет

      3. Достаточно для большинства кейсов, я не верю что у вас на телефонии или nginx больше чем 2к rps, или больше чем 2к строк в сек. прилетает в логи. Грепать и тайлить логи это нормально так делают большинство logstash fluentd и т.д.. А хранить логи в файлах локально это в первую очередь надежно и быстро в отличии отправке по сети. Анализ трафика на лету как правило выходит дороже и опасней.

      4. Если так хочется баньте ip+port для udp он меняется раз в 30 минут по умолчанию или не меняется вовсе днями. С tcp сложней, но даже те провайдеры кто прячет пользователей за nat как правило присваивают 1 ip на пользователя и ротейтят с какой то периодичностью, а не прячут овер +100500 узверей за 1 ip. Так что проблема в большинстве надумана. С организациями немного сложней но для этого есть белые списки.

      5. Вас хостер первым попросит свалить с его хостинга, при хорошем ddos могут и с ДЦ попросить свалить. Если вы достаточно жирный клиент то поставщик как правило сам решает вопрос ddos-а, за отдельные деньги или уже в пакете, соответствующим оборудованием и это не как не в плоскости f2b, да и инструмент решает иные задачи.

      6. Fail2ban - работает на python его легко править, в нем легко разобраться для него легко писать правила. В конечном итоге f2b делает ровно 2-е вещи вызывает cli командочку для того что бы забанить и cli командочку для того что бы разбанить. Что вы там будете вызывать это ваш вопрос, не нравится iptables используйте любой другой фаервол, для ускорения вполне можно юзать ipset. Не знаю что такое ebpf но если он умеет добавлять правила и удалять cli командой то f2b с этим справится. Можете на fail2ban легко сделать не добавление правила а отправку сообщеньки в тот же telegram с ip и идти руками проверять, что там происходит. Всяко лучше чем проспать и что то делать когда уже клиенты приходят.

      Это в первую очередь простой инструмент закрывающий вопрос с назойливыми ботами и мамкиными кулхацкерами. В случае с asterisk это вообще маст хев, там sip обрабатывается в 1 ядро, брутить могут так что на остальных клиентов cpu не хватает и если таких не банить страдают остальные клиенты. А если у вас публичный сервис для всех и масштабы соответствующие у вас с вероятностью в 99% кластер, и там простым f2b уже не обойтись. Ну или мощности позволяют забить на вопрос борьбы с мамкиными кулхацкерами.

      Мой посыл такой это инструмент закрывающий вопрос брутфорса паролей на сервисе будь то sip, ssh или что то еще, почти из коробки, требующий минимальных усилий на внедрение. И это все отлично работает пока вы вмещаетесь на 1 сервер. Когда вы не влазите на 1 сервер то и решения нужно применять соответствующие.


      1. gecube
        03.06.2022 19:00

        не убедительно, от слова совсем.

        Это инструмент для конкретной задачи и он с ней справляется хорошо

        define что это за конкретная задача?

        Если у вас кривые правила в фаерволе не какой f2b не поможет

        проблема была не в файрволле, а в самом f2b. Может мне еще ссылку на ишью дать? Не проблема, кстати, только поискать надо будет поиском. Он (f2b) как был студенческой поделкой без каких-либо гарантий, так и остался. Это не промышленные штуки вроде nginx - которыми пользуются все, на скейле, да еще и есть коммерческая версия с поддержкой.

        Знаете, уровень большинства инструкций в интернете точно такой же. Что там обычно делают? Ставим центос линукс (потому что это же почти RHEL!!!). Затем отключаем SElinux (это же сложно его настраивать, давайте сразу отключим). Потом давайте затащим nginx или апач. И в довершение подпихнем f2b...

        Грепать и тайлить логи это нормально так делают большинство logstash fluentd и т.д.. А хранить логи в файлах локально это в первую очередь надежно и быстро в отличии отправке по сети. Анализ трафика на лету как правило выходит дороже и опасней.

        не нормально. В том же флюенте народ прекрасно с разбега налетает на race condition и на достаточную сложность настройки (когда формат входящих логов у разных приложений разный). Я уж не говорю о гарантиях доставки (краткий спойлер - их нет, ну, потерялись логи - ничего страшного)

        Если так хочется баньте ip+port для udp он меняется раз в 30 минут по умолчанию или не меняется вовсе днями. ....

        я уже ничего не хочу, мне пофиг, я в состоянии дзена и без f2b и сервисы защищены.

        А если у вас публичный сервис для всех и масштабы соответствующие у вас с вероятностью в 99% кластер, и там простым f2b уже не обойтись

        чтд


  1. Abyss777
    02.06.2022 16:32
    +1

    А куда денутся все эти преимущества при реальном бане IP? накладные расходы на вызов iptables всё ускорение нивелируют.


    1. m03r Автор
      02.06.2022 16:39
      +2

      При реальном бане с запасом хватает скорости наивной реализации, а бан делается пачкой через ipset, потому что добавление правил по одному в iptables, конечно же, сильно тормозит (в том числе и обработку длинной цепочки правил). Кстати, в README репозитория есть инструкции на этот счёт.


  1. ky0
    02.06.2022 18:34
    +2

    Я бы начал с того (собственно, так и делаю), что парсил не огромные access-логи, а складывал бы активность тех, кого потенциально может понадобиться забанить куда-то ещё, например, в тот же error-лог.

    Понимаю, что такой вариант подходит не для всех случаев, но для бана активно долбящихся в какую-нибудь API — то, что надо. Nginx, ограничив спамера через limit_req, отправляет его айпишник в error_log, который, в свою очередь, просматривает f2b и в какой-то момент блокирует уже на сетевом уровне.


  1. slepnoga
    03.06.2022 00:52
    +1

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

    1. Похоже, что архитектуры нету,. Миллион строк логов на том же хосте, где и фронтенд ? А там еще и постфикс крутится? :) и сислог , и крон с logratade? А почему бы и нет ;). Ну если баним на апстолах на том же хосте...

    2. Проблема разбора миллионов строк логов с ~40k bare-metal ( и бог знает сколько контейнеров) была решена почти 10 лет назад уже знакомыми с 6-го этажа одного бизнесцентра в моем городе и успешно применялась ( а может и сейчас применяется) в одном из 2-х двухбуквенных сайтов Рф.

      Так как все равно логи льем на сервера логгирования, то именно там до записи на FS стоит мелкая незаметная крохотулька https://github.com/quadrantsec/sagan, в те времена не такая навороченная, но так же быстрая.

    П.С. Забанить DDoS iptables вы все равно не сможете, т.к. DDoS - это когда в 10Г канал вам вдувают 50Г трффика...


    1. m03r Автор
      03.06.2022 08:05
      +2

      Вы совершенно правы, и именно поэтому статья располагается в разделе "ненормальное программирование". Практических целей у дальнейших оптимизаций нет, просто было интересно.