I-다른 네임스페이스 같은 포트 리스닝 서버 구현

개요

Ztunnel의 원리에 대한 부분을 보다가, 문득 너무나도 궁금했다.
네임스페이스에 들어가서 소켓만 딸깍 만들어서 리스닝을 한다는 게 가능하다고?
말이 됨?

해보니까 말이 됐다.
말이 되도록 시도해본 코드를 여기에 담는다.
레포 참고.

전체 로직

다음은 전체 로직 부분에서 코어하다 싶은 부분만 남긴 것이다.

func main() {
	// This serves the same addr, but in the other net ns.
	// It also calls listenAndAccept internally.
	go handleOtherNamespace(ctx, wg)
	go listenAndAccept(ctx, wg)
}

func handleOtherNamespace(ctx context.Context, wg *sync.WaitGroup) {
	runtime.LockOSThread()
	defer runtime.UnlockOSThread()

	fd, err := os.Open("/var/run/netns/" + testNamespace)
	if err := unix.Setns(int(fd.Fd()), unix.CLONE_NEWNET); err != nil {
		log.Fatalf("Failed to set namespace to netns: %v", err)
	}
	listenAndAccept(ctx, wg)
}

func listenAndAccept(ctx context.Context, wg *sync.WaitGroup) {
	ln, err := net.Listen("tcp", addr)
	for {
		ln.(*net.TCPListener).SetDeadline(time.Now().Add(time.Second))
		conn, err := ln.Accept()
		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	for scanner.Scan() {
		line := scanner.Text()
		fmt.Println(line)
	}
}

그냥 리슨 액셉트 하는 함수 코드가 있다.
리슨을 하는 순간 커널에서 소켓을 만들어줘서 유저스페이스에 소켓 인터페이스를 반환할 것이다.
근데 이렇게 소켓을 만들 때 한쪽은 먼저 다른 네임스페이스에 접속(unix.SetNs)한 뒤에 만들고, 다른 쪽은 그냥 만드는 것이다.
접속하는 건 너무나도 간단하게, 그냥 네임스페이스의 파일 디스크립터(fd.Fd)만 있으면 된다.

이때 네임스페이스의 분리는 스레드마다 이뤄져야만 한다.
그래서 다른 네임스페이스로 접속하여 실행되는 코드 부분에는 OS 스레드를 고정시켰다.

마구 만들다보니 코드 구조가 조금 맘에 안 든다.
listenAndAccept가 다른 두 코드에서 실행되게 하는 게 최선인가?
이럴 거면 기본 네임스페이스에서 리슨할 때도 별도로 함수 분리를 하는 게 낫지 않나 싶다.

새롭게 시도한 내용

요즘 잘 때, 화장실 갈 때 터커의 유튜브를 보면서 뇌내 코딩하고 있는데, 마침 궁금한 게 있어서 잘 됐다고 생각했다.
딱 동시성에 대한 유툽을 볼 때 궁금증이 생겼기 때문에 관련한 부분에 대해 조금 코드를 넣어서 도전해봤다.

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	wg := &sync.WaitGroup{}

	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, unix.SIGINT, unix.SIGTERM)

	wg.Add(1)
	go handleOtherNamespace(ctx, wg)

	wg.Add(1)
	go listenAndAccept(ctx, wg)

	<-sigs
	fmt.Println("Shutting down")

	cancel()
	wg.Wait()
	fmt.Println("Bye!")
}

크게 도전한 것은 두 가지이다.
첫번째는 sync 라이브러리를 사용해서 고루틴 간 종료를 명백하게 보장하는 것.
각 고루틴은 defer로 Done을 호출한다.

그리고 컨텍스트를 이용해서 사용자 입력이 들어왔을 때 명확하게 모든 고루틴을 취소하는 것이다.
메인에서는 sigs 채널에 값이 들어오는 순간 다음 로직이 수행되고, cancel이 동작하여 전체를 취소시킨다.

	for {
		ln.(*net.TCPListener).SetDeadline(time.Now().Add(time.Second))
		conn, err := ln.Accept()
		if err != nil {
			if ne, ok := err.(net.Error); ok && ne.Timeout() {
				select {
				case <-ctx.Done():
					return
				default:
					continue
				}
			}
			fmt.Printf("Failed to accept connection: %v", err)
			continue
		}
		go handleConnection(conn)
	}

이 부분은 gpt의 도움을 받아 작성했다.
리스너의 타입 단언을 통해 SetDeadline 메서드를 호출한다.
그래서 커넥션 생성(Accept)을 위해 대기하는 시간을 1초로 고정해버린다.
이렇게 해서 얻을 수 있는 것은 주기적으로 컨텍스트로부터의 종료 신호를 받을 수 있다는 것이다.
커넥션을 기다리다가 1초가 지나던가 모종의 이유로 err가 생길 것이다.
이때 타임아웃으로 인한 에러면 select로 컨텍스트 Done 채널에 값이 들어왔는지 확인한다.
종료 신호가 들어왔다면 해당 고루틴은 종료된다.

