CoreDNS 커스터마이즈하기

CoreDNS는 DNS 서버이다. 다양한 환경에서 사용 가능하며, 쿠버네티스의 기본 네임 서버로도 채택되었다. 쿠버네티스를 다루다보면 CoreDNS를 맞닥뜨리지 않을 수 없다.

CoreDNS의 특징을 알아보고 직접 커스터마이즈를 해보자.

특징

왜 쿠버네티스는 CoreDNS를 채택했을까? ChatGPT한테 물어보았다.

  • 다양한 작업을 수행하도록 유연하게 구성될 수 있다. 서비스 디스커버리, 요청 재작성, 트래픽 관리 등의 작업을 할 수 있다.
  • 고성능, 저지연에 최적화되어 트래픽이 많은 대규모 클러스터에 어울린다.
  • 커지는 클러스터에 대응하도록 스케일할 수 있다.
  • 오픈 소스이고 개발자 커뮤니티가 활발해 쉽게 확장하고 커스터마이즈할 수 있다.
  • 쿠버네티스 클러스터의 리소스 변경 사항을 자동으로 감지하고 DNS 레코드를 업데이트하는 기능이 있다.

여기서 가장 중요한 것은 유연성이다. CoreDNS는 수많은 플러그인이 합쳐져 다양한 기능을 수행할 수 있다. 거기에 원한다면 얼마든지 새로운 플러그인을 추가할 수 있다. Go 언어를 다룰 줄 안다면 직접 플러그인을 개발해 적용할 수도 있다.

일반적으로 “플러그인”이라고 하면, 소프트웨어의 기본 기능에 덧대어 추가 기능을 붙인다고 생각하기 쉽다. 하지만 CoreDNS는 모든 것이 플러그인으로 이루어졌다. DNS 핵심 기능에서부터 로그, 메트릭, 캐시 등등, 기능 하나 하나가 플러그인 단위로 제공된다.

설치 및 구성

공식 문서를 보자.

정말 간단하게도 그냥 바이너리 하나 받으면 끝이다. 이 바이너리에 필요한 플러그인이 전부 포함되어있다.

실행 시 현재 위치의 Corefile 파일을 읽어서 설정 값을 불러들인다. (혹은 -conf 플래그를 줘서 설정 파일을 지정할 수 있다.) 설정 파일 작성 방법은 여기를 참고. 읽어보면 대충 어떤 느낌인지 알 것이다.

설정 파일에는 {, }서버 블록(Server Block)이 정의된다. 각각의 서버 블록이 하나의 서버에 대응된다. 서버마다 사용할 플러그인을 따로 지정할 수 있다. 즉, CoreDNS 바이너리 하나를 실행하면 설정에 구성된 서버 블록 개수만큼 논리적으로 여러 개의 서버가 돌아간다고 보면 된다. 문서를 보면 “multiple Servers”라는 표현이 나오는데 이걸 얘기하는 것이다.

작동 방식

CoreDNS에서 쿼리를 처리할 때 다음과 같은 일이 수행된다.

  1. 서버가 여러 개인 경우, 이 쿼리에 대해 가장 구체적인 Zone을 갖는 서버를 찾는다.
    (예를 들어 두 서버가 각각 example.org, a.example.org에 대해 구성되고, 쿼리가 www.a.example.org이라면 후자에서 처리한다.)
  2. 해당 서버에 대해 구성된 플러그인 체인을 통해 쿼리가 라우트된다. 플러그인의 처리 순서는 plugin.cfg에 정의된다.
  3. 각 플러그인이 쿼리의 처리 여부를 결정한다. 이 때 다음과 같은 일이 발생할 수 있다.
    1. 쿼리를 처리한다.
    2. 쿼리를 처리하지 않고, 다음 플러그인을 호출한다.
    3. 쿼리를 처리하는 중에 체인의 다음 플러그인을 호출한다. 이를 fallthrough라고 한다.
      (예시: hosts 플러그인은 호스트 테이블레서 쿼리에 대한 답을 찾으려 시도하고, 답이 없으면 다음 플러그인에 넘긴다.)
    4. 쿼리를 처리하고 힌트를 추가하여 다음 플러그인을 호출한다. 이 힌트는 최종 응답을 확인하고 이에 따른 행동을 할 수 있도록 한다.
      (예시: prometheus 플러그인)

