mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
448 lines
16 KiB
Go
448 lines
16 KiB
Go
// Copyright 2023 The gVisor Authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package metricclient provides utility functions to start, stop, and talk to a metric server.
|
|
package metricclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff"
|
|
"github.com/prometheus/common/expfmt"
|
|
"golang.org/x/sys/unix"
|
|
"gvisor.dev/gvisor/pkg/cleanup"
|
|
"gvisor.dev/gvisor/pkg/prometheus"
|
|
"gvisor.dev/gvisor/pkg/sync"
|
|
"gvisor.dev/gvisor/pkg/test/testutil"
|
|
"gvisor.dev/gvisor/runsc/config"
|
|
)
|
|
|
|
// MetricClient implements an HTTP client that can spawn and connect to a running runsc metrics
|
|
// server process and register/unregister sandbox metrics.
|
|
type MetricClient struct {
|
|
addr string
|
|
rootDir string
|
|
dialer net.Dialer
|
|
client http.Client
|
|
mu sync.Mutex
|
|
server *exec.Cmd
|
|
}
|
|
|
|
// NewMetricClient creates a new MetricClient that can talk to the metric server at address addr.
|
|
func NewMetricClient(addr, rootDir string) *MetricClient {
|
|
c := &MetricClient{
|
|
addr: strings.ReplaceAll(addr, "%RUNTIME_ROOT%", rootDir),
|
|
rootDir: rootDir,
|
|
dialer: net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
},
|
|
client: http.Client{
|
|
Transport: &http.Transport{
|
|
// We only talk over the local network, so no need to spend CPU on compression.
|
|
DisableCompression: true,
|
|
MaxIdleConns: 1,
|
|
IdleConnTimeout: 30 * time.Second,
|
|
ResponseHeaderTimeout: 30 * time.Second,
|
|
ExpectContinueTimeout: 30 * time.Second,
|
|
},
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
// In order to support talking HTTP over Unix domain sockets, we use a custom dialer
|
|
// which knows how to dial the right address.
|
|
// The HTTP address passed as URL to the client is ignored.
|
|
c.client.Transport.(*http.Transport).DialContext = c.dialContext
|
|
return c
|
|
}
|
|
|
|
// dialContext dials the metric server. It ignores whatever address is given to it.
|
|
func (c *MetricClient) dialContext(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
network := "tcp"
|
|
if strings.HasPrefix(c.addr, fmt.Sprintf("%c", os.PathSeparator)) {
|
|
network = "unix"
|
|
}
|
|
return c.dialer.DialContext(ctx, network, c.addr)
|
|
}
|
|
|
|
// Close closes any idle HTTP connection.
|
|
func (c *MetricClient) Close() {
|
|
c.client.CloseIdleConnections()
|
|
}
|
|
|
|
// req performs an HTTP request against the metrics server.
|
|
// It returns an http.Response, and a function to close out the request that should be called when
|
|
// the response is no longer necessary.
|
|
func (c *MetricClient) req(ctx context.Context, timeout time.Duration, method, endpoint string, params map[string]string) (*http.Response, func(), error) {
|
|
cancelFunc := context.CancelFunc(func() {})
|
|
if timeout != 0 {
|
|
ctx, cancelFunc = context.WithTimeout(ctx, timeout)
|
|
}
|
|
var bodyBytes io.Reader
|
|
var getSuffix string
|
|
if len(params) != 0 {
|
|
switch method {
|
|
case http.MethodGet:
|
|
getParams := url.Values{}
|
|
for k, v := range params {
|
|
getParams.Add(k, v)
|
|
}
|
|
getSuffix = fmt.Sprintf("?%s", getParams.Encode())
|
|
case http.MethodPost:
|
|
values := url.Values{}
|
|
for k, v := range params {
|
|
values.Set(k, v)
|
|
}
|
|
bodyBytes = strings.NewReader(values.Encode())
|
|
default:
|
|
cancelFunc()
|
|
return nil, nil, fmt.Errorf("unsupported method: %v", method)
|
|
}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("http://runsc-metrics%s%s", endpoint, getSuffix), bodyBytes)
|
|
if err != nil {
|
|
cancelFunc()
|
|
return nil, nil, fmt.Errorf("cannot create request object: %v", err)
|
|
}
|
|
if method == http.MethodPost {
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
cancelFunc()
|
|
return nil, nil, err
|
|
}
|
|
return resp, func() {
|
|
resp.Body.Close()
|
|
cancelFunc()
|
|
}, err
|
|
}
|
|
|
|
// HealthCheck pokes the metrics server and checks that it is running.
|
|
func (c *MetricClient) HealthCheck(ctx context.Context) error {
|
|
// There are multiple scenarios here:
|
|
// - The server isn't running. We'll get a "connection failed" error.
|
|
// - There is an HTTP server bound to the address, but it is not the metric server.
|
|
// We'll fail the /runsc-metrics/healthcheck request with an HTTP error code.
|
|
// - There is a server bound to the address, but it is not the metric server and doesn't speak
|
|
// HTTP. We'll fail the request if that's the case.
|
|
// - There is a server bound to the address, it is the metric server, but it is not serving the
|
|
// same root directory. The server will reject the request if that's the case.
|
|
// - The server is running, and the /runsc-metrics/healthcheck request succeeds.
|
|
// - The server is running, but it is shutting down. The metrics server will fail the
|
|
// /runsc-metrics/healthcheck request in this case.
|
|
resp, closeReq, err := c.req(ctx, 5*time.Second, http.MethodPost, "/runsc-metrics/healthcheck", map[string]string{
|
|
"root": c.rootDir,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeReq()
|
|
var buf bytes.Buffer
|
|
if _, err := buf.ReadFrom(resp.Body); err != nil {
|
|
return err
|
|
}
|
|
if !strings.HasPrefix(buf.String(), "runsc-metrics:OK") {
|
|
return errors.New("server responded to request but not with the expected prefix")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SpawnServer starts a metric server at the expected address.
|
|
// It blocks until it responds to healthchecks, or the context expires.
|
|
// Fails if the server fails to start or to bind within the context.
|
|
// Callers should call ShutdownServer to stop the server.
|
|
// A running server must be stopped before a new one can be successfully started.
|
|
// baseConf is used for passing other flags to the server, e.g. debug log directory.
|
|
func (c *MetricClient) SpawnServer(ctx context.Context, baseConf *config.Config, extraArgs ...string) error {
|
|
metricServerBinPath, err := testutil.FindFile("runsc/cmd/metricserver/runsc-metric-server")
|
|
if err != nil {
|
|
return fmt.Errorf("cannot find runsc-metric-server: %w", err)
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.server != nil {
|
|
return errors.New("this metric client already has a server associated with it")
|
|
}
|
|
bindCtx, bindCancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer bindCancel()
|
|
launchBackoff := backoff.WithContext(&backoff.ExponentialBackOff{
|
|
InitialInterval: time.Millisecond,
|
|
Multiplier: 1.5,
|
|
MaxInterval: 250 * time.Millisecond,
|
|
RandomizationFactor: 0.1,
|
|
Clock: backoff.SystemClock,
|
|
}, bindCtx)
|
|
// Overriden metric server address with the address this metric client is configured to use.
|
|
// This should be the same but may contain string replacements (e.g. "%ID%").
|
|
overriddenConf := *baseConf
|
|
overriddenConf.MetricServer = c.addr
|
|
overriddenConf.RootDir = c.rootDir
|
|
c.server = exec.Command(metricServerBinPath, overriddenConf.ToFlags()...)
|
|
cu := cleanup.Make(func() {
|
|
c.server = nil
|
|
})
|
|
defer cu.Clean()
|
|
c.server.SysProcAttr = &unix.SysProcAttr{
|
|
// Detach from this session, otherwise cmd will get SIGHUP and SIGCONT
|
|
// when re-parented.
|
|
Setsid: true,
|
|
}
|
|
devnull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0755)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot open devnull at %s: %w", os.DevNull, err)
|
|
}
|
|
defer devnull.Close() // Don't leak file descriptors.
|
|
c.server.Stdin = devnull
|
|
c.server.Stdout = devnull
|
|
c.server.Stderr = devnull
|
|
// Set Args[0] to make easier to spot the sandbox process. Otherwise it's
|
|
// shown as `exe`.
|
|
c.server.Args[0] = "runsc-metrics"
|
|
c.server.Args = append(c.server.Args, "metric-server")
|
|
c.server.Args = append(c.server.Args, extraArgs...)
|
|
if err := c.server.Start(); err != nil {
|
|
return fmt.Errorf("cannot start metrics server: %w", err)
|
|
}
|
|
launchBackoff.Reset()
|
|
for bindCtx.Err() == nil && c.HealthCheck(bindCtx) != nil {
|
|
nextBackoff := launchBackoff.NextBackOff()
|
|
if nextBackoff == backoff.Stop {
|
|
break
|
|
}
|
|
time.Sleep(nextBackoff)
|
|
}
|
|
if err := unix.Kill(c.server.Process.Pid, 0); err != nil {
|
|
return fmt.Errorf("metrics server crashed: %w", c.server.Wait())
|
|
}
|
|
if bindCtx.Err() != nil {
|
|
return fmt.Errorf("metrics server did not bind to %s in time: %w", c.addr, bindCtx.Err())
|
|
}
|
|
cu.Release()
|
|
return nil
|
|
}
|
|
|
|
// ShutdownServer asks the metrics server to shut itself down.
|
|
// It blocks until the server process has exitted or the context expires.
|
|
func (c *MetricClient) ShutdownServer(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.server == nil {
|
|
return errors.New("server not started")
|
|
}
|
|
c.Close()
|
|
// The server will shut itself down ASAP after it gets SIGTERM.
|
|
if err := c.server.Process.Signal(syscall.SIGTERM); err != nil {
|
|
return fmt.Errorf("cannot send signal to metrics server: %w", err)
|
|
}
|
|
// Wait for the process to exit.
|
|
if err := c.server.Wait(); err != nil {
|
|
// When used in tests that use testutil.Reaper, it's possible that the metric server
|
|
// has already been reaped by it. In this case, do not treat this as an error.
|
|
if strings.Contains(err.Error(), "no child process") {
|
|
c.server = nil
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to wait for metrics server to exit: %w", err)
|
|
}
|
|
c.server = nil
|
|
return nil
|
|
}
|
|
|
|
// MetricData is the raw contents returned by GetMetrics, with helper functions
|
|
// to extract single values out of it.
|
|
type MetricData string
|
|
|
|
// GetMetrics returns the raw Prometheus-formatted metric data from the metric server.
|
|
// `urlParams` may contain a special parameter with the empty string as the key.
|
|
// If this is set, that string is used to override the request path from its default
|
|
// value of `/metrics`.
|
|
func (c *MetricClient) GetMetrics(ctx context.Context, urlParams map[string]string) (MetricData, error) {
|
|
path := "/metrics"
|
|
if overridePath, found := urlParams[""]; found {
|
|
path = overridePath
|
|
delete(urlParams, "")
|
|
}
|
|
resp, closeReq, err := c.req(ctx, 10*time.Second, http.MethodGet, path, urlParams)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot get /metrics: %v", err)
|
|
}
|
|
defer closeReq()
|
|
var buf bytes.Buffer
|
|
if _, err := buf.ReadFrom(resp.Body); err != nil {
|
|
return "", fmt.Errorf("cannot read from response body: %v", err)
|
|
}
|
|
return MetricData(buf.String()), nil
|
|
}
|
|
|
|
// GetPrometheusInteger returns the integer value of a Prometheus metric with given name and labels.
|
|
func (m MetricData) GetPrometheusInteger(metricName string, wantLabels map[string]string) (int64, time.Time, error) {
|
|
// Parse raw Prometheus-formatted data.
|
|
var buf bytes.Buffer
|
|
buf.WriteString(string(m))
|
|
parsed, err := (&expfmt.TextParser{}).TextToMetricFamilies(&buf)
|
|
if err != nil {
|
|
return 0, time.Time{}, err
|
|
}
|
|
// See if there is any data for the given metric name.
|
|
metricData, found := parsed[metricName]
|
|
if !found {
|
|
return 0, time.Time{}, fmt.Errorf("metric %q not found", metricName)
|
|
}
|
|
// See if we can find exactly one data point for which the labels match `wantLabels`.
|
|
// foundIndex is the index within `metricData.Metric` of the most-recently-found data point
|
|
// that matches `wantLabels`.
|
|
foundIndex := -1
|
|
for i, data := range metricData.GetMetric() {
|
|
// Convert data.Label (which is a list of key-value tuples) into a Go map.
|
|
dataLabels := make(map[string]string, len(data.GetLabel()))
|
|
for _, label := range data.GetLabel() {
|
|
dataLabels[label.GetName()] = label.GetValue()
|
|
}
|
|
// Check if `wantLabels` is a subset of `dataLabels`.
|
|
allMatching := true
|
|
for wantLabel, wantValue := range wantLabels {
|
|
if dataLabels[wantLabel] != wantValue {
|
|
allMatching = false
|
|
break
|
|
}
|
|
}
|
|
if !allMatching {
|
|
// This data point is for a different label combination than the one we want.
|
|
continue
|
|
}
|
|
// Record the index at which we found this data point within `metricData.Metric`.
|
|
// If this index isn't -1, this means we found multiple such indexes.
|
|
// This could happen if the metric has multiple data points with `wantLabels` + an
|
|
// additional label which isn't in `wantLabels` and which takes on multiple distinct
|
|
// values. This function doesn't support retrieving data for such cases.
|
|
if foundIndex != -1 {
|
|
return 0, time.Time{}, fmt.Errorf("found multiple metric data matching requested labels %v", wantLabels)
|
|
}
|
|
foundIndex = i
|
|
}
|
|
if foundIndex == -1 {
|
|
return 0, time.Time{}, fmt.Errorf("no metric data matching requested labels %v", wantLabels)
|
|
}
|
|
// We've found exactly one data point.
|
|
data := metricData.GetMetric()[foundIndex]
|
|
// Convert the value of this data point to an int regardless of its underlying Prometheus type.
|
|
var floatValue float64
|
|
if data.GetCounter() != nil && data.GetCounter().Value != nil {
|
|
floatValue = data.GetCounter().GetValue()
|
|
} else if data.GetGauge() != nil && data.GetGauge().Value != nil {
|
|
floatValue = data.GetGauge().GetValue()
|
|
} else {
|
|
return 0, time.Time{}, fmt.Errorf("metric is not numerical: %v", data)
|
|
}
|
|
if math.Floor(floatValue) != floatValue {
|
|
return 0, time.Time{}, fmt.Errorf("value %v cannot be rounded to an integer", floatValue)
|
|
}
|
|
return int64(math.Floor(floatValue)), time.UnixMilli(data.GetTimestampMs()), nil
|
|
}
|
|
|
|
// WantMetric designates the metadata required to select a single metric from a single sandbox.
|
|
type WantMetric struct {
|
|
// Metric is the name of the metric to get.
|
|
Metric string
|
|
// Sandbox is the ID of the sandbox to look up the metric for.
|
|
Sandbox string
|
|
// Pod and Namespace are the pod and namespace labels associated with the sandbox.
|
|
// Leave empty if the sandbox metadata doesn't contain this information.
|
|
Pod, Namespace string
|
|
// ExtraLabels are additional key-value labels that must match.
|
|
ExtraLabels map[string]string
|
|
}
|
|
|
|
// GetPrometheusContainerInteger returns the integer value of a Prometheus metric from the
|
|
// given WantMetric data.
|
|
func (m MetricData) GetPrometheusContainerInteger(want WantMetric) (int64, time.Time, error) {
|
|
labels := map[string]string{
|
|
"sandbox": want.Sandbox,
|
|
}
|
|
if want.Pod != "" {
|
|
labels["pod_name"] = want.Pod
|
|
}
|
|
if want.Namespace != "" {
|
|
labels["namespace_name"] = want.Namespace
|
|
}
|
|
for k, v := range want.ExtraLabels {
|
|
labels[k] = v
|
|
}
|
|
return m.GetPrometheusInteger(want.Metric, labels)
|
|
}
|
|
|
|
// GetSandboxMetadataMetric returns the labels attached to the metadata metric for a given sandbox.
|
|
func (m MetricData) GetSandboxMetadataMetric(want WantMetric) (map[string]string, error) {
|
|
var buf bytes.Buffer
|
|
buf.WriteString(string(m))
|
|
parsed, err := (&expfmt.TextParser{}).TextToMetricFamilies(&buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metricData, found := parsed[want.Metric]
|
|
if !found {
|
|
return nil, fmt.Errorf("metric %q not found", want.Metric)
|
|
}
|
|
foundIndex := -1
|
|
for i, data := range metricData.GetMetric() {
|
|
dataLabels := make(map[string]string, len(data.GetLabel()))
|
|
for _, label := range data.GetLabel() {
|
|
dataLabels[label.GetName()] = label.GetValue()
|
|
}
|
|
allMatching := true
|
|
for wantLabel, wantValue := range map[string]string{
|
|
prometheus.SandboxIDLabel: want.Sandbox,
|
|
prometheus.NamespaceLabel: want.Namespace,
|
|
prometheus.PodNameLabel: want.Pod,
|
|
} {
|
|
if dataLabels[wantLabel] != wantValue {
|
|
allMatching = false
|
|
break
|
|
}
|
|
}
|
|
if allMatching {
|
|
if foundIndex != -1 {
|
|
return nil, errors.New("found multiple metadata metrics matching requested labels")
|
|
}
|
|
foundIndex = i
|
|
}
|
|
}
|
|
if foundIndex == -1 {
|
|
return nil, errors.New("no metadata metric matching requested labels")
|
|
}
|
|
data := metricData.GetMetric()[foundIndex]
|
|
metadataLabels := make(map[string]string, len(data.GetLabel()))
|
|
for _, label := range data.GetLabel() {
|
|
if label.GetName() == prometheus.SandboxIDLabel || label.GetName() == prometheus.NamespaceLabel || label.GetName() == prometheus.PodNameLabel {
|
|
continue
|
|
}
|
|
metadataLabels[label.GetName()] = label.GetValue()
|
|
}
|
|
return metadataLabels, nil
|
|
}
|