How To: Use Right-Size Recommendations within a CI-CD pipeline

Prev Next

After deploying the RealTheory collector to a Kubernetes cluster (See Installing RealTheory in a Cluster), the cluster and its associated objects, including any Recommendations, become available in the RealTheory API within minutes.

Overview

RealTheory analyzes how your Kubernetes workloads actually use CPU and memory, and recommends new requests and limits for each container. These right-size recommendations can be retrieved from a CI/CD pipeline, decoded, and applied to your deployment manifests so that the next deploy picks them up. This is a four-step process:

  1. Retrieve the right-size recommendations for a workload using the Recommendations endpoint with a query scoped to the workload's cluster, namespace, and name.

    Example query: classification=RightSizeWorkload AND metadata.clusterName=prod-east AND metadata.namespaceName=checkout AND metadata.objectName=checkout-api

  2. Choose the recommendation to use, skipping any recommendation that your team has dismissed or postponed in the RealTheory console.

  3. Decode the container entries held in the recommendation's properties.

  4. Apply the recommended values to your manifest, leaving any field without a recommendation unchanged.

A worked example and a sample Python implementation follow the steps.

Notes:

  • All calls to RealTheory APIs must be authenticated and authorized. See Authentication for more details.
  • Queries should be URL encoded; otherwise the request might be rejected.

Step 1: Retrieve the right-size recommendations for a workload

To get the right-size recommendations for a workload, call the method below on the K8sRecommendations endpoint.

GET <endpoint>/theory/api/v1/k8srecommendations/recommendations/search/query/{query}?index={index}&count={count}&order={order}&direction={direction}

where

  • {endpoint} - is the URL of the RealTheory SaaS portal for your account.
  • {query} - is a query used to limit the results to right-size recommendations for a given workload.
  • {index} - is the starting index of the recommendations to include in the list. This parameter is optional and defaults to 0 if not specified.
  • {count} - is the maximum number of recommendations to return, up to a limit of 500. This parameter is optional and defaults to 100 if not specified.
  • {order} - is a string containing the name of the attribute to sort on. This parameter is optional.
  • {direction} - is a number that controls the direction of the sort. A value equal to or greater than 0 indicates ascending order; a value less than 0 indicates descending order. This parameter is optional and defaults to 0 if not specified.

The {query} should include the following conditions:

  • classification = RightSizeWorkload - returns only workload right-size recommendations, rather than other kinds of recommendation for the same workload.
  • metadata.clusterName, metadata.namespaceName, and metadata.objectName - scope the results to a single workload. Workload names are not unique across clusters and namespaces, so all three should be included. metadata.objectName is the workload's metadata.name in your manifest.
  • metadata.objectType - is optional, and is the workload kind, for example Deployment, StatefulSet, or DaemonSet. Include it if a namespace can contain workloads of different kinds with the same name.

See Metadata for more details on the metadata fields available to queries.

Example

GET theory/api/v1/k8srecommendations/recommendations/search/query/classification=RightSizeWorkload AND metadata.clusterName=prod-east AND metadata.namespaceName=checkout AND metadata.objectName=checkout-api?index=0&count=100

which returns the right-size recommendations for the workload checkout-api in namespace checkout on cluster prod-east.

Notes:

  • The query forms part of the path, so it must be URL encoded. For example, spaces become %20.
  • See Queries for the full query syntax, including the supported operators and logical connectors.
  • Use names exactly as they appear in Kubernetes: the cluster name as shown in the RealTheory console, and the namespace and workload names from your manifest.
  • If your pipeline already knows the cluster, namespace, or workload ID, use metadata.clusterId, metadata.namespaceId, or metadata.objectId in place of metadata.clusterName, metadata.namespaceName, or metadata.objectName respectively. Queries on IDs are indexed and faster than queries on names, which are not indexed, so IDs should be preferred where available. If you know the workload ID, classification=RightSizeWorkload AND metadata.objectId=<workload-id> is sufficient on its own: the workload ID is guaranteed to be unique across your fleet, so the cluster and namespace conditions are not needed. See Objects for more details on querying related objects.
  • Results are paged, and the response's x-total-count header gives the total number of matches. A fully scoped query normally returns at most one recommendation. See Paging for more details on how to page through all results.
  • Expired recommendations are not returned.
  • Your credentials only return recommendations for clusters you are authorized to view.