기초 실습

이제 file 플러그인을 사용해서 Zone 데이터를 읽어들이게 해보자. db.example.org 파일을 생성해 아래와 같은 내용을 작성한다. 이 파일은 example.org. zone에 대한 데이터를 담고 있다.

$ORIGIN example.org.
@	3600 IN	SOA sns.dns.icann.org. noc.dns.icann.org. (
				2017042745 ; serial
				7200       ; refresh (2 hours)
				3600       ; retry (1 hour)
				1209600    ; expire (2 weeks)
				3600       ; minimum (1 hour)
				)

	3600 IN NS a.iana-servers.net.
	3600 IN NS b.iana-servers.net.

www     IN A     127.0.0.1     ; www.example.org.에 대한 IPv4 주소
        IN AAAA  ::1           ; www.example.org.에 대한 IPv6 주소

그리고 설정 파일(Corefile)을 아래와 같이 작성한다.

example.org {
    file db.example.org
    log
}

. {
    forward . 8.8.8.8
    log
}

먼저 example.org zone을 담당하는 서버가 있다. file 플러그인이 db.example.org 파일로부터 데이터를 읽어들이고, log 플러그인은 쿼리 로그를 남긴다. 이 zone에 해당되지 않는 모든 쿼리는 8.8.8.8로 포워딩한다.

CoreDNS를 실행하고 dig 명령어로 확인해본다.

$ dig @localhost +noall +answer www.example.org
www.example.org.        3600    IN      A       127.0.0.1

$ dig @localhost +noall +answer www.google.com
www.google.com.         191     IN      A       142.250.206.196

이 밖에도 Redis에서 zone 데이터를 읽는 등 (redis 플러그인) 필요에 따라 다른 플러그인을 적용할 수 있다. 공식 문서를 읽어보면 어렵지 않게 이해할 수 있을 것이다.

CoreDNS 커스터마이즈

앞에서 CoreDNS의 가장 큰 특징이 유연성이라고 했다. CoreDNS는 3가지 방법으로 커스터마이즈를 할 수 있다.

  1. 외부 플러그인을 적용하여 새롭게 빌드
  2. CoreDNS를 라이브러리로 이용
  3. 나만의 플러그인 작성

CoreDNS 빌드하기

CoreDNS의 바이너리 릴리즈에는 수십 개의 플러그인이 포함되어있다. 여기에 포함되지 않은 외부 플러그인을 적용하고 싶으면 직접 빌드를 해야 한다.

플러그인의 목록과 순서는 컴파일 타임에 고정된다. 동적으로 로드되는 것이 아니다.

빌드 과정은 간단하다.

  1. CoreDNS 소스 클론
  2. plugin.cfg 파일 수정
  3. 빌드

Go 언어를 알 필요는 없지만, 빌드는 해야 하니 Go가 설치되어 있어야 한다. (혹은 golang Docker 이미지를 사용할 수도 있다.)

$ git clone https://github.com/coredns/coredns.git
$ cd coredns

plugin.cfg 파일을 편집해서 firewall 플러그인을 넣어보자.

...
log:lo
dnstap:dnstap
local:local
dns64:dns64
acl:acl
firewall:github.com/coredns/policy/plugin/firewall
any:any
...

ACL 플러그인 바로 다음에 삽입했다. 순서가 중요하므로 아무데나 집어넣으면 안 된다.

$ make

이제 새로운 coredns 바이너리가 빌드되었다. 위의 예제에서 사용한 바이너리를 이걸로 교체하고, 설정 파일을 아래와 같이 수정한다.

example.org {
    firewall query {
        allow client_ip == '127.0.0.1'
        refuse true
    }
    file db.example.org
    log
}

