Mono 웹 서버와 OWIN 웹 서버의 크래시 이슈 패치

OWIN 웹 서버 크래시

OWIN(Open Web Interface for .NET) 를 이용하여 mono 환경에서 웹 서버를 띄후 특정 명령으로 웹 서버 프로세스가 크래시가 발생한다.

간단한 아래의 OWIN 호스트를 mono 런타임으로 실행한 후 서버 크래시를 발생해 보자.

mono ./OwinConsoleApp1.exe

그리고 터미널을 열어 아래의 명령을 실행해보자.

curl -X POST http://localhost:8080

https://user-images.githubusercontent.com/1943755/80348607-01d7ec80-88a9-11ea-8795-be9f5c1d7726.gif

그러면 아래와 같이 서버 프로세스가 비정상 종료되고 아래와 같은 오류 메시지를 보여준다.

Unhandled Exception:
System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'System.Net.HttpListenerResponse'.
  at System.Net.HttpListenerResponse.set_StatusCode (System.Int32 value) [0x00013] in <b4473693dd3c4d45883c574a53529fbe>:0 
  at Microsoft.Owin.Host.HttpListener.RequestProcessing.OwinHttpListenerResponse.End () [0x0001c] in <68f7adf518f945aaa528fe9acf594456>:0 
  at Microsoft.Owin.Host.HttpListener.RequestProcessing.OwinHttpListenerContext.End () [0x00010] in <68f7adf518f945aaa528fe9acf594456>:0 
  at Microsoft.Owin.Host.HttpListener.RequestProcessing.OwinHttpListenerContext.End (System.Exception ex) [0x0001e] in <68f7adf518f945aaa528fe9acf594456>:0 
  at Microsoft.Owin.Host.HttpListener.OwinHttpListener.ProcessRequestAsync (System.Net.HttpListenerContext context) [0x0019c] in <68f7adf518f945aaa528fe9acf594456>:0 
  at Microsoft.Owin.Host.HttpListener.OwinHttpListener.ProcessRequestsAsync () [0x00125] in <68f7adf518f945aaa528fe9acf594456>:0 
  at System.Runtime.CompilerServices.AsyncMethodBuilderCore+<>c.<ThrowAsync>b__7_1 (System.Object state) [0x00000] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.QueueUserWorkItemCallback.WaitCallback_Context (System.Object state) [0x00007] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.ExecutionContext.RunInternal (System.Threading.ExecutionContext executionContext, System.Threading.ContextCallback callback, System.Object state, System.Boolean preserveSyncCtx) [0x00071] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.ExecutionContext.Run (System.Threading.ExecutionContext executionContext, System.Threading.ContextCallback callback, System.Object state, System.Boolean preserveSyncCtx) [0x00000] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem () [0x00021] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.ThreadPoolWorkQueue.Dispatch () [0x00074] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading._ThreadPoolWaitCallback.PerformWaitCallback () [0x00000] in <f759957039b44a0190b1110fdfe3030f>:0 
[ERROR] FATAL UNHANDLED EXCEPTION: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'System.Net.HttpListenerResponse'.
  at System.Net.HttpListenerResponse.set_StatusCode (System.Int32 value) [0x00013] in <b4473693dd3c4d45883c574a53529fbe>:0 
  at Microsoft.Owin.Host.HttpListener.RequestProcessing.OwinHttpListenerResponse.End () [0x0001c] in <68f7adf518f945aaa528fe9acf594456>:0 
  at Microsoft.Owin.Host.HttpListener.RequestProcessing.OwinHttpListenerContext.End () [0x00010] in <68f7adf518f945aaa528fe9acf594456>:0 
  at Microsoft.Owin.Host.HttpListener.RequestProcessing.OwinHttpListenerContext.End (System.Exception ex) [0x0001e] in <68f7adf518f945aaa528fe9acf594456>:0 
  at Microsoft.Owin.Host.HttpListener.OwinHttpListener.ProcessRequestAsync (System.Net.HttpListenerContext context) [0x0019c] in <68f7adf518f945aaa528fe9acf594456>:0 
  at Microsoft.Owin.Host.HttpListener.OwinHttpListener.ProcessRequestsAsync () [0x00125] in <68f7adf518f945aaa528fe9acf594456>:0 
  at System.Runtime.CompilerServices.AsyncMethodBuilderCore+<>c.<ThrowAsync>b__7_1 (System.Object state) [0x00000] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.QueueUserWorkItemCallback.WaitCallback_Context (System.Object state) [0x00007] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.ExecutionContext.RunInternal (System.Threading.ExecutionContext executionContext, System.Threading.ContextCallback callback, System.Object state, System.Boolean preserveSyncCtx) [0x00071] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.ExecutionContext.Run (System.Threading.ExecutionContext executionContext, System.Threading.ContextCallback callback, System.Object state, System.Boolean preserveSyncCtx) [0x00000] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.QueueUserWorkItemCallback.System.Threading.IThreadPoolWorkItem.ExecuteWorkItem () [0x00021] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading.ThreadPoolWorkQueue.Dispatch () [0x00074] in <f759957039b44a0190b1110fdfe3030f>:0 
  at System.Threading._ThreadPoolWaitCallback.PerformWaitCallback () [0x00000] in <f759957039b44a0190b1110fdfe3030f>:0

