← posts/b.log()

blog92@web:~$ cat posts/nginx-caddy-06-caddy-advanced.md

DEVOPS9 min read

Nginx & Caddy 완전 정복 6편 — Caddy 심화: 매처, 지시어 순서, 헬스 체크, Admin API, 플러그인

이름 있는 매처와 지시어 실행 순서, handle/route/handle_path, 스니펫, 로드 밸런싱과 액티브 헬스 체크, DNS 챌린지와 On-Demand TLS, JSON 설정과 Admin API, xcaddy 플러그인 빌드, 메트릭과 튜닝을 다룹니다.

1. 매처(Matcher) 제대로 쓰기

매처는 "이 지시어를 어떤 요청에 적용할지" 정하는 조건입니다. 5편에서 본 것처럼 지시어 바로 뒤에 매처 토큰을 둡니다.

매처 토큰예시의미
*root * /var/www모든 요청
경로/api/*경로 매처 (와일드카드 필수)
@이름@static이름 있는 매처

이름 있는 매처 (Named Matcher)

여러 조건을 묶어 이름을 붙입니다. 블록 안의 조건들은 AND로 결합됩니다.

caddyfile
example.com {
	# 단일 조건은 한 줄로
	@static path *.css *.js *.png *.svg *.woff2
 
	# 여러 조건은 블록으로 (AND)
	@admin_internal {
		path /admin/*
		remote_ip 192.168.0.0/24 10.0.0.0/8
	}
 
	# 부정
	@admin_external {
		path /admin/*
		not remote_ip 192.168.0.0/24 10.0.0.0/8
	}
 
	# 메서드 + 헤더
	@api_write {
		path /api/*
		method POST PUT PATCH DELETE
		header Content-Type application/json*
	}
 
	# 정규식 (캡처 그룹은 {re.이름.1}로 사용)
	@legacy path_regexp legacy ^/old/(\d+)$
	redir @legacy /new/{re.legacy.1} permanent
 
	# CEL 표현식: 가장 강력한 매처
	@mobile expression {header.User-Agent}.contains("Mobile")
 
	header @static Cache-Control "public, max-age=31536000, immutable"
	respond @admin_external 403
	reverse_proxy @admin_internal localhost:9000
	reverse_proxy localhost:3000
}

같은 매처 안에서 같은 종류의 조건 값들은 OR입니다(path *.css *.js → css 또는 js). 정리하면 "같은 종류끼리 OR, 다른 종류끼리 AND"입니다.

주요 매처: path, path_regexp, host, method, header, header_regexp, query, remote_ip, client_ip, protocol, file, not, expression, vars.

file 매처는 "파일이 실제로 존재하는가"를 검사합니다. 사실 try_files는 file 매처 + rewrite의 축약형입니다.

2. 지시어 실행 순서: Caddyfile에서 가장 중요한 개념

Nginx 사용자가 Caddy에서 가장 크게 당황하는 지점입니다. Caddyfile에 쓴 순서대로 실행되지 않습니다. Caddy는 지시어마다 미리 정해진 순서가 있고, 파싱할 때 그 순서로 재정렬합니다.

대표적인 기본 순서(일부):

text
tracing → map → vars → root → header → copy_response_headers
→ request_body → redir → method → rewrite → uri → try_files → basic_auth
→ forward_auth → request_header → encode → push → intercept → templates
→ handle → handle_path → route → abort → error → copy_response
→ respond → metrics → reverse_proxy → php_fastcgi → file_server → acme_server

그래서 이 설정은:

caddyfile
example.com {
	file_server
	root * /var/www
	redir /old /new
}

실제로는 root → redir → file_server 순으로 동작합니다. 순서를 신경 쓰지 않아도 대체로 "올바르게" 동작하게 만든 설계입니다. 정확한 전체 순서는 공식 문서의 "Directive order"를 참고하세요.

순서를 직접 제어하는 세 가지 방법

① handle: 상호 배타적 그룹 (가장 많이 씀)

caddyfile
example.com {
	handle /api/* {
		reverse_proxy localhost:8080
	}
	handle /docs/* {
		root * /var/www/docs
		file_server
	}
	handle {                 # 매처 없는 handle = fallback
		reverse_proxy localhost:3000
	}
}
  • 같은 레벨의 handle 블록 중 처음 매칭되는 하나만 실행됩니다. (Nginx의 location과 가장 비슷한 개념)
  • handle 블록끼리는 경로가 더 구체적인 것이 먼저 평가되도록 정렬되고, 매처 없는 handle은 맨 마지막입니다.
  • 블록 내부의 지시어들은 여전히 기본 순서로 정렬됩니다.

② handle_path: handle + 경로 접두사 제거

caddyfile
handle_path /api/* {
	reverse_proxy localhost:8080    # /api/users → /users
}

내부적으로는 handle + uri strip_prefix /api와 같습니다.

③ route: 쓴 순서 그대로 실행

caddyfile
route /special/* {
	header X-Step "1"
	rewrite * /rewritten{uri}
	reverse_proxy localhost:5000
}

route 블록 안에서는 작성한 순서대로 실행됩니다. 기본 순서가 원하는 동작과 맞지 않을 때만 쓰세요.

④ 전역 order 옵션: 주로 플러그인 지시어(기본 순서가 없는)의 위치를 지정할 때 씁니다.

caddyfile
{
	order rate_limit before basic_auth
}

요약 비교

매칭 방식내부 실행 순서
그냥 나열각 지시어가 개별적으로기본 순서
handle첫 번째 일치 하나만 (배타적)기본 순서
handle_path위와 같음 + 접두사 제거기본 순서
route매칭되면 실행 (배타적 아님)작성 순서

3. 스니펫과 import

반복되는 설정은 스니펫으로 만듭니다. Nginx의 include snippets/...에 해당합니다.

caddyfile
(security_headers) {
	header {
		Strict-Transport-Security "max-age=63072000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
}
 
(logging) {
	log {
		output file /var/log/caddy/{args[0]}.log {
			roll_size 50MiB
			roll_keep 5
		}
		format json
	}
}
 
app.example.com {
	import security_headers
	import logging app
	reverse_proxy localhost:3000
}
 
admin.example.com {
	import security_headers
	import logging admin
	reverse_proxy localhost:9000
}
  • {args[0]}, {args[1]}…로 인자를 받을 수 있습니다. (예전 문서의 {args.0} 표기도 볼 수 있습니다.)
  • 파일 단위 import도 가능합니다: import sites/*.caddy. 사이트가 많으면 Nginx의 conf.d/처럼 쪼개서 관리하세요.
  • Nginx와 달리 header가 상위에서 "덮어써지는" 상속 함정이 없습니다. 지시어가 각자 독립적으로 적용되기 때문입니다.

4. 로드 밸런싱과 헬스 체크

caddyfile
api.example.com {
	reverse_proxy {
		to 10.0.0.11:8080 10.0.0.12:8080 10.0.0.13:8080
 
		# 로드 밸런싱
		lb_policy least_conn
		lb_retries 2
		lb_try_duration 5s
		lb_try_interval 250ms
 
		# 액티브 헬스 체크 (오픈소스에 내장!)
		health_uri      /health
		health_interval 10s
		health_timeout  2s
		health_status   2xx
		health_body     "ok"
 
		# 패시브 헬스 체크
		fail_duration     30s
		max_fails         3
		unhealthy_status  5xx
		unhealthy_latency 3s
 
		# 업스트림 연결 설정
		transport http {
			dial_timeout 3s
			response_header_timeout 30s
			keepalive 90s
			keepalive_idle_conns 64
		}
	}
}

lb_policy 종류

정책설명
random무작위 (기본값)
random_choose 2두 개 무작위 선택 후 덜 바쁜 쪽
round_robin / weighted_round_robin 3 1순차 / 가중치 순차
least_conn연결 수 최소
first사용 가능한 첫 번째 (액티브-스탠바이 구성에 유용)
ip_hash / client_ip_hash클라이언트 IP 기반 고정
uri_hashURI 기반 (캐시 서버 분산)
header X-User-Id특정 헤더 값 기반
cookie lb_session쿠키 기반 스티키 세션 (쿠키 자동 발급)

이것이 Nginx와의 큰 차이

3편에서 봤듯이 오픈소스 Nginx에는 액티브 헬스 체크가 없습니다. Caddy는 health_uri 한 줄로 주기적 헬스 체크가 됩니다. 장애 서버가 첫 사용자 요청을 실패시키기 전에 미리 제외된다는 뜻입니다.

lb_try_duration도 실용적입니다. 배포 중 모든 백엔드가 잠깐 재시작되는 순간에도, Caddy가 그 시간만큼 기다리며 재시도하므로 사용자에게 502가 덜 노출됩니다.

동적 업스트림

컨테이너 오케스트레이션 환경에서는 백엔드 목록이 계속 바뀝니다. Caddy는 DNS 조회로 업스트림을 동적으로 결정할 수 있습니다.

caddyfile
reverse_proxy {
	dynamic a app.internal 8080 {
		refresh 10s
	}
	# 또는 SRV 레코드: dynamic srv _http._tcp.app.service.consul
	lb_policy least_conn
	health_uri /health
}

5. 프록시 뒤의 Caddy: trusted_proxies

Caddy 앞에 Cloudflare나 L4 로드 밸런서가 있다면 실제 클라이언트 IP를 신뢰할 대상을 지정합니다. (Nginx의 set_real_ip_from)

caddyfile
{
	servers {
		trusted_proxies static 173.245.48.0/20 103.21.244.0/22
		# 또는 사설 대역 전체: trusted_proxies static private_ranges
		client_ip_headers CF-Connecting-IP X-Forwarded-For
	}
}

이걸 설정하면 client_ip 매처와 {client_ip} 플레이스홀더가 실제 사용자 IP를 가리킵니다. (remote_ip는 항상 직접 연결된 상대의 IP입니다.) 신뢰하지 않는 프록시에서 들어온 X-Forwarded-* 헤더는 Caddy가 백엔드로 넘기기 전에 덮어쓰므로 위조 위험도 줄어듭니다.

6. TLS 심화

DNS 챌린지와 와일드카드 인증서

80/443을 외부에 열 수 없거나 *.example.com 와일드카드 인증서가 필요하면 DNS-01 챌린지를 씁니다. DNS 제공자 모듈이 필요하므로 커스텀 빌드를 해야 합니다(7절 참고).

bash
xcaddy build --with github.com/caddy-dns/cloudflare
caddyfile
*.example.com, example.com {
	tls {
		dns cloudflare {env.CF_API_TOKEN}
	}
 
	@app host app.example.com
	handle @app {
		reverse_proxy localhost:3000
	}
 
	@grafana host grafana.example.com
	handle @grafana {
		reverse_proxy localhost:3001
	}
 
	handle {
		abort
	}
}

Route53, DigitalOcean, Gandi 등 대부분의 DNS 제공자 모듈이 caddy-dns 조직에 있습니다.

On-Demand TLS: Caddy만의 기능

SaaS에서 고객이 자기 도메인(shop.customer.com)을 연결하는 기능을 생각해 봅시다. 고객 도메인을 미리 알 수 없으니 설정 파일에 적을 수도 없습니다. Caddy의 On-Demand TLS는 TLS 핸드셰이크가 들어오는 순간 인증서를 발급합니다.

caddyfile
{
	on_demand_tls {
		ask http://localhost:5555/domain-check
	}
}
 
https:// {
	tls {
		on_demand
	}
	reverse_proxy localhost:3000
}
  • ask 엔드포인트는 필수에 가깝습니다. Caddy가 ?domain=shop.customer.com으로 물어보고, 200이면 발급, 아니면 거부합니다. 이게 없으면 공격자가 임의 도메인을 당신 서버로 향하게 해서 인증서 발급을 남발시킬 수 있습니다.
  • Nginx에서 같은 기능을 구현하려면 OpenResty + Lua(예: lua-resty-auto-ssl) 같은 별도 스택이 필요합니다.

클라이언트 인증서(mTLS), TLS 세부 옵션

caddyfile
internal-api.example.com {
	tls {
		protocols tls1.3
		client_auth {
			mode require_and_verify
			trust_pool file /etc/caddy/ca/clients-ca.pem
		}
	}
	reverse_proxy localhost:8443
}

Caddy를 사내 ACME 서버로

acme_server 지시어로 Caddy 자체를 사내 ACME CA로 만들 수도 있습니다. 폐쇄망의 다른 Caddy 인스턴스들이 이 서버에서 인증서를 자동 발급받게 할 수 있어, 사내 인증서 관리가 크게 단순해집니다.

caddyfile
# CA 역할 서버
ca.internal {
	tls internal
	acme_server
}
 
# 다른 서버의 Caddyfile
app.internal {
	tls {
		ca https://ca.internal/acme/local/directory
		# CA 서버는 tls internal(자체 루트)로 서빙한다. CA 서버의 데이터 디렉터리에 있는
		# pki/authorities/local/root.crt를 복사해 두고 신뢰시켜야 발급이 된다
		ca_root /etc/caddy/ca-internal-root.crt
	}
	reverse_proxy localhost:8080
}

7. 플러그인: xcaddy로 커스텀 빌드

Caddy는 Go 언어 특성상 런타임에 모듈을 로드하지 않고, 컴파일 시점에 포함시킵니다. 이를 쉽게 해주는 도구가 xcaddy입니다.

bash
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
 
xcaddy build \
  --with github.com/caddy-dns/cloudflare \
  --with github.com/mholt/caddy-ratelimit \
  --with github.com/mholt/caddy-l4
 
./caddy list-modules | grep -E "dns|rate|layer4"

Docker에서는 멀티 스테이지 빌드가 정석입니다.

dockerfile
FROM caddy:2-builder AS builder
RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/mholt/caddy-ratelimit
 
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

공식 다운로드 페이지에서 플러그인을 체크해 빌드된 바이너리를 받는 방법도 있습니다.

자주 쓰는 플러그인

플러그인용도Nginx에서는
caddy-dns/*DNS 챌린지certbot DNS 플러그인
mholt/caddy-ratelimitRate Limit내장 (limit_req)
caddyserver/cache-handlerHTTP 캐시내장 (proxy_cache)
mholt/caddy-l4L4(TCP/UDP) 프록시내장 (stream)
greenpau/caddy-securityOAuth/OIDC/SAML 인증 포털서드파티/외부 프록시
ueffel/caddy-brotliBrotli 압축서드파티 모듈

이 표가 두 서버의 철학 차이를 잘 보여줍니다. Rate Limit, 캐시, L4 프록시는 Nginx에선 기본 기능이지만 Caddy에선 플러그인입니다. 반대로 자동 HTTPS, 액티브 헬스 체크, 동적 설정 API는 Caddy가 기본으로 갖고 있습니다.

Rate Limit 예시 (플러그인)

caddyfile
{
	order rate_limit before basic_auth
}
 
api.example.com {
	rate_limit {
		zone per_ip {
			key    {client_ip}
			events 100
			window 1m
		}
	}
	reverse_proxy localhost:8080
}

8. JSON 설정과 Admin API

Caddyfile은 JSON으로 변환된다

bash
caddy adapt --config Caddyfile --pretty
caddyfile
example.com {
	reverse_proxy localhost:3000
}

위 Caddyfile은 대략 이런 JSON이 됩니다.

json
{
  "apps": {
    "http": {
      "servers": {
        "srv0": {
          "listen": [":443"],
          "routes": [
            {
              "match": [{ "host": ["example.com"] }],
              "handle": [
                {
                  "handler": "subroute",
                  "routes": [
                    {
                      "handle": [
                        {
                          "handler": "reverse_proxy",
                          "upstreams": [{ "dial": "localhost:3000" }]
                        }
                      ]
                    }
                  ]
                }
              ],
              "terminal": true
            }
          ]
        }
      }
    }
  }
}

JSON은 장황하지만 Caddy의 모든 기능을 표현할 수 있는 원본 설정입니다. Caddyfile로 표현이 안 되는 고급 기능도 JSON으로는 가능합니다.

Admin API로 실시간 설정 변경

Caddy는 기본적으로 localhost:2019에 관리 API를 엽니다.

bash
# 현재 설정 조회
curl localhost:2019/config/ | jq
 
# 특정 경로만 조회
curl localhost:2019/config/apps/http/servers/srv0/routes
 
# 전체 설정 교체 (무중단)
curl localhost:2019/load \
  -H "Content-Type: application/json" \
  -d @caddy.json
 
# Caddyfile을 그대로 로드
curl localhost:2019/load \
  -H "Content-Type: text/caddyfile" \
  --data-binary @Caddyfile
 
# 업스트림 상태 확인
curl localhost:2019/reverse_proxy/upstreams | jq

/config/ 하위 경로에 대해 GET, POST(배열에 추가), PUT(삽입), PATCH(교체), DELETE가 모두 가능합니다. 예를 들어 배포 스크립트에서 새 업스트림을 추가하고 구 버전을 빼는 블루-그린 전환을 설정 파일 수정 없이 API 호출로 할 수 있습니다. 설정에 @id 필드를 넣어두면 /id/my_upstreams처럼 긴 경로 대신 ID로 접근할 수도 있습니다.

caddy reload도 내부적으로는 이 /load 엔드포인트를 호출합니다.

주의: Admin API 보안과 설정 일관성

  • Admin API는 기본적으로 localhost에만 바인딩됩니다. 원격에 노출하지 마세요. 필요 없다면 끌 수 있습니다: { admin off }
  • API로 바꾼 설정은 Caddyfile에 반영되지 않습니다. Caddy는 마지막 설정을 autosave.json에 저장하고 caddy run --resume으로 복원할 수 있지만, "진실의 원천(source of truth)"을 Caddyfile로 할지 API로 할지 팀에서 정해두지 않으면 재시작 시 설정이 되돌아가는 사고가 납니다.

9. 관측성과 운영

Prometheus 메트릭

caddyfile
{
	metrics {
		per_host
	}
}
 
:2020 {
	metrics /metrics
}

Admin 엔드포인트(localhost:2019/metrics)에서도 메트릭을 제공합니다. 요청 수, 응답 시간 히스토그램, 업스트림 상태 등이 노출됩니다.

로그 필터링 (민감 정보 마스킹)

caddyfile
log {
	output file /var/log/caddy/access.log
	format filter {
		request>headers>Authorization delete
		request>headers>Cookie delete
		request>remote_ip ip_mask {
			ipv4 24
			ipv6 32
		}
	}
}

개인정보 보호 요구사항이 있는 환경에서 유용합니다.

성능 관련 설정

Caddy는 Nginx만큼 튜닝 포인트가 많지 않고, 기본값이 대부분의 환경에서 합리적입니다. 손볼 만한 것은 다음 정도입니다.

caddyfile
{
	servers {
		timeouts {
			read_body   30s
			read_header 10s
			write       60s
			idle        5m
		}
		max_header_size 16KB
		protocols h1 h2 h3
	}
}
 
example.com {
	request_body {
		max_size 10MB        # Nginx client_max_body_size
	}
	reverse_proxy localhost:3000 {
		flush_interval -1    # SSE/스트리밍: 즉시 플러시 (Nginx proxy_buffering off)
	}
}
  • Go 런타임 환경 변수 GOMAXPROCS(사용할 CPU 수), GOMEMLIMIT(메모리 소프트 상한)로 컨테이너 리소스 제한에 맞출 수 있습니다.
  • 파일 디스크립터 제한(LimitNOFILE)은 Nginx와 마찬가지로 확인이 필요합니다. 공식 systemd 유닛에는 넉넉한 값이 설정되어 있습니다.
  • 참고로 reverse_proxy는 응답이 text/event-stream이면 자동으로 즉시 플러시합니다. flush_interval -1은 그 외의 스트리밍 응답에 필요합니다.

정리

  • 이름 있는 매처는 "같은 종류 OR, 다른 종류 AND"로 동작하고, CEL expression으로 복잡한 조건도 표현한다.
  • Caddyfile의 지시어는 작성 순서가 아니라 기본 순서로 실행된다. 제어가 필요하면 handle(배타적), handle_path(접두사 제거), route(작성 순서)를 쓴다.
  • 액티브 헬스 체크, 동적 업스트림, 재시도 대기가 오픈소스에 내장되어 있다.
  • DNS 챌린지, On-Demand TLS, 내장 ACME 서버는 TLS 자동화에서 Caddy의 강력한 무기다.
  • Rate Limit, 캐시, L4 프록시는 플러그인이 필요하며, xcaddy로 커스텀 빌드한다.
  • Caddyfile은 JSON의 어댑터이며, Admin API로 무중단 실시간 설정 변경이 가능하다.

다음 편에서는 지금까지 배운 것을 종합해, 같은 실전 아키텍처를 Nginx와 Caddy로 각각 구성하고 나란히 비교해 봅니다.

COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..