Як ми робили моніторинг запитів mongodb


Використання монги у production — досить спірна тема.
З одного боку все просто і зручно: поклали дані, налаштували реплікацію, розуміємо як шардировать базу при зростанні обсягу даних. З іншого боку, існує досить багато страшилок, Aphyr у своєму останньому jepsen тесті зробив не дуже позитивні висновки.
За фактом виявляється, що є досить багато проектів, де mongo є основним сховищем даних, і нас часто запитували про підтримку mongodb в окметр. Ми довго тягнули з цим завданням, тому що зробити "осмислений" моніторинг на порядок складніше, ніж просто зібрати якісь метрики і налаштувати які-небудь алерти. Потрібно спочатку розібратися в особливостях поведінки софта, щоб зрозуміти, які саме показники відстежувати.
Як раз про труднощі і проблеми, я і хочу розповісти на прикладі реалізації моніторингу запитів до mongodb.
На будь-яку базу даних потрібно дивитися з трьох сторін:
  • Моніторинг ресурсів сервера (процесор, пам'ять, дискова підсистема, мережа). Тут немає нічого складного, більшість систем моніторингу з цим справляються досить добре.
  • Моніторинг нутрощів БД (з'єднання, індекси, кеші, робота з диском, тимчасові таблиці, реплікація, сортування, ...). Такі метрики зазвичай потрібні для розуміння, як переналаштувати БД, яких ресурсів сервера не вистачає, які індекси створити.
  • Моніторинг запитів (скільки яких, які запити створюють навантаження, трафік, часи запитів). З нашого досвіду більшість проблем з базою виникають при зміні профілю навантаження/запитів від додатка, наприклад:
    • з'явився якийсь новий неоптимальний запиту від програми
    • змінилися умови запиту та індекс перестав ефективно працювати

    • виросла таблиця і послідовне читання перестало бути швидким
Ми поки обмежилися лише моніторингом запитів.
Так як ми говоримо про моніторинг, нас не цікавить кожен конкретний запит, ми швидше хочемо всі запити згрупувати по деякому однаковим планом виконання (наприклад postgresql pg_stat_statements групує запити щодо реального плану).
Для mongodb ідентифікатором запиту є тип запиту (find, insert, update, findAndModify, aggregate та інші), база даних, колекція і bson документ з самим запитом.
Для простоти ми вирішили, що запити можна згрупувати, замінивши всі значення полів запиту на "?" і відсортувавши по полях.
Наприклад запит:
{"country": "RU", "центр": "Moscow", "$orderby": {"age": -1}}

перетворюємо в
{country: ?, city: ?, $orderby: {age: ?}}

а потім сортуємо по ключам
{$orderby: {age: ?}, city: ?, country: ?}

Швидше за все подібні запити будуть використовувати одні і ті ж індекси незалежно від конкретних умов.
Наступний велике питання: як отримувати в реальному часі весь потік запитів.
Єдиний штатний спосіб mongodb — це profiler. Він записує статистику по кожному запиту в обмежену за розміром колекцію (capped collection). Профайлер може записувати або тільки повільні запити (якщо час виконання більше заданого slowOpThresholdMs або записувати абсолютно всі запити. У другому випадку може просісти продуктивність самої mongodb.
До переваг даного підходу варто віднести дуже докладну статистику про виконання кожного запиту.
Але для нас дуже критично не чинити негативного впливу на продуктивність серверів наших клієнтів, тому використовувати профайлер у режимі запису всіх запитів ми не можемо. Тільки "повільних" запитів нам недостатньо, так як ми не побачимо повної картини:
  • які запити створюють найбільше навантаження на сервер
  • які запити створюють основний вхідний/вихідний трафік на сервер
  • за конкретними запитами подивитися розподіл часу відповіді
  • яких запитів скільки в штуках в секунду
За нашим досвідом проблеми частіше створюють високочастотні запити, які раніше виконувалися 1ms, а потім з якоїсь причини стали виконуватися наприклад 5ms. А запити >100ms (дефолтний slowOpThresholdMs) зазвичай службові (адмінка/статистика) і дуже рідкісні.
Так як стандартний профайлер не підійшов, ми стали копати в сторону сниффинга трафіку. На першому етапі необхідно було з'ясувати ряд питань:
  • бібліотеки для go (наш агент написаний на golang) для сниффинга
  • продуктивність (скільки агент буде споживати ресурсів при прослуховуванні великого потоку трафіку)
  • розбір протоколу mongodb
Прототип нашого плагіна mongodb був написаний за кілька днів з використання бібліотеки gopacket. Ми перехоплювали пакети через libpcap, розбирали протокол, bson документи десериализовались з використанням mgo.
Так як у нас немає інсталяції mongodb під навантаженням, ми зробили стенд і запустили готовий benchmark. У нашому випадку mongodb і грузилка жили на одній віртуальній машині з 2 ядрами та 2Gb пам'яті. За навантаженням ми бачили близько 10 тисяч пакетів в секунду при трафік ~60Mbit/s.
Наш прототип під навантаженням утилізував близько 70% одного процесорного ядра. Стало зрозуміло, що необхідно профілювати і оптимізувати код. Тут варто віддати належне стандартного профайлеру golang, нам не потрібно було нічого вигадувати, а просто тюнить самі ненажерливі за CPU ділянки коду і намагатися якомога менше аллоцировать пам'ять для зниження навантаження на GC.
В точності процес оптимізації я вже відтворити не зможу, але наведу приклади найбільш значних змін:
bson.Unmarshal повільний
Bson документ запиту в mongo — це грубо кажучи словник, значення якого можуть бути в тому числі і такими ж словниками.
Так як з самого початку ми вирішили, що будемо нормалізувалася запити, можемо взагалі не читати значення елементів вихідного словника, якщо вони не є словниками.
Беремо специфікацію і пишемо свій примітивний десериализатор. В результаті вийшла функція ~100 рядків
для прикладу наведу шматок розбору елемента словника
elementValueType, err = reader.ReadByte()
if err != nil {
break
}
payload, err = reader.ReadBytes(nullByte)
if err != nil {
break
}
elementName = string(payload)
switch elementValueType {
case bsonDouble, bsonDatetime, bsonTimestamp, bsonInt64:
if _, err = reader.ReadN(8); err != nil {
break
}
case bsonString:
l, err = reader.ReadInt()
if err != nil {
break
}
payload, err = reader.ReadN(l)
if err != nil {
break
}
elementValue = string(payload[:len(payload)-1])
case bsonJsCode, bsonDeprecated, bsonBinary, bsonJsWithScope, bsonArray:
l, err = reader.ReadInt()
if err != nil {
break
}
if _, err = reader.ReadN(l - 4); err != nil {
break
}
case bsonDoc:
elementValue, _, _, err = readDocument(reader)
if err != nil {
break
}
case bsonObjId:
if _, err = reader.ReadN(12); err != nil {
break
}
case bsonBool:
if _, err = reader.ReadByte(); err != nil {
break
}
case bsonRegexp:
if _, err = reader.ReadBytes(nullByte); err != nil {
break
}
if _, err = reader.ReadBytes(nullByte); err != nil {
break
}
case bsonDbPointer:
l, err = reader.ReadInt()
if err != nil {
break
}
if _, err = reader.ReadN(l - 4 + 12); err != nil {
break
}
case bsonInt32:
if _, err = reader.ReadN(4); err != nil {
break
}
}

З усіх варіантів полів ми читаємо значення тільки для bsonDocument (рекурсивно викликаючи себе ж) і bsonString (у нас є додаткова логіка за визначенням колекції і типу запиту), інші поля ми просто пропускаємо.
Як ловити пакети
На наших тестах використання raw sockets безпосередньо виявилося быстре, ніж через pcap.
Можливо це було за старої версії libpcap, але ми планували робити сніффер тільки під linux, тому вирішили не розбиратися, а використовувати gopacket.af_packet (тим більше не потрібно линковать агента з libpcap).
Raw sockets — це специльные сокети в linux, через які можна відправити повністю сформований в userspace (а не ядрі) пакет або отримати пакети з певного мережевого інтерфейсу. Якщо говорити про сниффинг, пакети від ядра потрапляють в userspace через циклічний буфер, що дозволяє не робити syscall на перехоплення кожного пакета. На цю тему є докладний хардкор в документації ядра.
ZeroCopy
Так як ми обробляємо пакети в один потік, то можемо використовувати "ZeroCopy" інтерфейс сніфер. Але при цьому потрібно пам'ятати, що посилань на цю ділянку пам'яті далі в коді залишати не можна.
Розбір пакетів
Інтерфейс розбору пакетів в gopacket влаштований досить гнучко, підтримує з коробки багато різних протоколів, користувачеві не потрібно думати про те, як інкапсульовані дані верхнього рівня. Але разом з цим цей інтерфейс нав'язує необхідність великого числа копіювань даних і, як наслідок, велике навантаження на CPU так і на GC.
Ми знову вирішили відкинути все зайве:)
Наше завдання з вихідного ethernet фрэйма (а на виході AF_PACKET ми отримуємо завжди ethernet) отримати:
  • source ip
  • destination ip
  • source port
  • destination port
  • TCP seq (нижче поясню, навіщо він потрібен)
  • TCP payload (власне дані протоколу верхнього рівня)
Для простоти було вирішено поки не підтримувати IPv6.
В результаті вийшла ось така страшна функція
func DecodePacket(data []byte, linkType layers.LinkType, packet *TcpIpPacket) (err error) {
var l uint16
switch linkType {
case layers.LinkTypeEthernet:
if len(data) < 14 {
ethernetTooSmall.Inc(1)
err = errors.New("Ethernet packet too small")
return
}
l = binary.BigEndian.Uint16(data[12:14])
switch layers.EthernetType(l) {
case layers.EthernetTypeIPv4:
data = data[14:]
case layers.EthernetTypeLLC:
l = uint16(data[2])
if l&0x1 == 0 || l&0x3 == 0x1 {
data = data[4:]
} else {
data = data[3:]
}
default:
ethernetUnsupportedType.Inc(1)
err = errors.New("Unsupported ethernet type")
return
}
default:
unsupportedLinkProto.Inc(1)
err = errors.New("Unsupported link protocol")
return
}
//IP
var cmp int
if len(data) < 20 {
ipTooSmallLength.Inc(1)
err = errors.New("Too small IP length")
return
}
версія := data[0] >> 4
switch version {
case 4:
if binary.BigEndian.Uint16(data[6:8])&0x1FFF != 0 {
ipNonFirstFragment.Inc(1)
err = errors.New("Non first IP fragment")
return
}
if len(data) < 20 {
ipTooSmall.Inc(1)
err = errors.New("Too small IP packet")
return
}
hl := uint8(data[0]) & 0x0F
l = binary.BigEndian.Uint16(data[2:4])
packet.SrcIp[0] = data[12]
packet.SrcIp[1] = data[13]
packet.SrcIp[2] = data[14]
packet.SrcIp[3] = data[15]

packet.DstIp[0] = data[16]
packet.DstIp[1] = data[17]
packet.DstIp[2] = data[18]
packet.DstIp[3] = data[19]

if l < 20 {
ipTooSmallLength.Inc(1)
err = errors.New("Too small IP length")
return
} else if hl < 5 {
ipTooSmallHeaderLength.Inc(1)
err = errors.New("Too small IP header length")
return
} else if int(hl*4) > int(l) {
ipInvalieHeaderLength.Inc(1)
err = errors.New("Invalid IP header length > IP length")
return
}
if cmp = len(data) - int(l); cmp > 0 {
data = data[:l]
} else if cmp < 0 {
if int(hl)*4 > len(data) {
ipTruncatedHeader.Inc(1)
err = errors.New("Not all IP header bytes available")
return
}
}
data = data[hl*4:]
case 6:
ipV6IsNotSupported.Inc(1)
err = errors.New("IPv6 is not supported")
return
default:
ipInvalidVersion.Inc(1)
err = errors.New("Invalid IP packet version")
return
}
//TCP
if len(data) < 13 {
tcpTooSmall.Inc(1)
err = errors.New("Too small TCP packet")
return
}
packet.SrcPort = binary.BigEndian.Uint16(data[0:2])
packet.DstPort = binary.BigEndian.Uint16(data[2:4])
packet.Seq = binary.BigEndian.Uint32(data[4:8])

dataOffset := data[12] >> 4
if dataOffset < 5 {
tcpInvalidDataOffset.Inc(1)
err = errors.New("Invalid TCP data offset")
return
}
dataStart := int(dataOffset) * 4
if dataStart > len(data) {
tcpOffsetGreaterThanPacket.Inc(1)
err = errors.New("TCP data offset greater than packet length")
return
}
packet.Payload = data[dataStart:]
return
}

Для подібних функцій завжди варто писати бенчмарки, на цей раз вийшла досить приємна картина:
Benchmark_DecodePacket-4 50000000 27.9 ns/op
Benchmark_Gopacket-4 1000000 3351 ns/op

тобто ми одержали прискорення більше ніж у 100 разів.
Значительнуя частина коду цієї функції займає обробка помилок, там же видно инкременты різних лічильників з яких ми потім робимо службові метрики агента і можемо легко зрозуміти, чому у нас якось не так працює сніффер. Наприклад, про необхідність додати підтримку IPv6 ми плануємо дізнатися саме за такою метрикою.
Ще ми не намагаємося склеювати tcp payload з різних пакетів, в випадку коли дані не влазять в 1 ethernet фрейм.
Якщо такий пакет — відповідь mongodb, нас цікавить тільки заголовок, а для великих insert запитів наприклад, ми просто візьмемо частину запиту з першого пакету.
Дублі пакетів
З'ясувалося, що якщо клієнт і сервер знаходяться на одному сервері, то ми ловимо один і той же пакет 2 рази.
Довелося робити простий дедубликатор пакетів на основі src ip+port, dest ip+port і TCP seq.
Разом
  • В результаті на нашому бенчмарку агент став споживати ~5% ядра замість 70%
  • На цьому ми поки вирішили зупинитися з оптимизациями, але залишилося кілька ідей, як ще трохи прискоритися
  • Під реальним навантаженням у клієнтів агент працює приблизно з тими ж показниками (споживання cpu в тій же пропорції до кількості пакетів, що і на бенчмарку)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.