---
title: "Caddy 인증서 만료 원인은 systemd-resolved와 NextDNS 조합이었다"
published: 2026-05-11T22:35:56.000Z
canonical: https://jeff.news/article/2627
---
# Caddy 인증서 만료 원인은 systemd-resolved와 NextDNS 조합이었다

Matrix 홈서버 앞단의 Caddy 인증서가 만료됐고, 원인은 Caddy가 아니라 특정 도메인의 NXDOMAIN 응답에서 멈추는 systemd-resolved 경로였어. Docker DNS, host stub resolver, NextDNS over TLS, ACME DNS-01 챌린지가 겹치면서 42시간 동안 갱신 실패가 조용히 누적된 장애 후기야.

## 장애는 인증서 만료 알림에서 시작됨

- Matrix 홈서버의 federation 포트 인증서가 만료되면서 알림이 터졌음
  - federation 포트는 다른 Matrix 홈서버가 이벤트를 주고받는 공개 TLS 엔드포인트임
  - 인증서가 신뢰되지 않으면 외부 서버가 메시지를 보내지 못하고, 내 서버도 outbound federation이 깨짐

- 문제의 인증서는 `matrix-fed.takeonme.org`용 Let's Encrypt 인증서였음
  - 앞단에는 Caddy 컨테이너가 있었고, Cloudflare DNS-01 챌린지로 자동 갱신하도록 설정돼 있었음
  - 인증서는 2월 초 발급, 5월 3일 만료였고 보통 60일 즈음 갱신됐어야 함

- 컨테이너 상태는 멀쩡했음
  - Synapse, Element, Postgres, Caddy, Redis 등 Matrix 관련 컨테이너는 전부 떠 있었음
  - 그래서 애플리케이션 장애가 아니라 TLS 엔드포인트의 인증서 갱신 문제로 범위가 좁혀짐

## Caddy는 계속 재시도하고 있었지만 실패 중이었음

- Caddy 로그에는 인증서 갱신 필요 메시지가 반복되고 있었음
  - ACME Renewal Information(ARI)이 갱신 시점을 알려줬고, Caddy의 cache maintenance도 갱신 큐에 넣고 있었음
  - 겉으로는 자동 갱신 루프가 잘 돌고 있는 것처럼 보였음

- 실제 실패 지점은 DNS-01 챌린지였음
  - Caddy가 `_acme-challenge.matrix-fed.takeonme.org`의 zone을 찾으려다 `SERVFAIL`을 받았음
  - 로그에는 29번째 시도, 경과 시간 151,329초가 찍혀 있었는데 대략 42시간 동안 실패한 셈임
  - 다음 재시도는 6시간 뒤로 밀려 있었고, 그 사이 인증서는 이미 만료될 수 있었음

> [!WARNING]
> 자동 갱신이 켜져 있어도 갱신 실패가 알림으로 올라오지 않으면 그냥 조용히 만료될 수 있음. Caddy 로그에 `tls.renew` 에러가 쌓이는지 따로 봐야 함.

- 더 찜찜한 점은 Caddy가 Let's Encrypt staging으로 넘어가 있었다는 것임
  - 반복 실패 후 certmagic이 production 대신 staging CA를 시도한 상태였음
  - staging 인증서는 브라우저나 Matrix 홈서버가 신뢰하지 않기 때문에, 설령 성공해도 운영 장애는 해결되지 않음

## 진짜 원인은 로컬 DNS 경로였음

- upstream DNS를 직접 찌르면 응답은 정상적이었음
  - Cloudflare `1.1.1.1`, NextDNS, VPS 제공 DNS 모두 `_acme-challenge.matrix-fed.takeonme.org`에 즉시 NXDOMAIN을 반환함
  - NXDOMAIN은 레코드가 없다는 정상 응답이고, Caddy가 챌린지 때만 TXT 레코드를 만들었다 지우는 구조라 기대한 결과임

- 그런데 로컬 stub resolver인 `127.0.0.53`은 타임아웃이 났음
  - 컨테이너 내부에서는 Docker의 내장 DNS `127.0.0.11`을 거침
  - 그 다음 host의 `systemd-resolved` stub resolver `127.0.0.53`으로 가고, 거기서 upstream으로 나가는 체인임
  - Caddy 컨테이너에서 보이는 실패는 사실 host resolver의 실패가 전파된 결과였음

```mermaid
sequenceDiagram
    participant 캐디컨테이너
    participant 도커DNS
    participant 호스트리졸버
    participant 넥스트DNS
    participant 렛츠인크립트
    캐디컨테이너->>도커DNS: _acme-challenge SOA 질의
    도커DNS->>호스트리졸버: 127.0.0.53으로 전달
    호스트리졸버->>넥스트DNS: DoT로 질의
    넥스트DNS-->>호스트리졸버: NXDOMAIN 응답
    호스트리졸버--x도커DNS: 특정 zone에서 타임아웃
    캐디컨테이너-->>렛츠인크립트: DNS-01 챌린지 실패
```

- 이상한 건 systemd-resolved가 완전히 죽은 게 아니었다는 점임
  - `google.com`은 바로 풀렸고, 다른 zone의 NXDOMAIN도 바로 돌아왔음
  - `matrix-fed.takeonme.org`처럼 존재하는 이름도 정상 응답함
  - 오직 `takeonme.org` 아래의 존재하지 않는 이름, 즉 특정 zone의 NXDOMAIN만 타임아웃이 났음

## 먼저 불을 끄고, 그다음 뿌리를 잘랐음

