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

Caddy 확장하기

원문 보기 위키 갱신

Caddy 확장하기 (Extend Caddy)

출처: Caddy 공식 문서

본문

Caddy는 모듈식 아키텍처 덕분에 확장하기 쉬워요. Caddy의 설정 구조를 확장하거나 연결한다면 대부분의 Caddy 확장(또는 플러그인)은 모듈로 알려져 있어요. 명확히 하자면, Caddy 모듈은 Go 모듈과는 구별돼요(하지만 그것들도 Go 모듈이에요).

사전 준비:

퀵 스타트

Caddy 모듈은 패키지가 임포트될 때 스스로 Caddy 모듈로 등록하는 이름이 있는 타입이에요. 핵심적으로 모듈은 항상 caddy.Module 인터페이스를 구현하며, 이는 이름과 생성자 함수를 제공해요.

새 Go 모듈에서 다음 템플릿을 Go 파일에 붙여넣고 패키지 이름, 타입 이름, Caddy 모듈 ID를 커스터마이즈해요:

package mymodule

import "github.com/caddyserver/caddy/v2"

func init() {
	caddy.RegisterModule(Gizmo{})
}

// Gizmo is an example; put your own type here.
type Gizmo struct {
}

// CaddyModule returns the Caddy module information.
func (Gizmo) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "foo.gizmo",
		New: func() caddy.Module { return new(Gizmo) },
	}
}

그런 다음 프로젝트 디렉터리에서 이 명령을 실행하면 목록에서 모듈을 볼 수 있어요:

xcaddy list-modules
...
foo.gizmo
...

xcaddy명령은 모든 모듈 개발자 워크플로우의 중요한 부분이에요. 플러그인과 함께 Caddy를 컴파일한 다음 주어진 인수로 실행해요. 매번 임시 바이너리를 버려요(go run과 비슷).

축하해요, 여러분의 모듈은 Caddy에 등록되며 Caddy 설정 문서에서 같은 네임스페이스의 모듈을 사용하는 모든 곳에서 사용할 수 있어요.

내부적으로 xcaddy는 Caddy와 플러그인을 모두 요구하는 새 Go 모듈(로컬 개발 버전을 사용하도록 적절한 replace 포함)을 만든 다음 임포트를 추가해 컴파일되도록 보장해요:

import _ "github.com/example/mymodule"

모듈 기본

Caddy 모듈:

  1. ID와 생성자를 제공하기 위해 caddy.Module 인터페이스를 구현

  2. 적절한 네임스페이스에 고유한 이름을 가짐

  3. 보통 해당 네임스페이스의 호스트 모듈에 의미가 있는 일부 인터페이스(들)를 충족

호스트 모듈(또는 부모 모듈)은 다른 모듈을 로드/초기화하는 모듈이에요. 일반적으로 게스트 모듈의 네임스페이스를 정의해요.

게스트 모듈(또는 자식 모듈)은 로드되거나 초기화되는 모듈이에요. 모든 모듈은 게스트 모듈이에요.

모듈 ID

각 Caddy 모듈은 네임스페이스와 이름으로 구성된 고유한 ID를 가져요:

  • 완전한 ID는 foo.bar.module_name처럼 보여요

  • 네임스페이스는 foo.bar

  • 이름은 module_name이며 그 네임스페이스에서 고유해야 해요

모듈 ID는 snake_case 규약을 사용해야 해요.

네임스페이스

네임스페이스는 클래스와 같아요, 즉 네임스페이스는 그 안의 모든 모듈에 공통인 일부 기능을 정의해요. 예를 들어 http.handlers 네임스페이스의 모든 모듈이 HTTP 핸들러라고 기대할 수 있어요. 따라서 호스트 모듈은 그 네임스페이스의 게스트 모듈을 interface{} 타입에서 caddyhttp.MiddlewareHandler 같은 더 구체적이고 유용한 타입으로 타입 단언할 수 있어요.

게스트 모듈은 호스트 모듈이 인식하도록 적절히 네임스페이스되어야 해요. 호스트 모듈은 호스트 모듈이 원하는 기능을 제공하기 위해 특정 네임스페이스 내의 모듈을 Caddy에 요청하기 때문이에요. 예를 들어 gizmo라는 HTTP 핸들러 모듈을 작성한다면, 모듈 이름은 http.handlers.gizmo가 돼요. http 앱이 http.handlers 네임스페이스에서 핸들러를 찾기 때문이에요.

다시 말하면, Caddy 모듈은 모듈 네임스페이스에 따라 특정 인터페이스를 구현할 것으로 기대돼요. 이 규약으로 모듈 개발자는 "http.handlers 네임스페이스의 모든 모듈은 HTTP 핸들러예요" 같은 직관적인 말을 할 수 있어요. 더 기술적으로 이는 보통 "http.handlers 네임스페이스의 모든 모듈은 caddyhttp.MiddlewareHandler 인터페이스를 구현해요"를 의미해요. 그 메서드 집합이 알려져 있으므로 더 구체적인 타입을 단언해 사용할 수 있어요.

모든 표준 Caddy 네임스페이스를 Go 타입에 매핑하는 표 보기.

caddy와 admin 네임스페이스는 예약되어 있으며 앱 이름이 될 수 없어요.

3rd-party 호스트 모듈에 연결되는 모듈을 작성하려면 해당 모듈의 네임스페이스 문서를 참조하세요.

이름

네임스페이스 내의 이름은 중요하고 사용자에게 잘 보이지만, 고유하고 간결하며 하는 일에 맞는다면 그다지 중요하지 않아요.

앱 모듈

앱은 빈 네임스페이스가 있는 모듈이며, 관례상 자신만의 최상위 네임스페이스가 돼요. 앱 모듈은 caddy.App 인터페이스를 구현해요.

이 모듈들은 Caddy 구성 최상위의 "apps" 속성에 나타나요:

{
	"apps": {}
}

예시 앱은 http와 tls예요. 그것들이 빈 네임스페이스예요.

이 앱들을 위해 작성된 게스트 모듈은 앱 이름에서 파생된 네임스페이스에 있어야 해요. 예를 들어 HTTP 핸들러는 http.handlers 네임스페이스를 사용하고 TLS 인증서 로더는 tls.certificates 네임스페이스를 사용해요.

모듈 구현

모듈은 사실상 모든 타입일 수 있지만, 사용자 구성(configuration)을 담을 수 있기 때문에 구조체(struct)가 가장 일반적이에요.

구성

대부분의 모듈은 일부 구성이 필요해요. 여러분의 타입이 JSON과 호환되는 한 Caddy가 자동으로 처리해요. 따라서 모듈이 구조체 타입이라면 필드에 구조체 태그가 필요하며 Caddy 규약에 따라 snake_casing을 사용해야 해요:

type Gizmo struct {
	MyField string `json:"my_field,omitempty"`
	Number  int    `json:"number,omitempty"`
}

구조체 태그에 omitempty 옵션을 사용하면 값이 타입의 제로 값이면 JSON 출력에서 필드를 생략해요. 이는 마샬링 시(marshal, 예: Caddyfile을 JSON으로 어댑트) JSON 구성을 깔끔하고 간결하게 유지하는 데 유용해요.

모듈이 초기화되면 이미 구성이 채워져 있어요. 모듈 초기화 후 추가적인 프로비저닝과 검증 단계를 수행할 수도 있어요.

모듈 생명주기

모듈의 수명은 호스트 모듈에 의해 로드될 때 시작돼요. 다음이 일어나요:

  1. New()가 호출되어 모듈 값의 인스턴스를 얻어요.

  2. 모듈의 구성이 그 인스턴스로 언마샬링돼요.

  3. 모듈이 caddy.Provisioner라면 Provision() 메서드가 호출돼요.

  4. 모듈이 caddy.Validator라면 Validate() 메서드가 호출돼요.

  5. 이 시점에 호스트 모듈에게 로드된 게스트 모듈이 interface{} 값으로 주어지므로, 호스트 모듈은 보통 게스트 모듈을 더 유용한 타입으로 타입 단언해요. 호스트 모듈의 문서를 확인해 그 네임스페이스의 게스트 모듈에 무엇이 요구되는지(예: 어떤 메서드를 구현해야 하는지) 알아보세요.

  6. 모듈이 더 이상 필요하지 않을 때, 그리고 caddy.CleanerUpper라면 Cleanup() 메서드가 호출돼요.

여러 로드된 모듈 인스턴스가 주어진 시간에 겹칠 수 있다는 점을 기억하세요! 구성 변경 중에는 새 모듈이 이전 모듈이 중지되기 전에 시작돼요. 전역 상태를 조심해서 사용하세요. caddy.UsagePool 타입을 사용해 모듈 로드 전반의 전역 상태를 관리하세요. 모듈이 소켓에서 수신 대기한다면 caddy.Listen*()을 사용해 겹치는 사용을 지원하는 소켓을 얻으세요.

프로비저닝

모듈의 구성은 (JSON 구성을 로드할 때) 자동으로 그 값으로 언마샬링돼요. 예를 들어 구조체 필드가 자동으로 채워져요.

하지만 모듈에 추가 프로비저닝 단계가 필요하다면 (선택적) caddy.Provisioner 인터페이스를 구현할 수 있어요:

// Provision sets up the module.
func (g *Gizmo) Provision(ctx caddy.Context) error {
	// TODO: set up the module
	return nil
}

여기서 사용자가 제공하지 않은 필드(제로 값이 아닌 필드)에 대한 기본값을 설정해야 해요. 필드가 필수라면 설정되지 않았을 때 오류를 반환할 수 있어요. 제로 값에 의미가 있는 숫자 필드(예: 타임아웃 지속 시간)에서는 0보다 -1을 "꺼짐"을 의미하도록 지원하고 싶을 수 있어요. 사용자가 구성하지 않았다면 기본값을 설정할 수 있어요.

또한 호스트 모듈이 게스트/자식 모듈을 로드하는 곳이기도 해요.

모듈은 ctx.App()을 호출해 다른 앱에 접근할 수 있지만, 모듈은 순환 의존성을 가질 수 없어요. 즉, http 앱이 로드한 모듈은 tls 앱이 로드한 모듈이 http 앱에 의존한다면 tls 앱에 의존할 수 없어요. (Go에서 임포트 순환을 금지하는 규칙과 매우 유사해요.)

또한 구성이 검증만 되는 경우에도 프로비저닝이 수행되므로 Provision에서 값비싼 연산을 피해야 해요. 프로비저닝 단계에서는 모듈이 실제로 사용될 것이라고 기대하지 마세요.

로그

Caddy에서 로깅이 동작하는 방식을 참고하세요. 모듈에 로깅이 필요하다면 Go 표준 라이브러리의 log.Print*()를 사용하지 마세요. 즉, Go의 전역 로거를 사용하지 마세요. Caddy는 zap으로 고성능의 매우 유연한 구조화 로깅을 사용해요.

로그를 내보내려면 모듈의 Provision 메서드에서 로거를 얻어요:

func (g *Gizmo) Provision(ctx caddy.Context) error {
	g.logger = ctx.Logger() // g.logger is a *zap.Logger
}

그런 다음 g.logger로 구조화되고 레벨이 있는 로그를 내보낼 수 있어요. 자세한 내용은 zap의 godoc을 참고하세요.

검증

구성을 검증하고 싶은 모듈은 (선택적) caddy.Validator 인터페이스를 충족해 그렇게 할 수 있어요:

// Validate validates that the module has a usable config.
func (g Gizmo) Validate() error {
	// TODO: validate the module's setup
	return nil
}

Validate는 읽기 전용 함수여야 해요. Provision() 메서드 이후 실행돼요.

인터페이스 가드

Go 인터페이스는 암시적으로 충족되기 때문에 Caddy 모듈 동작도 암시적이에요. 모듈의 타입에 올바른 메서드를 추가하기만 하면 모듈의 정확성이 좌우돼요. 따라서 오타를 내거나 메서드 시그니처를 잘못 쓰면 예기치 않은 (없는) 동작이 발생할 수 있어요.

다행히 올바른 메서드를 추가했는지 보장하는 쉽고 오버헤드 없는 컴파일 타임 검사를 코드에 추가할 수 있어요. 이를 인터페이스 가드(interface guards)라고 해요:

var _ InterfaceName = (*YourType)(nil)