일반적으로 HTTP Header 의 Content-Length 속성은 반드시 포함되어야 하는 속성이다. (https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html) HTTP Body 의 Payload 가 있는 경우 무시할 수 있지만, Payload 가 없는 경우 웹 서버는 Length Required 응답코드로 연결을 끊어버린다.

Mono 런타임의 HttpListener 는 이 같은 예외에 대해 <h1>Length Required</h1> HTML 메시지를 전송한 후 Response 의 객체의 Disposed() 를 호출하고 HTTP 연결을 끊어버린다. 이 코드에서 HTTP 411 Error를 전송하고, 이 코드에서 Response 를 닫는 것을 알 수 있다. 그러나 OWIN 파이프라인에서 이 요청을 받아 사용자 코드를 실행한 후 Response.StatusCode 에 값을 설정하려고 하니 ObjectDisposedException 이 발생하고 프로세스는 죽어버린다. 이 코드에서 Mono 의 HttpListenerResponse.StatusCode 를 호출하는 이 코드에서 복구할 수 없는 예외가 발생한다.

OWIN 서버 패치

OWIN 서버의 구조를 살펴보면 다행스럽게도 요청을 처리하기 위해 아래의 코드의 생성자에서 대리자(Delegate)에 메서드를 할당한 것을 볼 수 있다. (코드) 그러므로 우리는 이 대리자에 내가 다시 구현한 메서드를 등록해 주면 된다.

internal OwinHttpListener()
{
    _listener = new System.Net.HttpListener();
    _startNextRequestAsync = new Action(ProcessRequestsAsync);
    _startNextRequestError = new Action<Task>(StartNextRequestError);
    SetRequestProcessingLimits(DefaultMaxAccepts, DefaultMaxRequests);
}

내부적으로 sealed class, internal, private 으로 정의된 것들이 많기 때문에 요청을 처리하는 구현에서 기존 동작과 일치하도록 처리해야 한다. 그리고 Mono 런타임에 의해 이미 클라이언트 연결에게 응답을 보냈다면 사용자 코드 및 더 이상 파이프라인을 실행하지 않고 중단해야 한다.

아래와 같이 IsEmptyPayloadAndContentLength 메서드에서 오류가 발생하는 경우인지 판단하고, 이미 클라이언트 연결에게 응답을 보냈다면 새로운 요청을 받을 준비를 시킨다.

if (IsEmptyPayloadAndContentLength(context))
{
    Interlocked.Decrement(ref currentOutstandingAccepts); // Decrement currentOutstandingAccepts counting.
    owinHttpListenerOffloadStartNextRequest(); // New request processing on Task if possible.
    continue;
}

아래는 OwinServerFactory 를 구현하여 OwinHttpListener 의 동작을 변경하는 전체 소스 코드이며, 더 이상 프로세스의 크래시가 발생하지 않는다.

이와 관련된 내용으로 OWIN 프로젝트의 구현체인 aspnet/AspNetKatana 공식 프로젝트에 이슈PR 을 요청하였다. 그러나 AspNetKatana 는 이 문제에 대해 '공식적으로 mono 를 지원하지 않는다' 고 하였고, 근본적으로 mono 의 문제가 패치되어야 한다고 한다.

일단 mono 는 이 이슈에 대해 해결되지 않았기 때문에 mono 런타임에서 OWIN 웹 서버를 구동할 경우 꾸준히 문제의 소지가 있다.

Mono 공식 저장소의 코드 패치