이제 클라이언트 IP가 127.0.0.1인 경우에만 DNS 요청을 허용하고, 그 외에는 모두 거부하는 규칙이 적용된다.

CoreDNS 실행 시 Corefile:2 - Error during parsing: Unknown directive 'firewall' 에러가 뜬다면 plugin.cfg에 오타가 없는지, 새로 빌드한 바이너리를 사용한 것이 맞는지 확인해보자.

라이브러리로 사용

나만의 소스에 CoreDNS의 플러그인을 임포트해서 사용할 수 있다. CoreDNS를 따로 실행하지 않아도 되고, 내 서버에 필요한 기능만 뽑아다 넣을 수 있다는 장점이 있다. 물론 Go 언어를 다룰 줄 알아야 한다.

간단한 DNS 캐시 서버 예제를 보자.

이 서버는 다섯 개의 플러그인(bind, cache, errors, forward, log)을 포함한다. import 구문으로 기능을 사용한다.

// dnscached.go
import (
	"bytes"
	"flag"
	"fmt"
	"os"

	_ "github.com/coredns/coredns/plugin/bind"
	_ "github.com/coredns/coredns/plugin/cache"
	_ "github.com/coredns/coredns/plugin/errors"
	_ "github.com/coredns/coredns/plugin/forward"
	_ "github.com/coredns/coredns/plugin/log"

	"github.com/coredns/caddy"
)
...

그리고 CLI 플래그로 설정 값을 지정하면 내부적으로 알아서 Corefile을 작성해서 구성을 완료한다.

func parseFlags() *dnscached {
	d := &dnscached{}
	f := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
	f.StringVar(&caddy.PidFile, "pidfile", "", "File `path` to write pid file")
	f.BoolVar(&d.printVersion, "version", false, "Show version")

	...
}

func (d *dnscached) corefile() (caddy.Input, error) {
	var b bytes.Buffer
	_, err := b.WriteString(fmt.Sprintf(".:%d {\n errors\n bind %s\n",
		d.port, d.bindIP))
	if err != nil {
		return nil, err
	}

	if d.enableLog {
		_, err = b.WriteString(" log\n")
		if err != nil {
			return nil, err
		}
	}

	...
}
func main() {
	d := parseFlags()
	d.handleVersion()
	input, err := d.corefile()

	...
}

플러그인 작성하기

마음에 드는 플러그인이 없다면, Go 언어로 직접 플러그인을 만들어보자.

플러그인은 일반적으로 setup.go 파일과 <플러그인_이름>.go 파일로 구성된다. 각 파일에는 다음 함수가 구현되어야 한다.

  • setup.go
    • init: 플러그인을 등록
    • Setup function: 설정 파일(Corefile)을 파싱
  • <플러그인_이름>.go
    • Name: 이름
    • ServeDNS: 요청 처리

이제 onlyone 플러그인을 보면서 플러그인의 작성 방법을 알아보자. 이 플러그인은 응답에 레코드가 여러 개인 경우, 하나만 남기고 나머지를 응답에서 제거하는 역할을 한다.

더 단순한 example 플러그인도 있다. 다만 이건 너무 최소한의 내용밖에 없어서 onlyone 플러그인을 보기로 했다.

// onlyone.go
func (o *onlyone) Name() string { return "onlyone" }

Name() 함수는 말 그대로 플러그인의 이름을 리턴한다.

// setup.go
func init() {
	caddy.RegisterPlugin("onlyone", caddy.Plugin{
		ServerType: "dns",
		Action:     setup,
	})
}

init()에는 이 플러그인의 설정을 파싱해줄 함수(setup)를 지정하여 플러그인을 등록한다. 설정 파일을 파싱하면서 onlyone을 찾으면, 그 부분에 대한 나머지 파싱은 setup 함수가 진행한다.

// setup.go
func setup(c *caddy.Controller) error {
	t, err := parse(c)
	if err != nil {
		return plugin.Error("onlyone", err)
	}

	dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler {
		t.Next = next
		return t
	})

	return nil
}

