istio.io/istio@v0.0.0-20240520182934-d79c90f27776/pkg/kube/krt/README.md (about)

     1  # `krt`: **K**ubernetes Declarative Controller **R**un**t**ime
     2  
     3  `krt` provides a framework for building _declarative_ controllers.
     4  See the [design doc](https://docs.google.com/document/d/1-ywpCnOfubqg7WAXSPf4YgbaFDBEU9HIqMWcxZLhzwE/edit#heading=h.ffjmk8byb9gt) and [KubeCon talk](https://sched.co/1R2oY) for more background.
     5  
     6  The framework aims to solve a few problems with writing controllers:
     7  * Operate on any types, from any source. Kubernetes provides informers, but these only work on Kubernetes types read from Kubernetes objects.
     8    * `krt` can accept any object type, from any source, and handle them the same.
     9  * Provide high level abstractions.
    10    * Controller authors can write simple transformation functions from `Input` -> `Output` (with dependencies); the framework handles all the state automatically.
    11  
    12  ## Key Primitives
    13  
    14  The most important primitive provided is the `Collection` interface.
    15  This is basically an `Informer`, but not tied to Kubernetes.
    16  
    17  Currently, there are three ways to build a `Collection`:
    18  * Built from an `Informer` with `WrapClient` or `NewInformer`.
    19  * Statically configured with `NewStatic`.
    20  * Derived from other collections (more information on this below).
    21  
    22  Unlike `Informers`, these primitives work on arbitrary objects.
    23  However, these objects are expected to have some properties, depending on their usage.
    24  These are *not* expressed as generic constraints due to limitations in Go's type system.
    25  
    26  * Each object `T` must have a unique `Key[T]` (which is just a typed wrapper around `string`) that uniquely identifies the object.
    27      Default implementations exist for Kubernetes objects, Istio `config.Config` objects, and `ResourceName() string` implementations.
    28  * `Equals(k K) bool` may be implemented to provide custom implementations to compare objects. Comparison is done to detect if changes were made.
    29    Default implementations are available for Kubernetes and protobuf objects, and will fallback to `reflect.DeepEqual`.
    30  * A `Name`, `Namespace`, `Labels`, and `LabelSelector` may optionally be included, for use with filters (see below).
    31  
    32  ## Derived Collections
    33  
    34  The core of the framework is in the ability to derive collections from others.
    35  
    36  In general, these are built by providing some `func(inputs...) outputs...` (called "transformation" functions).
    37  While more could be expressed, there are currently three forms implemented.
    38  
    39  * `func() *O` via `NewSingleton`
    40      * This generates a collection that has a single value. An example would be some global configuration.
    41  * `func(input I) *O` via `NewCollection`
    42      * This generates a one-to-one mapping of input to output. An example would be a transformation from a `Pod` type to a generic `Workload` type.
    43  * `func(input I) []O` via `NewManyCollection`
    44      * This generates a one-to-many mapping of input to output. An example would be a transformation from a `Service` to a _set_ of `Endpoint` types.
    45      * The order of the response does not matter. Each response must have a unique key.
    46  
    47  The form used and input type only represent the _primary dependencies_, indicating the cardinality.
    48  Each transformation can additionally include an arbitrary number of dependencies, fetching data from other collections.
    49  
    50  For example, a simple `Singleton` example that keeps track of the number of `ConfigMap`s in the cluster:
    51  
    52  ```go
    53  ConfigMapCount := krt.NewSingleton[int](func(ctx krt.HandlerContext) *int {
    54      cms := krt.Fetch(ctx, ConfigMaps)
    55      return ptr.Of(len(cms))
    56  })
    57  ```
    58  
    59  The `Fetch` operation enables querying against other collections.
    60  If the result of the `Fetch` operation changes, the collection will automatically be recomputed; the framework handles the state and event detection.
    61  In the above example, the provided function will be called (at least) every time there is a change to a configmap.
    62  The `ConfigMapCount` collection will produce events only when the count changes.
    63  The framework will use generic Equals on the underlying object to determine whether or not to recompute collections.
    64  
    65  ### Picking a collection type
    66  
    67  There are a variety of collection types available.
    68  Picking these is about simplicity, usability, and performance.
    69  
    70  The `NewSingleton` form (`func() *O`), in theory, could be used universally.
    71  Consider a transformation from `Pod` to `SimplePod`:
    72  
    73  ```go
    74  SimplePods := krt.NewSingleton[SimplePod](func(ctx krt.HandlerContext) *[]SimplePod {
    75      res := []SimplePod{}
    76      for _, pod := range krt.Fetch(ctx, Pod) {
    77          res = append(res, SimplePod{Name: pod.Name})
    78      }
    79      return &res
    80  }) // Results in a Collection[[]SimplePod]
    81  ```
    82  
    83  While this *works*, it is inefficient and complex to write.
    84  Consumers of SimplePod can only query the entire list at once.
    85  Anytime *any* `Pod` changes, *all* `SimplePod`s must be recomputed.
    86  
    87  A better approach would be to lift `Pod` into a primary dependency:
    88  
    89  ```go
    90  SimplePods := krt.NewCollection[SimplePod](func(ctx krt.HandlerContext, pod *v1.Pod) *SimplePod {
    91      return &SimplePod{Name: pod.Name}
    92  }) // Results in a Collection[SimplePod]
    93  ```
    94  
    95  Not only is this simpler to write, its far more efficient.
    96  Consumers can more efficiently query for `SimplePod`s using label selectors, filters, etc.
    97  Additionally, if a single `Pod` changes we only recompute one `SimplePod`.
    98  
    99  Above we have a one-to-one mapping of input and output.
   100  We may have one-to-many mappings, though.
   101  In these cases, usually its best to use a `ManyCollection`.
   102  Like the above examples, its *possible* to express these as normal `Collection`s, but likely inefficient.
   103  
   104  Example computing a list of all container names across all pods:
   105  
   106  ```go
   107  ContainerNames := krt.NewManyCollection[string](func(ctx krt.HandlerContext, pod *v1.Pod) (res []string) {
   108      for _, c := range pod.Spec.Containers {
   109        res = append(res, c.Name)
   110      }
   111      return res
   112  }) // Results in a Collection[string]
   113  ```
   114  
   115  Example computing a list of service endpoints, similar to the Kubernetes core endpoints controller:
   116  
   117  ```go
   118  Endpoints := krt.NewManyCollection[Endpoint](func(ctx krt.HandlerContext, svc *v1.Service) (res []Endpoint) {
   119      for _, c := range krt.Fetch(ctx, Pods, krt.FilterLabel(svc.Spec.Selector)) {
   120        res = append(res, Endpoint{Service: svc.Name, Pod: pod.Name, IP: pod.status.PodIP})
   121      }
   122      return res
   123  }) // Results in a Collection[Endpoint]
   124  ```
   125  
   126  As a rule of thumb, if your `Collection` type is a list, you most likely should be using a different type to flatten the list.
   127  An exception to this would be if the list represents an atomic set of items that are never queried independently;
   128  in these cases, however, it is probably best to wrap it in a struct.
   129  For example, to represent the set of containers in a pod, we may make a `type PodContainers struct { Name string, Containers []string }` and have a
   130  `Collection[PodContainers]` rather than a `Collection[[]string]`.
   131  
   132  In theory, other forms could be expressed such as `func(input1 I1, input2 I2) *O`.
   133  However, there haven't yet been use cases for these more complex forms.
   134  
   135  ### Transformation constraints
   136  
   137  In order for the framework to properly handle dependencies and events, transformation functions must adhere by a few properties.
   138  
   139  Basically, Transformations must be stateless and idempotent.
   140  * Any querying of other `Collection`s _must_ be done through `krt.Fetch`.
   141  * Querying other data stores that may change is not permitted.
   142  * Querying external state (e.g. making HTTP calls) is not permitted.
   143  * Transformations _may_ be called at any time, including many times for the same inputs. Transformation functions should not make any assumptions about calling patterns.
   144  
   145  Violation of these properties will result in undefined behavior (which would likely manifest as stale data).
   146  
   147  ### Fetch details
   148  
   149  In addition to simply fetching _all_ resources from a collection, a filter can be provided.
   150  This is more efficient than filtering outside of `Fetch`, as the framework can filter un-matched objects earlier, skipping redundant work.
   151  The following filters are provided
   152  
   153  * `FilterName(name, namespace)`: filters an object by Name and Namespace.
   154  * `FilterNamespace(namespace)`: filters an object by Namespace.
   155  * `FilterKey(key)`: filters an object by key.
   156  * `FilterLabel(labels)`: filters to only objects that match these labels.
   157  * `FilterSelects(labels)`: filters to only objects that **select** these labels. An empty selector matches everything.
   158  * `FilterSelectsNonEmpty(labels)`: filters to only objects that **select** these labels. An empty selector matches nothing.
   159  * `FilterGeneric(func(any) bool)`: filters by an arbitrary function.
   160  
   161  Note that most filters may only be used if the objects being `Fetch`ed implement appropriate functions to extract the fields filtered against.
   162  Failures to meet this requirement will result in a `panic`.
   163  
   164  ## Library Status
   165  
   166  This library is currently "experimental" and is not used in Istio production yet.
   167  The intent is this will be slowly rolled out to controllers that will benefit from it and are lower risk;
   168  likely, the ambient controller will be the first target.
   169  
   170  While its _plausible_ all of Istio could be fundamentally re-architected to fully embrace `krt` throughout (replacing things like `PushContext`),
   171  it is not yet clear this is desired.
   172  
   173  ### Performance
   174  
   175  Compared to a perfectly optimized hand-written controller, `krt` adds some overhead.
   176  However, writing a perfectly optimized controller is hard, and often not done.
   177  As a result, for many scenarios it is expected that `krt` will perform on-par or better.
   178  
   179  This is similar to a comparison between a high level programming language compared to assembly;
   180  while its always possible to write better code in assembly, smart compilers can make optimizations humans are unlikely to,
   181  such as loop unrolling.
   182  Similarly, `krt` can make complex optimizations in one place, so each controller implementation doesn't, which is likely to increase
   183  the amount of optimizations applied.
   184  
   185  The `BenchmarkControllers` puts this to the test, comparing an *ideal* hand-written controller to one written in `krt`.
   186  While the numbers are likely to change over time, at the time of writing the overhead for `krt` is roughly 10%:
   187  
   188  ```text
   189  name                  time/op
   190  Controllers/krt-8     13.4ms ±23%
   191  Controllers/legacy-8  11.4ms ± 6%
   192  
   193  name                  alloc/op
   194  Controllers/krt-8     15.2MB ± 0%
   195  Controllers/legacy-8  12.9MB ± 0%
   196  ```
   197  
   198  ### Future work
   199  
   200  #### Object optimizations
   201  
   202  One important aspect of `krt` is its ability to automatically detect if objects have changed, and only trigger dependencies if so.
   203  This works better when we only compare fields we actually use.
   204  Today, users can do this manually by making a transformation from the full object to a subset of the object.
   205  
   206  This could be improved by:
   207  * Automagically detecting which subset of the object is used, and optimize this behind the scenes. This seems unrealistic, though.
   208  * Allow a lightweight form of a `Full -> Subset` transformation, that doesn't create a full new collection (with respective overhead), but rather overlays on top of an existing one.
   209  
   210  #### Internal dependency optimizations
   211  
   212  Today, the library stores a mapping of `Input -> Dependencies` (`map[Key[I]][]dependency`).
   213  Often times, there are common dependencies amongst keys.
   214  For example, a namespace filter probably has many less unique values than unique input objects.
   215  Other filters may be completely static and shared by all keys.
   216  
   217  This could be improved by:
   218  * Optimize the data structure to be a bit more advanced in sharing dependencies between keys.
   219  * Push the problem to the user; allow them to explicitly set up static `Fetch`es.
   220  
   221  #### Debug tooling
   222  
   223  `krt` has an opportunity to add a lot of debugging capabilities that are hard to do elsewhere, because it would require
   224  linking up disparate controllers, and a lot of per-controller logic.
   225  
   226  Some debugging tooling ideas:
   227  * Add OpenTelemetry tracing to controllers ([prototype](https://github.com/howardjohn/istio/commits/experiment/cv2-tracing)).
   228  * Automatically generate mermaid diagrams showing system dependencies.
   229  * Automatically detect violations of [Transformation constraints](#transformation-constraints).