Files
weave-scope/probe/endpoint/connection_tracker.go
Bryan Boreham de3c34ddc6 performance(probe): thin out many connections between the same point
The app will only show one line, regardless of how many connections we
have, so reduce the number to save bandwidth and rendering time.

We filter by choosing a modulus, e.g. send every connection that is a
multiple of 3, or 9, and so on. We avoid multiples of 2 because port
numbers are often a multiple of 2 or 4 for bit-encoding reasons.
2020-01-13 08:53:47 +00:00

383 lines
12 KiB
Go

// +build linux
package endpoint
import (
"net"
"strconv"
"time"
log "github.com/sirupsen/logrus"
"github.com/typetypetype/conntrack"
"github.com/weaveworks/scope/probe/endpoint/procspy"
"github.com/weaveworks/scope/probe/process"
"github.com/weaveworks/scope/report"
)
type connectionTracker struct {
conf ReporterConfig
flowWalker flowWalker // Interface
ebpfTracker *EbpfTracker
reverseResolver *reverseResolver
// time of the previous ebpf failure, or zero if it didn't fail
ebpfLastFailureTime time.Time
}
func newConnectionTracker(conf ReporterConfig) connectionTracker {
ct := connectionTracker{
conf: conf,
reverseResolver: newReverseResolver(),
}
if conf.UseEbpfConn {
et, err := newEbpfTracker()
if err == nil {
ct.ebpfTracker = et
go feedEBPFInitialState(conf, et)
return ct
}
log.Warnf("Error setting up the eBPF tracker, falling back to proc scanning: %v", err)
}
ct.useProcfs()
return ct
}
func flowToTuple(f conntrack.Conn) (ft fourTuple) {
if f.Orig.Dst.Equal(f.Reply.Src) {
return makeFourTuple(f.Orig.Src, f.Orig.Dst, uint16(f.Orig.SrcPort), uint16(f.Orig.DstPort))
}
// Handle DNAT-ed connections in the initial state
return makeFourTuple(f.Orig.Dst, f.Orig.Src, uint16(f.Orig.DstPort), uint16(f.Orig.SrcPort))
}
func (t *connectionTracker) useProcfs() {
t.ebpfTracker = nil
if t.conf.WalkProc && t.conf.Scanner == nil {
t.conf.Scanner = procspy.NewConnectionScanner(t.conf.ProcessCache, t.conf.SpyProcs)
}
if t.flowWalker == nil {
t.flowWalker = newConntrackFlowWalker(t.conf.UseConntrack, t.conf.ProcRoot, t.conf.BufferSize, false /* natOnly */)
}
}
// ReportConnections calls trackers according to the configuration.
func (t *connectionTracker) ReportConnections(rpt *report.Report) {
hostNodeID := report.MakeHostNodeID(t.conf.HostID)
if t.ebpfTracker != nil {
if !t.ebpfTracker.isDead() {
t.performEbpfTrack(rpt, hostNodeID)
return
}
// We only restart the EbpfTracker if the failures are not too frequent to
// avoid repeatitive restarts.
ebpfLastFailureTime := t.ebpfLastFailureTime
t.ebpfLastFailureTime = time.Now()
if ebpfLastFailureTime.After(time.Now().Add(-1 * time.Minute)) {
// Multiple failures in the last minute, fall back to proc parsing
log.Warnf("ebpf tracker died again, gently falling back to proc scanning")
t.useProcfs()
} else {
// Tolerable failure rate, restart the tracker
log.Warnf("ebpf tracker died, restarting it")
err := t.ebpfTracker.restart()
if err == nil {
feedEBPFInitialState(t.conf, t.ebpfTracker)
t.performEbpfTrack(rpt, hostNodeID)
return
}
log.Warnf("could not restart ebpf tracker, falling back to proc scanning: %v", err)
t.useProcfs()
}
}
// consult the flowWalker for short-lived (conntracked) connections
seenTuples := map[string]fourTuple{}
t.flowWalker.walkFlows(func(f conntrack.Conn, alive bool) {
tuple := flowToTuple(f)
seenTuples[tuple.key()] = tuple
t.addConnection(rpt, "", tuple, 0, 0, 0, 1)
})
if t.conf.WalkProc && t.conf.Scanner != nil {
t.performWalkProc(rpt, hostNodeID, seenTuples)
}
}
func existingFlowsFromConntrack(conf ReporterConfig) map[string]fourTuple {
seenTuples := map[string]fourTuple{}
if !conf.UseConntrack {
// log.Warnf("Not using conntrack: disabled")
} else if err := IsConntrackSupported(conf.ProcRoot); err != nil {
log.Warnf("Not using conntrack: not supported by the kernel: %s", err)
} else if existingFlows, err := conntrack.ConnectionsSize(conf.BufferSize); err != nil {
log.Errorf("conntrack existingConnections error: %v", err)
} else {
for _, f := range existingFlows {
if (f.Status & conntrack.IPS_NAT_MASK) == 0 {
continue
}
tuple := flowToTuple(f)
seenTuples[tuple.key()] = tuple
}
}
return seenTuples
}
func (t *connectionTracker) performWalkProc(rpt *report.Report, hostNodeID string, seenTuples map[string]fourTuple) error {
conns, err := t.conf.Scanner.Connections()
if err != nil {
return err
}
for conn := conns.Next(); conn != nil; conn = conns.Next() {
tuple, namespaceID, incoming := connectionTuple(conn, seenTuples)
if incoming {
t.addConnection(rpt, hostNodeID, reverse(tuple), 0, conn.Proc.PID, namespaceID, 1)
} else {
t.addConnection(rpt, hostNodeID, tuple, conn.Proc.PID, 0, namespaceID, 1)
}
}
return nil
}
// feedEBPFInitialState runs conntrack and proc parsing synchronously only
// once to initialize ebpfTracker
// This is run on a background goroutine during initial setup, so does
// not take *connectionTracker which could change under it
func feedEBPFInitialState(conf ReporterConfig, ebpfTracker *EbpfTracker) {
var processCache *process.CachingWalker
walker := process.NewWalker(conf.ProcRoot, true)
processCache = process.NewCachingWalker(walker)
processCache.Tick()
scanner := procspy.NewSyncConnectionScanner(processCache, conf.SpyProcs)
// Consult conntrack to get the initial state
seenTuples := existingFlowsFromConntrack(conf)
conns, err := scanner.Connections()
if err != nil {
log.Errorf("Error initializing ebpfTracker while scanning /proc, continuing without initial connections: %s", err)
}
scanner.Stop()
processesWaitingInAccept := []int{}
processCache.Walk(func(p, prev process.Process) {
if p.IsWaitingInAccept {
processesWaitingInAccept = append(processesWaitingInAccept, p.PID)
}
})
ebpfTracker.feedInitialConnections(conns, seenTuples, processesWaitingInAccept, report.MakeHostNodeID(conf.HostID))
}
type pidPair struct {
fromPid uint32 // zero if unknown
toPid uint32
}
type mapPortToPids map[uint16]pidPair
func (t *connectionTracker) performEbpfTrack(rpt *report.Report, hostNodeID string) error {
/* Collect the connections by from/to address pairs (scoped by namespace) plus destination port
There are three main cases:
* connections from address+port off-box to a local process
- in this case we know the pid of the local process
* connections from local processes to an off-box address+port
- we will know the pids of the local processes but not the remote
* connections from local processes to a local process
- these connections will each be reported twice by ebpf, as incoming and as outgoing.
*/
type triple struct {
fromAddr, toAddr [net.IPv4len]byte
networkNamespace uint32
toPort uint16
}
connectionsByTriple := make(map[triple]mapPortToPids, 1000)
t.ebpfTracker.walkConnections(func(key ebpfKey, e ebpfDetail) {
var t triple
var fromPort uint16
if e.incoming {
t = triple{
fromAddr: key.toAddr,
toAddr: key.fromAddr,
toPort: key.fromPort,
networkNamespace: key.networkNamespace,
}
fromPort = key.toPort
} else {
t = triple{
fromAddr: key.fromAddr,
toAddr: key.toAddr,
toPort: key.toPort,
networkNamespace: key.networkNamespace,
}
fromPort = key.fromPort
}
portToPids := connectionsByTriple[t]
if portToPids == nil {
portToPids = make(mapPortToPids)
}
pids := portToPids[fromPort]
if e.incoming {
pids.toPid = e.pid
} else {
pids.fromPid = e.pid
}
portToPids[fromPort] = pids
connectionsByTriple[t] = portToPids
})
for triple, portToPids := range connectionsByTriple {
filter, count := makeFilter(portToPids)
i, sent, skipped := 0, 0, 0
// Now do the actual sends
for fromPort, pids := range portToPids {
if filter(fromPort) {
tuple := fourTuple{
fromAddr: triple.fromAddr,
fromPort: fromPort,
toAddr: triple.toAddr,
toPort: triple.toPort,
}
sent++
if sent == count {
skipped += (len(portToPids) - i - 1)
}
t.addConnection(rpt, hostNodeID, tuple, uint(pids.fromPid), uint(pids.toPid), triple.networkNamespace, skipped+1)
skipped = 0
} else {
skipped++
}
i++
}
}
return nil
}
// Pick a subset of the connections to send, such that if two probes
// on different machines go through the same process there is a good
// chance of overlap.
// return value is a function to filter from ports, and a count of how many will match
func makeFilter(ports mapPortToPids) (filter func(uint16) bool, count int) {
var modulus uint16 = 1
count = len(ports)
// Check they all come from/to the same pid (or zero): if differing we need another strategy to thin them down
var firstToPid, firstFromPid uint32
for _, pids := range ports {
firstFromPid = pids.fromPid
firstToPid = pids.toPid
break
}
for _, pids := range ports {
if pids.fromPid != firstFromPid || pids.toPid != firstToPid {
return func(uint16) bool { return true }, count
}
}
const (
power = 3 // Don't use powers of two to reduce aliasing with ephemeral port number selection.
lowerBound = 3
upperBound = 5
)
// Find modulus such that we choose at least the lower bound, and
// ideally no more than the upper bound
for count > upperBound {
modulus *= power
prevCount := count
// Count how many are sent for this modulus
count = 0
for fromPort := range ports {
if (fromPort % modulus) == 0 {
count++
}
}
if count < lowerBound { // too few: step back and stop there
modulus /= power
count = prevCount
break
}
}
return func(port uint16) bool { return (port % modulus) == 0 }, count
}
// tuple is canonicalised - always opened from-to
func (t *connectionTracker) addConnection(rpt *report.Report, hostNodeID string, ft fourTuple, fromPid, toPid uint, namespaceID uint32, connectionCount int) {
extraToNode := map[string]string{}
extraFromNode := map[string]string{}
if fromPid > 0 {
extraFromNode = map[string]string{
process.PID: strconv.FormatUint(uint64(fromPid), 10),
report.HostNodeID: hostNodeID,
}
}
if toPid > 0 {
extraToNode = map[string]string{
process.PID: strconv.FormatUint(uint64(toPid), 10),
report.HostNodeID: hostNodeID,
}
}
if connectionCount > 1 {
// Tell the app we have elided several connections to a common IP and port onto this one
extraFromNode[report.ConnectionCount] = strconv.Itoa(connectionCount)
}
var (
fromAddr = net.IP(ft.fromAddr[:])
fromNode = t.makeEndpointNode(namespaceID, fromAddr, ft.fromPort, extraFromNode)
toAddr = net.IP(ft.toAddr[:])
toNode = t.makeEndpointNode(namespaceID, toAddr, ft.toPort, extraToNode)
)
rpt.Endpoint.AddNode(fromNode.WithAdjacent(toNode.ID))
rpt.Endpoint.AddNode(toNode)
t.addDNS(rpt, fromAddr.String())
t.addDNS(rpt, toAddr.String())
}
func (t *connectionTracker) makeEndpointNode(namespaceID uint32, addr net.IP, port uint16, extra map[string]string) report.Node {
node := report.MakeNodeWith(report.MakeEndpointNodeIDB(t.conf.HostID, namespaceID, addr, port), nil)
if len(extra) > 0 {
node = node.WithLatests(extra)
}
return node
}
// Add DNS record for address to report, if not already there
func (t *connectionTracker) addDNS(rpt *report.Report, addr string) {
if _, found := rpt.DNS[addr]; !found {
forward := t.conf.DNSSnooper.CachedNamesForIP(addr)
record := report.DNSRecord{
Forward: report.MakeStringSet(forward...),
}
if names, err := t.reverseResolver.get(addr); err == nil && len(names) > 0 {
record.Reverse = report.MakeStringSet(names...)
}
rpt.DNS[addr] = record
}
}
func (t *connectionTracker) Stop() error {
if t.ebpfTracker != nil {
t.ebpfTracker.stop()
}
if t.flowWalker != nil {
t.flowWalker.stop()
}
t.reverseResolver.stop()
return nil
}
func connectionTuple(conn *procspy.Connection, seenTuples map[string]fourTuple) (fourTuple, uint32, bool) {
tuple := makeFourTuple(conn.LocalAddress, conn.RemoteAddress, conn.LocalPort, conn.RemotePort)
// If we've already seen this connection, we should know the direction
// (or have already figured it out), so we normalize and use the
// canonical direction. Otherwise, we can use a port-heuristic to guess
// the direction.
canonical, ok := seenTuples[tuple.key()]
incoming := (ok && canonical != tuple) || (!ok && tuple.fromPort < tuple.toPort)
return tuple, conn.Proc.NetNamespaceID, incoming
}