最近突然有点上瘾,添置了数台线路超炫酷的vps,认为自己几个网站分别部署在这些个vps上有点太浪费了,于是萌生出自建CDN的想法。这篇文章讲述的主要是思路,不会放出细节代码因为我写的实在是太烂了555()
CDN的原理其实很简单,将静态资源缓存到边缘节点以供用户就近访问,如果有未缓存的或动态资源则进行回源。回源即CDN的边缘节点访问源站的过程。CDN有一个GSLB(全局负载均衡)通过返回不同的DNS记录来把用户引导到最合适的边缘节点。

首先来决定GSLB选什么,自己搭一个DNS权威服务器其实可行,可是对用户来说不太美好,毕竟咱没有那么多节点,光查DNS就得等半天那CDN不成减速器了么(。于是购置了GCore的GeoDNS,GeoDNS类似GSLB的功能,可以区分不同的ASN、国家、区域...来返回不同的DNS结果,很适合我们的CDN。

有了负载均衡和一堆边缘节点,我们还需要让边缘节点知道他要缓存/反代什么。传统的配置推送个人认为ssh上去有点不安全,所以在边缘节点进行一个配置的拉。
主要采用Caddy代理,主要是配置好写。节点用虚拟主机(https用sni,http用Host)判断你访问的到底是哪个站点。
在配置服务器上开了一个Caddy,把每个节点的Caddyfile和证书们放到里面,节点带着Token就能获取到。Token是添加的时候随机生成的。然后我们只需要生成Caddyfile就好了。
现在又出来一个需求,我的一个HK节点是小水管,只有30M,所以显然不能让他下载大文件,把大文件都放到static里302过去。问题是现在每个节点的配置都是自动生成的,无法手动修改。于是又草率地写了一个Caddyfile合并算法,把Modifier和生成的Caddyfile进行一个合并。
其实边缘节点主要做的就是缓存和反代,所以生成的Caddyfile有这俩功能就好。Caddy用to https://example.com做反代,然后再给caddy add一个cache的package就完事了。
不过要想实现cf那种完全https模式(回源也走https)还是要再来点配置。主要是reverse_proxy里写to https://ip再在transport http块里的tls_server_name写一下你的源站证书上的域名。
在header里添加一个X-CDN-Node方便调试,对于static文件(这里我用path /static/* /assets/* *.css *.js *.png *.jpg *.jpeg *.webp *.gif *.svg)开cache,其他的就直接reverse_proxy就行。
证书签发使用了acme.sh,添加一个site就签一个证书,放在Caddy里。这里有一个小坑:如果你用的是dns_gcore,gcore的api_key里是带$的,如果直接export GCORE_Key=""会被shell转义走,所以这里用单引号。
现在节点就可以带着Token来拉配置和证书了。在节点的安装脚本里简单写了一个服务和一个Timer每一分钟拉一次配置和cert,如果有变化就reload caddy。
现在应该可以了,还需要做的一步就是用Gcore api把节点的ip传到GeoDNS里,由于GCore的api手册有点精简了,这里悄悄放一段小shitcode
def gcore_create_rrset(zone: str, name: str, type_: str, ttl: int, values: list[GcoreRRsetMember], pickers: list[Dict[str, Any]]) -> bool:
records = []
groups = defaultdict(list)
for value in values:
meta_key = json.dumps(value.meta, sort_keys=True)
groups[meta_key].append(value.content)
for meta_key, contents in groups.items():
records.append({"content": contents, "meta": json.loads(meta_key), "enabled": True})
try:
gcore_client.dns.zones.rrsets.create(
rrset_type = type_,
zone_name = zone,
rrset_name = name,
ttl = ttl,
resource_records = records,
pickers = pickers
)
return True
except Exception as e:
log_error(f"Failed to add RRset {name}:{zone} {type_}: {e}")
return False
def gcore_list_rrset(zone: str) -> List:
rrset = gcore_client.dns.zones.rrsets.list(
zone_name = zone
)
return rrset.model_dump()["rrsets"]
def gcore_get_rrset(zone: str, name: str, type_: str) -> List:
rrset = gcore_client.dns.zones.rrsets.list(
zone_name = zone,
rrset_name = name,
rrset_type = type_
)
return rrset.model_dump()
def gcore_update_rrset(zone: str, name: str, type_: str, ttl: int, values: list[GcoreRRsetMember], pickers: list[Dict[str, Any]]) -> bool:
records = []
groups = defaultdict(list)
for value in values:
meta_key = json.dumps(value.meta, sort_keys=True)
groups[meta_key].append(value.content)
for meta_key, contents in groups.items():
records.append({"content": contents, "meta": json.loads(meta_key), "enabled": True})
try:
gcore_client.dns.zones.rrsets.replace(
rrset_type = type_,
zone_name = zone,
rrset_name = name,
ttl = ttl,
resource_records = records,
pickers = pickers
)
return True
except Exception as e:
log_error(f"Failed to update RRset {name}:{zone} {type_}: {e}")
return False
def gcore_delete_rrset(zone: str, name: str, type_: str) -> bool:
try:
gcore_client.dns.zones.rrsets.delete(
rrset_type = type_,
zone_name = zone,
rrset_name = name
)
return True
except Exception as e:
log_error(f"Failed to delete RRset {name}:{zone} {type_}: {e}")
return False
def dns_sync_edges(cfg: dict) -> None:
edges: List[Dict[str, Any]] = cfg.get("edges", [])
if not edges:
log_warn("No edges configured, skipping DNS sync.")
return
sites: List[Dict[str, Any]] = cfg.get("sites", [])
if not sites:
log_warn("No sites configured, skipping DNS sync.")
return
zones = [_zone_from_ext(tldextract.extract(site["domain"])) for site in sites]
for zone in zones:
rrsets = gcore_list_rrset(zone)
existing = [rrset["name"] for rrset in rrsets]
current = [tldextract.extract(site["domain"]).subdomain for site in sites if _zone_from_ext(tldextract.extract(site["domain"])) == zone]
for name in existing:
if name not in current:
# gcore_delete_rrset(zone=zone, name=name, type_="A")
# gcore_delete_rrset(zone=zone, name=name, type_="AAAA")
# log_ok(f"Deleted obsolete RRset {name}.{zone}")
log_warn(f"Found Obsolete record: {name}.{zone}, manual deletion may be required.")
for site in sites:
ext = tldextract.extract(site["domain"])
record_name = f"{ext.subdomain}.{ext.domain}.{ext.suffix}" if ext.subdomain else f"{ext.domain}.{ext.suffix}"
zone_name = _zone_from_ext(ext)
values4 = []
values6 = []
for origin in site.get("origins", []):
v4 = origin.get("host", {}).get("v4", "")
v6 = origin.get("host", {}).get("v6", "")
if v4:
values4.append(GcoreRRsetMember(content=v4, meta={"backup": True}))
if v6:
values6.append(GcoreRRsetMember(content=v6, meta={"backup": True}))
for edge in edges:
edge_name = edge["name"]
if edge_name not in site.get("edges", []):
continue
v4 = edge["host"].get("v4", "")
v6 = edge["host"].get("v6", "")
paused = edge.get("paused", False)
if paused:
continue
meta = edge.get("meta", {})
meta.update({"notes": meta.get("notes", []) + [f"edge:{edge_name}"]})
if v4:
values4.append(GcoreRRsetMember(content=v4, meta=meta))
if v6:
values6.append(GcoreRRsetMember(content=v6, meta=meta))
health = site.get("health_check", {}).get("uri", "").strip()
health_parsed = urlparse(health) if health else None
health_protocol = "https"
health_port = 80
health_frequency = site.get("health_check", {}).get("frequency", 60)
health_timeout = site.get("health_check", {}).get("timeout", 5)
health_method = "GET"
if health_parsed:
if health_parsed.scheme in ["http", "https"]:
health_protocol = health_parsed.scheme
if health_parsed.port:
health_port = health_parsed.port
else:
health_port = 443 if health_protocol == "https" else 80
pickers = [
{"type": "geodns"}
]
if health:
pickers += [
{"type": "healthcheck", "protocol": health_protocol, "port": health_port, "uri": health_parsed.path if health_parsed else "/", "method": health_method, "frequency": health_frequency, "timeout": health_timeout},
]
pickers += [
{"type": "weighted_shuffle"},
{"type": "first_n", "limit": 1}
]
ttl = site.get("dns_ttl", 60)
if values4:
gcore_update_rrset(zone=zone_name, name=record_name, type_="A", ttl=ttl, values=values4, pickers=pickers)
log_ok(f"Updated RRset {record_name} A with {len(values4)} records.")
if values6:
gcore_update_rrset(zone=zone_name, name=record_name, type_="AAAA", ttl=ttl, values=values6, pickers=pickers)
log_ok(f"Updated RRset {record_name} AAAA with {len(values6)} records.")现在应该可以正常访问了。CDN大成功~
用AI写了个状态监控,丢在这里。为了避免CORS对Komari和Gcore Status都进行了一个反的代。效果如下







Comments | NOTHING