feat(docker): 메일 엔진 전용 DNS 리졸버(unbound)와 Spamhaus 조회 키(DQS)

엔진의 DNS 조회(DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라
이 서버의 리졸버로 푼다. Spamhaus 는 공용 DNS 를 거친 조회를 거부하고, DNSSEC 을 검증해야
엔진의 DANE 이 켜지기 때문이다.

- mail-resolver: unbound 1.26.2(digest 고정 · 비-root · cap_drop ALL). mail 과만 닿는
  internal 네트워크 mail-dns 와 바깥 길 mail-dns-egress. 사설 주소의 답 · 질의를 막는다.
- setup: 바꾸기 전에 리졸버 네트워크 · 주소 · DNSSEC 을 확인한다(UDP 는 루트 SOA, TCP 는
  루트 DNSKEY — UDP DNSKEY 는 루트 키 교체 기간에 잘려 오탐이 난다). 엔진 DNS 는 운영 Custom,
  개발 System. Spamhaus 키가 있으면 시험 항목을 확인한 뒤 DQS 로 켜고 없으면 두 항목을 끈다.
  키는 모든 출력에서 가린다.
- mail 은 리졸버를 기다리되(required: false) 리졸버 오류 하나로 메일 전체가 서지 않게 한다.
- README: 리졸버와 스팸 차단 목록, 운영 배포 차례, 리졸버 장애 때(리로드 거절 · restart 비상 길),
  DNSSEC 이 깨진 도메인의 예외.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 19:01:12 +09:00
parent 4047bd30e7
commit 9466a85daa
8 changed files with 1180 additions and 47 deletions
+76
View File
@@ -11,6 +11,7 @@ services:
# edge frontend ↔ backend 전용
# app-network backend ↔ db·redis·mail — 메일 엔진의 신뢰 대역이다. frontend 는 붙이지 않는다
# (붙이면 frontend 가 mail:8080 관리 API 에 닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다)
# mail-dns mail ↔ mail-resolver(메일 전용 DNS) 전용 — 바깥 길 없음. 리졸버의 바깥 조회는 mail-dns-egress 로만 (6번 참조)
# ----------------------------------------
frontend:
container_name: ${PROJECT_NAME}-frontend
@@ -255,14 +256,75 @@ services:
- mail-config:/etc/stalwart
- mail-data:/var/lib/stalwart
restart: unless-stopped
# 컨테이너의 DNS — 도커 DNS(127.0.0.11)가 도커 내부 이름(mailpit 등)은 직접 풀고 바깥 이름은 메일 전용 리졸버에 묻는다.
# 엔진 자신의 DNS(x:DnsResolver)는 mail setup 이 맞춘다 — 운영은 이 리졸버에 바로(도커 DNS 를 거치지 않는다), 개발은 이 길(System)
dns:
- ${MAIL_RESOLVER_IP:-172.16.73.254}
networks:
- app-network
- mail-dns
# 리졸버가 답한 뒤에 뜬다 — 엔진은 기동할 때 리졸버가 DNSSEC 을 검증하는지 보고, 못 하면 DANE 을 끈다.
# required: false — 평소에는 healthy 를 기다리고, 리졸버가 unhealthy 거나 떠 있지 못하면(설정 오류로 바로 끝남 · 재시작을 되풀이함)
# compose 가 경고("optional dependency … failed to start")만 내고 mail 을 띄운다. 없으면 리졸버 오류 하나로 `up -d mail` 이 옛 mail 을 지운 뒤
# 새 mail 을 띄우지 않고 끝나 메일 전체가 선다 — 바깥 DNS 만 안 되는 편이 낫다 (2026-10-30 개발 Compose 2.40 에서 넷 다 확인, mail/README.md "리졸버 장애 때")
depends_on:
mail-resolver:
condition: service_healthy
required: false
deploy:
resources:
limits:
cpus: '1.0'
memory: 1g
# ----------------------------------------
# 6. Mail resolver (unbound) — 메일 엔진 전용 재귀 DNS · profile `mail`
#
# 엔진의 DNS 조회(스팸 차단 목록 DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라 이 서버에서 직접 푼다.
# Spamhaus 는 공용 · ISP DNS 를 거친 조회를 거부한다(127.255.255.254). DNSSEC 을 검증해 엔진의 DANE 이 켜진다.
# 설정 mail/resolver/unbound.conf · mail setup 이 엔진의 x:DnsResolver 를 이 주소로 맞춘다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").
#
# 네트워크 — 메일 엔진만 닿게:
# mail-dns mail ↔ 리졸버 전용. internal(바깥으로 나가는 길 없음). 리졸버 주소는 MAIL_RESOLVER_IP 로 고정한다 —
# 엔진 설정 · mail 의 dns 가 주소(IP)만 받는다. 서브넷의 끝 쪽에 둔다(도커는 mail 에 앞쪽부터 차례로 준다)
# mail-dns-egress 리졸버의 바깥 조회 길 — 리졸버 혼자 붙는다
# app-network(메일 엔진의 신뢰 대역)에는 붙이지 않는다 — mail setup 이 두 네트워크의 구성원을 확인한다 (운영은 그 밖이 있으면 중단)
# 이미지는 판까지 고정한다 (docker 스킬 "이미지 버전 고정"). 1.26.2 는 NLnet Labs 의 보안 릴리스(2026-10-21)라 2주를 기다리지 않았다
# ----------------------------------------
mail-resolver:
profiles: ["mail"]
container_name: ${PROJECT_NAME}-mail-resolver
# 공식이 아닌(커뮤니티) 이미지라 판에 digest 까지 묶는다 — 같은 태그를 누가 다시 올려도 바뀌지 않게 (2026-10-30 manifest list digest)
image: klutchell/unbound:1.26.2@sha256:50cbeb32b5838a829a7226b19cb151f9535bd7f9f3190f950780f452fe807478
# 이미지는 root 로 떠서 53 에 붙은 뒤 내려가는 꼴이다 — 처음부터 비-root(이미지의 unbound 사용자 101:102)로 띄운다.
# 53 에 붙는 것은 도커가 컨테이너마다 net.ipv4.ip_unprivileged_port_start=0 으로 두기 때문이다 — 권한(capability)은 모두 뺀다
user: "101:102"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
# 이미지의 기본 설정을 통째로 바꾼다. DNSSEC 신뢰 앵커(/var/unbound/root.key)는 이미지의 것을 쓰고 컨테이너 안에서 고쳐 쓴다
- ./mail/resolver/unbound.conf:/etc/unbound/unbound.conf:ro
restart: unless-stopped
networks:
mail-dns:
ipv4_address: ${MAIL_RESOLVER_IP:-172.16.73.254}
mail-dns-egress: {}
healthcheck:
# 데몬이 답하는지만 본다(자기 안의 localhost) — 바깥 DNS 가 잠깐 막혀도 mail 이 뜨지 못하는 일이 없게.
# 바깥 조회 · DNSSEC 은 mail setup 이 메일 컨테이너에서 확인한다. drill-hc 는 이미지에 든 drill 의 종료 코드를 0 · 1 로 맞춘 것
test: ["CMD", "drill-hc", "localhost", "@127.0.0.1", "A"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: '0.25'
memory: 128m
networks:
app-network:
# 이름·서브넷을 고정한다. 메일 엔진은 이 대역만 신뢰한다 — 게이트웨이 로그인·자동 차단 예외
@@ -283,6 +345,20 @@ networks:
ipam:
config:
- subnet: ${EDGE_NETWORK_SUBNET:-172.16.72.0/24}
mail-dns:
# mail ↔ mail-resolver 전용. internal — 이 네트워크로는 바깥에 나가지 못한다(mail 의 바깥 길은 app-network 그대로).
# 리졸버 주소(MAIL_RESOLVER_IP)를 고정하려고 서브넷을 고정한다. 겹치면 MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP 를 함께 바꾼다
name: ${PROJECT_NAME}_mail-dns
driver: bridge
internal: true
ipam:
config:
- subnet: ${MAIL_RESOLVER_SUBNET:-172.16.73.0/24}
mail-dns-egress:
# 리졸버가 루트 · 각 도메인의 DNS 서버에 묻는 길 — 리졸버 혼자 붙는다. 믿는 대역이 아니라 서브넷을 고정하지 않는다 —
# 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다. 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail/README.md)
name: ${PROJECT_NAME}_mail-dns-egress
driver: bridge
proxy:
external: true