feat(services): крутить weighted как доли времени на одном IP
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 55s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 1m36s

Веса задают долю слотов на общем FQDN (1 к 3 = ¼ и ¾ цикла), TTL 60.
В селекте режима показывать текстовое имя, не ID.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-20 17:35:13 +07:00
co-authored by Cursor
parent 8a13888db7
commit 3d0ea33baf
8 changed files with 259 additions and 66 deletions
+5
View File
@@ -38,6 +38,10 @@ import {
healthEngineFallbacksFromConfig,
scheduleHealthCheckJob,
} from "./services/health-check-scheduler.js";
import {
createWeightedDnsTask,
scheduleWeightedDnsJob,
} from "./services/weighted-dns-scheduler.js";
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
import { AsyncTask, CronJob } from "toad-scheduler";
@@ -133,6 +137,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.decorate("reloadHealthCheckJob", () => {
scheduleHealthCheckJob(app, config, healthTask);
});
scheduleWeightedDnsJob(app, createWeightedDnsTask(app));
if (config.cloudflareApiToken) {
fireEnsureHealthWorker(
app.db,
+7 -2
View File
@@ -2,25 +2,30 @@ import type { LbMode } from "@cfdm/shared";
import { failoverDesired } from "./failover.js";
import { roundRobinDesired } from "./round-robin.js";
import type { LbIpRow, LbTargetConfig } from "./types.js";
import { weightedDesired } from "./weighted.js";
export type { LbIpRow, LbTargetConfig } from "./types.js";
export { isHealthy } from "./health.js";
export { withBindingLock } from "./binding-lock.js";
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
export function selectActiveIpsByMode(
config: LbTargetConfig,
rows: LbIpRow[],
nowMs = Date.now(),
): string[] {
if (rows.length === 0) return [];
if (config.lb_mode === "failover") {
return failoverDesired(rows);
}
// weighted = round_robin on DNS (one A per IP)
if (config.lb_mode === "weighted") {
return weightedDesired(rows, nowMs);
}
return roundRobinDesired(rows);
}
export function strategyLabel(mode: LbMode): string {
if (mode === "failover") return "Failover";
if (mode === "weighted") return "Round Robin (weighted alias)";
if (mode === "weighted") return "Weighted";
return "Round Robin";
}
+24
View File
@@ -0,0 +1,24 @@
import type { LbIpRow } from "./types.js";
import { isHealthy } from "./health.js";
/** Slot length for time-sliced weighted DNS (one A at a time). */
export const WEIGHTED_SLOT_MS = 60_000;
/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */
export const WEIGHTED_DNS_TTL = 60;
export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] {
if (rows.length === 0) return [];
const healthy = rows.filter((r) => isHealthy(r.health));
const pool = healthy.length > 0 ? healthy : rows;
if (pool.length === 1) return [pool[0]!.ip];
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
const cycle: string[] = [];
for (const row of sorted) {
const weight = Math.max(1, Math.round(row.weight));
for (let i = 0; i < weight; i++) cycle.push(row.ip);
}
const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;
return [cycle[slot]!];
}
+132 -31
View File
@@ -32,6 +32,7 @@ import {
isHealthy,
selectActiveIpsByMode,
withBindingLock,
WEIGHTED_DNS_TTL,
type LbIpRow,
type LbTargetConfig,
} from "./routing/index.js";
@@ -39,6 +40,12 @@ import {
export type { LbIpRow, LbTargetConfig };
export { selectActiveIpsByMode };
const AUTO_DNS_TTL = 1;
function ttlForLbMode(mode: LbMode): number {
return mode === "weighted" ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
}
export function failoverARecordDiff(
existingA: readonly string[],
desiredIps: readonly string[],
@@ -276,6 +283,23 @@ function computeActiveIps(
return selectActiveIpsByMode(state.config, state.rows);
}
function desiredAIps(
db: Db,
scope: HealthCheckScope,
refId: number,
fallbackIps: string[],
): string[] {
const config =
scope === "binding"
? getBindingLbState(db, refId).config
: getGroupLbState(db, refId).config;
if (config.lb_mode === "weighted" || config.health_check_enabled) {
const activeIps = computeActiveIps(db, scope, refId);
if (activeIps.length > 0) return activeIps;
}
return fallbackIps;
}
async function collectKnownZones(
db: Db,
cf: CloudflareClient,
@@ -613,7 +637,16 @@ async function syncBindingDns(
return;
}
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
const binding = repos.getBinding(db, bindingId);
await syncBindingADns(
db,
cf,
bindingId,
domainId,
hostname,
desiredIps,
ttlForLbMode(binding.lb_mode),
);
}
async function syncBindingCnameDns(
@@ -700,6 +733,7 @@ async function syncBindingADns(
domainId: number,
hostname: string,
desiredIps: string[],
ttl: number,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -735,13 +769,15 @@ async function syncBindingADns(
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
const recordName = dnsNameForBinding(hostname, zoneName);
let recordId: number;
if (existing) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
@@ -759,12 +795,24 @@ async function syncBindingADns(
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
recordId = adopted.id;
if (
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
adopted.ttl !== ttl
) {
await dnsService.update(db, cf, domainId, adopted.id, {
record_type: "A",
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
} else {
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
name: recordName,
content: ip,
ttl: 1,
ttl,
proxied: false,
});
repos.linkBindingRecord(db, bindingId, record.id);
@@ -1001,12 +1049,7 @@ async function syncServiceBindingsToDns(
}
validateTargetIpsInPool(targetIps, ips);
if (binding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
targetIps = activeIps;
}
}
const desiredIps = desiredAIps(db, "binding", binding.id, targetIps);
await syncBindingDns(
db,
@@ -1014,7 +1057,7 @@ async function syncServiceBindingsToDns(
binding.id,
binding.domain_id,
binding.hostname,
targetIps,
desiredIps,
null,
);
}
@@ -1046,6 +1089,7 @@ async function syncGroupDomainDnsRecords(
domainId: number,
hostname: string,
desiredIps: string[],
ttl: number = AUTO_DNS_TTL,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
@@ -1061,14 +1105,16 @@ async function syncGroupDomainDnsRecords(
if (desiredIps.length === 0) return;
const refreshed = repos.listGroupDnsRecords(db, groupId);
const recordName = dnsNameForBinding(hostname, zoneName);
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
if (existing) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
@@ -1084,13 +1130,25 @@ async function syncGroupDomainDnsRecords(
);
if (adopted) {
repos.linkGroupDnsRecord(db, groupId, adopted.id);
if (
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
adopted.ttl !== ttl
) {
await dnsService.update(db, cf, domainId, adopted.id, {
record_type: "A",
name: recordName,
content: ip,
ttl,
proxied: false,
});
}
continue;
}
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
name: recordName,
content: ip,
ttl: 1,
ttl,
proxied: false,
});
repos.linkGroupDnsRecord(db, groupId, record.id);
@@ -1141,9 +1199,8 @@ async function syncGroupDomainDns(
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const desiredIps = group.health_check_enabled
? computeActiveIps(db, "group", groupId)
: await collectGroupDnsIps(db, groupId);
const fallbackIps = await collectGroupDnsIps(db, groupId);
const desiredIps = desiredAIps(db, "group", groupId, fallbackIps);
await syncGroupDomainDnsRecords(
db,
cf,
@@ -1151,6 +1208,7 @@ async function syncGroupDomainDns(
domainId,
hostname,
desiredIps,
ttlForLbMode(group.lb_mode),
);
}
@@ -1331,14 +1389,7 @@ export async function updateConfig(
}
if (pushDns) {
let effectiveIps = targetIps;
const refreshedBinding = repos.getBinding(db, binding.id);
if (refreshedBinding.health_check_enabled) {
const activeIps = computeActiveIps(db, "binding", binding.id);
if (activeIps.length > 0) {
effectiveIps = activeIps;
}
}
const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps);
await syncBindingDns(
db,
cf,
@@ -1644,7 +1695,7 @@ export async function reconcileDnsForTarget(
if (scope === "binding") {
await withBindingLock(refId, async () => {
const binding = repos.getBinding(db, refId);
if (!binding.health_check_enabled) return;
if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return;
const service = repos.getService(db, binding.service_id);
if (!shouldPushDns(db, service)) return;
const cnameTarget = binding.cname_target?.trim() || null;
@@ -1652,8 +1703,7 @@ export async function reconcileDnsForTarget(
const ips = repos.listServiceIps(db, service.id);
const targetIps = repos.listBindingIps(db, binding.id);
validateTargetIpsInPool(targetIps, ips);
const activeIps = computeActiveIps(db, "binding", refId);
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
const desiredIps = desiredAIps(db, "binding", refId, targetIps);
await syncBindingDns(
db,
cf,
@@ -1668,8 +1718,59 @@ export async function reconcileDnsForTarget(
}
const group = repos.getServiceGroup(db, refId);
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
if (!group.enabled || !group.domain?.trim()) {
return;
}
if (!group.health_check_enabled && group.lb_mode !== "weighted") {
return;
}
await syncGroupDomainDns(db, cf, refId);
}
export async function reconcileWeightedDns(
db: Db,
cf: CloudflareClient,
): Promise<number> {
let n = 0;
for (const binding of repos.listAllBindings(db)) {
if (binding.lb_mode !== "weighted") continue;
if (binding.cname_target?.trim()) continue;
try {
await withBindingLock(binding.id, async () => {
const latest = repos.getBinding(db, binding.id);
if (latest.lb_mode !== "weighted") return;
if (latest.cname_target?.trim()) return;
const service = repos.getService(db, latest.service_id);
if (!shouldPushDns(db, service)) return;
const targetIps = repos.listBindingIps(db, latest.id);
if (targetIps.length === 0) return;
const ips = repos.listServiceIps(db, service.id);
validateTargetIpsInPool(targetIps, ips);
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
await syncBindingDns(
db,
cf,
latest.id,
latest.domain_id,
latest.hostname,
desiredIps,
null,
);
n += 1;
});
} catch {
continue;
}
}
for (const group of repos.listServiceGroups(db)) {
if (group.lb_mode !== "weighted") continue;
if (!group.enabled || !group.domain?.trim()) continue;
try {
await syncGroupDomainDns(db, cf, group.id);
n += 1;
} catch {
continue;
}
}
return n;
}
@@ -0,0 +1,39 @@
import type { FastifyInstance } from "fastify";
import { AsyncTask, SimpleIntervalJob } from "toad-scheduler";
import * as serviceConfigService from "./service-config-service.js";
import { WEIGHTED_SLOT_MS } from "./routing/weighted.js";
export const WEIGHTED_DNS_JOB_ID = "weighted-dns";
export function createWeightedDnsTask(app: FastifyInstance): AsyncTask {
return new AsyncTask(
WEIGHTED_DNS_JOB_ID,
async () => {
const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf);
if (n > 0) {
app.log.info({ reconciled: n }, "weighted dns rotated");
}
},
(err) => {
app.log.warn({ err }, "weighted dns rotate failed");
},
);
}
export function scheduleWeightedDnsJob(
app: FastifyInstance,
task: AsyncTask,
): void {
const scheduler = app.scheduler;
if (!scheduler) return;
if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {
scheduler.removeById(WEIGHTED_DNS_JOB_ID);
}
scheduler.addSimpleIntervalJob(
new SimpleIntervalJob(
{ seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true },
task,
{ id: WEIGHTED_DNS_JOB_ID, preventOverrun: true },
),
);
}
+43 -10
View File
@@ -4,6 +4,7 @@ import {
type LbIpRow,
type LbTargetConfig,
} from "../src/services/service-config-service.js";
import { WEIGHTED_SLOT_MS } from "../src/services/routing/weighted.js";
function row(
ip: string,
@@ -17,6 +18,11 @@ function row(
};
}
const weightedConfig: LbTargetConfig = {
lb_mode: "weighted",
health_check_enabled: true,
};
describe("selectActiveIpsByMode", () => {
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
const config: LbTargetConfig = {
@@ -75,22 +81,49 @@ describe("selectActiveIpsByMode", () => {
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
});
it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => {
const config: LbTargetConfig = {
lb_mode: "weighted",
health_check_enabled: true,
};
it("weighted 1:3 picks the lighter ip on slot 0 and the heavier on slot 1", () => {
const rows = [
row("1.1.1.1", { weight: 3, health: "up" }),
row("2.2.2.2", { weight: 1, health: "up" }),
row("3.3.3.3", { weight: 2, health: "down" }),
row("1.1.1.1", { weight: 1, health: "up" }),
row("2.2.2.2", { weight: 3, health: "up" }),
];
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
"1.1.1.1",
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
"2.2.2.2",
]);
});
it("weighted excludes down ips from the cycle", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "up" }),
row("2.2.2.2", { weight: 3, health: "down" }),
];
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
).toEqual(["1.1.1.1"]);
});
it("weighted with one ip always returns that ip", () => {
expect(
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
).toEqual(["1.1.1.1"]);
});
it("weighted with all unknown rotates across every ip", () => {
const rows = [
row("1.1.1.1", { weight: 1, health: "unknown" }),
row("2.2.2.2", { weight: 3, health: "unknown" }),
];
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
"2.2.2.2",
]);
});
it("weighted returns empty array for no rows", () => {
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
});
it("round_robin excludes unknown when another ip is up", () => {
const config: LbTargetConfig = {
lb_mode: "round_robin",
@@ -1,6 +1,7 @@
import { FormFieldSimple } from '@/components/form-field'
import { AppInput } from '@/components/app-input'
import { SettingRow } from '@/components/setting-row'
import { SelectField } from '@/components/select-field'
import { Badge } from '@/components/reui/badge'
import {
NumberField,
@@ -9,13 +10,6 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Button } from '@cfdm/ui/components/button'
@@ -59,7 +53,7 @@ export interface LbAndHealthConfig extends HealthCheckConfig {
const defaultLbModeOptions = [
{ value: 'round_robin', label: 'Round Robin' },
{ value: 'failover', label: 'Failover (приоритет)' },
{ value: 'weighted', label: 'Weighted (веса)' },
{ value: 'weighted', label: 'Веса (подмена IP)' },
]
export interface LbPoolMetaChange {
@@ -126,7 +120,7 @@ function PoolLbMetaFields({
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
description={
isWeighted
? 'Больше — чаще в пуле'
? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)'
: '1 — основной, больше — запасной'
}
compact
@@ -230,22 +224,14 @@ export function HealthCheckConfigFields({
compact
className={rowClass}
>
<Select
<SelectField
modal={false}
value={value.lb_mode}
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
>
<SelectTrigger id={`${idPrefix}-lb-mode`} className="w-full">
<SelectValue placeholder="Выберите режим" />
</SelectTrigger>
<SelectContent>
{lbModeOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
triggerId={`${idPrefix}-lb-mode`}
placeholder="Выберите режим"
options={lbModeOptions}
/>
</SettingRow>
) : null}
@@ -71,7 +71,7 @@ const LB_MODE_META: Record<
weighted: {
icon: ScaleIcon,
className: 'text-info',
label: 'Weighted (веса)',
label: 'Веса (подмена IP)',
},
}