ExternalDNS 域名过滤器(Domain Filter)完全指南:--domain-filter 与正则过滤的实战与原理
ExternalDNS 域名过滤器(Domain Filter)完全指南:--domain-filter 与正则过滤的实战与原理
本文围绕 ExternalDNS 的域名过滤机制展开,讲解
--domain-filter、--exclude-domains、--regex-domain-filter、--regex-domain-exclusion四个核心 flag 的语义、匹配逻辑与调试方法。读完本文,你将掌握如何精确圈定 ExternalDNS 管理的域名集合、规避区域分区(zone partition)场景下常见的正则陷阱,并能够结合源码理解过滤判定背后的真实执行路径。
安全边界声明:过滤器只是意图表达,不是防护边界
重要: 域名过滤 flag 表达的是应用层意图(application-level intent),它不是强制执行边界(enforcement boundary)。真正起到强制隔离作用的是凭据(Credentials)——IAM 策略、API Token 的 scope、ACL 等。一个配置错误或遗漏的 flag 会让 ExternalDNS 暴露凭据所能触达的全部 zone。因此,务必把 API Key 或 IAM 角色严格限定到 ExternalDNS 实际管理的 zone 范围,把过滤 flag 作为该边界的补充手段,而不是替代品。
这一条是官方文档在开篇反复强调的安全红线:过滤只是"计划表",凭据才是"门锁"。任何生产部署都应该先收紧凭据,再谈过滤规则。
两种模式与四个 Flag 概览
ExternalDNS 提供两种选择托管域名的模式:plain domain filter(普通域名过滤器) 与 regex domain filter(正则域名过滤器)。
- 只要使用了任意一个正则过滤 flag,就会启用 regex domain filter 模式,该模式会覆盖并忽略 plain domain filter 的全部 flag(即
--domain-filter与--exclude-domains)。 - 四个 flag 均作用于 DNS 记录名(record names);对于在管理记录之前会先按 zone 分区的 Provider(例如 PowerDNS),过滤规则同样会应用到 zone 名上。
| Flag | 模式 | 语义 |
|---|---|---|
--domain-filter |
plain | 后缀匹配 —— 包含该域名及其全部子域名 |
--exclude-domains |
plain | 后缀匹配 —— 从 --domain-filter 的结果中排除某域名或子域名 |
--regex-domain-filter |
regex | 完整正则匹配 —— 一旦设置便覆盖 --domain-filter |
--regex-domain-exclusion |
regex | 从 --regex-domain-filter 匹配结果中剔除命中的域名;也可以单独使用 |
在源码层面,这四个 flag 的注册位于 pkg/apis/externaldns/types.go:
--domain-filter:"Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)",可重复指定;--exclude-domains:"Exclude subdomains (optional)";--regex-domain-filter:"Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional)",注意帮助文本明确写了 "Overrides domain-filter";--regex-domain-exclusion:"Regex filter that excludes domains and target zones matched by regex-domain-filter (optional)"。
而四个 flag 真正汇合成一个过滤器对象的地方在 controller/execute.go:
domainFilter := endpoint.NewDomainFilterWithOptions(
endpoint.WithDomainFilter(cfg.DomainFilter),
endpoint.WithDomainExclude(cfg.DomainExclude),
endpoint.WithRegexDomainFilter(cfg.RegexDomainFilter),
endpoint.WithRegexDomainExclude(cfg.RegexDomainExclude),
)
这里构建出的 *endpoint.DomainFilter 会被传入 providerfactory.Select 传给各 Provider,同时传入 buildController,作为整个控制回路中"该记录是否被管理"的判定依据。
Plain 模式:--domain-filter 与 --exclude-domains
指定一个或多个域名后缀。ExternalDNS 会管理名字以其中任意一个值结尾的记录。
--domain-filter=example.com
--domain-filter=other.org
以上配置意味着:example.com、other.org 以及它们的所有子域名(如 www.example.com、api.other.org)都会被纳入管理范围。
若要排除特定子域名,使用 --exclude-domains:
--domain-filter=example.com
--exclude-domains=staging.example.com
这样 staging.example.com 及其子域将被跳过,而 example.com 本身、prod.example.com 等仍然受管。
后缀匹配的底层语义
普通模式的匹配逻辑实现在 endpoint/domain_filter.go 的 matchFilter 函数中,三种命中方式:
switch {
case strings.HasPrefix(filter, ".") && strings.HasSuffix(strippedDomain, filter):
return true
case strings.Count(strippedDomain, ".") == strings.Count(filter, ".") && strippedDomain == filter:
return true
case strings.HasSuffix(strippedDomain, "."+filter):
return true
}
- 若过滤器以
.开头(如--domain-filter=.org),则做纯后缀匹配,example.org、test.example.org、foo.test.example.org全部命中; - 若过滤器不含前导点,则要求标签边界匹配(
strings.HasSuffix(strippedDomain, "."+filter)),因此--domain-filter=example.org不会命中anexample.org或test.anexample.org—— 这可以从 endpoint/domain_filter_test.go 的测试用例中得到印证。
而最终判定为:
return matchFilter(df.Filters, domain, true) && !matchFilter(df.exclude, domain, false)
即:先看是否命中 include 列表,再看是否命中 exclude 列表;空 include 匹配一切(emptyval=true),空 exclude 不排除任何域(emptyval=false)。
Regex 模式:--regex-domain-filter 与 --regex-domain-exclusion
--regex-domain-filter 接受一个 Go RE2 正则表达式。当后缀匹配的表达力不足时(例如需要按区域名模式选择 zone),使用正则模式。
--regex-domain-filter='\.org$'
用 --regex-domain-exclusion 剔除本应命中但需要排除的 zone:
--regex-domain-filter='^([\w-]+\.)*example\.com$'
--regex-domain-exclusion='^staging\.'
匹配逻辑(含判定顺序)
排除(exclusion)总是先被检查:
- 若
--regex-domain-exclusion命中 → 拒绝(rejected) - 若
--regex-domain-filter命中 → 接受(accepted) - 若仅设置了
--regex-domain-exclusion(且域名未命中)→ 接受(排除独占模式 exclusion-only mode) - 若设置了
--regex-domain-filter(且域名未命中)→ 拒绝
flowchart TD
A["Domain candidate"] --> B{"Is regex filter<br/>or exclusion set?"}
B -- "No (use plain filters)" --> C{"Matches<br/> --domain-filter?"}
C -- "No" --> REJECT["❌ Rejected"]
C -- "Yes" --> D{"Matches<br/> --exclude-domains?"}
D -- "Yes" --> REJECT
D -- "No" --> ACCEPT["✅ Accepted"]
B -- "Yes (regex mode)" --> E{"Matches<br/>--regex-domain-exclusion?"}
E -- "Yes" --> REJECT
E -- "No" --> F{"--regex-domain-filter set?"}
F -- "No (exclusion-only mode)" --> ACCEPT
F -- "Yes" --> G{"Matches<br/>--regex-domain-filter?"}
G -- "Yes" --> ACCEPT
G -- "No" --> REJECT
这套顺序与源码 endpoint/domain_filter.go 中 matchRegex 的注释逐条对应:
// 1. If negativeRegex is set and matches the domain, return false (excluded)
// 2. If regex is set and matches the domain, return true (included)
// 3. If regex is not set but negativeRegex is set, return true (not excluded, no inclusion filter)
// 4. If regex is set but doesn't match, return false (not included)
func matchRegex(regex *regexp.Regexp, negativeRegex *regexp.Regexp, domain string) bool {
strippedDomain := normalizeDomain(domain)
// First check exclusion - if domain matches exclusion, reject it
if negativeRegex != nil && negativeRegex.String() != "" {
if negativeRegex.MatchString(strippedDomain) {
return false
}
}
// Then check inclusion filter if set
if regex != nil && regex.String() != "" {
return regex.MatchString(strippedDomain)
}
// If only exclusion is set (no inclusion filter), accept the domain
// since it didn't match the exclusion
return true
}
同时,进入 regex 模式的开关在 endpoint/domain_filter.go 的 Match 方法中:
if df.regex != nil && df.regex.String() != "" || df.regexExclusion != nil && df.regexExclusion.String() != "" {
return matchRegex(df.regex, df.regexExclusion, domain)
}
只要任一正则 flag 非空,就走 matchRegex,plain 过滤器被完全忽略 —— 这就是文档所述"regex 覆盖 plain"的代码依据。对应的边界行为测试见 endpoint/domain_filter_test.go 的 regexDomainFilterTests 与 TestMatchRegex。
实战示例
只包含 .org 域名:
--regex-domain-filter='\.org$'
包含一组特定域名:
--regex-domain-filter='(?:foo|bar)\.org$'
包含 + 排除:
# foo.org, bar.org, a.example.foo.org → accepted
# example.foo.org, example.bar.org → rejected
--regex-domain-filter='(?:foo|bar)\.org$'
--regex-domain-exclusion='^example\.(?:foo|bar)\.org$'
生产环境 + 临时排除:
--regex-domain-filter='\.prod\.example\.com$'
--regex-domain-exclusion='^temp-'
排除独占模式(除某模式外全部接受):
--regex-domain-exclusion='test-v1\.3\.example-test\.in'
排除复杂模式:
--regex-domain-exclusion='^(internal|private)-.*\.example\.com$'
上述示例在 endpoint/domain_filter_test.go 中均有等价的测试用例覆盖,例如"包含但不命中排除 → accepted"、"同时命中包含与排除 → 排除优先 rejected"、"不命中包含 → rejected"等场景。
Zone 分区陷阱:+ 与 * 之辨
这是过滤 zone(而不仅是记录)时最常见的错误配置:用 [\w-]+(至少一次)代替 ([\w-]+\.)*(零次或多次)作为标签前缀分组。由于 + 要求至少重复一次:
- apex zone(如
example.com)没有任何标签前缀,永远无法命中; - 多标签子域 zone(如
long.sub.example.com)含有点号,[\w-]+无法跨越。
这两种 zone 最终都会变成无人管理,ExternalDNS 会对其中包含的每一条记录输出 Ignoring Endpoint,且没有任何其他线索提示出错原因。
| 正则 | 命中 | 未命中 |
|---|---|---|
^[\w-]+\.example\.com$ |
sub.example.com |
example.com, long.sub.example.com |
^([\w-]+\.)*example\.com$ |
example.com, sub.example.com, long.sub.example.com |
— |
永远使用 *:apex 在零次重复时命中,子域 zone 在一次及以上重复时命中。
这个陷阱在 provider/pdns/pdns_test.go 的测试注释中被明确记录:
// Worst case: no subdomain zone exists at all.
// Both apex zones fail the regex → filtered is empty →
// ConvertEndpointsToZones logs "Ignoring Endpoint" for every record.
//
// "example.com" → no label prefix → residual ← BUG
即:正则写错时 apex zone 全部沦为 residual zone,日志中只会出现大量 Ignoring Endpoint。PowerDNS 这类"先分区、后管理记录"的 Provider 正是最容易踩中该陷阱的地方,相关实现见 provider/pdns/pdns.go,其会在 zone 未命中 Domain Filter 时打印 Ignoring Endpoint because it was matched to a zone that was not specified within Domain Filter(s)。
多区域(Multi-region)综合示例
将正则模式应用于按区域命名的 zone:
--regex-domain-filter='^([\w-]+\.)*(?:us-east-1|eu-central-1)\.example\.com$'
--regex-domain-exclusion='^staging\.'
| Zone | 结果 |
|---|---|
us-east-1.example.com |
managed |
prod.us-east-1.example.com |
managed |
eu-central-1.example.com |
managed |
staging.us-east-1.example.com |
excluded |
other.com |
not managed |
注意此处包含正则以 ^ 锚定并以 $ 结尾,同时使用 ([\w-]+\.)* 让 apex 与多级子域 zone 都能命中,规避了上一节的 +/* 陷阱。
匹配前的域名规范化(Notes)
官方文档在 Notes 中总结了四个关键行为,它们在源码中都有对应实现:
- 正则语法:标准 Go RE2。需要转义点号(
\.),在需要精确的地方使用锚点(^、$)。--regex-domain-filter与--regex-domain-exclusion在 flag 解析阶段直接编译为*regexp.Regexp(见 pkg/apis/externaldns/types.go 的b.RegexpVar(...)),非法正则会在启动时直接报错。 - 大小写敏感:匹配区分大小写。域名在匹配前会被转为小写并剥离尾部点号。测试用例
{"eXaMPle.ORG", "API.example.ORG"}命中FoOoo.Api.Example.Org即为例证(endpoint/domain_filter_test.go)。 - IDN / Unicode:域名在匹配前会被转换为 Unicode 形式(IDNA 规范化,遵循 RFC 5891 Section 5),因此针对 emoji 或 Unicode 标签书写的模式可以正常工作。实现位于 endpoint/domain_filter.go 的
normalizeDomain:先strings.TrimSuffix(domain, "."),再用idna.Profile.ToUnicode转换;测试用例包括sTOnks📈.ORG与xn--StonkS-u354e.ORG的等价匹配(endpoint/domain_filter_test.go)。 - 互斥性:一旦任意正则 flag 非空,基于列表的过滤器被整体忽略(见上文
Match方法的判定分支)。
另外,prepareFilters(endpoint/domain_filter.go)会对 plain 模式的过滤器做一致性处理:TrimSpace 去空白、normalizeDomain 去尾部点号并做 IDNA 转换,空串条目被丢弃。因此 --domain-filter=" foo.org. " 与 --domain-filter=foo.org 等价(测试见 endpoint/domain_filter_test.go)。
调试:记录被静默丢弃怎么办
如果记录被静默丢弃,请先查看日志中的 Ignoring Endpoint —— 它表示没有任何受管 zone 与该记录匹配。要定位是否为域名过滤器导致:
- 临时切换回
--domain-filter(plain 后缀)方式; - 如果记录重新出现,说明问题出在正则上。
此时应回到前文的 +/* 陷阱检查正则写法,尤其是 zone 分区场景下的 apex zone 是否被意外排除。
上线前测试正则
在部署之前,用真实 zone 名验证正则:
- regex101.com —— 交互式测试器;务必选择 Golang 风格(flavor),以精确匹配 Go 的 RE2 引擎。把每个 zone 名单独粘贴一行,并启用 global 标志。
- AI 助手(ChatGPT、Claude、DeepWiki 等)—— 描述你想要匹配/排除的 zone,让其生成正则;但无论来源如何,使用前都要在 regex101 中复核输出。
说明:本仓库为只读镜像,运行与验证均在你自己的部署环境中进行;本文所有命令均为 ExternalDNS 启动参数,不改动仓库任何文件。
扩展阅读
- Flags 参考 ——
--domain-filter、--exclude-domains、--regex-domain-filter、--regex-domain-exclusion的完整说明 - AWS 过滤器教程 —— 过滤 flag 的交互组合表
- FAQ —— 常规配置问题
- DomainFilter 实现源码 ——
Match/matchFilter/matchRegex的完整实现 - DomainFilter 测试 —— plain 与 regex 双模式的覆盖用例
- 过滤器装配点 —— 四个 flag 汇合成
DomainFilter并注入 Provider 与 Controller - PowerDNS Provider —— 观察 zone 分区场景下 Domain Filter 的实际应用与
Ignoring Endpoint日志