Files
bucketeer/internal/pipeline/pipeline.go
T
StasonJatham 9710efec1a fix memory blowup and keep direct matches from being dropped
- dns.TTLCache: stop preallocating every shard map to maxShard; this
  allocated the full theoretical cache capacity up front (hundreds of MB
  of empty buckets) and pushed the container over its memory limit,
  causing an OOM restart loop under Docker's default 1g cap.
- pipeline: write direct provider matches immediately without a blocking
  DNS enrichment step, so they can no longer be starved by the optional
  CNAME queue. Add TestDirectNotStarvedByDNSBacklog.
- config: add DEDUP_SIZE, lower DNS_CACHE_SIZE/DEDUP_SIZE defaults to 256k.
- .env.example / README: document DEDUP_SIZE and cache sizing.

Live: stable at ~250-300 MiB with no restarts, direct queue draining to 0.
2026-09-22 13:33:05 +02:00

337 lines
7.9 KiB
Go

// Package pipeline wires the in-process CertSpotter identifier callback to the
// classifier, DNS enrichment and the batched PostgreSQL writer using bounded
// queues throughout.
package pipeline
import (
"context"
"net"
"sync"
"time"
"software.sslmate.com/src/certspotter"
"tea.karlcom.de/karl/bucketeer/internal/classify"
"tea.karlcom.de/karl/bucketeer/internal/dns"
"tea.karlcom.de/karl/bucketeer/internal/metrics"
"tea.karlcom.de/karl/bucketeer/internal/model"
)
// Finding is a fully enriched candidate ready to be persisted.
type Finding struct {
model.Candidate
CNAMEChain []string
IPv4 []string
IPv6 []string
}
// WriteFunc persists a batch of findings.
type WriteFunc func(ctx context.Context, batch []Finding) error
type Config struct {
DirectQueueSize int
DNSQueueSize int
DBQueueSize int
DirectWorkers int
DNSWorkers int
BatchSize int
FlushInterval time.Duration
DedupTTL time.Duration
DedupSize int
Enricher *dns.Enricher
Write WriteFunc
Metrics *metrics.Metrics
}
// Pipeline is the certificate-to-database data flow. It is safe to call
// HandleIdentifiers from the CertSpotter monitor goroutines concurrently.
type Pipeline struct {
cfg Config
directQ chan model.Candidate
dnsQ chan string
dbQ chan Finding
dedup *dns.TTLCache[bool] // hostnames already queued for DNS
seen *dns.TTLCache[bool] // provider+service+provider_hostname already written
producer sync.WaitGroup
workers sync.WaitGroup
}
func New(cfg Config) *Pipeline {
if cfg.DirectQueueSize <= 0 {
cfg.DirectQueueSize = 8192
}
if cfg.DNSQueueSize <= 0 {
cfg.DNSQueueSize = 8192
}
if cfg.DBQueueSize <= 0 {
cfg.DBQueueSize = 8192
}
if cfg.DirectWorkers <= 0 {
cfg.DirectWorkers = 2
}
if cfg.DNSWorkers <= 0 {
cfg.DNSWorkers = 16
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = 500
}
if cfg.FlushInterval <= 0 {
cfg.FlushInterval = 2 * time.Second
}
if cfg.DedupTTL <= 0 {
cfg.DedupTTL = time.Hour
}
if cfg.DedupSize <= 0 {
cfg.DedupSize = 1 << 20
}
p := &Pipeline{
cfg: cfg,
directQ: make(chan model.Candidate, cfg.DirectQueueSize),
dnsQ: make(chan string, cfg.DNSQueueSize),
dbQ: make(chan Finding, cfg.DBQueueSize),
dedup: dns.NewTTLCache[bool](cfg.DedupSize, 64),
seen: dns.NewTTLCache[bool](cfg.DedupSize, 64),
}
if p.cfg.Metrics != nil {
p.cfg.Metrics.SetQueueDepth("direct", func() float64 { return float64(len(p.directQ)) })
p.cfg.Metrics.SetQueueDepth("dns", func() float64 { return float64(len(p.dnsQ)) })
p.cfg.Metrics.SetQueueDepth("db", func() float64 { return float64(len(p.dbQ)) })
}
return p
}
// Start launches the worker pools. Close must be called to drain.
func (p *Pipeline) Start(ctx context.Context) {
for i := 0; i < p.cfg.DirectWorkers; i++ {
p.producer.Add(1)
go p.directWorker(ctx)
}
for i := 0; i < p.cfg.DNSWorkers; i++ {
p.producer.Add(1)
go p.dnsWorker(ctx)
}
p.workers.Add(1)
go p.dbWorker(ctx)
}
// Close stops intake and drains all queues. It must only be called after the
// CertSpotter monitor has stopped calling HandleIdentifiers.
func (p *Pipeline) Close() {
close(p.directQ)
close(p.dnsQ)
p.producer.Wait()
close(p.dbQ)
p.workers.Wait()
}
// HandleIdentifiers implements certspotter.IdentifierSink. It is deliberately
// allocation-light and never blocks: queues are bounded and overflow is dropped.
func (p *Pipeline) HandleIdentifiers(ctx context.Context, ids *certspotter.Identifiers) {
if p.cfg.Metrics != nil {
p.cfg.Metrics.CTCertificates.Add(1)
p.cfg.Metrics.CTIdentifiers.Add(uint64(len(ids.DNSNames)))
}
for _, host := range ids.DNSNames {
p.HandleHost(host)
}
}
// HandleHost classifies a single hostname and enqueues it for direct handling
// or, when it does not match, for DNS/CNAME enrichment.
func (p *Pipeline) HandleHost(host string) {
if m, ok := classify.Classify(host); ok {
if !m.ResourceSpecific {
// Generic service endpoints (bucket name in the path) are not
// customer resources; do not create findings for them.
return
}
if p.cfg.Metrics != nil {
p.cfg.Metrics.DirectMatches.Add(1)
p.cfg.Metrics.Candidates.Add(1)
}
if !p.markSeen(host) {
return
}
cand := candidateFrom(host, host, m, model.SourceDirectCT)
select {
case p.directQ <- cand:
default:
p.drop()
}
return
}
if !classify.ShouldEnrich(host) {
return
}
if _, dup := p.dedup.Get(host); dup {
return
}
p.dedup.Set(host, true, p.cfg.DedupTTL)
select {
case p.dnsQ <- host:
default:
p.drop()
}
}
func (p *Pipeline) drop() {
if p.cfg.Metrics != nil {
p.cfg.Metrics.QueueFull.Add(1)
p.cfg.Metrics.QueueDrops.Add(1)
}
}
func candidateFrom(host, original string, m model.Match, src model.Source) model.Candidate {
return model.Candidate{
Hostname: host,
Original: original,
Region: m.Region,
Resource: m.Resource,
Provider: m.Provider,
Service: m.Service,
Source: src,
PublicSemantic: m.PublicSemantic,
ResourceSpecific: m.ResourceSpecific,
}
}
func appendIPs(f *Finding, ips []net.IP) {
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
f.IPv4 = append(f.IPv4, v4.String())
} else {
f.IPv6 = append(f.IPv6, ip.String())
}
}
}
func (p *Pipeline) directWorker(ctx context.Context) {
defer p.producer.Done()
for {
select {
case <-ctx.Done():
return
case c, ok := <-p.directQ:
if !ok {
return
}
// Direct provider matches are written immediately and are never
// queued behind optional DNS work, so they cannot be shed in favour
// of speculative CNAME enrichment. The matched provider hostname is
// the finding; A/AAAA enrichment is a DNS-bound luxury that the
// shared resolver cannot keep up with at these volumes.
p.pushDB(ctx, Finding{Candidate: c})
}
}
}
func (p *Pipeline) dnsWorker(ctx context.Context) {
defer p.producer.Done()
for {
select {
case <-ctx.Done():
return
case host, ok := <-p.dnsQ:
if !ok {
return
}
if len(p.dbQ) >= cap(p.dbQ)/2 {
// Load shedding: optional CNAME enrichment is dropped before
// direct matches when the database writer is backed up.
p.drop()
continue
}
p.enrichHost(ctx, host)
}
}
}
func (p *Pipeline) enrichHost(ctx context.Context, host string) {
res, ok := p.cfg.Enricher.Resolve(ctx, host)
if !ok {
return
}
for _, target := range res.Chain[1:] {
m, matched := classify.Classify(target)
if !matched || !m.ResourceSpecific {
continue
}
if p.cfg.Metrics != nil {
p.cfg.Metrics.CNAMEMatches.Add(1)
p.cfg.Metrics.Candidates.Add(1)
}
if !p.markSeen(target) {
continue
}
f := Finding{
Candidate: candidateFrom(target, host, m, model.SourceCNAME),
CNAMEChain: res.Chain,
}
appendIPs(&f, res.IPs)
p.pushDB(ctx, f)
}
}
// markSeen dedups by hostname alone: a given hostname always classifies to the
// same provider/service, so no key concatenation (and no allocation) is needed.
func (p *Pipeline) markSeen(host string) bool {
if _, dup := p.seen.Get(host); dup {
return false
}
p.seen.Set(host, true, p.cfg.DedupTTL)
return true
}
func (p *Pipeline) pushDB(ctx context.Context, f Finding) {
select {
case p.dbQ <- f:
default:
p.drop()
}
}
func (p *Pipeline) dbWorker(ctx context.Context) {
defer p.workers.Done()
batch := make([]Finding, 0, p.cfg.BatchSize)
ticker := time.NewTicker(p.cfg.FlushInterval)
defer ticker.Stop()
flush := func() {
if len(batch) == 0 {
return
}
if p.cfg.Write != nil {
if err := p.cfg.Write(ctx, batch); err != nil {
if p.cfg.Metrics != nil {
p.cfg.Metrics.DBErrors.Add(1)
}
} else if p.cfg.Metrics != nil {
p.cfg.Metrics.DBRows.Add(uint64(len(batch)))
}
}
batch = batch[:0]
}
for {
select {
case <-ctx.Done():
flush()
return
case f, ok := <-p.dbQ:
if !ok {
flush()
return
}
batch = append(batch, f)
if len(batch) >= p.cfg.BatchSize {
flush()
}
case <-ticker.C:
flush()
}
}
}