C# - 컨테이너에서 실행하기 위한 (소켓) 콘솔 프로젝트 구성
예전 글에서,
.NET Core 콘솔 응용 프로그램을 배포(publish) 시 docker image 자동 생성 - 두 번째 이야기
; https://www.sysnet.pe.kr/2/0/12197
비주얼 스튜디오의 경우, 콘솔 프로젝트에도 "Add" / "Docker Support..." 메뉴를 이용하면 쉽게 Docker 컨테이너를 위한 개발 환경을 누릴 수 있다고 했습니다.
그렇다면 IPv4 소켓을 사용하면 어떨까요? ^^
using System.Net;
using System.Net.Sockets;
namespace ConsoleApp2;
internal class Program
{
static void Main(string[] args)
{
Socket socket = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ep = new IPEndPoint(IPAddress.Loopback, 16000);
Console.WriteLine(ep);
socket.Bind(ep);
socket.Listen(10);
new Thread(() =>
{
byte[] buffer = new byte[10];
while (true)
{
Socket client = socket.Accept();
Console.WriteLine("Client connected");
int recvBytes = client.Receive(buffer);
client.Send(new byte[] { 1, 2, 3, 4 });
client.Close();
}
}).Start();
Console.WriteLine("Press any key to exit...");
Console.ReadLine();
socket.Close();
}
}
위의 경우, "Add" / "Docker Support..."로 Docker 지원을 추가 후 F5 키를 눌러 실행하면 단순히 컨테이너 내에서 응용 프로그램을 실행만 시킬 뿐, 가장 중요한 16000번 포트를 노출시켜주지 않아 외부에서 테스트하기가 불편합니다.
왜냐하면 비주얼 스튜디오는 docker run을 다음과 같이 실행하기 때문입니다.
docker run -dt ...[생략: 각종 볼륨 매핑 및 환경 변수 설정]... --name ConsoleApp1 --entrypoint tail consoleapp1:dev -f /dev/null
즉, "-p 16000:16000"과 같은 설정이 있어야 하는 건데요, ASP.NET Core 웹 프로젝트와는 달리 Console App의 경우에는 저것을 자동으로 해주지 않습니다. 따라서 개발자가 직접 설정해야 하는데요, 이에 대해서는 이미
지난 글에서 2가지 방법을 설명했습니다.
- 랜덤 포트 매핑 - dockerfile에 EXPOSE + launchSettings.json에 publishAllPorts
- 고정 포트 매핑 - csproj에 DockerfileRunArguments를 이용한 포트 명시
하지만, 1번 방법은 C# 프로젝트가 "Microsoft.NET.Sdk.Web"인 경우의 빌드 템플릿에서만 제공하는 기능이므로 콘솔 프로젝트인 "Microsoft.NET.Sdk"라면 DockerfileRunArguments 옵션에 "-P" 옵션을 명시하는 식으로 우회해야 합니다.
결국, 2번 방법만 가능한데요, 여기서는 테스트를 편하게 하도록 고정 포트를 할당하는 것으로 가정하겠습니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<!-- Container Tools build properties -->
<DockerfileRunArguments>-p 16000:16000</DockerfileRunArguments>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.5" />
</ItemGroup>
</Project>
이후 실행하면, 비주얼 스튜디오는 이렇게 -p 옵션을 추가해 docker run을 해줍니다. ^^
docker run -dt ...[생략: 각종 볼륨 매핑 및 환경 변수 설정]... --name ConsoleApp1 -p 16000:16000 --entrypoint tail consoleapp1:dev -f /dev/null
위의 예제 코드는 IPAddress.Loopback 주소로 바인딩하고 있는데요, 이렇게 되면 컨테이너 외부에서는 접근할 수 없다고
이전 글에서도 언급했습니다. 그러면서 아래와 같은 내용도 언급했는데요,
그나저나, 위의 출력에서 "Container 외부에서 실행"하는 경우 curl의 출력이 "Empty reply from server"인 것이 좀 이상하지 않나요? ^^ 원래는 "Couldn't connect to server"라고 나와야 하는데요, 분량상 이에 대해서는 다른 글에서 자세하게 더 다뤄보겠습니다.
이제 저 현상을 자세하게 살펴보겠습니다. 실습을 위해 서버 측 바인딩을 IPAddress.Any로 바꾸고,
IPEndPoint ep = new IPEndPoint(IPAddress.Any, 16000);
이에 대응하는 소켓 클라이언트를 다음과 같은 코드로 함께 만들어 두겠습니다.
using System.Net;
using System.Net.Sockets;
namespace ConsoleApp1;
internal class Program
{
static void Main(string[] args)
{
Socket client = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ep = new IPEndPoint(IPAddress.Loopback, 16000);
Console.WriteLine(ep);
client.Connect(ep);
Console.WriteLine("Connected");
byte[] buffer = new byte[1024];
client.Send(new byte[] { 1, 2, 3, 4 });
int recvBytes = client.Receive(buffer);
Console.WriteLine("Press any key to exit...");
Console.ReadLine();
client.Close();
}
}
자, 이제 서버와 클라이언트 프로그램의 실행을 한 번 해보면, 서버 측 컨테이너 내부의 네트워크 연결이 이렇게 나옵니다.
$ netstat -an | grep 16000
tcp 0 0 0.0.0.0:16000 0.0.0.0:* LISTEN
tcp 0 0 172.17.0.2:16000 172.17.0.1:40454 FIN_WAIT2
또한, 클라이언트를 실행한 (컨테이너 외부에 해당하는) 윈도우 호스트 측의 연결 상황을 보면,
C:\temp> netstat -ano | findstr 16000
TCP 0.0.0.0:16000 0.0.0.0:0 LISTENING 9324
TCP 127.0.0.1:16000 127.0.0.1:32826 FIN_WAIT_2 9324
TCP 127.0.0.1:32826 127.0.0.1:16000 CLOSE_WAIT 40240
TCP [::]:16000 [::]:0 LISTENING 9324
TCP [::1]:16000 [::]:0 LISTENING 26120
위의 출력에서 9324는 com.docker.backend.exe 프로세스입니다. 즉, docker run 시 "-p 16000:16000" 옵션을 준 경우, Docker Desktop for Windows는 해당 포트를 com.docker.backend.exe에서 미리 열어두고 있는 것입니다.
따라서 위의 연결을 정리해 보면 대략 이런 네트워크 연결 구조가 나옵니다.
클라이언트 <---> 16000 포트 com.docker.backend <---> docker 가상 네트워크 <---> 16000 포트 socket server in docker container
그렇다면 만약, 서버 소켓 프로그램을 종료해 두고 컨테이너만 실행해 둔 채로 클라이언트를 실행해 보면 어떨까요? 서버는 없어졌지만, 여전히 com.docker.backend가 살아 있기 때문에 16000 포트로의 연결은 정상적으로 이뤄지지만 곧바로 소켓이 닫혀 버리는 현상이 나옵니다.
바로 이것이 "localhost"로 바인딩하고 있는 ASP.NET Core로 웹 브라우저 접속을 했을 때 "Empty reply from server" 응답이 나오는 이유입니다. 즉, 웹 브라우저는 com.docker.backend가 열어 놓고 있는 포트로 TCP 연결은 되었지만, com.docker.backend 측은 컨테이너 내부에 연결의 실질적인 대상이 되는 포트가 열려 있지 않아 곧바로 소켓 연결을 끊어버리기 때문에 나타나는 현상입니다.
그리고, 왜? "localost/127.0.0.1"로 바인딩한 ASP.NET Core를 컨테이너 외부에서 접속하지 못하는 이유도 위의 컨테이너 측 연결에서 찾을 수 있습니다.
tcp 0 0 172.17.0.2:16000 172.17.0.1:40454 FIN_WAIT2
보는 바와 같이, 외부의 연결을 컨테이너 내부의 소켓 연결을 중재할 때 "172.17.0.2"로 시도를 하기 때문에 "localhost/127.0.0.1"로 바인딩하고 있는 서버 소켓에 대해서는 동작하지 않았던 것입니다.
중요하지 않은 이야기 하나 덧붙이자면.
Docker Desktop for Windows의 경우, 포트 처리에서 다소 이상한 점이 하나 있습니다. 아래는 위에서 출력한 netstat를 클라이언트 연결이 없을 때를 보여줍니다.
C:\temp> netstat -ano | findstr 16000
TCP 0.0.0.0:16000 0.0.0.0:0 LISTENING 9324
TCP [::]:16000 [::]:0 LISTENING 9324
TCP [::1]:16000 [::]:0 LISTENING 26120
윈도우 호스트 측의 연결을 컨테이너 내부로 연결하기 위해 "0.0.0.0", "[::]" 주소에 대해 com.docker.backend 프로세스가 대기하는 것은 알겠는데요, 특이하게도 26120 pid를 가진 프로세스는 유독 IPv6인 [::1] Loopback 주소에 대해 연결을 지정하고 있습니다. 이 프로세스의 이름은 wslrelay.exe인데,
wslrelay.exe --mode 2 --vm-id {541d40ee-8df6-4f85-b1ca-8088aa0e2e41} --handle 2156
어떤 의도로 이렇게 작성된 것인지는 모르겠습니다. ^^; 어쨌든, 127.0.0.1의 연결 시도는 com.docker.backend 프로세스가, [::1]의 연결 시도는 wslrelay가 중계합니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]