Output

Returns an array of Recommendation objects. See the worked example below for a sample right-size recommendation.

Step 2: Choose the recommendation to use

The query does most of the filtering. Three checks remain on your side:

  • Skip dismissed recommendations, where state is Dismissed. Your team has dismissed them in the RealTheory console.
  • Skip postponed recommendations, where state is Postponed and postponedUntil is still in the future. Your team has postponed them in the RealTheory console, so they should not be applied yet.
  • If more than one recommendation remains, use the one with the latest updated timestamp.

Notes:

  • state is one of New, Read, Postponed, Dismissed, or Unknown. Respecting Dismissed and Postponed means your pipeline honors the decisions your team makes in the RealTheory console. RealTheory's own automation applies the same rule.
  • You may also want to gate on how much evidence the recommendation rests on. See right-size-metrics-span and confidence under Recommendation Properties below.

Step 3: Decode the container entries

The recommended values are carried in the recommendation's properties object, which is a map of string keys to string values.

Recommendation Properties

  • right-size-container-count: Represents the number of container entries, numbered from 0, for example 2. Its presence also identifies a right-size recommendation.
  • right-size-container-{n}: Represents one entry per container, for n from 0 to count − 1. See Container Entries below.
  • right-size-strategy: Represents the optimization strategy used. Supported values are performance, cost, and balanced.
  • right-size-basis: Represents why the change is recommended. Supported values are cost, reliability, and performance. The value may be a comma-separated list, for example performance,cost, and should be treated as case-insensitive with whitespace trimmed. This property is optional.
  • right-size-metrics-span: Represents how much metric history the recommendation is based on, for the least-observed container, for example 3d2h30m. The value is expressed in days, hours, and minutes in descending order, and is capped at 8d. This property is optional: if it is missing, the span is unknown, which is not the same as 0m (measured, under a minute).

Note: The top-level confidence field of the Recommendation (None, Low, Medium, or High) is also useful for gating.

Container Entries

Each right-size-container-{n} value is a list of key=value fields separated by semicolons:

name=checkout-api;cpu-request=100m->250m;cpu-limit=<not set>->500m;memory-request=70Mi->128Mi;memory-limit=210Mi->256Mi;replicas=3;cost-impact=$40.32;

where

  • name - is the container name, matching spec.containers[].name in your pod template, for example checkout-api.
  • cpu-request - is the CPU request, in the form currently deployed value -> recommended value, for example 100m->250m.
  • cpu-limit - is the CPU limit, in the same form, for example <not set>->500m.
  • memory-request - is the memory request, in the same form, for example 70Mi->128Mi.
  • memory-limit - is the memory limit, in the same form, for example 210Mi->256Mi.
  • replicas - is the replica count used to estimate cost, for example 3. This value is informational only: the recommendation never changes your replica count.
  • cost-impact - is the estimated cost impact in US dollars, formatted as currency, for example $40.32. It can include thousands separators ($1,234.56) and can be negative. This value is informational only.

Notes:

  • The left-hand side of -> is the value RealTheory observed deployed when it made the recommendation. The right-hand side is the recommended value.
  • <not set> means there is no value: on the left, the field was not set on the deployed container; on the right, there is no recommendation for that field.

CPU and Memory Limits

RealTheory can be configured to include or leave out CPU limits and memory limits in its recommendations. Requests are always included. By default:

  • CPU limits are not included, so the recommended value is <not set>.
  • Memory limits are included, so the recommended value is a quantity, for example 512Mi.

With the default configuration, each container's cpu-limit ends in -><not set>. Following the rules in Step 4, your pipeline leaves the CPU limits in your manifests as they are and updates the memory limits. If you want CPU limits recommended as well, or do not want memory limits recommended, ask your RealTheory administrator to change the configuration. Your parser does not need to change: it simply sees quantities instead of <not set>, or the other way round.

Values are standard Kubernetes resource quantities that can be written straight into a manifest:

  • CPU - millicores below one core (2m, 250m), otherwise cores with up to four decimal places (1, 1.5, 2.25).
  • Memory - Gi, Mi, or Ki, whichever represents the value exactly (1Gi, 1.5Gi, 128Mi, 1500Ki).

Parsing Rules

