Post

Flannel 코드 분석하기 (1편)

Flannel 코드 살펴보기

들어가며

작년 말 Golang 오픈소스 코드 분석하기라는 스터디를 진행했다. 우리는 분석할 프로젝트로 Flannel을 선택했는데 이유는 다음과 같았다. 첫 번째로 Golang에 대한 지식이 부족했고 스터디 기간은 짧았기에 복잡도와 코드의 양이 많은 프로젝트를 피해야 했다. 두 번째로 조원 모두 쿠버네티스에 관심이 많았기에 쿠버네티스 관련 프로젝트를 희망했다.

Flannel은 두 조건에 부합하는 입문으로 좋은 프로젝트였고 덕분에 재밌게 공부할 수 있었다.

Flannel이란

Flannel이란 Flannel은 Kubernetes CNI(Container Network Interface) 구현체 중 하나로 네트워킹 자체에만 초점을 맞춘 프로젝트로 k3s 등 가벼운 목적으로 주로 사용한다. Flannel은 다른 CNI와 다르게 Network Policy를 지원하지 않는다. 호스트 내부 통신에 브릿지를 사용하기에 Network Policy를 지원하고 싶어도 구현이 어려울 것으로 예상된다. 또한, 다른 CNI와 다르게 별도의 커스텀 리소스를 이용하지 않고 ETCD를 통해 라우팅 정보를 교환한다.

네트워크 구성 확인하기

Flannel이 CNI의 구현체가 되기 위해선 CNI 명세서에 나온 사항을 만족해야 한다. 그 중 특히 아래와 같은 주요 사항을 만족해야 한다.

💡 CNI 명세서 중 일부
CNI 구현체는 컨테이너 네트워크 연결에 책임을 가지고 있습니다. 컨테이너가 네트워크에 연결되기 위한 모든 작업에 책임을 가집니다.

CNI 구현체는 IPAM(IP Address Management)에 책임을 가지고 있습니다. 이것은 IP주소 할당을 포함하여 적절한 라우팅 정보를 입력하는 것까지 포함됩니다.

여기서 우선 컨테이너 네트워크 연결 부분을 자세히 살펴보자. 컨테이너 통신은 크게 호스트 내부 통신, 클러스터 내 통신, 외부 통신 3가지로 구분된다. Flannel에선 각각을 어떻게 구현하고 있을까?

호스트 내부 통신

위에서도 살짝 언급했지만, Docker와 같이 브릿지 방식으로 같은 노드 내의 파드를 연결한다.

클러스터 내 통신

우선 다른 노드에 위치하는 파드와 통신을 위해선 해당 파드가 어떤 노드에 위치해 있는지 알아야 한다. 이를 위해 가령 “파드1은 노드1에 있습니다.“와 같은 정보를 ETCD에 저장하고 각 노드의 Flannel 데몬이 이를 확인하고 라우팅 정보를 업데이트한다.

노드의 IP(ex. 10.0.0.1)와 파드의 IP(ex. 192.168.1.1)가 다르므로 파드로 향하는 패킷을 다른 노드에 전달하기 위해선 별도의 작업이 필요하다. Flannel은 별도의 백엔드를 이용하는데, 주로 IPIP VXLAN과 같은 가상화 기술을 이용한다. 여기선 IPIP를 예시로 들면, 아래의 그림과 같이 추가로 노드의 IP 헤더를 쌓아 다른 노드로 전달하고 이후 헤더를 제거한 후 파드로 전달되도록 한다.

image.png

외부 통신

구글 DNS(8.8.8.8)와 같은 클러스터 외부의 기기와 통신을 할 땐 iptables를 이용한다. Iptables는 리눅스 커널에 구현된 netfilter의 인터페이스이며 netfilter는 규칙을 기반으로 패킷을 처리하는 엔진이다. 커널에서 패킷을 관찰하다 규칙에 맞는 패킷을 확인하면 정해진 동작을 취한다. 여기서는 외부로 나가는 패킷을 Masquerade(SNAT)하는 용도로 사용된다. 파드1에서 외부와 통신할 때는 netfilter에서 노드의 IP로 변경하여 외부통신이 진행되도록 동작한다.

외부통신을 마지막으로 Flannel이 네트워킹을 지원하는 방식에 대해 알아봤다. 이제 코드를 살펴보며 위에서 설명된 네트워크 구성이 맞는지 확인해보자.

코드 살펴보기

코드 구성

flannel의 소스 코드는 /pkg 에 존재하며, 주요한 폴더로 backend, subnet, trafficmanager, ip, mac가 존재한다. ip, mac은 실제 네트워크 계층에 대한 정보를 조회하거나 작업을 위한 코드이고, 주요 동작 방식을 살펴볼 수 있는 코드는 backend, subnet, trafficmanager이다.

flannel은 라우팅 정보 교환을 etcd를 통해 진행하는데, 이와 관련된 폴더가 subnet이다. 정보 교환을 통해 트래픽을 전달 시 목적지 노드를 파악했으면 파드와 노드의 IP 대역이 다르기에 별도의 가상화 기술을 이용해야 한다. 가상화 기술에 대한 코드가 담겨있는 곳이 backend이며 이곳에 VXLAN과 IPIP를 비롯하여 IPsec, wireguard 등 여러 가상화 기술을 지원한다. 마지막으로 trafficmanager는 외부 통신과 관련된 코드다. iptables를 이용해 외부 통신이 원활하게 진행되도록 구현한다.

여기서 뭔가 빠졌는데라는 생각이 들것이다. 호스트 내부 통신(bridge)에 대한 부분이 없다. 그것은 Flannel CNI-Plugin 프로젝트에서 확인할 수 있다.

Flannel CNI-Plugin