그게 아니라면 Accept를 다시금 수행할 것이다.
커넥션이 생겼다면 해당 커넥션을 처리하는 고루틴이 커넥션을 처리할 것이다.

이제 보니 이 부분도 잘못 짠 게, 결국 커넥션을 핸들링하는 고루틴도 컨텍스트 전파를 받아야 한다.

동작

테스트는 다음과 같이 하면 된다.

# You need to execute commands in root privileges.
# At least you need to have cap_sys_admin, for enter others namespace

# Terminal 1
sudo ip netns add test
sudo ip netns exec test ip link set lo up
go build .
sudo ./tunnel

# Terminal 2
sudo ip netns exec test telnet localhost 3000
# Terminal 3
telnet localhost 3000

# Clean up
sudo ip netns delete test
rm ./tunnel

test 네임스페이스를 만들고 해당 네임스페이스의 lo 인터페이스를 활성화시킨다.
그 다음 서버 가동! 하면 각 네임스페이스에서 3000번 포트로 수신하는 것이 가능해진다.
image.png
맨 처음에는 다른 네트워크 네임스페이스에 진입해서 리스닝 소켓을 만들고, 해당 네임스페이스에서 접근이 가능한지 테스트했다.
image.png
이후에는 프로세스가 실행되는 기본 네임스페이스와, 테스트로 만들어둔 네임스페이스 두 쪽에 동시에 3000번 포트를 리스닝하도록 코드를 짰다.
스레드만 분리해서 실행하면 다른 네임스페이스에 침입해서 소켓을 만드는 건 ssaf 가능이다.
네임스페이스 간 자원은 공유되지 않기 때문에, 단순하게 한 스레드가 잠시라도 해당 네임스페이스에 속할 수 있다면 해당 네임스페이스에서 리스닝 소켓을 만드는 게 가능한 것 같다.

결론

해보면서 느낀 점은 ztunnel의 획기적인라는 이 방식은 마치 잘 알려진 알고리즘 공식을 보는 것 같다는 것이다.
원리를 알고 구조를 알고 보면 정말 별 거 아닌데, 그 발상의 순간은 창조의 영역에 맞닿아 있었을 것만 같은 경이로움이 있다.
당연하게 컨테이너를 사용하다보니 자연스레 개별 프로세스가 다른 네임스페이스에 개입해서 지속적인 영향을 줄 수 있다는 게 새삼 놀랍다.

이런 간단한 방식을 구현함으로서 기존 ztunnel이 개발되며 발생했던 많은 문제들이 해결됐다고 한다.[1] (갓 solo.io..)
가장 큰 문제는 다른 cni들과의 충돌이었는데, cni는 노드의 iptables를 조작하거나 ebpf를 수정하는 등 다양한 네트워크 영역을 조작하기 때문에 이전 방식에서는 cni들과 호환이 되지 않는 경우가 많았다고 한다.
그래서 가급적 ztunnel은 파드에서 패킷이 나오기 이전에 패킷을 처리할 수 있는 방법이 필요했고, 결과적으로 이런 방법이 고안된 것이다.
이렇게 파드 내부의 네임스페이스에 대놓고 빨대를 꽂아버리면 노드 영역의 네트워크 세팅 동작이랑 엮일 필요가 없다.

이러한 방식의 가장 큰 특징은 실상 ztunnel로 향하는 통로가 파드 네임스페이스 내부에 있다는 것이다.
기존의 사이드카의 단점을 덜면서도 ztunnel은 거의 사이드카마냥 가까운 영역에서 트래픽을 처리할 수 있게 된 것이다.

개선사항

관련 문서

지식 문서, EXPLAIN

이름4is-folder생성 일자
E-앰비언트 모드에서 메시 기능 활용false2025-06-07 20:56
E-앰비언트 모드 헬름 세팅false2025-06-03 19:27
E-앰비언트 ztunnel 트래픽 경로 분석false2025-06-07 20:36
앰비언트 모드false2025-06-02 14:51

기타 문서

Z0-연관 knowledge, Z1-트러블슈팅 Z2-디자인,설계, Z3-임시, Z5-프로젝트,아카이브, Z8,9-미분류,미완
이름3코드타입생성 일자
I-ztunnel이 다른 네임스페이스에서 요청 보내는 코드 분석Z1topic/idea2025-06-07 20:44
9W - 앰비언트 모드 구조, 원리Z8published2025-06-07 19:17
9W - 앰비언트 헬름 설치, 각종 리소스 실습Z8published2025-06-07 19:27

참고


  1. https://istio.io/latest/blog/2024/inpod-traffic-redirection-ambient/#istio-ambient-traffic-redirection-the-new-model ↩︎