Большую часть прошлой недели мне пришлось экспериментировать с виндовым .NET проектом под Linux и в Kubernetes. Это на самом деле не такая уж и дурка, как кажется поначалу. Проект мы заранее перевели на кросс-платформенный .NET Core, я отловил практически все проблемы, которые вплыли в никсе под контейнерами, и по итогу в K8s проект действительно заработал. Почти.
На практике всё равно остались мелкие неприятности. Эпизодически выскакивали всякие StackOverflow (хорошо хоть не segmentation faults), да и мой дебагерский виндовый опыт оказался практически бесполезным на никсах.
Например, практически сразу мы заметили, что, запускаясь под контейнером, проект с ходу отъедал 300 мегабайт физической памяти и около 2-х гигов виртуальной. В виндовом продакшене, конечно, под нагрузкой бывало и на порядки больше, но вот тут, на Linux, в демо-режиме, это много, мало, или вообще как? Как это проверить в принципе? На Винде я бы сделал дамп процесса, запустил Visual Studio или WinDBG и гуглил, что теперь делать дальше. А что тут?
Как выяснилось, гугл под Linux тоже работает, так что через пару часов медитации на монитор я выучил несколько интересных штук, о которых и хотел бы рассказать сегодня.
Песочница (дебаггинг пойдёт потом)
Естественно, коммерческий проект в качестве примера я притянуть сюда не могу, но это и не нужно — практически любой «Hello world» на .NET Core тоже подойдёт. Я просто создам виртуальную Ubuntu 16.04 (при помощи Vagrant и VirtualBox), положу туда тестовый проект, и будем экспериментировать уже там.
Виртуальная машина с Ubuntu
…запросто сделается вот этим Vagrantfile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
Vagrant.configure("2") do |config| config.vm.box = "ubuntu/xenial64" config.vm.provider "virtualbox" do |vb| vb.memory = "3072" end config.vm.provision "shell", inline: <<-SHELL # Install .net core SDK curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-xenial-prod xenial main" > /etc/apt/sources.list.d/dotnetdev.list' apt-get update && apt-get install -y dotnet-sdk-2.0.2 # Dev tools apt-get install -y vim gdb lldb-3.6 SHELL end |
В нём нет ничего особенного: обычная виртуалка с 3-мя гигами памяти и установленными .NET Core 2.0.2 SDK, vim, gdb и lldb-3.6. Впоследствии станет понятно, зачем они нужны.
Теперь создаём машину при помощи vagrant up
, заходим внутрь через vagrant ssh
и начинаем делать тестовый проект.
Тестовый проект
Как я уже сказал, любой hello-world на .NET Core подойдёт, но в идеале было бы здорово, если бы у него в памяти был какой-нибудь шлак для исследования, да и сам процесс не завершался мгновенно, дабы успеть сделать его дамп.
dotnet new console -o memApp
сделает практически всё, что нужно. Я только добавил туда массив с текстовым мусором и дописал ReadLine в конце, чтобы процесс не завершался, пока не скажут:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
using System; using System.Linq; using System.Text; namespace memApp { class Program { static Random random = new Random((int)DateTime.Now.Ticks); static char RandomChar() => Convert.ToChar(random.Next(65, 90)); static string RandomString(int length) => String.Concat(Enumerable.Range(0, length).Select(_ => RandomChar())); static void Main(string[] args) { var dummyStringsCollection = Enumerable.Range(0, 10000) .Select(_ => "Random string: " + RandomString(10000)).ToArray(); Console.WriteLine("Hello World!"); Console.ReadLine(); } } } |
Теперь пример можно сбилдить, запустить и приступать к экспериментам:
1 2 3 4 5 6 7 8 9 |
dotnet build #... #Build succeeded. # 0 Warning(s) # 0 Error(s) # #Time Elapsed 00:00:02.06 dotnet bin/Debug/netcoreapp2.0/memApp.dll # Hello World! |
Делаем дамп процесса (core dump)
Для начала посмотрим, сколько же памяти процесс уже умудрился отожрать:
1 2 3 4 |
ps u #USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND #ubuntu 4058 7.9 7.9 2752512 243908 pts/0 SLl+ 04:10 0:06 dotnet bin/Debug/netcoreapp2.0/memApp.dll #... |
Весьма неплохо: ~2.6 гига виртуальной памяти и примерно 238 мегабайт физической. Конечно, размер виртуальной памяти не означает, что вся эта память теперь недоступна другим, или мы её хоть как-то используем, но это точно повлияет на размер дампа и он скорее всего займёт те же 2.6 гига.
Самый простой способ сделать дамп на никсе — воспользоваться утилитой gcore
. Она идёт в комплекте с gdb
дебаггером и это единственная причина, почему я его устанавливал.
Но запустить gcore
без sudo
, скорее всего, не получится, а в Kubernetes даже sudo
было недостаточно — приходилось лезть в sysctl.conf
и играть с настройками:
1 2 |
echo "kernel.yama.ptrace_scope=0" | sudo tee -a /etc/sysctl.conf # Append config line sudo sysctl -p # Apply changes |
Но в Ubuntu всё хорошо, и sudo gcore
с айдишкой процесса делает всё, как нужно:
1 2 3 |
sudo gcore 4058 # ... # Saved corefile core.4058 |
И, как я и говорил раньше, размер дампа оказался равен размеру виртуальной памяти:
1 2 3 |
ls -lh #total 2.6G #-rw-r--r-- 1 root root 2.6G Dec 12 04:25 core.4058 |
Это, кстати, было проблемой в нашем тестовом Kubernetes кластере, с .NET’ом, сборщик мусора которого работал в серверном режиме. У сервера было аж 208 гигов RAM, и при таких настройках процесс со старта обозначил себе 49 гигов виртуальной памяти и, соответственно, его дампы занимали столько же. Зато, выключив серверный режим (gcServer - false
), аппетиты процесса упали в десять раз — до более вменяемых 5-ти гигов.
Но я отвлёкся. У нас есть дамп, будем препарировать.
LLDB и поддержка .NET
С дампом могут работать как gdb
, так и lldb
дебаггеры, но только lldb
можно научить любить .NET, так что выбор простой. «Любовь» обеспечивается специальным SOS плагином — libsosplugin.so
. Проблема в том, что этот плагин, так же как и дотнетовский CoreCLR, собирается под конкретный LLDB, и поэтому если нет желания перекомпилировать ещё и рантайм (не так уж и сложно), то придётся брать не самый свежий lldb-3.6
.
Кстати, аббревиатуру SOS я видел ещё на винде, и всё гадал, что же она значит. Оказалось, что SOS не имеет никакого отношения ни к ABBA, ни к азбуке Морзе и сигналу «спасите наши души». Нашёлся интереснейший ответ на StackOverflow, который раскрыл, что SOS означает Son of Strike. Кто такой Страйк? О, это ещё более классная история. Кодовое имя для .NET 1.0 было Lightning — молния. А его дебаггер звали Strike — типа Strike of Lightning, удар молнии. Ну а SOS для последующий версий дотнэта уже создавался на базе Страйка. Всё просто.
Именно такие истории мне помогают оставаться в профессии в моменты, когда хочется всё бросить и уйти в геологи или хотя бы в судмедэксперты. История про браузеровский userAgent когда-то тоже помогла. Но я снова отвлёкся.
Итак, у нас есть lldb
, сам dotnet
и дамп приложения. Где искать SOS плагин? А он идёт в комплекте с .NET Core SDK, и его просто надо поискать среди файлов:
1 2 |
find /usr -name libsosplugin.so #/usr/share/dotnet/shared/Microsoft.NETCore.App/2.0.0/libsosplugin.so |
Всё, теперь можно запускать lldb
, показать ему, где находится dotnet
(он же был точкой входа в тестовый проект), дамп, и грузим плагин:
1 2 3 4 5 |
$ lldb-3.6 `which dotnet` -c core.4058 # (lldb) target create "/usr/bin/dotnet" --core "core.4058" # Core file '/home/ubuntu/core.4058' (x86_64) was loaded. # (lldb) plugin load /usr/share/dotnet/shared/Microsoft.NETCore.App/2.0.0/libsosplugin.so # (lldb) |
А вот теперь начнётся самое интересное.
Смотрим на managed объекты
SOS добавил несколько новых комманд, которые в курсе, что .NET работает с управляемой памятью и объектами. Благодаря этому мы будем не просто таращиться в биты и байты, а сможем посмотреть их .NET тип (например, System.String).
Команда soshelp
выводит вполне полезную справку по .NET командам и даже может сказать что-то более подробное про каждую из них — soshelp commandname
. Ну кроме тех моментов, когда она это не делает.
Например, команда DumpHeap
— самая первая команда для анализа памяти, идёт вообще без справки. К счастью, справка нашлась прямо возле исходников на гитхабе.
1 2 3 4 5 6 7 8 9 10 |
(lldb) soshelp #... #Object Inspection Examining code and stacks #----------------------------- ----------------------------- #DumpObj (dumpobj) Threads (clrthreads) #DumpArray ThreadState #.. (lldb) soshelp DumpHeap ------------------------------------------------------------------------------- (lldb) |
Статистика объектов
Итак, всё работает. Посмотрим, что вообще в памяти творится — для этого есть команда DumpHeap
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
(lldb) sos DumpHeap -stat #Statistics: # MT Count TotalSize Class Name #00007f6d32992aa8 1 24 UNKNOWN #00007f6d329911d8 1 24 UNKNOWN #.... #00007f6d323defd8 4 17528 System.Object[] #00007f6d323e08a8 25 40644 System.Int32[] #00007f6d323e0168 29 82664 System.String[] #00007f6d323e3440 335 952398 System.Char[] #000000000223b860 10092 6083604 Free #00007f6d3242b460 150846 204845172 System.String #Total 161886 objects (lldb) |
Как и ожидалось, больше всего памяти ушло на System.String объекты. Зря что ли создавал их. Что интересно, если просуммировать размер всех объектов, то получится примерно 202 MiB, что не намного меньше размера памяти, о которой отрапортовала ps u
— 238 MiB. Я так полагаю, что дельта ушла на сам код и, собственно, среду выполнения.
Более детальный взгляд на объекты
Но мы можем пойти ещё дальше. Строки занимают практически всю память — значит будем работать только с ними:
1 2 3 4 5 6 7 8 9 10 11 |
(lldb) sos DumpHeap -type System.String # Address MT Size #00007f6d0bfff3f0 00007f6d3242b460 26 #00007f6d0bfff4c0 00007f6d3242b460 42 #... #00007f6d0c099ab0 00007f6d3242b460 20056 #00007f6d0c09e920 00007f6d3242b460 20056 #... #00007f6d323e0168 29 82664 System.String[] #00007f6d3242b460 150846 204845172 System.String #Total 150895 objects |
Параметр -type
работает как маска, поэтому кроме System.String в результат попал и System.String[]. Но строк много, а мне интересны только большие. Например, от тысячи байт:
1 2 3 4 5 6 |
sos DumpHeap -type System.String -min 1000 # ... # 00007f6d0e8810f0 00007f6d3242b460 20056 # 00007f6d0e885f60 00007f6d3242b460 20056 # 00007f6d0e88add0 00007f6d3242b460 20056 # ... |
Имея на руках список подозрительно одинаковых строк, можно посмотреть на них ещё ближе.
DumpObj
DumpObj
может заглянуть в детали объекта по конкретному адресу памяти. Адресов у нас целый первый столбец из предыдущей команды, так что я взял первый попавшийся:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
(lldb) sos DumpObj 00007f6d0e8810f0 #Name: System.String #MethodTable: 00007f6d3242b460 #EEClass: 00007f6d31c49eb8 #Size: 20056(0x4e58) bytes #File: /usr/share/dotnet/shared/Microsoft.NETCore.App/2.0.0/System.Private.CoreLib.dll #String: #Fields: # MT Field Offset Type VT Attr Value Name #00007f6d3244b020 40001c9 8 System.Int32 1 instance 10015 m_stringLength #00007f6d3242f420 40001ca c System.Char 1 instance 52 m_firstChar #00007f6d3242b460 40001cb 38 System.String 0 shared static Empty # >> Domain:Value 00000000022ab050:NotInit << |
Вообще-то, это реально круто. Команда тут же выводит, что за объект находится по этому адресу, его размер, список полей и их смещения по адресам. Ещё я заметил, что для маленьких строк их содержимое выводится тут же, седьмой строкой. Но в нашем случае нужны дополнительные телодвижения.
Какие именно телодвижения нужны, мне пришлось некоторое время подумать. Например, есть поле m_firstChar
(строка 11). А где m_secondChar
? Это связанный список, или как? А где указатель вообще на всё? Но, слазив в исходники System.String, оказалось, что m_firstChar
можно и использовать как указатель на непрерывный блок памяти, и вся строка будет там.
Чтобы посмотреть, что лежит в памяти по случайному адресу, можно взять родную lldb’шную команду memory read
, а чтобы найти адрес начала строки, мы просто берём адрес самого объекта, (00007f6d0e8810f0), и добавляем к нему смещение m_firstChar
(0xC) поля, и всё:
1 2 3 |
(lldb) memory read 00007f6d0e8810f0+0xc #0x7f6d0e8810fc: 52 00 61 00 6e 00 64 00 6f 00 6d 00 20 00 73 00 R.a.n.d.o.m. .s. #0x7f6d0e88110c: 74 00 72 00 69 00 6e 00 67 00 3a 00 20 00 43 00 t.r.i.n.g.:. .C. |
Если проскроллить вправо, то выглядит знакомо, правда? «R.a.n.d.o.m. .s.t.r.i.n.g.». Шарповый тип char
— по-умолчанию двух-байтовый юникодовый (UTF16), и по этому для ASCII диапазона старший байт будет нулём. Что, собственно, мы и видим.
С memory read
можно поиграться немного дольше — добавить форматирование, количество выводимых байт, но, в принципе, и так уже всё ясно.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
(lldb) memory read 00007f6d0e8810f0+0xc -f s -c 13 #0x7f6d0e8810fc: "R" #0x7f6d0e8810fe: "a" #0x7f6d0e881100: "n" #0x7f6d0e881102: "d" #0x7f6d0e881104: "o" #0x7f6d0e881106: "m" #0x7f6d0e881108: " " #0x7f6d0e88110a: "s" #0x7f6d0e88110c: "t" #0x7f6d0e88110e: "r" #0x7f6d0e881110: "i" #0x7f6d0e881112: "n" #0x7f6d0e881114: "g" |
Мораль
Я только-только начал экспериментировать со всем этим, но уже в восторге. Всё-таки я работал с .NET достаточно долго, и только сейчас начал задумываться, а что же происходит внутри него. Из чего сделана System.String? Что там за поля? Как они выровнены в памяти? Смещение первого поля в String — 8 байтов. А на что эти 8 байт используются? На идентификатор типа?
А ещё, дотнетовские строки же интернированы. Значит ли это, что m_firstChar будет указывать на тот же участок памяти для одинаковых строк? Или это только на этапе компиляции? А если проверю?
Ещё интересно (я туда пока не полез), как вообще выглядит сам дебаггинг .NET в lldb
. Как оно выглядит для C++, я примерно представляю, когда-то сталкивался. Но .NET ведь компилируется на лету, Just-in-time. Как SOS справляется с этим? В общем, куча интересных вопросов.