Flannel CNI-Pluginflannel_linux.go 코드를 살펴보면 bridge plugin에 컨테이너 통신을 위임하고 있는 모습을 볼 수 있다. 추가로 IPAM 또한 쿠버네티스 내장 IPAM에게 위임하여 사용하는 것을 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func doCmdAdd(args *skel.CmdArgs, n *NetConf, fenv *subnetEnv) error {
	n.Delegate["name"] = n.Name

	if !hasKey(n.Delegate, "type") {
		n.Delegate["type"] = "bridge"
	}
	...
	if n.Delegate["type"].(string) == "bridge" {
		if !hasKey(n.Delegate, "isGateway") {
			n.Delegate["isGateway"] = true
		}
	}
  ...
	ipam, err := getDelegateIPAM(n, fenv)
	if err != nil {
		return fmt.Errorf("failed to assemble Delegate IPAM: %w", err)
	}
	n.Delegate["ipam"] = ipam
	fmt.Fprintf(os.Stderr, "\n%#v\n", n.Delegate)

	return delegateAdd(args.ContainerID, n.DataDir, n.Delegate)
}

가장 중요한 main.go를 살펴보자.

main.go

우선 전체 Context를 생성하고, 서브넷 매니저를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
	// 전체의 context이며, ctx를 통해 모든 코드를 종료하는 것이 가능하다.
	ctx, cancel := context.WithCancel(context.Background())

	// 서브넷 매니저 생성(default kube-api)
	sm, err := newSubnetManager(ctx)
	wg := sync.WaitGroup{}
	wg.Add(1)
	go func() {
		shutdownHandler(ctx, sigs, cancel)
		wg.Done()
	}()

백엔드 매니저를 생성하고, 클라이언트가 선택한 백엔드로 네트워크를 구성한다.

1
2
3
4
  // Create a backend manager then use it to create the backend and register the network with it.
	bm := backend.NewManager(ctx, sm, extIface)
	be, err := bm.GetBackend(config.BackendType)
	bn, err := be.RegisterNetwork(ctx, &wg, config)

트래픽 매니저를 생성하고, ipMasq 옵션(default true)에 따라 masquerade를 구성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
	// 외부 통신을 위한 트래픽 매니저
	trafficMngr := newTrafficManager(config.EnableNFTables)
	// Set up ipMasq if needed
	if opts.ipMasq {
		prevNetwork := ReadCIDRFromSubnetFile(opts.subnetFile, "FLANNEL_NETWORK")
		prevSubnet := ReadCIDRFromSubnetFile(opts.subnetFile, "FLANNEL_SUBNET")

		prevIPv6Network := ReadIP6CIDRFromSubnetFile(opts.subnetFile, "FLANNEL_IPV6_NETWORK")
		prevIPv6Subnet := ReadIP6CIDRFromSubnetFile(opts.subnetFile, "FLANNEL_IPV6_SUBNET")

		err = trafficMngr.SetupAndEnsureMasqRules(ctx,
			config.Network, prevSubnet,
			prevNetwork,
			config.IPv6Network, prevIPv6Subnet,
			prevIPv6Network,
			bn.Lease(),
			opts.iptablesResyncSeconds)
		if err != nil {
			log.Errorf("Failed to setup masq rules, %v", err)
			cancel()
			wg.Wait()
			os.Exit(1)
		}
	}

ip masq 구성까지 끝났으면 관련 주요 정보를 모두 /run/flannel/subnet.env 경로에 저장한다. 이 정보는 flannel이 다시 실행될 때 기존 config 정보를 가져오는 데 사용된다.

1
2
3
4
5
6
	if err := sm.HandleSubnetFile(opts.subnetFile, config, opts.ipMasq, bn.Lease().Subnet, bn.Lease().IPv6Subnet, bn.MTU()); err != nil {
		// Continue, even though it failed.
		log.Warningf("Failed to write subnet file: %s", err)
	} else {
		log.Infof("Wrote subnet file to %s", opts.subnetFile)
	}

백엔드를 고루틴으로 실행하여, 병렬처리한다.

1
2
3
4
5
6
7
8
	log.Info("Running backend.")
	wg.Add(1)
	go func() {
		bn.Run(ctx)
		wg.Done()
	}()
	...

마지막으로 서브넷 매니저의 CompleteLease 함수를 호출하여 현재 노드의 네트워크 구성이 완료되었음을 알린다.

1
2
3
4
5
6
7
8
	err = sm.CompleteLease(ctx, bn.Lease(), &wg)
	...
	log.Info("Waiting for all goroutines to exit")
	// Block waiting for all the goroutines to finish.
	wg.Wait()
	log.Info("Exiting cleanly...")
	os.Exit(0)
}

요약하면, 라우팅 정보를 책임지는 서브넷 매니저를 먼저 생성한다. 이후 백엔드 구성을 위해 백엔드 매니저와 외부 통신을 위한 트래픽 매니저를 연이어 생성한다. 여기까지 진행이 완료되면 주요 요소의 설정이 완료되었기에 Config를 /run/flannel/subnet.env 경로에 백업한다. 백엔드를 고루틴으로 실행하고, 클러스터에 현재 노드의 네트워크 구성이 완료되었음을 알린다.

마치며

이번 포스팅에서는 Flannel에 대한 설명과 Flannel 프로젝트의 구조에 대해 간단하게 알아봤다. 원래 한 포스팅에 모든 내용을 담으려고 했으나, 방대해지는 바람에 포스팅을 구분한다. 다음 포스팅에서는 서브넷 매니저, 백엔드, 트래픽 매니저에 대해 살펴본 내용을 다룰 예정이다.

This post is licensed under CC BY 4.0 by the author.