Andrew Kozlowskiy
July 31, 2023 ・ Kubernetes
How to write basic Admission Controller and how it works
Introduction
Kubernetes has become the de facto standard for container orchestration, providing a powerful platform for managing the deployment, scaling, and maintenance of containerized applications. One of its lesser-known, yet invaluable features is the Admission Controller, which helps ensure that your Kubernetes cluster runs securely and efficiently. In this article, we will discuss how the Admission Controller works and how to write one.
Understanding Kubernetes Admission Controller
Kubernetes admission controller is a plugin that manages how the cluster is used. It intercepts API requests and changes them or verifies they meet specific criteria.
You can consider it as a gatekeeper for incoming changes in the cluster’s objects’ state.
The Admission Controller relies on 3 main concepts - Validating and Mutating Admission and webook configuration.
-
Validating admission verifies whether the incoming requests meet specific criteria, such as security, policy, and resource constraints. If a request fails to meet these criteria, the Admission Controller can reject the request, ensuring the cluster's stability and security.
-
Mutating admission can modify the incoming request. It’s a useful feature for adding default values, annotations, or labels to the object. However, the possibilities are endless and mutating admission can add or alter anything in the incoming object. This ensures that the object is in the desired state before it’s stored in etcd.
-
Webhook configuration (validating or mutating) is an object that describes rules on which objects should be intercepted by the admission controller as well as where to route them.
The admission controller can act as a mutating or validating controller or as a combination of both. If the admission controller is responsible for both mutating and validating the request, the mutating phase is executed first, followed by the validating phase. For Kubernetes administrators probably the most widely known example of an admission controller which can alter and verify objects is the LimitRanger admission controller. It can augment pods with default requests and limits (mutating phase) and verify, that pods with set resource requests do not exceed namespace limits.
Here’s an example of the validating webhook configuration:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: validation
webhooks:
- name: validation.default.svc
clientConfig:
service:
name: admission-server
namespace: default
path: "/validate"
caBundle: ${CA_PEM_B64}
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: ["apps"]
apiVersions: ["v1"]
resources: ["deployments", "statefulsets"]
failurePolicy: Fail
sideEffects: None
admissionReviewVersions: ["v1"]
namespaceSelector:
matchLabels:
admission-control: "true"
This configuration describes following rules:
-
On requests
CREATE
andUPDATE
for objectsdeployment
andstatefulset
ofapps/v1
api version the request is intercepted. -
It’s sent to the service
admission-server
in thedefault
namespae by/validate
path. -
Additionally, we’ve added namespace selector to limit namespaces managed by this admission controller. Namespaces that should be managed by this controller should be labeled with
admission-control: "true"
label.
Let’s create a simple admission controller:
We will rely on the Admission Controller open source project.
Webserver
First of all, we need to create a web server, which will process intercepted requests:
func main() {
flag.StringVar(&tlscert, "tlscert", "/etc/certs/tls.crt", "Path to the TLS certificate")
flag.StringVar(&tlskey, "tlskey", "/etc/certs/tls.key", "Path to the TLS key")
flag.StringVar(&port, "port", "8443", "The port on which to listen")
flag.Parse()
server := http.NewServer(port)
go func() {
// listen shutdown signal
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
sig := <-signalChan
log.Errorf("Received %s signal; shutting down...", sig)
if err := server.Shutdown(context.Background()); err != nil {
log.Error(err)
}
}()
log.Infof("Starting server on port: %s", port)
if err := server.ListenAndServeTLS(tlscert, tlskey); err != nil {
log.Errorf("Failed to listen and serve: %v", err)
os.Exit(1)
}
}
Next, we need to describe the locations for out admission requests. As it was described in validating webhook configuration, the admission server should listen on location /validate for incoming requests. Besides, /healthz location will be responsible for reporting server health. We will configure health probes later in this article :
func NewServer(port string) *http.Server {
// Instances hooks
validation := validation.NewValidationHook()
// Routers
ah := newAdmissionHandler()
mux := http.NewServeMux()
mux.Handle("/validate", ah.Serve(validation))
return &http.Server{
Addr: fmt.Sprintf(":%s", port),
Handler: mux,
}
}
Core logic
We need to define 3 main structs - AdmitFunc
, Hook
, and Result
AdmitFunc
is a function type that defines how to process admission request
type AdmitFunc func(request *admission.AdmissionRequest) (*Result, error)
Hook
represents the set of functions for each operation in an admission webhook
type Hook struct {
Create AdmitFunc
Update AdmitFunc
}
Result
contains a result of an admission request:
type Result struct {
Allowed bool
Msg string
PatchOps []PatchOperation
}
PatchOps
is necessary for mutating webhook, for now it’s out of the scope of this article
Finally, the Execute function will try to execute the function specified for the request:
func (h *Hook) Execute(r *v1.AdmissionRequest) (*Result, error) {
switch r.Operation {
case v1.Create:
return wrapperExecution(h.Create, r)
case v1.Update:
return wrapperExecution(h.Update, r)
}
return &Result{Msg: fmt.Sprintf("Invalid operation: %s", r.Operation)}, nil
}
func wrapperExecution(fn AdmitFunc, r *v1.AdmissionRequest) (*Result, error) {
if fn == nil {
return nil, fmt.Errorf("operation %s is not registered", r.Operation)
}
return fn(r)
}
Handling requests
Let’s write function for handling HTTP request:
// admissionHandler represents the HTTP handler for an admission webhook
type admissionHandler struct {
decoder runtime.Decoder
}
// newAdmissionHandler returns an instance of AdmissionHandler
func newAdmissionHandler() *admissionHandler {
return &admissionHandler{
decoder: serializer.NewCodecFactory(runtime.NewScheme()).UniversalDeserializer(),
}
}
And process incoming request. We need to decode incoming request to :
func (h *admissionHandler) Serve(hook admissioncontroller.Hook) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("could not read request body: %v", err), http.StatusBadRequest)
return
}
var review v1.AdmissionReview
if _, _, err := h.decoder.Decode(body, nil, &review); err != nil {
http.Error(w, fmt.Sprintf("could not deserialize request: %v", err), http.StatusBadRequest)
return
}
if review.Request == nil {
http.Error(w, "malformed admission review: request is nil", http.StatusBadRequest)
return
}
result, err := hook.Execute(review.Request)
if err != nil {
log.Error(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
admissionResponse := v1.AdmissionReview{
TypeMeta: meta.TypeMeta{
Kind: "AdmissionReview",
APIVersion: "admission.k8s.io/v1",
},
Response: &v1.AdmissionResponse{
UID: review.Request.UID,
Allowed: result.Allowed,
Result: &meta.Status{Message: result.Msg},
},
}
res, err := json.Marshal(admissionResponse)
if err != nil {
log.Error(err)
http.Error(w, fmt.Sprintf("could not marshal response: %v", err), http.StatusInternalServerError)
return
}
log.Infof("Webhook [%s - %s] - Allowed: %t", r.URL.Path, review.Request.Operation, result.Allowed)
w.WriteHeader(http.StatusOK)
w.Write(res)
}
}
Writing checks
In our example admission controller, we will check that pods have probes configured. First of all we need to parse received object. It can be done with the following function:
func parseObject(object []byte) (*v1.ReplicaSet, error) {
var objectToParse v1.ReplicaSet
if err := json.Unmarshal(object, &objectToParse); err != nil {
return nil, err
}
return &objectToParse, nil
}
Once the object is parsed, we can check that the replicaset has Liveness and Startup probes configured. Since replicaset is the abstraction over pod and both deployment and statefulset are abstractions over replicaset, to be able to check both deployment and statefulset with the same code, it’s easier to work with replicaset or pod for parsing.
To check if the container have probes configured, following function can be used:
func hasProbes(spec core.PodSpec) bool {
for _, container := range spec.Containers {
if container.LivenessProbe == nil && container.StartupProbe == nil {
log.Errorf("Container %s doesn't have probes set", container.Name)
return false
}
}
return true
}
Validating
Now, last thing to do is to describe what function should be triggered once CREATE or UPDATE request is received. To do this we can create a function which will create a new instance of the hook:
func NewValidationHook() admissioncontroller.Hook {
return admissioncontroller.Hook{
Create: validateCreate(),
}
}
And write this function, in our example - validateCreate:
func validateCreate() admissioncontroller.AdmitFunc {
return func(r *v1.AdmissionRequest) (*admissioncontroller.Result, error) {
receivedObject, err := parseObject(r.Object.Raw)
if err != nil {
return &admissioncontroller.Result{Msg: err.Error()}, err
}
if !hasProbes(receivedObject.Spec.Template.Spec) {
return &admissioncontroller.Result{Msg: "Created resource doesn't have probes set."}, nil
}
return &admissioncontroller.Result{Allowed: true}, nil
}
}
In this function, we are triggering described previously functions parseObject
and hasProbes
and return the result of admission review.
Deploying admission controller to k8s cluster
Now once all the necessary functions are in place, we can move on to deploying application to the cluster.
The deployment is pretty straight forward - as it was described in validating webhook configuration, the admission server should be located in the default
namespace, it should be accessible via the admission-server
service.
It’s worth noting, that admission controller only works with authenticated requests, hence it requires HTTPS support. So the admission-server should use certificates generated specifically for it and those certificates should have SAN’s configured for the domain name, by which this admission server is accessible. Certificates should be signed by CA, that is mentioned in the webhook configuration. The process of generating such certificates is pretty simple, but it differs a little from the process of creating certificates that relies only on Common Name.
Necessary certificates may be generated like this:
openssl genrsa -out ca.key 2048
openssl req -new -x509 -days 365 -key ca.key -subj "/C=US/O=Acme, Inc./CN=Acme Root CA" -out ca.crt
openssl req -newkey rsa:2048 -nodes -keyout server.key -subj "/C=US/O=Acme, Inc./CN=*.example.com" -out server.csr
openssl x509 -req -extfile <(printf "subjectAltName=DNS:example.com,DNS:www.example.com") -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt
Note subjectAltName
options that are passed while signing the certificate request
Conclusion
Kubernetes Admission Controllers play a critical role in maintaining the security, stability, and policy compliance of your cluster. By understanding how they work and implementing them in your Kubernetes deployments, engineers can enhance the overall quality and reliability of containerized applications. We’ve passed every step in the way of writing our own admission controller and created a webhook configuration to make it work in the kubernetes cluster. Now you should be able to write your own admission controller and enhance your cluster shape.
- Kubernetes
- Basics