본문으로 바로가기 본문 바로가기
WIKI 기술 지식 베이스

UDP와 TCP에서 앱 실행하기

원문 보기 위키 갱신

어떤 프로토콜로 통신하든 컨테이너화된 앱을 플랫폼에서 실행할 수 있어요. TCP 외에 UDP를 사용하는 앱도 포함됩니다. 이 가이드에서는 Fly.io에서 UDP 앱을 설정하는 방법을 안내하며, 전용 IPv4 주소, fly-global-services 주소 바인딩, 포트 규칙, MTU 고려사항, 실제 UDP/TCP 예시 앱을 다룹니다.

출처: 문서

본문

TCP 외에 UDP를 사용하는 앱을 포함해, 어떤 프로토콜로 말하든 모든 컨테이너화된 앱을 우리 플랫폼에서 실행할 수 있어요. 이 가이드는 Fly.io에서 UDP 앱을 설정하는 방법을 안내합니다.

시작하기 전에 Fly.io에서 UDP를 사용할 때 중요한 요점을 정리하면 다음과 같습니다 (아래에서 자세히 다룰게요).

  • 전용 IPv4 주소가 필요해요. UDP에는 공유 IPv4 주소나 IPv6 주소를 사용할 수 없습니다. TCP에는 공개 IPv6와 공유 IPv4를 지원해요.

  • 앱의 UDP 쪽은 특별한 fly-global-services 주소에 바인딩해야 해요. 하지만 TCP 쪽은 그럴 수 없으며 0.0.0.0에 바인딩해야 합니다.

  • 앱의 UDP 쪽은 외부에서 사용되는 것과 같은 포트에 바인딩해야 해요. Fly는 포트를 다시 쓰지 않습니다. Fly는 UDP 패킷의 IP 주소만 다시 씁니다.

  • UDP를 위해 MTU에서 수 십 바이트를 가져가는데, 보통은 문제가 되지 않지만 드물게 문제가 될 수 있어요.

전용 IPv4 주소가 필요해요

앱이 UDP 패킷을 받으려면 전용 IPv4 주소가 필요해요. 공개 IPv6로 UDP를 지원하지 않습니다. 모든 Fly.io 앱은 공개 IPv6 주소와 공유 IPv4 주소를 받는데, 이들은 앱의 TCP 쪽에서 작동합니다.

참고: UDP는 Fly.io IPv6 프라이빗 네트워킹에서는 작동합니다.

앱은 fly-global-services 주소에 바인딩해야 해요

UDP 패킷을 받으려면 앱이 특별한 fly-global-services 주소에 바인딩해야 합니다 (그게 다예요. 그것이 주소입니다). 똑같이 중요한 것은: 그 주소로 메시지에 응답해야 합니다.

보통 UDP 서비스를 fly-global-services에 명시적으로 바인딩해야 해요. 죄송하지만 0.0.0.0, *, INADDR_ANY는 보통 안 됩니다. 그것들을 사용하면 Linux가 응답에 잘못된 소스 주소를 사용할 거예요.

하지만: TCP는 fly-global-services를 사용할 수 없어요

TCP 서비스를 fly-global-services에 바인딩할 수 없습니다. 앱의 TCP 쪽은 아마 *에 바인딩해야 해요.

왜 fly-global-services 주소인가요?

대부분 UDP는 Fly.io에서 여러분이 예상하는 대로 "그냥 잘 작동합니다".

UDP 전달을 HTTP와 다르게 만드는 점은 UDP 메시지에는 HTTP 헤더가 없다는 것입니다. 표준 프록시가 HTTP 메시지를 받아 대상으로 보낼 때 원래 소스 주소를 헤더에 저장하므로 대상이 누구와 대화 중인지 알 수 있어요. UDP 메시지에서는 일반적으로 그렇게 할 수 없습니다. 하지만 UDP 메시지에 응답하려면 누구와 대화 중인지 알아야 해요!

우리는 ⚡️eBPF magic⚡️을 사용해 전달 패브릭을 가로질러 UDP 패킷을 운반하면서 소스 주소를 투명하게 매핑합니다. 여러분에게 의미하는 바는 소스 주소를 바탕으로 UDP 메시지를 읽고 응답하는 표준 소켓 프로그램을 그냥 작성하면 된다는 것입니다.

만약 이 fly-global-services 방식을 하지 않았다면, 우리의 UDP 프록시 로직이 앱 자체의 DNS 트래픽도 프록시하려 할 거예요. 그것 외에는 사용자에게 응답하려 보내는 UDP 패킷과 앱이 일상 업무에서 생성하는 UDP 패킷을 구분할 수 없기 때문입니다.

앱은 외부와 내부의 같은 포트에서 듣고 있어야 해요

Fly는 UDP 패킷의 IP 주소만 다시 씁니다. 인바운드 패킷의 대상 포트는 다시 쓰지 않습니다. 즉 앱이 외부에서 포트 5000으로 UDP 트래픽을 받는다면, 앱은 그 UDP 트래픽을 받기 위해 fly-global-services:5000에 바인딩해야 합니다.

