From 064faf80a454ead06e3f35857be38a4b3bcfcf90 Mon Sep 17 00:00:00 2001 From: Etienne Perot Date: Wed, 8 Mar 2023 17:02:43 -0800 Subject: [PATCH] `runsc metric-server`: Optimize memory usage and allocation-heavy functions. This is an effort to reduce it to be a well-behaved background process. With 110 sandboxes running, at rest, this goes from ``` VmRSS: 72376 kB RssAnon: 51944 kB ``` to: ``` VmRSS: 45864 kB RssAnon: 25788 kB ``` This GCs much more aggressively, including after every single request, which means we do spend disproportionately more CPU in order to get that low memory usage. From my testing, serving requests takes about 12% more CPU, and it's all spent in GC. The optimizations that went into this are: - Add a method in `state` to discard the global type maps. - Add a custom "packed" number type in `prometheus` library that encodes small integers and floating-point numbers in 32 bits whenever possible without loss of precision, otherwise they are encoded in their full 64-bit glory and the 32-bit representation is used as a pointer to the 64-bit representation. These are stored either per-sandbox (for static-after-sandbox-creation numbers like distribution bucket boundaries), or per-metric-retrieval attempt otherwise. - Use string interning for commonly-seen strings across sandboxes, like metric names and label names. Label values are also interned, but only at a per-sandbox granularity. - Reworked allocation-heavy functions like `OrderedLabels` and some string rendering functions to be (almost) allocation-free. This doesn't reduce memory usage at rest, and does increase their CPU cost, but in return it significantly cuts down on the percentage of CPU time spent in GC (>50% -> 25%) enough to justify spending the extra CPU in these functions. PiperOrigin-RevId: 515181387 --- pkg/prometheus/prometheus.go | 433 +++++++++++++++++++++++++--- pkg/prometheus/prometheus_test.go | 145 +++++++++- pkg/prometheus/prometheus_verify.go | 321 ++++++++++++++++++--- pkg/state/types.go | 8 + runsc/cmd/BUILD | 1 + runsc/cmd/metric_server.go | 43 ++- 6 files changed, 855 insertions(+), 96 deletions(-) diff --git a/pkg/prometheus/prometheus.go b/pkg/prometheus/prometheus.go index 024f5d9b4..270524698 100644 --- a/pkg/prometheus/prometheus.go +++ b/pkg/prometheus/prometheus.go @@ -19,6 +19,7 @@ package prometheus import ( "bufio" + "errors" "fmt" "io" "math" @@ -65,8 +66,24 @@ type Metric struct { // writeHeaderTo writes the metric comment header to the given writer. func (m *Metric) writeHeaderTo(w io.Writer, options SnapshotExportOptions) error { if m.Help != "" { - // Prometheus metric description escape rules: Only backslashes and line breaks need escaping. - if _, err := io.WriteString(w, fmt.Sprintf("# HELP %s%s %s\n", options.ExporterPrefix, m.Name, strings.ReplaceAll(strings.ReplaceAll(m.Help, "\\", "\\\\"), "\n", "\\n"))); err != nil { + // This writes each string component one by one (rather than using fmt.Sprintf) + // in order to avoid allocating strings for each metric. + if _, err := io.WriteString(w, "# HELP "); err != nil { + return err + } + if _, err := io.WriteString(w, options.ExporterPrefix); err != nil { + return err + } + if _, err := io.WriteString(w, m.Name); err != nil { + return err + } + if _, err := io.WriteString(w, " "); err != nil { + return err + } + if _, err := writeEscapedString(w, m.Help, false); err != nil { + return err + } + if _, err := io.WriteString(w, "\n"); err != nil { return err } } @@ -82,7 +99,22 @@ func (m *Metric) writeHeaderTo(w io.Writer, options SnapshotExportOptions) error metricType = "untyped" } if metricType != "" { - if _, err := io.WriteString(w, fmt.Sprintf("# TYPE %s%s %s\n", options.ExporterPrefix, m.Name, metricType)); err != nil { + if _, err := io.WriteString(w, "# TYPE "); err != nil { + return err + } + if _, err := io.WriteString(w, options.ExporterPrefix); err != nil { + return err + } + if _, err := io.WriteString(w, m.Name); err != nil { + return err + } + if _, err := io.WriteString(w, " "); err != nil { + return err + } + if _, err := io.WriteString(w, metricType); err != nil { + return err + } + if _, err := io.WriteString(w, "\n"); err != nil { return err } } @@ -105,6 +137,47 @@ type Number struct { Int int64 `json:"int,omitempty"` } +// Common numbers which are reused and don't need their own memory allocations. +var ( + zero = Number{} + intOne = Number{Int: 1} + floatOne = Number{Float: 1.0} + floatNaN = Number{Float: math.NaN()} + floatInf = Number{Float: math.Inf(1)} + floatNegInf = Number{Float: math.Inf(-1)} +) + +// NewInt returns a new integer Number. +func NewInt(val int64) *Number { + switch val { + case 0: + return &zero + case 1: + return &intOne + default: + return &Number{Int: val} + } +} + +// NewFloat returns a new floating-point Number. +func NewFloat(val float64) *Number { + if math.IsNaN(val) { + return &floatNaN + } + switch val { + case 0: + return &zero + case 1.0: + return &floatOne + case math.Inf(1.0): + return &floatInf + case math.Inf(-1.0): + return &floatNegInf + default: + return &Number{Float: val} + } +} + // IsInteger returns whether this number contains an integer value. // This is defined as either having the `Float` part set to zero (in which case the `Int` part takes // precedence), or having `Float` be a value equal to its own rounding and not a special float. @@ -115,7 +188,7 @@ func (n *Number) IsInteger() bool { if math.IsNaN(n.Float) || n.Float == math.Inf(-1) || n.Float == math.Inf(1) { return false } - return math.Round(n.Float) == n.Float + return n.Float < float64(math.MaxInt64) && n.Float > float64(math.MinInt64) && math.Round(n.Float) == n.Float } // String returns a string representation of this number. @@ -147,7 +220,37 @@ func (n *Number) GreaterThan(other *Number) bool { return n.Float > other.Float } +// writeInteger writes the given integer to a writer without allocating strings. +func writeInteger(w io.Writer, val int64) (int, error) { + const decimalDigits = "0123456789" + if val == 0 { + return io.WriteString(w, decimalDigits[0:1]) + } + var written int + if val < 0 { + n, err := io.WriteString(w, "-") + written += n + if err != nil { + return written, err + } + val = -val + } + decimal := int64(1) + for ; val/decimal != 0; decimal *= 10 { + } + for decimal /= 10; decimal > 0; decimal /= 10 { + digit := (val / decimal) % 10 + n, err := io.WriteString(w, decimalDigits[digit:digit+1]) + written += n + if err != nil { + return written, err + } + } + return written, nil +} + // writeTo writes the number to the given writer. +// This only causes heap allocations when the number is a non-zero, non-special float. func (n *Number) writeTo(w io.Writer) error { var s string switch { @@ -155,9 +258,10 @@ func (n *Number) writeTo(w io.Writer) error { case n.Int == 0 && n.Float == 0: s = "0" - // Integer case: + // Integer case: case n.Int != 0: - s = fmt.Sprintf("%d", n.Int) + _, err := writeInteger(w, n.Int) + return err // Special float cases: case n.Float == math.Inf(-1): @@ -227,7 +331,7 @@ func NewIntData(metric *Metric, val int64) *Data { // LabeledIntData returns a new Data struct with the given metric, labels, and value. func LabeledIntData(metric *Metric, labels map[string]string, val int64) *Data { - return &Data{Metric: metric, Labels: labels, Number: &Number{Int: val}} + return &Data{Metric: metric, Labels: labels, Number: NewInt(val)} } // NewFloatData returns a new Data struct with the given metric and value. @@ -237,7 +341,7 @@ func NewFloatData(metric *Metric, val float64) *Data { // LabeledFloatData returns a new Data struct with the given metric, labels, and value. func LabeledFloatData(metric *Metric, labels map[string]string, val float64) *Data { - return &Data{Metric: metric, Labels: labels, Number: &Number{Float: val}} + return &Data{Metric: metric, Labels: labels, Number: NewFloat(val)} } // ExportOptions contains options that control how metric data is exported in Prometheus format. @@ -262,6 +366,60 @@ type SnapshotExportOptions struct { ExtraLabels map[string]string } +// writeEscapedString writes the given string in quotation marks and with some characters escaped, +// per Prometheus spec. It does this without string allocations. +// If `quoted` is true, quote characters will surround the string, and quote characters within `s` +// will also be escaped. +func writeEscapedString(w io.Writer, s string, quoted bool) (int, error) { + const ( + quote = '"' + backslash = '\\' + newline = '\n' + quoteStr = `"` + escapedQuote = `\\"` + escapedBackslash = "\\\\" + escapedNewline = "\\\n" + ) + written := 0 + var n int + var err error + if quoted { + n, err = io.WriteString(w, quoteStr) + written += n + if err != nil { + return written, err + } + } + for _, r := range s { + switch r { + case quote: + if quoted { + n, err = io.WriteString(w, escapedQuote) + } else { + n, err = io.WriteString(w, quoteStr) + } + case backslash: + n, err = io.WriteString(w, escapedBackslash) + case newline: + n, err = io.WriteString(w, escapedNewline) + default: + n, err = io.WriteString(w, string(r)) + } + written += n + if err != nil { + return written, err + } + } + if quoted { + n, err = io.WriteString(w, quoteStr) + written += n + if err != nil { + return written, err + } + } + return written, nil +} + // writeMetricPreambleTo writes the metric name to the io.Writer. It may also // write unwritten help and type comments of the metric if they haven't been // written to the io.Writer yet. @@ -290,44 +448,198 @@ func (d *Data) writeMetricPreambleTo(w io.Writer, options SnapshotExportOptions, return nil } -// OrderedLabels returns the list of 'label_key="label_value"' in sorted order, except "le" which is -// a reserved Prometheus label name and should go last. -func OrderedLabels(labels ...map[string]string) ([]string, error) { - var le string - totalLabels := 0 - for _, labelMap := range labels { - if leVal, found := labelMap["le"]; found { - le = leVal - totalLabels += len(labelMap) - 1 - } else { - totalLabels += len(labelMap) +// keyVal is a key-value pair used in the function below. +type keyVal struct{ Key, Value string } + +// sortedIterateLabels iterates through labels and outputs them to `out` in sorted key order, +// or stops when cancelCh is written to. It runs in O(n^2) time but makes no heap allocations. +func sortedIterateLabels(labels map[string]string, out chan<- keyVal, cancelCh <-chan struct{}) { + defer close(out) + if len(labels) == 0 { + return + } + + // smallestKey is the smallest key that we've already sent to `out`. + // It starts as the empty string, which means we haven't sent anything to `out` yet. + smallestKey := "" + // Find the smallest key of the whole set and send it out. + for k := range labels { + if smallestKey == "" || k < smallestKey { + smallestKey = k } } - if le != "" { - totalLabels++ + select { + case out <- keyVal{smallestKey, labels[smallestKey]}: + case <-cancelCh: + return } - keys := make(map[string]struct{}, totalLabels) + + // Iterate until we've sent as many items as we have as input to the output channel. + // We start at 1 because the loop above already sent out the smallest key to `out`. + for numOutput := 1; numOutput < len(labels); numOutput++ { + // nextSmallestKey is the smallest key that is strictly larger than `smallestKey`. + nextSmallestKey := "" + for k := range labels { + if k > smallestKey && (nextSmallestKey == "" || k < nextSmallestKey) { + nextSmallestKey = k + } + } + + // Update smallestKey and send it out. + smallestKey = nextSmallestKey + select { + case out <- keyVal{smallestKey, labels[smallestKey]}: + case <-cancelCh: + return + } + } +} + +// LabelOrError is used in OrderedLabels. +// It represents either a key-value pair, or an error. +type LabelOrError struct { + Key, Value string + Error error +} + +// OrderedLabels streams the list of 'label_key="label_value"' in sorted order, except "le" which is +// a reserved Prometheus label name and should go last. +// If an error is encountered, it is returned as the Error field of LabelOrError, and no further +// messages will be sent on the channel. +func OrderedLabels(labels ...map[string]string) <-chan LabelOrError { + // This function is quite hot on the metric-rendering path, and its naive "just put all the + // strings in one map to ensure no dupes it, then in one slice and sort it" approach is very + // allocation-heavy. This approach is more computation-heavy (it runs in + // O(len(labels) * len(largest label map))), but the only heap allocations it does is for the + // following tiny slices and channels. In practice, the number of label maps and the size of + // each label map is tiny, so this is worth doing despite the theoretically-longer run time. + + // Initialize the channels we'll use. + mapChannels := make([]chan keyVal, 0, len(labels)) + lastKeyVal := make([]keyVal, len(labels)) + resultCh := make(chan LabelOrError) + var cancelCh chan struct{} + // outputError is a helper function for when we have encountered an error mid-way. + outputError := func(err error) { + if cancelCh != nil { + for range mapChannels { + cancelCh <- struct{}{} + } + close(cancelCh) + } + resultCh <- LabelOrError{Error: err} + close(resultCh) + } + + // Verify that no label is the empty string. It's not a valid label name, + // and we use the empty string later on in the function as a marker of having + // finished processing all labels from a given label map. for _, labelMap := range labels { for label := range labelMap { - if _, found := keys[label]; found { - return nil, fmt.Errorf("duplicate label name %q", label) + if label == "" { + go outputError(errors.New("got empty-string label")) + return resultCh } - keys[label] = struct{}{} } } - orderedKeys := make([]string, 0, totalLabels) + + // Each label map is processed in its own goroutine, + // which will stream it back to this function in sorted order. + cancelCh = make(chan struct{}, len(labels)) for _, labelMap := range labels { - for k, v := range labelMap { - if k != "le" { - orderedKeys = append(orderedKeys, fmt.Sprintf("%s=%q", k, v)) + ch := make(chan keyVal) + mapChannels = append(mapChannels, ch) + go sortedIterateLabels(labelMap, ch, cancelCh) + } + + // This goroutine is the meat of this function; it iterates through + // the results being streamed from each `sortedIterateLabels` goroutine + // that we spawned earlier, until all of them are exhausted or until we + // hit an error. + go func() { + // The "le" label is special and goes last, not in sorted order. + // gotLe is the empty string if there is no "le" label, + // otherwise it's the value of the "le" label. + var gotLe string + + // numChannelsLeft tracks the number of channels that are still live. + for numChannelsLeft := len(mapChannels); numChannelsLeft > 0; { + // Iterate over all channels and ensure we have the freshest (smallest) + // label from each of them. + for i, ch := range mapChannels { + // A nil channel is one that has been closed. + if ch == nil { + continue + } + // If we already have the latest value from this channel, + // keep it there instead of getting a new one, + if lastKeyVal[i].Key != "" { + continue + } + // Otherwise, get a new label. + kv, open := <-ch + if !open { + // Channel has been closed, no more to read from this one. + numChannelsLeft-- + mapChannels[i] = nil + continue + } + if kv.Key == "le" { + if gotLe != "" { + outputError(errors.New("got duplicate 'le' label")) + return + } + gotLe = kv.Value + continue + } + lastKeyVal[i] = kv + } + + // We have one key-value pair from each still-active channel now. + // Find the smallest one between them. + smallestKey := "" + indexForSmallest := -1 + for i, kv := range lastKeyVal { + if kv.Key == "" { + continue + } + if smallestKey == "" || kv.Key < smallestKey { + smallestKey = kv.Key + indexForSmallest = i + } else if kv.Key == smallestKey { + outputError(fmt.Errorf("got duplicate label %q", smallestKey)) + return + } + } + + if indexForSmallest == -1 { + // There are no more key-value pairs to output. We're done. + break + } + + // Output the smallest key-value pairs out of all the channels. + resultCh <- LabelOrError{ + Key: smallestKey, + Value: lastKeyVal[indexForSmallest].Value, + } + // Mark the last key-value pair from the channel that gave us the + // smallest key-value pair as no longer present, so that we get a new + // key-value pair from it in the next iteration. + lastKeyVal[indexForSmallest] = keyVal{} + } + + // Output the "le" label last. + if gotLe != "" { + resultCh <- LabelOrError{ + Key: "le", + Value: gotLe, } } - } - sort.Strings(orderedKeys) - if le != "" { - orderedKeys = append(orderedKeys, fmt.Sprintf("le=%q", le)) - } - return orderedKeys, nil + close(resultCh) + close(cancelCh) + }() + + return resultCh } // writeLabelsTo writes a set of metric labels. @@ -336,25 +648,40 @@ func (d *Data) writeLabelsTo(w io.Writer, extraLabels map[string]string, leLabel if _, err := io.WriteString(w, "{"); err != nil { return err } - var orderedLabels []string - var err error + var orderedLabels <-chan LabelOrError if leLabel != nil { - orderedLabels, err = OrderedLabels(d.Labels, extraLabels, map[string]string{"le": leLabel.String()}) + orderedLabels = OrderedLabels(d.Labels, extraLabels, map[string]string{"le": leLabel.String()}) } else { - orderedLabels, err = OrderedLabels(d.Labels, extraLabels) + orderedLabels = OrderedLabels(d.Labels, extraLabels) } - if err != nil { - return err - } - for i, keyVal := range orderedLabels { - if i != 0 { + firstLabel := true + var foundError error + for labelOrError := range orderedLabels { + if foundError != nil { + continue + } + if labelOrError.Error != nil { + foundError = labelOrError.Error + continue + } + if !firstLabel { if _, err := io.WriteString(w, ","); err != nil { return err } } - if _, err := io.WriteString(w, keyVal); err != nil { + firstLabel = false + if _, err := io.WriteString(w, labelOrError.Key); err != nil { return err } + if _, err := io.WriteString(w, "="); err != nil { + return err + } + if _, err := writeEscapedString(w, labelOrError.Value, true); err != nil { + return err + } + } + if foundError != nil { + return foundError } if _, err := io.WriteString(w, "}"); err != nil { return err @@ -382,7 +709,13 @@ func (d *Data) writeMetricLine(w io.Writer, metricSuffix string, val *Number, wh if err := val.writeTo(w); err != nil { return err } - if _, err := io.WriteString(w, fmt.Sprintf(" %d\n", when.UnixMilli())); err != nil { + if _, err := io.WriteString(w, " "); err != nil { + return err + } + if _, err := writeInteger(w, when.UnixMilli()); err != nil { + return err + } + if _, err := io.WriteString(w, "\n"); err != nil { return err } return nil @@ -463,6 +796,14 @@ func (w *countingWriter) Write(b []byte) (int, error) { return written, err } +// WriteString implements io.StringWriter.WriteString. +// This avoids going into the slow, allocation-heavy path of io.WriteString. +func (w *countingWriter) WriteString(s string) (int, error) { + written, err := w.w.WriteString(s) + w.written += written + return written, err +} + // Written returns the number of bytes written to the underlying writer (minus buffered writes). func (w *countingWriter) Written() int { return w.written - w.w.Buffered() diff --git a/pkg/prometheus/prometheus_test.go b/pkg/prometheus/prometheus_test.go index 81b8c644e..c455a4e7b 100644 --- a/pkg/prometheus/prometheus_test.go +++ b/pkg/prometheus/prometheus_test.go @@ -874,7 +874,8 @@ func TestVerifier(t *testing.T) { } at(testTime, func() { t.Logf("Test is running with simulated time: %v", testTime) - verifier, err := NewVerifier(test.Registration) + verifier, cleanup, err := NewVerifier(test.Registration) + defer cleanup() if err != nil && !test.WantVerifierCreationErr { t.Fatalf("unexpected verifier creation error: %v", err) } @@ -1459,3 +1460,145 @@ func TestGroupSameNameMetrics(t *testing.T) { t.Errorf("Seen metrics: %v\nWant metrics: %v", seenMetrics, wantSeenMetrics) } } + +func TestNumberPacker(t *testing.T) { + interestingIntegers := map[uint64]struct{}{ + uint64(0): struct{}{}, + uint64(0x5555555555555555): struct{}{}, + uint64(0xaaaaaaaaaaaaaaaa): struct{}{}, + uint64(0xffffffffffffffff): struct{}{}, + } + for numBits := 0; numBits < 2; numBits++ { + newIntegers := map[uint64]struct{}{} + for interestingInt := range interestingIntegers { + for i := 0; i < 64; i++ { + newIntegers[interestingInt|(1< unpacked version %v (int: %d)", orig, interestingInt, uint32(packed), unpacked, unpacked.Int) + } + seenDirectInteger = seenDirectInteger || (uint32(packed)&storageField) == storageFieldDirect + seenIndirectInteger = seenIndirectInteger || (uint32(packed)&storageField) == storageFieldIndirect + } + if !seenDirectInteger { + t.Error("did not encounter any integer that could be packed directly") + } + if !seenIndirectInteger { + t.Error("did not encounter any integer that was packed indirectly") + } + }) + t.Run("packing_efficiency", func(t *testing.T) { + // Verify that we actually saved space by not packing every number in numberPacker itself. + if len(p.data) >= len(interestingIntegers) { + t.Errorf("packer had %d data points stored in its data, but we expected some of it to not be stored in it (tried to pack %d integers total)", len(p.data), len(interestingIntegers)) + } + }) + t.Run("floats", func(t *testing.T) { + interestingFloats := make(map[float64]struct{}, len(interestingIntegers)+21*21+17) + for divExp := -10; divExp < 10; divExp++ { + div := math.Pow(10, float64(divExp)) + for i := -10; i < 10; i++ { + interestingFloats[float64(i)*div] = struct{}{} + } + } + interestingFloats[0.0] = struct{}{} + interestingFloats[math.NaN()] = struct{}{} + interestingFloats[math.Inf(1)] = struct{}{} + interestingFloats[math.Inf(-1)] = struct{}{} + interestingFloats[math.Pi] = struct{}{} + interestingFloats[math.Sqrt2] = struct{}{} + interestingFloats[math.E] = struct{}{} + interestingFloats[math.SqrtE] = struct{}{} + interestingFloats[math.Ln2] = struct{}{} + interestingFloats[math.MaxFloat32] = struct{}{} + interestingFloats[-math.MaxFloat32] = struct{}{} + interestingFloats[math.MaxFloat64] = struct{}{} + interestingFloats[-math.MaxFloat64] = struct{}{} + interestingFloats[math.SmallestNonzeroFloat32] = struct{}{} + interestingFloats[-math.SmallestNonzeroFloat32] = struct{}{} + interestingFloats[math.SmallestNonzeroFloat64] = struct{}{} + interestingFloats[-math.SmallestNonzeroFloat64] = struct{}{} + for interestingInt := range interestingIntegers { + interestingFloats[math.Float64frombits(interestingInt)] = struct{}{} + } + seenDirectFloat := false + seenIndirectFloat := false + for interestingFloat := range interestingFloats { + orig := NewFloat(interestingFloat) + packed, err := p.pack(orig) + if err != nil { + t.Fatalf("float %v (64bits=%x, 32bits=%x. float32-encodable=%v): cannot pack: %v", orig, math.Float64bits(interestingFloat), math.Float32bits(float32(interestingFloat)), float64(float32(interestingFloat)) == interestingFloat, err) + } + unpacked := p.unpack(packed) + switch { + case interestingFloat == 0: // Zero-valued float becomes an integer. + if !unpacked.IsInteger() { + t.Errorf("Zero-valued float %v: got non-integer number: %v", orig, unpacked) + } else if unpacked.Int != 0 { + t.Errorf("Zero-valued float %v: got non-zero integer: %d", orig, unpacked.Int) + } + case math.IsNaN(orig.Float): + if !math.IsNaN(unpacked.Float) { + t.Errorf("NaN float %v: got non-NaN unpacked version %v", orig, unpacked) + } + default: // Not NaN, not integer + if !orig.SameType(unpacked) || orig.Float != unpacked.Float { + t.Errorf("float %v (64bits=%x, 32bits=%x, float32-encodable=%v): got packed=%x => unpacked version %v (float: %f)", orig, math.Float64bits(interestingFloat), math.Float32bits(float32(interestingFloat)), float64(float32(interestingFloat)) == interestingFloat, uint32(packed), unpacked, unpacked.Float) + } + } + seenDirectFloat = seenDirectFloat || (uint32(packed)&storageField) == storageFieldDirect + seenIndirectFloat = seenIndirectFloat || (uint32(packed)&storageField) == storageFieldIndirect + } + if !seenDirectFloat { + t.Error("did not encounter any float that could be packed directly") + } + if !seenIndirectFloat { + t.Error("did not encounter any float that was packed indirectly") + } + }) +} diff --git a/pkg/prometheus/prometheus_verify.go b/pkg/prometheus/prometheus_verify.go index 7b77409af..5cf85296f 100644 --- a/pkg/prometheus/prometheus_verify.go +++ b/pkg/prometheus/prometheus_verify.go @@ -32,28 +32,231 @@ const ( maxExportStaleness = 10 * time.Second ) +// internedStringMap allows for interning strings. +type internedStringMap map[string]*string + +// Intern returns the interned version of the given string. +// If it is not already interned in the map, this function interns it. +func (m internedStringMap) Intern(s string) string { + if existing, found := m[s]; found { + return *existing + } + m[s] = &s + return s +} + +// globalInternMap is a string intern map used for globally-relevant data that repeats across +// verifiers, such as metric names and field names, but not field values or combinations of field +// values. +var ( + globalInternMu sync.Mutex + verifierCount uint64 + globalInternMap = make(internedStringMap) +) + +// globalIntern returns the interned version of the given string. +// If it is not already interned in the map, this function interns it. +func globalIntern(s string) string { + globalInternMu.Lock() + defer globalInternMu.Unlock() + return globalInternMap.Intern(s) +} + +func globalInternVerifierCreated() { + globalInternMu.Lock() + defer globalInternMu.Unlock() + verifierCount++ +} + +func globalInternVerifierReleased() { + globalInternMu.Lock() + defer globalInternMu.Unlock() + verifierCount-- + if verifierCount <= 0 { + verifierCount = 0 + // No more verifiers active, so release the global map to not keep consuming needless resources. + globalInternMap = make(internedStringMap) + } +} + +// numberPacker holds packedNumber data. It is useful to store large amounts of Number structs in a +// small memory footprint. +type numberPacker struct { + data []uint64 +} + +// packedNumber is a non-serializable but smaller-memory-footprint container for a numerical value. +// It can be unpacked out to a Number struct. +// This contains 4 bytes where we try to pack as much as possible. +// For the overhwelmingly-common case of integers that fit in 30 bits (i.e. 30 bits where the first +// 2 bits are zero, we store them directly here. Otherwise, we store the offset of a 64-bit number +// within numberPacker. +// Layout, going from highest to lowest bit: +// Bit 0 is the type: 0 for integer, 1 for float. +// Bit 1 is 0 if the number's value is stored within the next 30 bits, or 1 if the next 30 bits +// refer to an offset within numberPacker instead. +// In the case of a float, the next two bits (bits 2 and 3) may be used to encode a special value: +// - 00 means not a special value +// - 01 means NaN +// - 10 means -infinity +// - 11 means +infinity +// +// When not using a special value, the 32-bit exponent must fit in 5 bits, and is encoded using a +// bias of 2^4, meaning it ranges from -15 (encoded as 0b00000) to 16 (encoded as 0b11111), and an +// exponent of 0 is encoded as 0b01111. +// Floats that do not fit within this range must be encoded indirectly as float64s, similar to +// integers that don't fit in 30 bits. +type packedNumber uint32 + +// Useful masks and other bit-twiddling stuff for packedNumber. +const ( + typeField = uint32(1 << 31) + typeFieldInteger = uint32(0) + typeFieldFloat = uint32(typeField) + storageField = uint32(1 << 30) + storageFieldDirect = uint32(0) + storageFieldIndirect = uint32(storageField) + valueField = uint32(1<<30 - 1) + float32ExponentField = uint32(0x7f800000) + float32ExponentShift = uint32(23) + float32ExponentBias = uint32(127) + float32FractionField = uint32(0x7fffff) + packedFloatExponentField = uint32(0x0f800000) + packedFloatExponentBias = uint32(15) + packedFloatNaN = packedNumber(typeFieldFloat | storageFieldDirect | 0x10000000) + packedFloatNegInf = packedNumber(typeFieldFloat | storageFieldDirect | 0x20000000) + packedFloatInf = packedNumber(typeFieldFloat | storageFieldDirect | 0x30000000) +) + +// errOutOfPackerMemory is returned when the number cannot be packed into a numberPacker. +var errOutOfPackerMemory = errors.New("out of numberPacker memory") + +// pack packs a Number into a packedNumber. +func (p *numberPacker) pack(n *Number) (packedNumber, error) { + if n.Float == 0.0 { + v := n.Int + if v >= 0 && v <= int64(valueField) { + // We can store the integer value directly. + return packedNumber(typeFieldInteger | storageFieldDirect | uint32(v)), nil + } + // Need to allocate a new entry in packedNumber. + newIndex := uint32(len(p.data)) + if newIndex > valueField { + return 0, errOutOfPackerMemory + } + p.data = append(p.data, uint64(v)) + return packedNumber(typeFieldInteger | storageFieldIndirect | newIndex), nil + } + // n is a float. + v := n.Float + if math.IsNaN(v) { + return packedFloatNaN, nil + } + if v == math.Inf(-1) { + return packedFloatNegInf, nil + } + if v == math.Inf(1) { + return packedFloatInf, nil + } + if v >= 0.0 && float64(float32(v)) == v { + float32Bits := math.Float32bits(float32(v)) + exponent := (float32Bits&float32ExponentField)>>float32ExponentShift - float32ExponentBias + packedExponent := (exponent + packedFloatExponentBias) << float32ExponentShift + if packedExponent&packedFloatExponentField == packedExponent { + float32Fraction := float32Bits & float32FractionField + return packedNumber(typeFieldFloat | storageFieldDirect | packedExponent | float32Fraction), nil + } + } + // Need to allocate a new entry in packedNumber. + newIndex := uint32(len(p.data)) + if newIndex > valueField { + return 0, errOutOfPackerMemory + } + p.data = append(p.data, math.Float64bits(v)) + return packedNumber(typeFieldFloat | storageFieldIndirect | newIndex), nil +} + +func (p *numberPacker) mustPack(n *Number) packedNumber { + packed, err := p.pack(n) + if err != nil { + panic(err) + } + return packed +} + +func (p *numberPacker) mustPackInt(val int64) packedNumber { + n := Number{Int: val} + return p.mustPack(&n) +} + +func (p *numberPacker) mustPackFloat(val float64) packedNumber { + n := Number{Float: val} + return p.mustPack(&n) +} + +// unpack unpacks a packedNumber back into a Number. +func (p *numberPacker) unpack(n packedNumber) *Number { + switch uint32(n) & typeField { + case typeFieldInteger: + switch uint32(n) & storageField { + case storageFieldDirect: + return NewInt(int64(uint32(n) & valueField)) + case storageFieldIndirect: + return NewInt(int64(p.data[uint32(n)&valueField])) + } + case typeFieldFloat: + switch uint32(n) & storageField { + case storageFieldDirect: + switch n { + case packedFloatNaN: + return NewFloat(math.NaN()) + case packedFloatNegInf: + return NewFloat(math.Inf(-1)) + case packedFloatInf: + return NewFloat(math.Inf(1)) + default: + exponent := ((uint32(n) & packedFloatExponentField) >> float32ExponentShift) - packedFloatExponentBias + float32Bits := ((exponent + float32ExponentBias) << float32ExponentShift) | (uint32(n) & float32FractionField) + return NewFloat(float64(math.Float32frombits(float32Bits))) + } + case storageFieldIndirect: + return NewFloat(math.Float64frombits(p.data[uint32(n)&valueField])) + } + } + panic("unreachable") +} + +func (p *numberPacker) mustUnpackInt(n packedNumber) int64 { + num := p.unpack(n) + if !num.IsInteger() { + panic("not an integer") + } + return num.Int +} + // verifiableMetric verifies a single metric within a Verifier. type verifiableMetric struct { metadata *pb.MetricMetadata wantMetric Metric - numFields int + numFields uint32 + verifier *Verifier allowedFieldValues map[string]map[string]struct{} - wantBucketUpperBounds []Number + wantBucketUpperBounds []packedNumber // The following fields are used to verify that values are actually increasing monotonically. // They are only read and modified when the parent Verifier.mu is held. // They are mapped by their combination of field values. // lastCounterValue is used for counter metrics. - lastCounterValue map[string]Number + lastCounterValue map[string]packedNumber // lastBucketSamples is used for distribution ("histogram") metrics. - lastBucketSamples map[string][]uint64 + lastBucketSamples map[string][]packedNumber } // newVerifiableMetric creates a new verifiableMetric that can verify the // values of a metric with the given metadata. -func newVerifiableMetric(metadata *pb.MetricMetadata) (*verifiableMetric, error) { +func newVerifiableMetric(metadata *pb.MetricMetadata, verifier *Verifier) (*verifiableMetric, error) { if metadata.GetName() == "" || metadata.GetPrometheusName() == "" { return nil, errors.New("metric has no name") } @@ -65,7 +268,7 @@ func newVerifiableMetric(metadata *pb.MetricMetadata) (*verifiableMetric, error) return nil, fmt.Errorf("invalid character %c in prometheus metric name %q", r, metadata.GetPrometheusName()) } } - numFields := len(metadata.GetFields()) + numFields := uint32(len(metadata.GetFields())) var allowedFieldValues map[string]map[string]struct{} if numFields > 0 { seenFields := make(map[string]struct{}, numFields) @@ -84,16 +287,17 @@ func newVerifiableMetric(metadata *pb.MetricMetadata) (*verifiableMetric, error) if _, alreadyExists := fieldValues[value]; alreadyExists { return nil, fmt.Errorf("field %s has duplicate allowed value %q", fieldName, value) } - fieldValues[value] = struct{}{} + fieldValues[globalIntern(value)] = struct{}{} } - allowedFieldValues[fieldName] = fieldValues + allowedFieldValues[globalIntern(fieldName)] = fieldValues } } v := &verifiableMetric{ metadata: metadata, + verifier: verifier, wantMetric: Metric{ - Name: metadata.GetPrometheusName(), - Help: metadata.GetDescription(), + Name: globalIntern(metadata.GetPrometheusName()), + Help: globalIntern(metadata.GetDescription()), }, numFields: numFields, allowedFieldValues: allowedFieldValues, @@ -104,7 +308,7 @@ func newVerifiableMetric(metadata *pb.MetricMetadata) (*verifiableMetric, error) v.wantMetric.Type = TypeGauge if metadata.GetCumulative() { v.wantMetric.Type = TypeCounter - v.lastCounterValue = make(map[string]Number, numFieldCombinations) + v.lastCounterValue = make(map[string]packedNumber, numFieldCombinations) } case pb.MetricMetadata_TYPE_DISTRIBUTION: v.wantMetric.Type = TypeHistogram @@ -112,12 +316,12 @@ func newVerifiableMetric(metadata *pb.MetricMetadata) (*verifiableMetric, error) if numBuckets <= 1 || numBuckets > 256 { return nil, fmt.Errorf("unsupported number of buckets: %d", numBuckets) } - v.wantBucketUpperBounds = make([]Number, numBuckets) + v.wantBucketUpperBounds = make([]packedNumber, numBuckets) for i, boundary := range metadata.GetDistributionBucketLowerBounds() { - v.wantBucketUpperBounds[i] = Number{Int: boundary} + v.wantBucketUpperBounds[i] = verifier.permanentPacker.mustPackInt(boundary) } - v.wantBucketUpperBounds[numBuckets-1] = Number{Float: math.Inf(1)} - v.lastBucketSamples = make(map[string][]uint64, numFieldCombinations) + v.wantBucketUpperBounds[numBuckets-1] = verifier.permanentPacker.mustPackFloat(math.Inf(1)) + v.lastBucketSamples = make(map[string][]packedNumber, numFieldCombinations) default: return nil, fmt.Errorf("invalid type: %v", metadata.GetType()) } @@ -133,13 +337,14 @@ func (v *verifiableMetric) numFieldCombinations() int { // field values that have already been seen. `verify` should populate this. // `dataToFieldsSeen` is passed across calls to `verify` and other methods of `verifiableMetric`. // It is used to store the canonical representation of the field values seen for each *Data. +// Precondition: `Verifier.mu` is held. func (v *verifiableMetric) verify(data *Data, metricFieldsSeen map[string]struct{}, dataToFieldsSeen map[*Data]string) error { if *data.Metric != v.wantMetric { return fmt.Errorf("invalid metric definition: got %+v want %+v", data.Metric, v.wantMetric) } // Verify fields. - if len(data.Labels) != v.numFields { + if uint32(len(data.Labels)) != v.numFields { return fmt.Errorf("invalid number of fields: got %d want %d", len(data.Labels), v.numFields) } var fieldValues strings.Builder @@ -193,7 +398,7 @@ func (v *verifiableMetric) verify(data *Data, metricFieldsSeen map[string]struct return fmt.Errorf("invalid number of buckets: got %d want %d", len(data.HistogramValue.Buckets), len(v.wantBucketUpperBounds)) } for i, b := range data.HistogramValue.Buckets { - if want := v.wantBucketUpperBounds[i]; b.UpperBound != want { + if want := v.wantBucketUpperBounds[i]; b.UpperBound != *v.verifier.permanentPacker.unpack(want) { return fmt.Errorf("invalid upper bound for bucket %d (0-based): got %v want %v", i, b.UpperBound, want) } } @@ -202,6 +407,7 @@ func (v *verifiableMetric) verify(data *Data, metricFieldsSeen map[string]struct } // All passed. Update the maps that are shared across calls. + fieldValuesStr = v.verifier.internMap.Intern(fieldValuesStr) dataToFieldsSeen[data] = fieldValuesStr metricFieldsSeen[fieldValuesStr] = struct{}{} return nil @@ -209,10 +415,10 @@ func (v *verifiableMetric) verify(data *Data, metricFieldsSeen map[string]struct // verifyIncrement verifies that incremental metrics are monotonically increasing. // Preconditions: `verify` has succeeded on the given `data`, and `Verifier.mu` is held. -func (v *verifiableMetric) verifyIncrement(data *Data, fieldValues string) error { +func (v *verifiableMetric) verifyIncrement(data *Data, fieldValues string, packer *numberPacker) error { switch v.wantMetric.Type { case TypeCounter: - last := v.lastCounterValue[fieldValues] + last := packer.unpack(v.lastCounterValue[v.verifier.internMap.Intern(fieldValues)]) if !last.SameType(data.Number) { return fmt.Errorf("counter number type changed: %v vs %v", last, data.Number) } @@ -220,14 +426,14 @@ func (v *verifiableMetric) verifyIncrement(data *Data, fieldValues string) error return fmt.Errorf("counter value decreased from %v to %v", last, data.Number) } case TypeHistogram: - lastBucketSamples := v.lastBucketSamples[fieldValues] + lastBucketSamples := v.lastBucketSamples[v.verifier.internMap.Intern(fieldValues)] if lastBucketSamples == nil { - lastBucketSamples = make([]uint64, len(v.wantBucketUpperBounds)) - v.lastBucketSamples[fieldValues] = lastBucketSamples + lastBucketSamples = make([]packedNumber, len(v.wantBucketUpperBounds)) + v.lastBucketSamples[v.verifier.internMap.Intern(fieldValues)] = lastBucketSamples } for i, b := range data.HistogramValue.Buckets { - if lastBucketSamples[i] > b.Samples { - return fmt.Errorf("number of samples in bucket %d (0-based) decreased from %d to %d", i, lastBucketSamples[i], b.Samples) + if uint64(packer.mustUnpackInt(lastBucketSamples[i])) > b.Samples { + return fmt.Errorf("number of samples in bucket %d (0-based) decreased from %d to %d", i, packer.mustUnpackInt(lastBucketSamples[i]), b.Samples) } } } @@ -236,14 +442,14 @@ func (v *verifiableMetric) verifyIncrement(data *Data, fieldValues string) error // update updates incremental metrics' "last seen" data. // Preconditions: `verifyIncrement` has succeeded on the given `data`, and `Verifier.mu` is held. -func (v *verifiableMetric) update(data *Data, fieldValues string) { +func (v *verifiableMetric) update(data *Data, fieldValues string, packer *numberPacker) { switch v.wantMetric.Type { case TypeCounter: - v.lastCounterValue[fieldValues] = *data.Number + v.lastCounterValue[v.verifier.internMap.Intern(fieldValues)] = packer.mustPack(data.Number) case TypeHistogram: - lastBucketSamples := v.lastBucketSamples[fieldValues] + lastBucketSamples := v.lastBucketSamples[v.verifier.internMap.Intern(fieldValues)] for i, b := range data.HistogramValue.Buckets { - lastBucketSamples[i] = b.Samples + lastBucketSamples[i] = packer.mustPackInt(int64(b.Samples)) } } } @@ -253,29 +459,50 @@ func (v *verifiableMetric) update(data *Data, fieldValues string) { // A single Verifier should be used per sandbox. It is expected to be reused across exports such // that it can enforce the export snapshot timestamp is strictly monotonically increasing. type Verifier struct { - knownMetrics map[string]*verifiableMetric - mu sync.Mutex + knownMetrics map[string]*verifiableMetric + + // permanentPacker is used to pack numbers seen at Verifier initialization time only. + permanentPacker *numberPacker + + // mu protects the fields below. + mu sync.Mutex + + // internMap is used to intern strings relevant to this verifier only. + // Globally-relevant strings should be interned in globalInternMap. + internMap internedStringMap + + // lastPacker is a reference to the numberPacker used to pack numbers in the last successful + // verification round. + lastPacker *numberPacker + + // lastTimestamp is the snapshot timestamp of the last successfully-verified snapshot. lastTimestamp time.Time } // NewVerifier returns a new metric verifier that can verify the integrity of snapshots against // the given metric registration data. -func NewVerifier(registration *pb.MetricRegistration) (*Verifier, error) { - knownMetrics := make(map[string]*verifiableMetric) +// It returns a cleanup function that must be called when the Verifier is no longer needed. +func NewVerifier(registration *pb.MetricRegistration) (*Verifier, func(), error) { + globalInternVerifierCreated() + verifier := &Verifier{ + knownMetrics: make(map[string]*verifiableMetric), + permanentPacker: &numberPacker{}, + internMap: make(internedStringMap), + } for _, metric := range registration.GetMetrics() { metricName := metric.GetPrometheusName() - if _, alreadyExists := knownMetrics[metricName]; alreadyExists { - return nil, fmt.Errorf("metric %q registered twice", metricName) + if _, alreadyExists := verifier.knownMetrics[metricName]; alreadyExists { + globalInternVerifierReleased() + return nil, func() {}, fmt.Errorf("metric %q registered twice", metricName) } - verifiableM, err := newVerifiableMetric(metric) + verifiableM, err := newVerifiableMetric(metric, verifier) if err != nil { - return nil, fmt.Errorf("metric %q: %v", metricName, err) + globalInternVerifierReleased() + return nil, func() {}, fmt.Errorf("metric %q: %v", metricName, err) } - knownMetrics[metricName] = verifiableM + verifier.knownMetrics[globalIntern(metricName)] = verifiableM } - return &Verifier{ - knownMetrics: knownMetrics, - }, nil + return verifier, globalInternVerifierReleased, nil } // Verify verifies the integrity of a snapshot against the metric registration data of the Verifier. @@ -292,6 +519,10 @@ func (v *Verifier) Verify(snapshot *Snapshot) error { return fmt.Errorf("snapshot is too old; it is from %v, expected at least %v (%v from now)", snapshot.When, now.Add(-maxExportStaleness), maxExportStaleness) } + // Start critical section. + v.mu.Lock() + defer v.mu.Unlock() + // Metrics checks. fieldsSeen := make(map[string]map[string]struct{}, len(v.knownMetrics)) dataToFieldsSeen := make(map[*Data]string, len(snapshot.Data)) @@ -301,6 +532,7 @@ func (v *Verifier) Verify(snapshot *Snapshot) error { if !found { return fmt.Errorf("snapshot contains unknown metric %q", metricName) } + metricName = globalIntern(metricName) metricFieldsSeen, found := fieldsSeen[metricName] if !found { metricFieldsSeen = make(map[string]struct{}, verifiableM.numFieldCombinations()) @@ -311,22 +543,21 @@ func (v *Verifier) Verify(snapshot *Snapshot) error { } } - // Start the critical section. - v.mu.Lock() - defer v.mu.Unlock() if v.lastTimestamp.After(snapshot.When) { return fmt.Errorf("consecutive snapshots are not chronologically ordered: last verified snapshot was exported at %v, this one is from %v", v.lastTimestamp, snapshot.When) } for _, data := range snapshot.Data { - if err = v.knownMetrics[data.Metric.Name].verifyIncrement(data, dataToFieldsSeen[data]); err != nil { + if err = v.knownMetrics[data.Metric.Name].verifyIncrement(data, dataToFieldsSeen[data], v.lastPacker); err != nil { return fmt.Errorf("metric %q: %v", data.Metric.Name, err) } } // All checks succeeded, update last-seen data. + newPacker := &numberPacker{} v.lastTimestamp = snapshot.When for _, data := range snapshot.Data { - v.knownMetrics[data.Metric.Name].update(data, dataToFieldsSeen[data]) + v.knownMetrics[globalIntern(data.Metric.Name)].update(data, v.internMap.Intern(dataToFieldsSeen[data]), newPacker) } + v.lastPacker = newPacker return nil } diff --git a/pkg/state/types.go b/pkg/state/types.go index 8df2ac64a..b96423e14 100644 --- a/pkg/state/types.go +++ b/pkg/state/types.go @@ -324,6 +324,14 @@ var globalTypeDatabase = map[string]reflect.Type{} // reverseTypeDatabase is a reverse mapping. var reverseTypeDatabase = map[reflect.Type]string{} +// Release releases references to global type databases. +// Must only be called in contexts where they will definitely never be used, +// in order to save memory. +func Release() { + globalTypeDatabase = nil + reverseTypeDatabase = nil +} + // Register registers a type. // // This must be called on init and only done once. diff --git a/runsc/cmd/BUILD b/runsc/cmd/BUILD index 0dde688ff..bf2011b87 100644 --- a/runsc/cmd/BUILD +++ b/runsc/cmd/BUILD @@ -64,6 +64,7 @@ go_library( "//pkg/sentry/kernel", "//pkg/sentry/kernel/auth", "//pkg/sentry/platform", + "//pkg/state", "//pkg/state/pretty", "//pkg/state/statefile", "//pkg/sync", diff --git a/runsc/cmd/metric_server.go b/runsc/cmd/metric_server.go index c91996936..952d327d6 100644 --- a/runsc/cmd/metric_server.go +++ b/runsc/cmd/metric_server.go @@ -28,6 +28,7 @@ import ( "os" "os/signal" "runtime" + "runtime/debug" "runtime/pprof" "strconv" "strings" @@ -38,6 +39,7 @@ import ( "gvisor.dev/gvisor/pkg/atomicbitops" "gvisor.dev/gvisor/pkg/log" "gvisor.dev/gvisor/pkg/prometheus" + "gvisor.dev/gvisor/pkg/state" "gvisor.dev/gvisor/pkg/sync" "gvisor.dev/gvisor/runsc/cmd/util" "gvisor.dev/gvisor/runsc/config" @@ -100,6 +102,9 @@ type servedSandbox struct { // be deleted from the server. // Once set, it is immutable. verifier *prometheus.Verifier + + // cleanupVerifier holds a reference to the cleanup function of the verifier. + cleanupVerifier func() } // sandboxPrometheusLabels returns a set of Prometheus labels that identifies the sandbox running @@ -183,11 +188,12 @@ func (s *servedSandbox) load() (*sandbox.Sandbox, *prometheus.Verifier, error) { if err != nil { return nil, nil, err } - verifier, err := prometheus.NewVerifier(registeredMetrics) + verifier, cleanup, err := prometheus.NewVerifier(registeredMetrics) if err != nil { return nil, nil, err } s.verifier = verifier + s.cleanupVerifier = cleanup } s.labelsWithMetadata = make(map[string]string, len(s.extraLabels)+len(s.sandbox.MetricMetadata)) for k, v := range s.extraLabels { @@ -199,25 +205,36 @@ func (s *servedSandbox) load() (*sandbox.Sandbox, *prometheus.Verifier, error) { return s.sandbox, s.verifier, nil } +func (s *servedSandbox) cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + if s.cleanupVerifier != nil { + s.cleanupVerifier() + } +} + // queryMetrics queries the sandbox for metrics data. func queryMetrics(ctx context.Context, sand *sandbox.Sandbox, verifier *prometheus.Verifier) (*prometheus.Snapshot, error) { ch := make(chan struct { snapshot *prometheus.Snapshot err error }, 1) - defer close(ch) + canceled := make(chan struct{}, 1) + defer close(canceled) go func() { snapshot, err := sand.ExportMetrics() select { + case <-canceled: case ch <- struct { snapshot *prometheus.Snapshot err error }{snapshot, err}: - default: + close(ch) } }() select { case <-ctx.Done(): + canceled <- struct{}{} return nil, ctx.Err() case ret := <-ch: if ret.err != nil { @@ -356,16 +373,19 @@ func (m *MetricServer) refreshSandboxesLocked() { } if !found { log.Warningf("Sandbox %s no longer exists but did not explicitly unregister. Removing it.", sandboxID) + sandbox.cleanup() delete(m.sandboxes, sandboxID) continue } if _, _, err := sandbox.load(); err != nil && err != container.ErrStateFileLocked { log.Warningf("Sandbox %s cannot be loaded, deleting it: %v", sandboxID, err) + sandbox.cleanup() delete(m.sandboxes, sandboxID) continue } if !sandbox.sandbox.IsRunning() { log.Infof("Sandbox %s is no longer running, deleting it.", sandboxID) + sandbox.cleanup() delete(m.sandboxes, sandboxID) continue } @@ -444,6 +464,7 @@ func (m *MetricServer) refreshSandboxesLocked() { if _, _, err := served.load(); err != nil && err != container.ErrStateFileLocked { log.Warningf("Sandbox %q cannot be loaded, ignoring it: %v", sid, err) m.lastStateFileStat[sid] = stat + served.cleanup() continue } m.sandboxes[sid] = served @@ -845,6 +866,8 @@ func logRequest(f func(w http.ResponseWriter, req *http.Request) httpResult) fun http.Error(w, result.err.Error(), result.code) log.Warningf("Request: %s %s: Failed with HTTP code %d: %v", req.Method, req.URL.Path, result.code, result.err) } + // Run GC after every request to keep memory usage as predictable and as flat as possible. + runtime.GC() } } @@ -979,6 +1002,9 @@ func (m *MetricServer) Execute(ctx context.Context, f *flag.FlagSet, args ...any log.Warningf("Profiling HTTP endpoints are exposed; this should only be used for development!") mux.HandleFunc("/runsc-metrics/profile-cpu", logRequest(m.profileCPU)) mux.HandleFunc("/runsc-metrics/profile-heap", logRequest(m.profileHeap)) + } else { + // Disable memory profiling, since we don't expose it. + runtime.MemProfileRate = 0 } mux.HandleFunc("/metrics", logRequest(m.serveMetrics)) mux.HandleFunc("/", logRequest(m.serveIndex)) @@ -994,9 +1020,18 @@ func (m *MetricServer) Execute(ctx context.Context, f *flag.FlagSet, args ...any log.Infof("Wrote PID %d to file %v.", m.pid, m.pidFile) } + // If not modified by the user from the environment, set the Go GC percentage lower than default. + if _, hasEnv := os.LookupEnv("GOGC"); !hasEnv { + debug.SetGCPercent(40) + } + + // Run GC immediately to get rid of all the initialization-related memory bloat and start from + // a clean slate. + state.Release() + runtime.GC() + // Initialization complete. log.Infof("Server serving on %s for root directory %s.", conf.MetricServer, conf.RootDir) - serveErr := m.srv.Serve(listener) log.Infof("Server has stopped accepting requests.") m.mu.Lock()