github.com/sequix/cortex@v1.1.6/pkg/chunk/aws/metrics_autoscaling.go (about)

     1  package aws
     2  
     3  import (
     4  	"context"
     5  	"flag"
     6  	"fmt"
     7  	"time"
     8  
     9  	"github.com/go-kit/kit/log/level"
    10  	"github.com/pkg/errors"
    11  	promApi "github.com/prometheus/client_golang/api"
    12  	promV1 "github.com/prometheus/client_golang/api/prometheus/v1"
    13  	"github.com/prometheus/common/model"
    14  	"github.com/weaveworks/common/mtime"
    15  
    16  	"github.com/sequix/cortex/pkg/chunk"
    17  	"github.com/sequix/cortex/pkg/util"
    18  )
    19  
    20  const (
    21  	cachePromDataFor          = 30 * time.Second
    22  	queueObservationPeriod    = 2 * time.Minute
    23  	targetScaledown           = 0.1 // consider scaling down if queue smaller than this times target
    24  	targetMax                 = 10  // always scale up if queue bigger than this times target
    25  	throttleFractionScaledown = 0.1
    26  	minUsageForScaledown      = 100 // only scale down if usage is > this DynamoDB units/sec
    27  
    28  	// fetch Ingester queue length
    29  	// average the queue length over 2 minutes to avoid aliasing with the 1-minute flush period
    30  	defaultQueueLenQuery = `sum(avg_over_time(cortex_ingester_flush_queue_length{job="cortex/ingester"}[2m]))`
    31  	// fetch write throttle rate per DynamoDB table
    32  	defaultThrottleRateQuery = `sum(rate(cortex_dynamo_throttled_total{operation="DynamoDB.BatchWriteItem"}[1m])) by (table) > 0`
    33  	// fetch write capacity usage per DynamoDB table
    34  	// use the rate over 15 minutes so we take a broad average
    35  	defaultUsageQuery = `sum(rate(cortex_dynamo_consumed_capacity_total{operation="DynamoDB.BatchWriteItem"}[15m])) by (table) > 0`
    36  	// use the read rate over 1hr so we take a broad average
    37  	defaultReadUsageQuery = `sum(rate(cortex_dynamo_consumed_capacity_total{operation="DynamoDB.QueryPages"}[1h])) by (table) > 0`
    38  	// fetch read error rate per DynamoDB table
    39  	defaultReadErrorQuery = `sum(increase(cortex_dynamo_failures_total{operation="DynamoDB.QueryPages",error="ProvisionedThroughputExceededException"}[1m])) by (table) > 0`
    40  )
    41  
    42  // MetricsAutoScalingConfig holds parameters to configure how it works
    43  type MetricsAutoScalingConfig struct {
    44  	URL              string  // URL to contact Prometheus store on
    45  	TargetQueueLen   int64   // Queue length above which we will scale up capacity
    46  	ScaleUpFactor    float64 // Scale up capacity by this multiple
    47  	MinThrottling    float64 // Ignore throttling below this level
    48  	QueueLengthQuery string  // Promql query to fetch ingester queue length
    49  	ThrottleQuery    string  // Promql query to fetch throttle rate per table
    50  	UsageQuery       string  // Promql query to fetch write capacity usage per table
    51  	ReadUsageQuery   string  // Promql query to fetch read usage per table
    52  	ReadErrorQuery   string  // Promql query to fetch read errors per table
    53  
    54  	deprecatedErrorRateQuery string
    55  }
    56  
    57  // RegisterFlags adds the flags required to config this to the given FlagSet
    58  func (cfg *MetricsAutoScalingConfig) RegisterFlags(f *flag.FlagSet) {
    59  	f.StringVar(&cfg.URL, "metrics.url", "", "Use metrics-based autoscaling, via this query URL")
    60  	f.Int64Var(&cfg.TargetQueueLen, "metrics.target-queue-length", 100000, "Queue length above which we will scale up capacity")
    61  	f.Float64Var(&cfg.ScaleUpFactor, "metrics.scale-up-factor", 1.3, "Scale up capacity by this multiple")
    62  	f.Float64Var(&cfg.MinThrottling, "metrics.ignore-throttle-below", 1, "Ignore throttling below this level (rate per second)")
    63  	f.StringVar(&cfg.QueueLengthQuery, "metrics.queue-length-query", defaultQueueLenQuery, "query to fetch ingester queue length")
    64  	f.StringVar(&cfg.ThrottleQuery, "metrics.write-throttle-query", defaultThrottleRateQuery, "query to fetch throttle rates per table")
    65  	f.StringVar(&cfg.UsageQuery, "metrics.usage-query", defaultUsageQuery, "query to fetch write capacity usage per table")
    66  	f.StringVar(&cfg.ReadUsageQuery, "metrics.read-usage-query", defaultReadUsageQuery, "query to fetch read capacity usage per table")
    67  	f.StringVar(&cfg.ReadErrorQuery, "metrics.read-error-query", defaultReadErrorQuery, "query to fetch read errors per table")
    68  
    69  	f.StringVar(&cfg.deprecatedErrorRateQuery, "metrics.error-rate-query", "", "DEPRECATED: use -metrics.write-throttle-query instead")
    70  }
    71  
    72  type metricsData struct {
    73  	cfg                  MetricsAutoScalingConfig
    74  	promAPI              promV1.API
    75  	promLastQuery        time.Time
    76  	tableLastUpdated     map[string]time.Time
    77  	tableReadLastUpdated map[string]time.Time
    78  	queueLengths         []float64
    79  	throttleRates        map[string]float64
    80  	usageRates           map[string]float64
    81  	usageReadRates       map[string]float64
    82  	readErrorRates       map[string]float64
    83  }
    84  
    85  func newMetrics(cfg DynamoDBConfig) (*metricsData, error) {
    86  	if cfg.Metrics.deprecatedErrorRateQuery != "" {
    87  		level.Warn(util.Logger).Log("msg", "use of deprecated flag -metrics.error-rate-query")
    88  		cfg.Metrics.ThrottleQuery = cfg.Metrics.deprecatedErrorRateQuery
    89  	}
    90  	client, err := promApi.NewClient(promApi.Config{Address: cfg.Metrics.URL})
    91  	if err != nil {
    92  		return nil, err
    93  	}
    94  	return &metricsData{
    95  		promAPI:              promV1.NewAPI(client),
    96  		cfg:                  cfg.Metrics,
    97  		tableLastUpdated:     make(map[string]time.Time),
    98  		tableReadLastUpdated: make(map[string]time.Time),
    99  	}, nil
   100  }
   101  
   102  func (m *metricsData) PostCreateTable(ctx context.Context, desc chunk.TableDesc) error {
   103  	return nil
   104  }
   105  
   106  func (m *metricsData) DescribeTable(ctx context.Context, desc *chunk.TableDesc) error {
   107  	return nil
   108  }
   109  
   110  func (m *metricsData) UpdateTable(ctx context.Context, current chunk.TableDesc, expected *chunk.TableDesc) error {
   111  
   112  	if err := m.update(ctx); err != nil {
   113  		return err
   114  	}
   115  
   116  	if expected.WriteScale.Enabled {
   117  		// default if no action is taken is to use the currently provisioned setting
   118  		expected.ProvisionedWrite = current.ProvisionedWrite
   119  
   120  		throttleRate := m.throttleRates[expected.Name]
   121  		usageRate := m.usageRates[expected.Name]
   122  
   123  		level.Info(util.Logger).Log("msg", "checking write metrics", "table", current.Name, "queueLengths", fmt.Sprint(m.queueLengths), "throttleRate", throttleRate, "usageRate", usageRate)
   124  
   125  		switch {
   126  		case throttleRate < throttleFractionScaledown*float64(current.ProvisionedWrite) &&
   127  			m.queueLengths[2] < float64(m.cfg.TargetQueueLen)*targetScaledown:
   128  			// No big queue, low throttling -> scale down
   129  			expected.ProvisionedWrite = scaleDown(current.Name,
   130  				current.ProvisionedWrite,
   131  				expected.WriteScale.MinCapacity,
   132  				computeScaleDown(current.Name, m.usageRates, expected.WriteScale.TargetValue),
   133  				m.tableLastUpdated,
   134  				expected.WriteScale.InCooldown,
   135  				"metrics scale-down",
   136  				"write",
   137  				m.usageRates)
   138  		case throttleRate == 0 &&
   139  			m.queueLengths[2] < m.queueLengths[1] && m.queueLengths[1] < m.queueLengths[0]:
   140  			// zero errors and falling queue -> scale down to current usage
   141  			expected.ProvisionedWrite = scaleDown(current.Name,
   142  				current.ProvisionedWrite,
   143  				expected.WriteScale.MinCapacity,
   144  				computeScaleDown(current.Name, m.usageRates, expected.WriteScale.TargetValue),
   145  				m.tableLastUpdated,
   146  				expected.WriteScale.InCooldown,
   147  				"zero errors scale-down",
   148  				"write",
   149  				m.usageRates)
   150  		case throttleRate > 0 && m.queueLengths[2] > float64(m.cfg.TargetQueueLen)*targetMax:
   151  			// Too big queue, some throttling -> scale up (note we don't apply MinThrottling in this case)
   152  			expected.ProvisionedWrite = scaleUp(current.Name,
   153  				current.ProvisionedWrite,
   154  				expected.WriteScale.MaxCapacity,
   155  				computeScaleUp(current.ProvisionedWrite, expected.WriteScale.MaxCapacity, m.cfg.ScaleUpFactor),
   156  				m.tableLastUpdated,
   157  				expected.WriteScale.OutCooldown,
   158  				"metrics max queue scale-up",
   159  				"write")
   160  		case throttleRate > m.cfg.MinThrottling &&
   161  			m.queueLengths[2] > float64(m.cfg.TargetQueueLen) &&
   162  			m.queueLengths[2] > m.queueLengths[1] && m.queueLengths[1] > m.queueLengths[0]:
   163  			// Growing queue, some throttling -> scale up
   164  			expected.ProvisionedWrite = scaleUp(current.Name,
   165  				current.ProvisionedWrite,
   166  				expected.WriteScale.MaxCapacity,
   167  				computeScaleUp(current.ProvisionedWrite, expected.WriteScale.MaxCapacity, m.cfg.ScaleUpFactor),
   168  				m.tableLastUpdated,
   169  				expected.WriteScale.OutCooldown,
   170  				"metrics queue growing scale-up",
   171  				"write")
   172  		}
   173  	}
   174  
   175  	if expected.ReadScale.Enabled {
   176  		// default if no action is taken is to use the currently provisioned setting
   177  		expected.ProvisionedRead = current.ProvisionedRead
   178  		readUsageRate := m.usageReadRates[expected.Name]
   179  		readErrorRate := m.readErrorRates[expected.Name]
   180  
   181  		level.Info(util.Logger).Log("msg", "checking read metrics", "table", current.Name, "errorRate", readErrorRate, "readUsageRate", readUsageRate)
   182  		// Read Scaling
   183  		switch {
   184  		// the table is at low/minimum capacity and it is being used -> scale up
   185  		case readUsageRate > 0 && current.ProvisionedRead < expected.ReadScale.MaxCapacity/10:
   186  			expected.ProvisionedRead = scaleUp(
   187  				current.Name,
   188  				current.ProvisionedRead,
   189  				expected.ReadScale.MaxCapacity,
   190  				computeScaleUp(current.ProvisionedRead, expected.ReadScale.MaxCapacity, m.cfg.ScaleUpFactor),
   191  				m.tableReadLastUpdated, expected.ReadScale.OutCooldown,
   192  				"table is being used. scale up",
   193  				"read")
   194  		case readErrorRate > 0 && readUsageRate > 0:
   195  			// Queries are causing read throttling on the table -> scale up
   196  			expected.ProvisionedRead = scaleUp(
   197  				current.Name,
   198  				current.ProvisionedRead,
   199  				expected.ReadScale.MaxCapacity,
   200  				computeScaleUp(current.ProvisionedRead, expected.ReadScale.MaxCapacity, m.cfg.ScaleUpFactor),
   201  				m.tableReadLastUpdated, expected.ReadScale.OutCooldown,
   202  				"table is in use and there are read throttle errors, scale up",
   203  				"read")
   204  		case readErrorRate == 0 && readUsageRate == 0:
   205  			// this table is not being used. -> scale down
   206  			expected.ProvisionedRead = scaleDown(current.Name,
   207  				current.ProvisionedRead,
   208  				expected.ReadScale.MinCapacity,
   209  				computeScaleDown(current.Name, m.usageReadRates, expected.ReadScale.TargetValue),
   210  				m.tableReadLastUpdated,
   211  				expected.ReadScale.InCooldown,
   212  				"table is not in use. scale down", "read",
   213  				nil)
   214  		}
   215  	}
   216  
   217  	return nil
   218  }
   219  
   220  func computeScaleUp(currentValue, maxValue int64, scaleFactor float64) int64 {
   221  	scaleUp := int64(float64(currentValue) * scaleFactor)
   222  	// Scale up minimum of 10% of max capacity, to avoid futzing around at low levels
   223  	minIncrement := maxValue / 10
   224  	if scaleUp < currentValue+minIncrement {
   225  		scaleUp = currentValue + minIncrement
   226  	}
   227  	return scaleUp
   228  }
   229  
   230  func computeScaleDown(currentName string, usageRates map[string]float64, targetValue float64) int64 {
   231  	usageRate := usageRates[currentName]
   232  	return int64(usageRate * 100.0 / targetValue)
   233  }
   234  
   235  func scaleDown(tableName string, currentValue, minValue int64, newValue int64, lastUpdated map[string]time.Time, coolDown int64, msg, operation string, usageRates map[string]float64) int64 {
   236  	if newValue < minValue {
   237  		newValue = minValue
   238  	}
   239  	// If we're already at or below the requested value, it's not a scale-down.
   240  	if newValue >= currentValue {
   241  		return currentValue
   242  	}
   243  
   244  	earliest := lastUpdated[tableName].Add(time.Duration(coolDown) * time.Second)
   245  	if earliest.After(mtime.Now()) {
   246  		level.Info(util.Logger).Log("msg", "deferring "+msg, "table", tableName, "till", earliest, "op", operation)
   247  		return currentValue
   248  	}
   249  
   250  	// Reject a change that is less than 20% - AWS rate-limits scale-downs so save
   251  	// our chances until it makes a bigger difference
   252  	if newValue > currentValue*4/5 {
   253  		level.Info(util.Logger).Log("msg", "rejected de minimis "+msg, "table", tableName, "current", currentValue, "proposed", newValue, "op", operation)
   254  		return currentValue
   255  	}
   256  
   257  	if usageRates != nil {
   258  		// Check that the ingesters seem to be doing some work - don't want to scale down
   259  		// if all our metrics are returning zero, or all the ingesters have crashed, etc
   260  		totalUsage := 0.0
   261  		for _, u := range usageRates {
   262  			totalUsage += u
   263  		}
   264  		if totalUsage < minUsageForScaledown {
   265  			level.Info(util.Logger).Log("msg", "rejected low usage "+msg, "table", tableName, "totalUsage", totalUsage, "op", operation)
   266  			return currentValue
   267  		}
   268  	}
   269  
   270  	level.Info(util.Logger).Log("msg", msg, "table", tableName, operation, newValue)
   271  	lastUpdated[tableName] = mtime.Now()
   272  	return newValue
   273  }
   274  
   275  func scaleUp(tableName string, currentValue, maxValue int64, newValue int64, lastUpdated map[string]time.Time, coolDown int64, msg, operation string) int64 {
   276  	if newValue > maxValue {
   277  		newValue = maxValue
   278  	}
   279  	earliest := lastUpdated[tableName].Add(time.Duration(coolDown) * time.Second)
   280  	if !earliest.After(mtime.Now()) && newValue > currentValue {
   281  		level.Info(util.Logger).Log("msg", msg, "table", tableName, operation, newValue)
   282  		lastUpdated[tableName] = mtime.Now()
   283  		return newValue
   284  	}
   285  
   286  	level.Info(util.Logger).Log("msg", "deferring "+msg, "table", tableName, "till", earliest)
   287  	return currentValue
   288  }
   289  
   290  func (m *metricsData) update(ctx context.Context) error {
   291  	if m.promLastQuery.After(mtime.Now().Add(-cachePromDataFor)) {
   292  		return nil
   293  	}
   294  
   295  	m.promLastQuery = mtime.Now()
   296  	qlMatrix, err := promQuery(ctx, m.promAPI, m.cfg.QueueLengthQuery, queueObservationPeriod, queueObservationPeriod/2)
   297  	if err != nil {
   298  		return err
   299  	}
   300  	if len(qlMatrix) != 1 {
   301  		return errors.Errorf("expected one sample stream for queue: %d", len(qlMatrix))
   302  	}
   303  	if len(qlMatrix[0].Values) != 3 {
   304  		return errors.Errorf("expected three values: %d", len(qlMatrix[0].Values))
   305  	}
   306  	m.queueLengths = make([]float64, len(qlMatrix[0].Values))
   307  	for i, v := range qlMatrix[0].Values {
   308  		m.queueLengths[i] = float64(v.Value)
   309  	}
   310  
   311  	deMatrix, err := promQuery(ctx, m.promAPI, m.cfg.ThrottleQuery, 0, time.Second)
   312  	if err != nil {
   313  		return err
   314  	}
   315  	if m.throttleRates, err = extractRates(deMatrix); err != nil {
   316  		return err
   317  	}
   318  
   319  	usageMatrix, err := promQuery(ctx, m.promAPI, m.cfg.UsageQuery, 0, time.Second)
   320  	if err != nil {
   321  		return err
   322  	}
   323  	if m.usageRates, err = extractRates(usageMatrix); err != nil {
   324  		return err
   325  	}
   326  
   327  	readUsageMatrix, err := promQuery(ctx, m.promAPI, m.cfg.ReadUsageQuery, 0, time.Second)
   328  	if err != nil {
   329  		return err
   330  	}
   331  	if m.usageReadRates, err = extractRates(readUsageMatrix); err != nil {
   332  		return err
   333  	}
   334  
   335  	readErrorMatrix, err := promQuery(ctx, m.promAPI, m.cfg.ReadErrorQuery, 0, time.Second)
   336  	if err != nil {
   337  		return err
   338  	}
   339  	if m.readErrorRates, err = extractRates(readErrorMatrix); err != nil {
   340  		return err
   341  	}
   342  
   343  	return nil
   344  }
   345  
   346  func extractRates(matrix model.Matrix) (map[string]float64, error) {
   347  	ret := map[string]float64{}
   348  	for _, s := range matrix {
   349  		table, found := s.Metric["table"]
   350  		if !found {
   351  			continue
   352  		}
   353  		if len(s.Values) != 1 {
   354  			return nil, errors.Errorf("expected one sample for table %s: %d", table, len(s.Values))
   355  		}
   356  		ret[string(table)] = float64(s.Values[0].Value)
   357  	}
   358  	return ret, nil
   359  }
   360  
   361  func promQuery(ctx context.Context, promAPI promV1.API, query string, duration, step time.Duration) (model.Matrix, error) {
   362  	queryRange := promV1.Range{
   363  		Start: mtime.Now().Add(-duration),
   364  		End:   mtime.Now(),
   365  		Step:  step,
   366  	}
   367  
   368  	value, wrngs, err := promAPI.QueryRange(ctx, query, queryRange)
   369  	if err != nil {
   370  		return nil, err
   371  	}
   372  	if wrngs != nil {
   373  		level.Warn(util.Logger).Log(
   374  			"query", query,
   375  			"start", queryRange.Start,
   376  			"end", queryRange.End,
   377  			"step", queryRange.Step,
   378  			"warnings", wrngs,
   379  		)
   380  	}
   381  	matrix, ok := value.(model.Matrix)
   382  	if !ok {
   383  		return nil, fmt.Errorf("Unable to convert value to matrix: %#v", value)
   384  	}
   385  	return matrix, nil
   386  }