MTU에 유의해야 할 수 있어요

거의 모든 네트워크의 불변 조건은 최대 패킷 크기, MTU입니다. 보통 MTU는 1500바이트입니다. 1500바이트를 넘으면 패킷이 조각화(fragment)됩니다. 조각화는 원하지 않을 거예요.

Fly.io는 두 가지 목적을 위해 패킷에서 일부를 떼어냅니다.

  1. 우리는 모든 트래픽을 WireGuard 메시로 전달하며, WireGuard는 모든 패킷에서 60바이트를 소비합니다.

  2. 우리가 사용하는 UDP 프록시 전달 시스템은 패킷의 원래 주소를 기록하기 위해 또 한 다스 바이트를 가져갑니다 — 클라이언트 패킷이 우리 엣지에 도달했을 때 본 소스 주소. 여러분은 이 바이트를 보지 못합니다. 앱에 전달되기 전에 패킷에서 벗겨집니다. 하지만 전송 중에는 사용됩니다.

대부분의 경우 이것은 전혀 문제가 되지 않아요. 현대 UDP 프로토콜은 인터넷에 더 작은 MTU를 가진 이상한 링크가 많기 때문에 1500바이트보다 훨씬 적게 사용할 수 있다고 가정하도록 설계되었습니다.

하지만 매우 커스텀한 작업을 한다면 1500이 아니라 1300바이트 정도로 작업할 수 있다고 가정하는 것이 좋아요.

UDP 앱 예시

간단한 에코 서비스를 만들어 봅시다. 홈 버전은 이 GitHub 리포지토리에서 즐길 수 있어요. 포트 5000, UDP와 TCP에서 듣고 클라이언트가 보내는 것을 그대로 되돌려 보낼 거예요.

flyctl launch로 fly.toml 파일을 만드세요. 그걸로 포트를 연결합니다.

[[services]]
  internal_port = 5000
  protocol = "udp"

  [[services.ports]]
    port = "5000"

[[services]]
  internal_port = 5000
  protocol = "tcp"

  [[services.ports]]
    port = "5000"

이 앱을 Go로 만들 거예요 (걱정 마세요, 간단해서 고퍼(Go 개발자)가 아니어도 따라갈 수 있어요). 바닐라 소켓 코드가 될 거예요.

코드를 보자

표준 소켓 코드로 UDP와 TCP를 켭니다. 보통 유일하게 까다로운 부분은 UDP의 fly-global-services 바인딩이에요. Go에서 그렇게 보입니다.

func main() {
  // in other programming languages, this might look like:
	//    s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
	//    s.bind("fly-global-services", port)

	udp, err := net.ListenPacket("udp", fmt.Sprintf("fly-global-services:%d", port))
	if err != nil {
		log.Fatalf("can't listen on %d/udp: %s", port, err)
	}

	// in other programming languages, this might look like:
	//    s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
	//    s.bind("0.0.0.0", port)
	//    s.listen()

	tcp, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
	if err != nil {
		log.Fatalf("can't listen on %d/tcp: %s", port, err)
	}

	go handleTCP(tcp)

	handleUDP(udp)
}

이 앱의 핸들러는 교과서적인 Go 코드예요. UDP 바인딩 이후에는 앱의 나머지가 Fly.io에 대해 신경 쓸 일이 없습니다.

TCP는 다음과 같아요.

func handleTCP(l net.Listener) {
	for {
		conn, err := l.Accept()
		if err != nil {
			if errors.Is(err, net.ErrClosed) {
				return
			}

			log.Printf("error accepting on %d/tcp: %s", port, err)
			continue
		}

		go handleConnection(conn)
	}
}

func handleConnection(c net.Conn) {
	defer c.Close()

	lines := bufio.NewReader(c)

	for {
		line, err := lines.ReadString('\n')
		if err != nil {
			return
		}

		c.Write([]byte(line))
	}
}

그리고 UDP는 다음과 같아요.

func handleUDP(c net.PacketConn) {
	packet := make([]byte, 2000)

	for {
		n, addr, err := c.ReadFrom(packet)
		if err != nil {
			if errors.Is(err, net.ErrClosed) {
				return
			}

			log.Printf("error reading on %d/udp: %s", port, err)
			continue
		}

		c.WriteTo(packet[:n], addr)
	}
}

DNS 같은 것을 하는 실제 Go 앱에서는 UDP 소켓에서 메시지를 읽는 단일 스레드가 버퍼된 채널을 통해 워커 풀에 전달하기를 원할 수도 있어요. 받는 UDP 메시지마다 스레드를 포크하고 싶지 않을 수 있으니까요. 이것은 에코 서비스일 뿐이므로 그 세부사항은 생략할게요.

이 프로그램은 간단한 Dockerfile과 위 fly.toml 구성으로 Fly.io에서 그냥 잘 작동해야 해요. 실행해서 netcat으로 UDP 패킷을 던져 보세요.

echo hello world | nc -w1 -u udp-echo-sample.fly.dev 5000

hello world

더 알아보기 (Learn more)