Following these rules keeps your parser compatible with future additions to the format:

  1. Read right-size-container-count as an integer. If it is missing or not a number, the recommendation is not a right-size recommendation.
  2. For each n from 0 to count − 1, read right-size-container-{n}. Skip any index that is missing or empty.
  3. Split the entry on ; and ignore empty parts. The entry ends with a trailing ;.
  4. Split each part at the first = into key and value, and trim whitespace from both. Ignore parts without an =.
  5. Keys are lower-case. Ignore keys you do not recognize, as new fields may be added.
  6. Split each resource value at the first -> into deployed and recommended. If there is no ->, treat the whole value as the recommended value.
  7. Compare <not set> case-insensitively, and treat it as "no value".

Note: Container names cannot contain ; or = (Kubernetes restricts them to lower-case letters, digits, and -), so no escaping is needed.

Step 4: Apply the values to your manifest

Each container entry maps to the container with the same name in your workload's pod template. For a Deployment, StatefulSet, or DaemonSet, that is spec.template.spec.containers[]:

  • cpu-request - maps to resources.requests.cpu.
  • cpu-limit - maps to resources.limits.cpu.
  • memory-request - maps to resources.requests.memory.
  • memory-limit - maps to resources.limits.memory.

Apply these rules:

  • If the recommended value is a quantity, set the manifest field to it.
  • If the recommended value is <not set>, leave the manifest field as it is. Do not add it and do not remove it: the recommendation has nothing to say about that field. RealTheory's own automation applies the same rule.
  • If a container is in the recommendation but not in your manifest, ignore it.
  • If a container is in your manifest but not in the recommendation, leave it unchanged.
  • Keep each request at or below its limit. Kubernetes rejects a container whose request exceeds its limit. This can happen when you apply a recommended request but keep an existing limit because the recommended limit is <not set>. In that case, skip that request change and flag it for review rather than writing a manifest that will not deploy.

Detecting Manifest Drift

The deployed (left-hand) values tell you what the recommendation was calculated against. Before applying a field, compare its deployed value with the value currently in your manifest. If they differ, the manifest has changed since the recommendation was made: someone may have tuned it by hand, or it has not been deployed yet. Consider skipping those fields and flagging them for review.

Note: Compare the values as quantities, not as text: 1000m and 1 are the same CPU, and 1024Mi and 1Gi are the same memory.

Worked Example

The query from Step 1 returns the Recommendation below. It uses the default limit configuration, so both CPU limits are <not set> on the recommended side.

[
    {
        "id": "6f1c2a9e-3d4b-4c1a-9b8e-2f7a5d0c1e34",
        "metadata": {
            "objectType": "Deployment",
            "objectName": "checkout-api",
            "clusterName": "prod-east",
            "namespaceName": "checkout",
            ...
        },
        "state": "New",
        "classification": "RightSizeWorkload",
        "confidence": "High",
        "properties": {
            "right-size-strategy": "balanced",
            "right-size-basis": "cost",
            "right-size-metrics-span": "8d",
            "right-size-container-count": "2",
            "right-size-container-0": "name=checkout-api;cpu-request=500m->250m;cpu-limit=<not set>-><not set>;memory-request=512Mi->384Mi;memory-limit=1Gi->512Mi;replicas=3;cost-impact=$40.32;",
            "right-size-container-1": "name=log-shipper;cpu-request=100m->20m;cpu-limit=200m-><not set>;memory-request=64Mi->32Mi;memory-limit=128Mi->64Mi;replicas=3;cost-impact=$6.10;"
        },
        "updated": "2026-10-04T08:15:00Z",
        "postponedUntil": null,
        ...
    }
]

Note: Content has been omitted from the JSON response above for brevity.

The recommendation decodes to:

Container Field Deployed Recommended Action
checkout-api CPU request 500m 250m Set to 250m
checkout-api CPU limit not set not set Leave as is
checkout-api Memory request 512Mi 384Mi Set to 384Mi
checkout-api Memory limit 1Gi 512Mi Set to 512Mi
log-shipper CPU request 100m 20m Set to 20m
log-shipper CPU limit 200m not set Leave as is (200m)
log-shipper Memory request 64Mi 32Mi Set to 32Mi
log-shipper Memory limit 128Mi 64Mi Set to 64Mi

Applied to the manifest:

