最近突然有点上瘾,添置了数台线路超炫酷的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都进行了一个反的代。效果如下


D3bug the w0r1d