우선 이 문제의 원인은 Mono 런타임의 구현체의 버그가 맞다. Mono 런타임에서 HTTP 클라이언트에게 오류를 전송하고 응답 객체 메모리를 정리하고 연결을 끊었다면 Request Context 를 OWIN 에게 넘겨주면 안된다. 그러나 Mono 내부의 Request Context 를 다음 파이프라인으로 넘기면서 OWIN 웹 서버에까지 크래시가 발생하는 영향을 준다.

이와 관련하여 Mono 공식 저장소에 'POST/PUT request without Content-Length Header crashes the Process and HttpListener #10435' 이슈가 존재하는 것을 확인 하였다. 2018년 9월에 이슈가 등록되었지만 여전히 해결이 되지 않았다. 그래서 'Fix if already send error to http client, do not callback. #19664' 의 PR 를 요청하여 머지되었다.

우선 이 문제를 해결하기 위해 오류가 발생하는 코드를 아래와 같이 작성했다.

그리고 curl -X POST [http://localhost:8080/](http://localhost:8080/) 로 HTTP 요청을 보내면 아래와 같이 유시한 오류가 발생하는 것을 알 수 있다.

Unhandled Exception:System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'System.Net.HttpListenerResponse'.  at System.Net.HttpListenerResponse.set_ContentLength64 (System.Int64 value) [0x00013] in <b44
73693dd3c4d45883c574a53529fbe>:0 
  at MonoConsoleApp1.Program.Main (System.String[] args) [0x000ff] in <60aed05f157f4c0c81f93d9221c9a2ec>:0 
  at MonoConsoleApp1.Program.<Main> (System.String[] args) [0x0000c] in <60aed05f157f4c0c81f93d9221c9a2ec>:0 
[ERROR] FATAL UNHANDLED EXCEPTION: System.ObjectDisposedException: Cannot access a disposed obj
ect.
Object name: 'System.Net.HttpListenerResponse'.
  at System.Net.HttpListenerResponse.set_ContentLength64 (System.Int64 value) [0x00013] in <b44
73693dd3c4d45883c574a53529fbe>:0 
  at MonoConsoleApp1.Program.Main (System.String[] args) [0x000ff] in <60aed05f157f4c0c81f93d92
21c9a2ec>:0 
  at MonoConsoleApp1.Program.<Main> (System.String[] args) [0x0000c] in <60aed05f157f4c0c81f93d9221c9a2ec>:0

Mono 런타임의 HttpListenerGetContextAsyncGetContext 메서드는 비동기로 동작하는 것을 알 수 있다. 그리고 EndGetContext 메서드는 스레드의 동기화 메서드를 통해 대기하는 것을 알 수 있다. 그럼 ares.AsyncWaitHandle 의 동기화 객체는 ListenerAsyncResult.AsyncWaitHandle 에서 ManualResetEvent 를 생성한다. 그리고 ListernerAsyncResult.Complete 메서드에서 ManualResetEvent.Set 을 호출하는 것을 알 수 있다.

HttpListener.BeginGetContext 에서 큐에서 컨텍스트를 정상적으로 가져오면 동기화 객체를 Set 하고, 그렇지 않으면 다시 wait_queue 에 넣는 동작이 반복된다.

그럼 이제 어디에서 연결을 맺는지 살펴보면 된다. HttpConnection.OnReadInternal 메서드에서 context 에 오류가 없으면 context.Request.FinishInitialization() 메서드를 호출하는데 이 메서드의 내용을 살펴보자. HttpRequest.FinishInitialize 메서드에서 올바른 연결에 대해 쿼리스트링을 생성하는 작업을 하는데 일부 조건에 만족하지 않는 경우 HTTP 클라이언트로 오류를 전송하는 코드를 발견할 수 있다. context.Connection.SendError (null, 411); 이 메서드는 HttpResponse 객체를 Dispose 를 수행하고 연결을 끊도록 내부 구현이 되어 있다.

그러나 HttpConnection.OnReadInternal 에서 HTTP 클라이언트에게 오류를 전송하고 연결이 끊어졌지면 파이프라인을 계속 실행하는 문제가 발생한다. 그래서 이 부분에서 HTTP 클라이언트로 오류 응답을 전송하였다면 더이상 파이프라인이 실행되지 않도록 PR 을 넣어 공식 Mono 저장소에 머지가 되었다.

그리고 Mono 저장소의 릴리즈는 빠른 편이 아니므로 OWIN 웹 서버를 패치하려면 위에 안내한 것처럼 임시방편으로 수정하면 되고, 차기 Mono 릴리즈 버전에서는 이 문제가 해결되니 기다리면 될 것 같다.

Posted by 땡초 POWERUMC

댓글을 달아 주세요