C# - 간이 dotnet-dump 프로그램 만들기
이전에 설명한 대로,
"dotnet-dump ps" 명령어가 닷넷 프로세스를 찾는 방법
; https://www.sysnet.pe.kr/2/0/13703
dotnet-dump는 대상 프로세스가 열어놓은 IPC 채널(윈도우: 파이프, 리눅스/MAC: Unix Domain 소켓)을 통해 메모리 덤프를 합니다.
github repo에 있는 WriteDump 메서드에 그 과정이 나오는데요,
// .\Microsoft.Diagnostics.NETCore.Client\DiagnosticsClient\DiagnosticsClient.cs
// https://learn.microsoft.com/en-us/dotnet/core/diagnostics/microsoft-diagnostics-netcore-client
public void WriteDump(DumpType dumpType, string dumpPath, WriteDumpFlags flags)
{
IpcMessage request = CreateWriteDumpMessage(DumpCommandId.GenerateCoreDump3, dumpType, dumpPath, flags);
IpcMessage response = IpcClient.SendMessage(_endpoint, request);
if (!ValidateResponseMessage(response, "Write dump", ValidateResponseOptions.UnknownCommandReturnsFalse | ValidateResponseOptions.ErrorMessageReturned))
{
request = CreateWriteDumpMessage(DumpCommandId.GenerateCoreDump2, dumpType, dumpPath, flags);
response = IpcClient.SendMessage(_endpoint, request);
if (!ValidateResponseMessage(response, "Write dump", ValidateResponseOptions.UnknownCommandReturnsFalse))
{
if ((flags & ~WriteDumpFlags.LoggingEnabled) != 0)
{
throw new ArgumentException($"Only {nameof(WriteDumpFlags.LoggingEnabled)} flag is supported by this runtime version", nameof(flags));
}
request = CreateWriteDumpMessage(dumpType, dumpPath, logDumpGeneration: (flags & WriteDumpFlags.LoggingEnabled) != 0);
response = IpcClient.SendMessage(_endpoint, request);
ValidateResponseMessage(response, "Write dump");
}
}
}
internal enum DumpCommandId : byte
{
GenerateCoreDump = 0x01,
GenerateCoreDump2 = 0x02,
GenerateCoreDump3 = 0x03,
}
이렇게 GenerateCoreDump3 명령어에 해당하는 메시지를 IPC 채널로 전송한 후, 실패하면 다시 GenerateCoreDump2, 또다시 실패하면 GenerateCoreDump 메시지를 보내게 됩니다. 그러니까, dotnet-dump 자체가 메모리 덤프를 위해 하는 일은 대상 프로세스 스스로 덤프를 뜨도록 신호를 보내는 것뿐이 없습니다.
따라서, 그 과정만 대충 흉내 내면 우리도 dotnet-dump와 유사한 프로그램을 만들 수 있는데요, 실제로 구현을 해보겠습니다. ^^
우선, Dump 하라는 명령어의 구조는 이런 식입니다.
- [byte] CommandSet
- [byte] CommandId
- [byte 배열] Payload
- [string] DumpPath
- [uint] DumpType
- [uint] Flags
Payload의 구성부터 해볼 텐데요, 간단하게 다음과 같이 처리할 수 있습니다.
DumpCommandId commandId = DumpCommandId.GenerateCoreDump;
byte[] payload = GetPayload(dumpPath, DumpType.Full, 0);
private static byte[] GetPayload(string path, DumpType dumpType, WriteDumpFlags flags)
{
string data1 = path;
uint data2 = (uint)dumpType;
uint data3 = (uint)flags;
using (MemoryStream stream = new())
using (BinaryWriter writer = new(stream))
{
writer.WriteString(data1);
writer.Write(data2);
writer.Write(data3);
writer.Flush();
return stream.ToArray();
}
}
그리고 위의 Payload를 담은 메시지는 대략 이렇게 구성할 수 있습니다.
IpcDiagnosticsMessage msg = new IpcDiagnosticsMessage(DiagnosticsServerCommandSet.Dump, commandId, payload);
public class IpcDiagnosticsMessage
{
byte _commandSet;
byte _command;
public byte CommandId => _command;
byte[] _payload;
public byte[] Payload => _payload;
public IpcDiagnosticsMessage(DiagnosticsServerCommandSet commandSet, DumpCommandId command, byte[] payload)
{
_commandSet = (byte)commandSet;
_command = (byte)command;
_payload = payload;
}
}
자, 그럼 저 메시지를 (윈도우의 경우) NamedPipe로 전송하면 되는데요, 이를 위해 NamedPipeClientStream으로 대상 프로세스의 Pipe에 연결하고,
int processId = ...[닷넷 프로세스 ID]...;
string pipeName = $"dotnet-diagnostic-{processId}";
NamedPipeClientStream namedPipe = new(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.None,
TokenImpersonationLevel.Impersonation);
namedPipe.Connect();
이후 IpcDiagnosticsMessage를 직렬화해 Stream에 쓰고(Request), 응답(Response)을 받습니다.
SendMessage(namedPipe, msg);
IpcDiagnosticsMessage response = ReceiveResponse(namedPipe);
private static void SendMessage(NamedPipeClientStream namedPipe, IpcDiagnosticsMessage msg)
{
byte[] packet = msg.Serialize();
namedPipe.Write(packet, 0, packet.Length);
}
private static IpcDiagnosticsMessage ReceiveResponse(NamedPipeClientStream namedPipe)
{
using (BinaryReader reader = new(namedPipe, Encoding.UTF8, true))
{
return IpcDiagnosticsMessage.Parse(reader);
}
}
public class IpcDiagnosticsMessage
{
public const ushort HeaderSizeInBytes = 20;
private const ushort MagicSizeInBytes = 14;
public byte[] Magic = DotnetIpcV1; // byte[14] in native code
public static byte[] DotnetIpcV1 => Encoding.ASCII.GetBytes("DOTNET_IPC_V1" + '\0');
// ...[생략]...
public byte[] Serialize()
{
byte[]? serializedData = null;
ushort packetSize = checked((ushort)(HeaderSizeInBytes + _payload.Length));
byte[] headerBytes = SerializeHeader(packetSize);
using (MemoryStream stream = new())
using (BinaryWriter writer = new(stream))
{
writer.Write(headerBytes);
writer.Write(_payload);
writer.Flush();
serializedData = stream.ToArray();
}
return serializedData;
}
byte[] SerializeHeader(ushort packetSize)
{
using (MemoryStream stream = new())
using (BinaryWriter writer = new(stream))
{
writer.Write(Magic);
Debug.Assert(Magic.Length == MagicSizeInBytes);
writer.Write(packetSize);
writer.Write(_commandSet);
writer.Write(_command);
writer.Write((ushort)0x0000);
writer.Flush();
return stream.ToArray();
}
}
public static IpcDiagnosticsMessage Parse(BinaryReader reader)
{
byte[] magic = reader.ReadBytes(14);
ushort size = reader.ReadUInt16();
byte commandSet = reader.ReadByte();
byte commandId = reader.ReadByte();
ushort reserved = reader.ReadUInt16();
byte[] payload = reader.ReadBytes(size - HeaderSizeInBytes);
return new IpcDiagnosticsMessage((DiagnosticsServerCommandSet)commandSet, (DumpCommandId)commandId, payload);
}
}
간단하죠? ^^ 남은 작업은, 응답에 대해 성공 여부를 가려내면 되는데요,
ValidateResponseOptions options = ValidateResponseOptions.None;
switch ((DiagnosticsServerResponseId)response.CommandId)
{
case DiagnosticsServerResponseId.OK:
Console.WriteLine($"Supported: {commandId}");
break;
case DiagnosticsServerResponseId.Error:
uint hr = BinaryPrimitives.ReadUInt32LittleEndian(new ReadOnlySpan<byte>(response.Payload, 0, 4));
Console.WriteLine($"Not supported: {commandId}, hr == {hr:x}");
// ...[생략]...
throw new ServerErrorException(message);
default:
throw new ServerErrorException($"{commandId} failed - Server responded with unknown response.");
}
Fail 시 반환하는 오류 메시지를 제외하면 사실상 OK, Fail 2가지 상태는 CommandId 필드 하나로 결정할 수 있습니다. 간단하죠?!!! ^^
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
테스트를 하면서 알게 된 사실인데, 현재 윈도우 환경의 .NET Core 3.0 응용 프로그램을 대상으로 위의 코드로 덤프를 시도하면 이런 오류가 발생합니다.
Unhandled exception. Microsoft.Diagnostics.NETCore.Client.UnsupportedCommandException: Write dump failed - Command is not supported.
at Microsoft.Diagnostics.NETCore.Client.DiagnosticsClient.ValidateResponseMessage(IpcMessage responseMessage, String operationName, ValidateResponseOptions options)
at Microsoft.Diagnostics.NETCore.Client.DiagnosticsClient.WriteDump(DumpType dumpType, String dumpPath, WriteDumpFlags flags)
at Microsoft.Diagnostics.NETCore.Client.DiagnosticsClient.WriteDump(DumpType dumpType, String dumpPath, Boolean logDumpGeneration)
...[생략]...
그러니까, IPC 채널로 GenerateCoreDump 명령어를 보냈는데 응답으로 DiagnosticsServerResponseId.Error / DiagnosticsIpcError.UnknownCommand 값이 온 것입니다. 다시 말해 .NET Core 3.0 윈도우 앱은 저 채널에 대해 GenerateCoreDump 명령어를 지원하지 않습니다. (반면 리눅스 버전의 .NET Core 3.0은 덤프 명령어를 지원합니다.)
반면, dotnet-dump로 하면 정상적으로 메모리 덤프 파일이 남습니다. 이유가 뭘까요? ^^ 왜냐하면,
// https://github.com/dotnet/diagnostics/blob/main/src/Tools/dotnet-dump/Dumper.cs#L95
// ...[생략]...
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
if (crashreport)
{
Console.WriteLine("Crash reports not supported on Windows.");
return -1;
}
// https://github.com/dotnet/diagnostics/blob/main/src/Tools/dotnet-dump/Dumper.Windows.cs#L16
Windows.CollectDump(processId, output, type);
}
else
{
DiagnosticsClient client = new(processId);
// ...[생략]...
// Send the command to the runtime to initiate the core dump
client.WriteDump(dumpType, output, flags);
}
윈도우 버전의 경우 dotnet-dump는 IPC 채널이 아닌 Windows.CollectDump를 호출하고, 그것은 결국
MiniDumpWriteDump Win32 API를 호출하기 때문입니다.
재미있는 건, .NET 5+ 버전부터는 IPC 방식을 윈도우 닷넷 런타임에서 지원함에도 dotnet-dump는 고정적으로 MiniDumpWriteDump를 호출하게 코드가 만들어져 있습니다.
더욱 재미있는 건, 자기 자신에게 IPC 연결을 하는 것도 가능하고, 이를 통해 자기 자신에게 덤프를 뜨는 것도 가능합니다. ^^
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]