Integrate external secrets management systems in Kubernetes

How to manage our Kubernetes secrets with AWS Secrets Manager as a single source of truth with External Secrets Operator

Introduction

Managing secrets in our Kubernetes project is always hard work, with a lot of coffee, discussions, best practices and sometimes beers.

Many providers offer centralized solutions for secrets management, like AWS Secrets Manager, HashiCorp Vault, Google Secrets Manager, Azure Key Vault and many others.

In this post we will see an example about how we can manage our Kubernetes secrets with AWS Secrets Manager as a single source of truth with External Secrets Operator in a AWS Elastic Kubernetes Service.

External Secrets Operator extends Kubernetes with Custom Resources, which define where secrets live and how to synchronize them. The controller fetches secrets from an external API and creates Kubernetes secrets. If the secret from the external API changes, the controller will reconcile the state in the cluster and update the secrets accordingly.

The birth of the project, kicked off by Container Solution, is worth a reading. You can have an idea here GoDaddy and here External Secrets Community

Prerequisites

During the example we’ll create a Kubernetes cluster in AWS Elastic Kubernetes Service and a sample application that uses secrets stored in AWS Secret Manager.

We’ll use Terraform to manage our infrastructure, the whole example source code can be found in this repository.

Before we start, let’s make sure we meet these requirements:

  • An AWS account and an IAM user with full permissions
  • Docker installed and running on your local machine
  • A basic knowledge of Terraform

Note: the project is NOT a production ready code, keep it as an example on how we can use an external secrets manager.

Deploy project

Clone the project from example repository and copy env.template to .env file and use your IAM user credentials to fill AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION variables.

Build the docker container using the make command, after successfully building type make cli, this is the cli we use to interact with the cluster.

The file terraform.tfvars contains some basic configuration of our infrastructure resources.

Use the following commands to initialize the terraform project and to provision the cluster:

$ terraform init
$ terraform validate
$ terraform apply

VPC

Looking at main.tf file, in the first step we create our VPC that spans two AWS Availability Zones.

module "vpc" {
  ...
  name = "${var.cluster}-vpc"
  cidr = var.cidr_block
  azs = var.azs
  private_subnets = var.private_subnet_block
  public_subnets = var.public_subnet_block
  enable_nat_gateway = true
  single_nat_gateway = true
  one_nat_gateway_per_az = false
  enable_dns_hostnames = true
  ...
}

Cluster setup

Next we create the kubernets cluster

module "eks" {
  ...
  cluster_name = var.cluster
  cluster_version  = "1.21"
  vpc_id  = var.vpc_id
  subnets = var.subnets
  cluster_endpoint_private_access = true
  cluster_endpoint_public_access = true
  cluster_endpoint_public_access_cidrs = ["0.0.0.0/0"]

  # Enable OIDC Provider
  enable_irsa = true
  write_kubeconfig = true

  node_groups = {
    core = {
      desired_capacity = var.node_desired
      min_capacity     = var.node_min
      max_capacity     = var.node_max

      instance_types = var.node_instance_types
      capacity_type  = "ON_DEMAND"
      subnets = var.subnets
      disk_size = 8     
    }
  }
  ...
}

Now we create a namespace for the sample application.

resource "kubernetes_namespace" "namespace" {
  metadata {
    labels = {
      Cluster = var.cluster
    }
    name = var.project
  }
}

Create a IAM role with AssumeRoleWithWebIdentity permission.

resource "aws_iam_role" "service_account" {
  name = "${var.cluster}-${var.project}-ServiceAccoutRole"
  assume_role_policy = templatefile(
    "${path.module}/files/iam-role.json",
    {
      oidc_provider_arn = module.eks.oidc_provider_arn
      cluster_oidc_issuer_url = replace(module.eks.cluster_oidc_issuer_url, "https://", "")
      service_account_namespace = var.project
      service_account_name = "${var.project}-eso"
    }
  ) 
}