func parse(c *caddy.Controller) (*onlyone, error) {
	...  // Corefile 파싱 로직 구현
}

*caddy.Controller는 설정 파일에서 해당 플러그인에 대한 토큰을 읽어들이는 역할을 한다. Next(), RemainingArgs(), NextBlock() 등의 메서드가 있는데, 예제 소스를 직접 보면 어떤 식인지 알 수 있을 것이다.

플러그인의 핵심 로직은 ServeDNS 함수에 구현된다. ServeDNS에는 3개의 파라미터가 있다.

  • context.Context
  • dns.ResponseWriter: 클라이언트와의 연결. WriteMsg 메서드로 응답을 전송할 수 있다.
  • *dns.Msg: 클라이언트의 요청.

ServeDNS는 응답 코드와 에러를 리턴한다. 응답 코드는 플러그인이 응답을 보냈는지 여부를 알려준다. 에러는 errors 플러그인에서 처리한다.

// onlyone.go
func (o *onlyone) ServeDNS(ctx context.Context, w dns.ResponseWriter,
	r *dns.Msg) (int, error) {
	// The request struct is a convenience struct.
	state := request.Request{W: w, Req: r}

	// If the zone does not match one of ours, just pass it on.
	if plugin.Zones(o.zones).Matches(state.Name()) == "" {
		return plugin.NextOrFailure(o.Name(), o.Next, ctx, w, r)
	}

	// The zone matches ours, so use a nonwriter to capture the response.
	nw := nonwriter.New(w)

	// Call all the next plugin in the chain.
	rcode, err := plugin.NextOrFailure(o.Name(), o.Next, ctx, nw, r)
	if err != nil {
		// Simply return if there was an error.
		return rcode, err
	}

	// Now we know that a successful response was received from a plugin
	// that appears later in the chain. Next is to examine that response
	// and trim out extra records, then write it to the client.
	w.WriteMsg(o.trimRecords(nw.Msg))
	return rcode, err
}

예제 onlyone 플러그인의 ServeDNS 구현이다.

요청의 zone이 이 플러그인에서 처리할 것이 아니라면, 이 플러그인은 plugin.NextOrFailure를 호출해서 다음 플러그인에다가 처리를 떠맡기고 자신은 아무 일도 하지 않는다.

이 플러그인이 처리해야 하는 경우, nonwriter라는 더미 dns.ResponseWriter를 만들어서 다음 플러그인에다 넘긴다. 그러면 다음 플러그인은 자신이 DNS 응답을 보낸다고 생각하지만, 실제로는 더미 연결인 nonwriter에 응답이 기록된다. 이 플러그인이 그 값(nw.Msg)을 읽은 후 o.trimRecords 메서드로 응답 데이터를 손본다. 그 후 최종 응답을 클라이언트와의 진짜 연결인 w에다 보낸다.

“진짜 연결”이라는 것은 onlyone 플러그인에서 보는 관점이다. 이 플러그인보다 앞에서 이런 짓을 하고 있는 다른 플러그인이 있을 수도 있다.

플러그인을 완성했으면, 빌드만 하면 된다. CoreDNS 소스 루트의 plugin 디렉터리에다가 작성한 플러그인을 놓고, plugin.cfg을 수정해 onlyone:onlyone을 끼워넣자. 그다음 위에서 설명한 것처럼 빌드하면 된다.

참고로, 플러그인은 크게 3가지로 분류된다. 플러그인을 어떻게 짜든 본인 맘이지만, 이 중 한 가지에 해당되도록 작성하는 것을 추천한다.

  • 데이터의 소스
    • file, forward, hosts, clouddns, template, kubernetes
  • 요청이나 응답을 수정
    • acl, cache, rewrite, nsid
  • CoreDNS의 내부 상태 조작
    • bind, log, health, ready

참고 자료

https://youtube.com/watch?v=rNlSgYZoIYs
https://coredns.io/manual/toc/#writing-plugins
https://coredns.io/2017/03/01/how-to-add-plugins-to-coredns

태그:

업데이트: