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

Caddy 확장하기

원문 보기 위키 갱신

Caddy 확장하기 (Extending 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와 여러분의 플러그인을 모두 요구하는(적절한 replace로 로컬 개발 버전을 사용하는) 새 Go 모듈을 만든 다음, 컴파일에 포함되도록 import를 추가해요:

import _ "github.com/example/mymodule"

모듈 기본

Caddy 모듈은:

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

  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 네임스페이스는 예약되어 있고 앱 이름이 될 수 없어요.

서드파티 호스트 모듈에 연결되는 모듈을 작성하려면 그 모듈의 네임스페이스 문서를 참조하세요.

이름

네임스페이스 안의 이름은 의미가 있고 사용자에게 매우 잘 보이지만, 고유하고 간결하며 하는 일에 부합하기만 하면 특별히 중요하지는 않아요.

앱 모듈

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

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

{
	"apps": {}
}

앱의 예로는 http와 tls가 있어요. 이것들이 빈 네임스페이스예요.

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

모듈 구현

모듈은 사실상 어떤 타입이든 될 수 있지만, 사용자 설정을 담을 수 있기 때문에 구조체가 가장 흔해요.

설정

대부분의 모듈은 어느 정도 설정이 필요해요. 타입이 JSON과 호환되기만 하면 Caddy가 이를 자동으로 처리해요. 따라서 모듈이 구조체 타입이라면 필드에 구조체 태그가 필요하며, Caddy 규약에 따라 snake_casing을 사용해야 해요:

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

구조체 태그에 omitempty 옵션을 사용하면 그 타입의 제로 값일 때 필드를 JSON 출력에서 생략해요. 이는 마샬링할 때(예: 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의 import 순환 금지 규칙과 매우 비슷해요.)

또한 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() 메서드 이후에 실행돼요.

인터페이스 가드

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

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

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 구조체 태그는 로드할 모듈의 네임스페이스와 이름(완전한 ID를 구성)을 Caddy가 알게 도와줘요. 문서 생성에도 사용돼요.

구조체 태그는 매우 간단한 형식이에요: 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)