InterfaceName을 충족하려는 인터페이스로, YourType을 모듈의 타입 이름으로 바꾸세요.

예를 들어 정적 파일 서버 같은 HTTP 핸들러는 여러 인터페이스를 충족할 수 있어요:

// Interface guards
var (
	_ caddy.Provisioner           = (*FileServer)(nil)
	_ caddyhttp.MiddlewareHandler = (*FileServer)(nil)
)

이렇게 하면 *FileServer가 그 인터페이스를 충족하지 않으면 프로그램 컴파일을 막아요.

인터페이스 가드가 없으면 혼란스러운 버그가 스며들 수 있어요. 예를 들어 모듈이 사용 전에 스스로 프로비저닝해야 하는데 Provision() 메서드에 실수가 있다면(예: 오타나 잘못된 시그니처), 프로비저닝이 절대 일어나지 않아 머리를 긁게 돼요. 인터페이스 가드는 엄청 쉬우며 그걸 막을 수 있어요. 보통 파일 맨 아래에 둬요.

호스트 모듈

모듈이 자신의 게스트 모듈을 로드하면 호스트 모듈이 돼요. 이는 모듈 기능의 일부를 다른 방식으로 구현할 수 있을 때 유용해요.

호스트 모듈은 거의 항상 구조체예요. 일반적으로 게스트 모듈을 지원하려면 두 구조체 필드가 필요해요: 하나는 원시 JSON을 담고, 다른 하나는 디코딩된 값을 담아요:

type Gizmo struct {
	GadgetRaw json.RawMessage `json:"gadget,omitempty" caddy:"namespace=foo.gizmo.gadgets inline_key=gadgeter"`

	Gadget Gadgeter `json:"-"`
}

첫 필드(이 예제의 GadgetRaw)는 게스트 모듈의 원시, 프로비저닝되지 않은 JSON 형태가 있는 곳이에요.

두 번째 필드(Gadget)는 최종 프로비저닝된 값이 결국 저장될 곳이에요. 두 번째 필드는 사용자에게 노출되지 않으므로 구조체 태그로 JSON에서 제외해요. (다른 패키지에 필요하지 않다면 비공개로 만들어 구조체 태그가 필요 없게 할 수도 있어요.)

Caddy 구조체 태그

원시 모듈 필드의 caddy 구조체 태그는 Caddy가 로드할 모듈의 네임스페이스와 이름(전체 ID 구성)을 알 수 있게 해줘요. 문서 생성에도 사용돼요.

구조체 태그는 매우 간단한 형식이에요: key1=val1 key2=val2 ...

모듈 필드의 구조체 태그는 이렇게 보여요:

`caddy:"namespace=foo.bar inline_key=baz"`

namespace= 부분은 필수예요. 모듈을 찾을 네임스페이스를 정의해요.

inline_key= 부분은 모듈의 이름이 모듈 자체와 인라인으로 발견될 때만 사용돼요. 즉 값이 객체이며, 그 키 중 하나가 인라인 키이고 그 값이 모듈 이름이란 뜻이에요. 생략하면 필드 타입이 caddy.ModuleMap 또는 []caddy.ModuleMap이어야 하며, 맵 키가 모듈 이름이에요.

게스트 모듈 로드

게스트 모듈을 로드하려면 프로비저닝 단계에서 ctx.LoadModule()을 호출해요:

// Provision sets up g and loads its gadget.
func (g *Gizmo) Provision(ctx caddy.Context) error {
	if g.GadgetRaw != nil {
		val, err := ctx.LoadModule(g, "GadgetRaw")
		if err != nil {
			return fmt.Errorf("loading gadget module: %v", err)
		}
		g.Gadget = val.(Gadgeter)
	}
	return nil
}

LoadModule() 호출이 구조체에 대한 포인터와 필드 이름을 문자열로 받는다는 점을 주목하세요. 이상하죠? 왜 구조체 필드를 직접 전달하지 않을까요? 구성 배치에 따라 모듈을 로드하는 방법이 몇 가지 다르기 때문이에요. 이 메서드 시그니처는 Caddy가 리플렉션으로 모듈을 로드하는 최선의 방법을 알아내고, 가장 중요하게는 구조체 태그를 읽을 수 있게 해줘요.