spec:
  template:
    spec:
      containers:
      - name: checkout-api
        resources:
          requests:
            cpu: 250m # was 500m
            memory: 384Mi # was 512Mi
          limits:
            memory: 512Mi # was 1Gi
      - name: log-shipper
        resources:
          requests:
            cpu: 20m # was 100m
            memory: 32Mi # was 64Mi
          limits:
            cpu: 200m # unchanged: no recommendation
            memory: 64Mi # was 128Mi

Sample Implementation (Python)

This sample covers Steps 1 to 4. It requires Python 3.11 or later, uses requests, and works on a manifest already loaded into a dictionary, for example with PyYAML.

from datetime import datetime, timezone
from urllib.parse import quote

import requests

NOT_SET = "<not set>"
RESOURCE_FIELDS = {
    "cpu-request": ("requests", "cpu"),
    "cpu-limit": ("limits", "cpu"),
    "memory-request": ("requests", "memory"),
    "memory-limit": ("limits", "memory"),
}


def get_recommendations(base_url, auth_header, cluster, namespace, name, kind=None):
    """Return the right-size recommendations for one workload, across all pages."""
    query = (
        f"classification=RightSizeWorkload"
        f" AND metadata.clusterName={cluster}"
        f" AND metadata.namespaceName={namespace}"
        f" AND metadata.objectName={name}"
    )
    if kind:
        query += f" AND metadata.objectType={kind}"
    url = f"{base_url}/theory/api/v1/k8srecommendations/recommendations/search/query/{quote(query, safe='')}"
    index, count, results = 0, 100, []
    while True:
        response = requests.get(url, params={"index": index, "count": count}, headers={"Authorization": auth_header}, timeout=30)
        response.raise_for_status()
        page = response.json()
        results.extend(page)
        if len(page) < count:
            return results
        index += count


def is_postponed(recommendation):
    until = recommendation.get("postponedUntil")
    if recommendation.get("state") != "Postponed" or not until:
        return False
    return datetime.fromisoformat(until.replace("Z", "+00:00")) > datetime.now(timezone.utc)


def choose(recommendations):
    """Return the newest recommendation that isn't dismissed or postponed, or None."""
    candidates = [r for r in recommendations if r.get("state") != "Dismissed" and not is_postponed(r)]
    return max(candidates, key=lambda r: r.get("updated", ""), default=None)


def normalize(value):
    value = value.strip()
    return None if value == "" or value.lower() == NOT_SET else value


def split_transition(value):
    """Split 'deployed->recommended'; a value without '->' is the recommended value."""
    if "->" in value:
        deployed, recommended = value.split("->", 1)
        return normalize(deployed), normalize(recommended)
    return None, normalize(value)


def decode(recommendation):
    """Decode the container entries into dictionaries keyed by field name."""
    properties = recommendation.get("properties") or {}
    try:
        count = int(properties.get("right-size-container-count", ""))
    except ValueError:
        return []

    containers = []
    for n in range(count):
        entry = properties.get(f"right-size-container-{n}")
        if not entry:
            continue
        fields = {}
        for part in entry.split(";"):
            if "=" in part:
                key, value = part.split("=", 1)
                fields[key.strip()] = value.strip()
        container = {"name": fields.get("name")}
        for key in RESOURCE_FIELDS:
            if key in fields:
                container[key] = split_transition(fields[key])
        containers.append(container)
    return containers


def apply(manifest, containers):
    """Write recommended values into the pod template; fields without a recommendation are left as they are."""
    targets = {c["name"]: c for c in manifest["spec"]["template"]["spec"].get("containers", [])}
    for container in containers:
        target = targets.get(container["name"])
        if target is None:
            continue
        resources = target.setdefault("resources", {})
        for key, (section, resource) in RESOURCE_FIELDS.items():
            _, recommended = container.get(key, (None, None))
            if recommended is not None:
                resources.setdefault(section, {})[resource] = recommended

Example

recommendations = get_recommendations("https://<endpoint>", AUTH_HEADER, cluster="prod-east", namespace="checkout", name="checkout-api", kind="Deployment")
chosen = choose(recommendations)
if chosen is not None:
    apply(manifest, decode(chosen))

Notes:

  • AUTH_HEADER is the value of the Authorization header for your RealTheory API credentials. See Authentication for more details.
  • The sample leaves out the request-versus-limit check and drift detection described in Step 4, because both require Kubernetes quantity parsing.