- 임시 복구는 Caddy 컨테이너가 host resolver를 우회하게 만드는 것이었음
  - compose 설정에 `dns: [1.1.1.1, 8.8.8.8]`를 추가함
  - 재배포 후 30초 안에 Caddy가 다시 갱신을 시도했고, 이번에는 production Let's Encrypt로 인증서 갱신에 성공함
  - 새 인증서는 5월 4일부터 8월 2일까지 유효했고 Matrix federation도 복구됨

- 하지만 host resolver 버그는 그대로 남아 있었음
  - 직접 NextDNS DoT로 `dig +tls`를 날리면 같은 도메인도 즉시 NXDOMAIN이 왔음
  - 즉 NextDNS 자체나 DoT 프로토콜이 아니라, systemd-resolved가 이 조합의 응답을 다루는 경로에서만 문제가 난 셈임

- 근본 조치는 서버에 불필요한 NextDNS DoT 설정을 제거하는 것이었음
  - `/etc/systemd/resolved.conf`에 NextDNS 프로필 기반 DNS와 `DNSOverTLS=yes`가 박혀 있었음
  - 이 서버는 클라이언트 기기가 아니라서 광고 차단이나 추적 방지 DNS 프로필의 이득이 거의 없었음
  - 설정을 주석 처리하고 systemd-resolved를 재시작하자, VPS 제공 link DNS로 돌아가며 NXDOMAIN이 즉시 응답됨

> [!TIP]
> DNS 헬스체크로 `google.com`만 보는 건 거의 의미가 없음. 인증서, 서비스 디스커버리, federation처럼 실제 워크로드가 의존하는 이름을 같은 경로로 질의해야 함.

## 운영 교훈은 꽤 아픔

- Caddy는 갱신 실패를 로그로는 남기지만 알아서 깨워주진 않음
  - 42시간 동안 구조화된 JSON 로그에 실패 원인이 찍혔는데, 모니터링은 그걸 보고 있지 않았음
  - 공개 엔드포인트의 인증서 만료 시점과 ACME 갱신 실패 로그를 따로 알림으로 잡아야 함

- 재시도 백오프도 장애를 키울 수 있음
  - Caddy가 CA를 과도하게 두드리지 않으려고 6시간 뒤 재시도를 잡는 건 이해됨
  - 하지만 인증서가 3시간 뒤 만료된다면 그 백오프는 운영 관점에서 너무 김

- 설정 드리프트가 진짜 무서운 부분임
  - NextDNS DoT 설정은 예전에 개인 기기 템플릿에서 서버로 흘러들어온 잔재였음
  - 몇 년 동안 정상처럼 보이다가 특정 zone, 특정 응답 타입, 특정 resolver 조합에서만 터짐
  - 감사 체크리스트에서 ‘이 서버에 클라이언트용 DNS 프로필이 왜 있지?’를 잡아내지 못하면 이런 문제는 계속 숨어 있음

---
## 기술 맥락

- 이 장애의 핵심 선택지는 Caddy를 고치는 게 아니라 DNS 경로를 어디까지 신뢰할지였어요. Caddy는 DNS-01 챌린지를 하려면 `_acme-challenge` 레코드의 zone을 찾아야 하고, 그 과정에서 SOA 질의가 안정적으로 돌아와야 하거든요.

- Docker 컨테이너 안에서 DNS가 실패하면 컨테이너만 보는 실수를 하기 쉬워요. 하지만 여기서는 컨테이너의 `127.0.0.11`이 host의 `127.0.0.53`으로 넘기고 있었기 때문에, 실제 장애 지점은 systemd-resolved였어요.

- NextDNS DoT 설정을 제거한 이유도 단순히 우회가 아니에요. 서버는 브라우징을 하지 않으니 클라이언트용 DNS 필터링 프로필의 이득이 작고, 반대로 인증서 갱신 같은 핵심 운영 경로에 추가 복잡도를 넣고 있었거든요.

- Caddy 컨테이너에 별도 DNS를 남겨둔 건 방어선이에요. host resolver가 다시 이상해져도 인증서 갱신 경로는 Cloudflare와 Google DNS를 직접 타니까, 같은 종류의 장애가 바로 반복될 가능성을 낮춰줘요.

- 모니터링 관점에서는 ‘서비스가 떠 있다’와 ‘인증서가 갱신되고 있다’를 분리해서 봐야 해요. 컨테이너는 전부 healthy였지만 public TLS endpoint는 이미 신뢰를 잃었고, 그 차이를 잡는 건 로그 알림과 실제 엔드포인트 인증서 만료 체크예요.

## 핵심 포인트

- Caddy는 10분마다 인증서 갱신을 큐에 넣었지만 DNS-01 챌린지에서 42시간 동안 실패했다
- 직접 upstream DNS에 질의하면 NXDOMAIN이 즉시 왔지만 systemd-resolved의 127.0.0.53 경로만 타임아웃됐다
- 문제 경로는 Docker 127.0.0.11에서 host 127.0.0.53을 거쳐 NextDNS DoT로 가는 DNS 체인이었다
- 임시 조치는 Caddy 컨테이너에 1.1.1.1과 8.8.8.8 DNS를 직접 지정하는 것이었고, 근본 조치는 서버에 불필요한 NextDNS DoT 설정을 제거하는 것이었다
- 인증서 만료 모니터링과 로그 기반 ACME 갱신 실패 알림이 없으면 자동 갱신 실패를 너무 늦게 알게 된다

## 인사이트

이 글은 ‘DNS가 된다’는 말이 얼마나 허술한지 잘 보여줌. 운영에서는 google.com이 풀리는지가 아니라, 내 서비스가 실제로 의존하는 이름과 응답 타입이 같은 경로에서 안정적으로 풀리는지가 중요함.