게스트 모듈이 사용자가 반드시 설정해야 한다면, 로드하려 시도하기 전에 Raw 필드가 nil이거나 비어 있으면 오류를 반환해야 해요.

로드된 모듈이 타입 단언되는 방식을 주목하세요: g.Gadget = val.(Gadgeter) - 반환된 val이 그다지 유용하지 않은 interface{} 타입이기 때문이에요. 하지만 선언된 네임스페이스(예제의 구조체 태그에서 foo.gizmo.gadgets)의 모든 모듈이 Gadgeter 인터페이스를 구현할 것으로 기대하므로 이 타입 단언은 안전하며, 그럼 그것을 사용할 수 있어요!

호스트 모듈이 새 네임스페이스를 정의한다면 여기서 우리가 한 것처럼 개발자를 위해 그 네임스페이스와 Go 타입을 문서화하세요.

모듈 문서화

모듈을 등록해 새 Caddy 모듈이 모듈 문서에 표시되고 http://caddyserver.com/download에서 사용 가능하게 해요. 등록은 http://caddyserver.com/account에서 가능해요. 계정이 없으면 새로 만들고 "Register package"를 클릭하세요.

완전한 예제

HTTP 핸들러 모듈을 작성한다고 가정해볼게요. 이것은 데모 목적의 인위적인 미들웨어로, 모든 HTTP 요청 시 방문자의 IP 주소를 스트림에 출력해요.

또한 대부분의 사람들이 비자동화 상황에서 Caddyfile을 선호하기 때문에 Caddyfile로 구성할 수 있게 하고 싶어요. 이를 위해 HTTP 라우트에 핸들러를 추가할 수 있는 종류의 지시문인 Caddyfile 핸들러 지시문을 등록해요. 또한 caddyfile.Unmarshaler 인터페이스를 구현해요. 이 몇 줄의 코드만 추가하면 이 모듈을 Caddyfile로 구성할 수 있어요! 예: visitor_ip stdout.

설명 주석이 있는 그러한 모듈의 코드는 다음과 같아요:

package visitorip

import (
	"fmt"
	"io"
	"net/http"
	"os"

	"github.com/caddyserver/caddy/v2"
	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
	"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)

func init() {
	caddy.RegisterModule(Middleware{})
	httpcaddyfile.RegisterHandlerDirective("visitor_ip", parseCaddyfile)
}

// Middleware implements an HTTP handler that writes the
// visitor's IP address to a file or stream.
type Middleware struct {
	// The file or stream to write to. Can be "stdout"
	// or "stderr".
	Output string `json:"output,omitempty"`

	w io.Writer
}

// CaddyModule returns the Caddy module information.
func (Middleware) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "http.handlers.visitor_ip",
		New: func() caddy.Module { return new(Middleware) },
	}
}

// Provision implements caddy.Provisioner.
func (m *Middleware) Provision(ctx caddy.Context) error {
	switch m.Output {
	case "stdout":
		m.w = os.Stdout
	case "stderr":
		m.w = os.Stderr
	default:
		return fmt.Errorf("an output stream is required")
	}
	return nil
}

// Validate implements caddy.Validator.
func (m *Middleware) Validate() error {
	if m.w == nil {
		return fmt.Errorf("no writer")
	}
	return nil
}

// ServeHTTP implements caddyhttp.MiddlewareHandler.
func (m Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
	m.w.Write([]byte(r.RemoteAddr))
	return next.ServeHTTP(w, r)
}

// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
func (m *Middleware) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
	d.Next() // consume directive name

	// require an argument
	if !d.NextArg() {
		return d.ArgErr()
	}

	// store the argument
	m.Output = d.Val()
	return nil
}

// parseCaddyfile unmarshals tokens from h into a new Middleware.
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
	var m Middleware
	err := m.UnmarshalCaddyfile(h.Dispenser)
	return m, err
}

// Interface guards
var (
	_ caddy.Provisioner           = (*Middleware)(nil)
	_ caddy.Validator             = (*Middleware)(nil)
	_ caddyhttp.MiddlewareHandler = (*Middleware)(nil)
	_ caddyfile.Unmarshaler       = (*Middleware)(nil)
)

더 알아보기 (Learn more)