Kubernetes image policy webhook explicado
Introducción
En este artículo vamos a explorar cómo funciona un webhook en Kubernetes, y más específicamente sobre el ImagePolicyWebhook. La documentación oficial de Kubernetes sobre esto es un poco escasa, ya que no proporciona ejemplos reales o implementaciones de las cuales puedas aprender. Aquí desglosaremos las diferentes alternativas. En un escenario real, yo prefiero confiar en OPA Gatekeeper. Estoy planeando hacer que esta implementación valga la pena añadiendo una base de datos que permita que el webhook acepte o rechace imágenes basadas en el análisis de vulnerabilidades, por ejemplo, permitiendo solo imágenes con vulnerabilidades de nivel medio o inferior. Pero eso lo dejaremos para otro día. Si te interesa, podés colaborar en este repositorio.
Existen dos formas de hacer que esto funcione, cada una con un comportamiento ligeramente diferente. Una forma es usar el ImagePolicyWebhook y la otra es utilizando un Admission Controller, ya sea validando o mutando. En este caso, usé el webhook de validación, podés aprender más aquí.
Este admission controller rechazará todos los pods que utilicen imágenes con la etiqueta latest
, y en el futuro veremos si los pods cumplen con los niveles de seguridad requeridos.
Comparación
El ImagePolicyWebhook es un admission controller que solo evalúa imágenes. Necesitás analizar las solicitudes, aplicar la lógica y devolver una respuesta para permitir o denegar las imágenes en el clúster.
Las ventajas del ImagePolicyWebhook
:
- El API server puede configurarse para rechazar imágenes si el webhook no está disponible, lo cual es útil, aunque también puede causar problemas, como que algunos pods esenciales no se puedan ejecutar.
Las desventajas del ImagePolicyWebhook
:
- La configuración es más compleja y requiere acceso a los nodos maestros o al archivo de configuración del apiserver. La documentación no es clara y puede ser difícil realizar cambios o actualizaciones.
- El despliegue no es trivial, ya que debés desplegarlo con systemd o ejecutarlo como un contenedor Docker en el host, actualizar el DNS, etc.
Por otro lado, el ValidatingAdmissionWebhook puede utilizarse para muchas más cosas además de imágenes (si usás el de mutación, incluso podés modificar cosas al vuelo).
Las ventajas del ValidatingAdmissionWebhook
:
- Despliegue más sencillo ya que el servicio se ejecuta como un pod.
- Todo puede ser un recurso de Kubernetes.
- Menos intervención manual, no se requiere acceso al nodo maestro.
-
Si el pod o el servicio no están disponibles, se permitirán todas las imágenes, lo que podría ser un riesgo de seguridad en algunos casos. Si elegís este camino, asegurate de hacerlo altamente disponible. Esto puede configurarse especificando el
failurePolicy
enFail
en lugar deIgnore
(por defecto está enFail
).
Las desventajas del ValidatingAdmissionWebhook
:
- Cualquier persona con suficientes permisos de RBAC puede cambiar la configuración, ya que es solo otro recurso de Kubernetes.
Construcción
Si querés usarlo como un servicio simple:
$ go get github.com/kainlite/kube-image-bouncer
También podés usar esta imagen Docker:
$ docker pull kainlite/kube-image-bouncer
Certificados
Podemos apoyarnos en el CA de Kubernetes para generar los certificados que necesitamos. Si querés aprender más, andá aquí.
Creá un CSR:
$ cat <<EOF | cfssl genkey - | cfssljson -bare server
{
"hosts": [
"image-bouncer-webhook.default.svc",
"image-bouncer-webhook.default.svc.cluster.local",
"image-bouncer-webhook.default.pod.cluster.local",
"192.0.2.24",
"10.0.34.2"
],
"CN": "system:node:image-bouncer-webhook.default.pod.cluster.local",
"key": {
"algo": "ecdsa",
"size": 256
},
"names": [
{
"O": "system:nodes"
}
]
}
EOF
Luego aplicalo en el clúster:
$ cat <<EOF | kubectl apply -f -
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
name: image-bouncer-webhook.default
spec:
request: $(cat server.csr | base64 | tr -d '\n')
signerName: kubernetes.io/kubelet-serving
usages:
- digital signature
- key encipherment
- server auth
EOF
Aprobá y obtené tu certificado para usarlo más adelante:
$ kubectl get csr image-bouncer-webhook.default -o jsonpath='{.status.certificate}' | base64 --decode > server.crt
Ruta para ImagePolicyWebhook
Hay dos maneras de desplegar este controller (webhook). Para que esto funcione, necesitás crear los certificados como se explicó antes, pero primero debés ocuparte de otros detalles. Agregá esto a tu archivo /etc/hosts
en el nodo maestro o donde se ejecutará el bouncer:
Usamos este nombre porque tiene que coincidir con los nombres del certificado. Dado que este se ejecutará fuera de Kubernetes (y podría incluso estar disponible externamente), simplemente lo falsificamos con una entrada en /etc/hosts
:
$ echo "127.0.0.1 image-bouncer-webhook.default.svc" >> /etc/hosts
Además, en el apiserver necesitás actualizarlo con estas configuraciones:
--admission-control-config-file=/etc/kubernetes/kube-image-bouncer/admission_configuration.json
--enable-admission-plugins=ImagePolicyWebhook
Si seguís este método, no necesitás crear el recurso validating-webhook-configuration.yaml
ni aplicar el despliegue de Kubernetes para ejecutar en el clúster.
Creá un archivo de configuración de control de admisión llamado /etc/kubernetes/kube-image-bouncer/admission_configuration.json
con el siguiente contenido:
{
"imagePolicy": {
"kubeConfigFile": "/etc/kubernetes/kube-image-bouncer/kube-image-bouncer.yml",
"allowTTL": 50,
"denyTTL": 50,
"retryBackoff": 500,
"defaultAllow": false
}
}
Ajustá los valores predeterminados si querés permitir imágenes por defecto.
Creá un archivo kubeconfig /etc/kubernetes/kube-image-bouncer/kube-image-bouncer.yml
con el siguiente contenido:
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority: /etc/kubernetes/kube-image-bouncer/pki/server.crt
server: https://image-bouncer-webhook.default.svc:1323/image_policy
name: bouncer_webhook
contexts:
- context:
cluster: bouncer_webhook
user: api-server
name: bouncer_validator
current-context: bouncer_validator
preferences: {}
users:
- name: api-server
user:
client-certificate: /etc/kubernetes/pki/apiserver.crt
client-key: /etc/kubernetes/pki/apiserver.key
Este archivo de configuración le indica al API server que debe conectarse al servidor webhook en https://image-bouncer-webhook.default.svc:1323
y utilizar el endpoint /image_policy
. Estamos reutilizando los certificados del apiserver y el generado para kube-image-bouncer.
Asegurate de estar en la carpeta que contiene los certificados para que funcione:
$ docker run --rm -v `pwd`/server-key.pem:/certs/server-key.pem:ro -v `pwd`/server.crt:/certs/server.crt:ro -p 1323:1323 --network host kainlite/kube-image-bouncer -k /certs/server-key.pem -c /certs/server.crt
Ruta para ValidatingAdmissionWebhook
Si optás por esta ruta, todo lo que necesitás hacer es generar los certificados; todo lo demás puede hacerse con kubectl
. Primero debés crear un secreto TLS que contenga el certificado y la clave del webhook (que generamos en el paso anterior):
$ kubectl create secret tls tls-image-bouncer-webhook \
--key server-key.pem \
--cert server.pem
Luego creá un despliegue de Kubernetes para el image-bouncer-webhook
:
$ kubectl apply -f kubernetes/image-bouncer-webhook.yaml
Finalmente, creá la ValidatingWebhookConfiguration
que utiliza nuestro endpoint del webhook. Podés usar este archivo, pero asegurate de actualizar el campo caBundle
con el contenido de server.crt
en formato base64:
$ kubectl apply -f kubernetes/validating-webhook-configuration.yaml
O también podés generar el archivo validating-webhook-configuration.yaml
directamente y aplicarlo de una sola vez como sigue:
$ cat <<EOF | kubectl apply -f -
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: image-bouncer-webook
webhooks:
- name: image-bouncer-webhook.default.svc
rules:
- apiGroups:
- ""
apiVersions:
- v1
operations:
- CREATE
resources:
- pods
failurePolicy: Ignore
sideEffects: None
admissionReviewVersions: ["v1", "v1beta1"]
clientConfig:
caBundle: $(kubectl get csr image-bouncer-webhook.default -o jsonpath='{.status.certificate}')
service:
name: image-bouncer-webhook
namespace: default
EOF
Este proceso se puede automatizar fácilmente (estoy trabajando en un Helm chart para esto…). Los cambios pueden tardar unos segundos en reflejarse, así que esperá un poco y luego probá si todo funciona correctamente.
Pruebas
Ambos métodos deberían funcionar de la misma manera y vas a ver un mensaje de error similar al siguiente ejemplo:
Error creating: pods "nginx-latest-sdsmb" is forbidden: image policy webhook backend denied one or more images: Images using latest tag are not allowed
o
Warning FailedCreate 23s (x15 over 43s) replication-controller Error creating: admission webhook "image-bouncer-webhook.default.svc" denied the request: Images using latest tag are not allowed
Creá un ReplicationController (RC) para nginx con una versión específica para validar que los lanzamientos con versiones funcionen correctamente:
$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ReplicationController
metadata:
name: nginx-versioned
spec:
replicas: 1
selector:
app: nginx-versioned
template:
metadata:
name: nginx-versioned
labels:
app: nginx-versioned
spec:
containers:
- name: nginx-versioned
image: nginx:1.13.8
ports:
- containerPort: 80
EOF
Verificando que esta corriendo:
$ kubectl get rc
NAME DESIRED CURRENT READY AGE
nginx-versioned 1 1 0 2h
Ahora creemos un despliegue de nginx usando latest
para validar que funciona:
$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ReplicationController
metadata:
name: nginx-latest
spec:
replicas: 1
selector:
app: nginx-latest
template:
metadata:
name: nginx-latest
labels:
app: nginx-latest
spec:
containers:
- name: nginx-latest
image: nginx
ports:
- containerPort: 80
EOF
Si verificamos el pod, no debería crearse, y el ReplicationController (RC) debería mostrar algo similar al siguiente resultado. También podés verificarlo con el comando kubectl get events --sort-by='{.lastTimestamp}'
:
$ kubectl describe rc nginx-latest
Name: nginx-latest
Namespace: default
Selector: app=nginx-latest
Labels: app=nginx-latest
Annotations: <none>
Replicas: 0 current / 1 desired
Pods Status: 0 Running / 0 Waiting / 0 Succeeded / 0 Failed
Pod Template:
Labels: app=nginx-latest
Containers:
nginx-latest:
Image: nginx
Port: 80/TCP
Host Port: 0/TCP
Environment: <none>
Mounts: <none>
Volumes: <none>
Conditions:
Type Status Reason
---- ------ ------
ReplicaFailure True FailedCreate
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedCreate 23s (x15 over 43s) replication-controller Error creating: admission webhook "image-bouncer-webhook.default.svc" denied the request: Images using latest tag are not allowed
Esto confirma que el webhook está bloqueando correctamente las imágenes con la etiqueta latest
.
Depuración
Es muy útil revisar los logs del apiserver si estás utilizando el camino del admission controller, ya que allí se registrará por qué falló, así como también los logs del image-bouncer. Aquí hay un ejemplo:
apiserver:
W0107 17:39:00.619560 1 dispatcher.go:142] rejected by webhook "image-bouncer-webhook.default.svc": &errors.StatusError{ErrStatus:v1.Status{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ListMeta:v1.ListMeta{ SelfLink:"", ResourceVersion:"", Continue:"", RemainingItemCount:(*int64)(nil)}, Status:"Failure", Message:"admission webhook \"image-bouncer-webhook.default.svc\" denied the request: Images using latest tag are not allowed", Reason:"", Details:(*v1.StatusDetails)(nil), Code:400}}
kube-image-bouncer:
echo: http: TLS handshake error from 127.0.0.1:49414: remote error: tls: bad certificate
method=POST, uri=/image_policy?timeout=30s, status=200
method=POST, uri=/image_policy?timeout=30s, status=200
method=POST, uri=/image_policy?timeout=30s, status=200
El error es de una prueba manual, los otros son solicitudes exitosas del apiserver.
El código en sí
Echemos un vistazo rápido a las partes críticas de la creación de un admission controller o webhook.
Esta es una sección del archivo main.go
. Como podemos ver, estamos manejando dos rutas POST con diferentes métodos y algunas otras validaciones. Lo que debemos saber es que recibiremos una llamada POST con un JSON como payload que necesitamos convertir en una solicitud de revisión del admission controller.
app.Action = func(c *cli.Context) error {
e := echo.New()
e.POST("/image_policy", handlers.PostImagePolicy())
e.POST("/", handlers.PostValidatingAdmission())
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: "method=${method}, uri=${uri}, status=${status}\n",
}))
if debug {
e.Logger.SetLevel(log.DEBUG)
}
if whitelist != "" {
handlers.RegistryWhitelist = strings.Split(whitelist, ",")
fmt.Printf(
"Accepting only images from these registries: %+v\n",
handlers.RegistryWhitelist)
fmt.Println("WARN: this feature is implemented only by the ValidatingAdmissionWebhook code")
} else {
fmt.Println("WARN: accepting images from ALL registries")
}
var err error
if cert != "" && key != "" {
err = e.StartTLS(fmt.Sprintf(":%d", port), cert, key)
} else {
err = e.Start(fmt.Sprintf(":%d", port))
}
if err != nil {
return cli.NewExitError(err, 1)
}
return nil
}
app.Run(os.Args)
Esta es una sección del archivo handlers/validating_admission.go
. Básicamente, analiza y valida si la imagen debe ser permitida o no, y luego envía una respuesta de AdmissionResponse con el valor Allowed
configurado como verdadero o falso.
Si querés aprender más sobre los diferentes tipos utilizados aquí, podés explorar la documentación de v1beta1.Admission.
func PostValidatingAdmission() echo.HandlerFunc {
return func(c echo.Context) error {
var admissionReview v1beta1.AdmissionReview
err := c.Bind(&admissionReview)
if err != nil {
c.Logger().Errorf("Something went wrong while unmarshalling admission review: %+v", err)
return c.JSON(http.StatusBadRequest, err)
}
c.Logger().Debugf("admission review: %+v", admissionReview)
pod := v1.Pod{}
if err := json.Unmarshal(admissionReview.Request.Object.Raw, &pod); err != nil {
c.Logger().Errorf("Something went wrong while unmarshalling pod object: %+v", err)
return c.JSON(http.StatusBadRequest, err)
}
c.Logger().Debugf("pod: %+v", pod)
admissionReview.Response = &v1beta1.AdmissionResponse{
Allowed: true,
UID: admissionReview.Request.UID,
}
images := []string{}
for _, container := range pod.Spec.Containers {
images = append(images, container.Image)
usingLatest, err := rules.IsUsingLatestTag(container.Image)
if err != nil {
c.Logger().Errorf("Error while parsing image name: %+v", err)
return c.JSON(http.StatusInternalServerError, "error while parsing image name")
}
if usingLatest {
admissionReview.Response.Allowed = false
admissionReview.Response.Result = &metav1.Status{
Message: "Images using latest tag are not allowed",
}
break
}
if len(RegistryWhitelist) > 0 {
validRegistry, err := rules.IsFromWhiteListedRegistry(
container.Image,
RegistryWhitelist)
if err != nil {
c.Logger().Errorf("Error while looking for image registry: %+v", err)
return c.JSON(
http.StatusInternalServerError,
"error while looking for image registry")
}
if !validRegistry {
admissionReview.Response.Allowed = false
admissionReview.Response.Result = &metav1.Status{
Message: "Images from a non whitelisted registry",
}
break
}
}
}
if admissionReview.Response.Allowed {
c.Logger().Debugf("All images accepted: %v", images)
} else {
c.Logger().Infof("Rejected images: %v", images)
}
c.Logger().Debugf("admission response: %+v", admissionReview.Response)
return c.JSON(http.StatusOK, admissionReview)
}
}
Todo está en este repositorio.
Palabras finales
Este ejemplo y el post original fueron creados aquí, así que muchas gracias a Flavio Castelli por crear un excelente ejemplo. Mis cambios son principalmente para explicar cómo funciona y las modificaciones necesarias para que funcione en la última versión de Kubernetes (en este momento v1.20.0), mientras aprendía a usarlo y a crear mi propio webhook.
El archivo readme del proyecto puede no coincidir completamente con este artículo, pero ambos deberían funcionar. Aún no he actualizado todo el readme.
Errata
Si encontrás algún error o tenés alguna sugerencia, por favor enviame un mensaje para que lo pueda corregir.
No tienes cuenta? Regístrate aqui
Ya registrado? Iniciar sesión a tu cuenta ahora.
-
Comentarios
Online: 0
Por favor inicie sesión para poder escribir comentarios.