Then we create a service account in the application namespace and annotate it with role arn created.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: ${service_account_name}
  namespace: ${namespace}
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::${account_id}:role/${iam_role_name}

Now we create an AWS Secrets Manager resource and an IAM policy with readonly permission to access it, then we attach the policy to the service account role so it can read secrets stored in AWS Secret Manager.

resource "aws_secretsmanager_secret" "secret" {
  name = var.project
  recovery_window_in_days = 0
}

resource "aws_iam_policy" "access_secrets" {
  name = "${var.project}-AccessSecrets"
  path = "/"

  policy = templatefile(
    "${path.module}/files/iam-policy.json",
    {
      aws_sm_secret_arn = aws_secretsmanager_secret.secret.arn
    }
  )
}

resource "aws_iam_role_policy_attachment" "access_secrets" {
  role = var.service_account_role
  policy_arn = aws_iam_policy.access_secrets.arn
}

External Secrets Operator

External Secrets Operator provides different modes of operation to fulfill ogranizational needs, Shared ClusterSecretStore, Managed SecretStore per Namespace and ESO as a Service, which I used in this example.

In ESO as a Service, every namespace is self-contained. Application developers manage SecretStore, ExternalSecret and secret infrastructure on their own. Cluster Administrators just provide the External Secrets Operator as a service.

App Screenshot

Complete reference is available in the official documentation.

Using helm, we install the External Secrets Operator

resource "helm_release" "external-secrets" {
  name       = "external-secrets"
  chart      = "external-secrets"
  repository = "https://charts.external-secrets.io"
  namespace  = "external-secrets"
  create_namespace = true
  version    = "0.3.11"
}

We define the SecretStore resource, the SecretStore is namespaced and it specifies how to access the external API. We then specify the service account (previously created) to be used.

apiVersion: external-secrets.io/v1alpha1
kind: SecretStore
metadata:
  name: secretstore-example
  namespace: example
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-west-1
      auth:
        jwt:
          serviceAccountRef:
            name: example

Once we create the ExternalSecret resource, the ExternalSecret describes what data should be fetched, and how data should be transformed and saved as a Kind=Secret resource. In the spec.target we specify which secret resource name should be created and which secret manager store should be used and stored in the secret.

apiVersion: external-secrets.io/v1alpha1
kind: ExternalSecret
metadata:
  name: externalsecret-example
  namespace: ${namespace}
spec:
  refreshInterval: 1m
  secretStoreRef:
    name: secretstore-example
    kind: SecretStore
  target:
    name: example-secret
    creationPolicy: Owner
  dataFrom:
  - key: ${namespace}

Our EKS cluster and all resources should be up and running. Typing the following command we can configure kubectl to access the cluster:

$ aws eks update-kubeconfig --region REGION --name CLUSTER_NAME

AWS Secrets Manager

As next step, we access our AWS account console and go to AWS Secrets Menager page, where we can see the empty secrets created from terraform, in our case “example”. We create a few example secrets values, for example “API_KEY” and “API_HOST”.

AWS Secrets Manager console

It’s time to verify that ESO is working as expected.

The ESO should have created the kubernetes secret resource “example-secret” and should contain all secret keys.

$ kubectl get secret -n example
NAME                                     TYPE                                  DATA   AGE
example-secret                           Opaque                                2      15m

kubectl get secret example-secret -n example -o jsonpath='{.data}'
{"API_HOST":"aHR0cHM6Ly9leGFtcGxlLmNvbQ==","API_KEY":"MTIzNDU2Nw=="}

Our secret is at last synchronized with the secret in AWS Secrets Manager: if we change the secret value or add a new secret value in the AWS console, the secrets in Kubernetes will be updated.

Tips

When you update or rotate the secrets value, ESO update the secrets value, but deployment already deployed did not see the changes, Reloader could be a good solution to perform a rolling upgrade when change happens in our secret.

The example repository contains the Reload installation and a sample deployment yaml file.