• In the past few articles, we saw how to construct a complete DevOps pipeline with GitHub Actions and integrate security-oriented tools such as Grype, Sigstore Cosign and policy-controller into our pipeline to implement an end-to-end DevSecOps workflow providing a comprehensive level of protection for our applications:

    DevSecOps pipeline

    However, no matter how well our applications are secured, the security of our entire IT environment ultimately depends on the security of our infrastructure. Therefore, in the lab to follow, we will shift our focus away from Kubernetes workloads and instead explore how we can evaluate and improve upon the security of our Kubernetes clusters with kube-bench, the industry-leading Kubernetes benchmarking solution developed by Aqua.

    Lab: Evaluating and improving upon the security of a two-node Kubernetes cluster

    Prerequisites

    Familiarity with Kubernetes cluster administration is assumed. If not, consider enrolling in the comprehensive LFS258: Kubernetes Fundamentals online training course offered by The Linux Foundation which is also the official training course for the CKA certification exam offered by the CNCF.

    Setting up your environment

    It is assumed you already have a public cloud account such as an AWS account or a laptop / workstation capable of hosting at least 2 Linux nodes each with 2 vCPUs and 8G of RAM, one of which will become the master node and the other the worker node. You may follow the lab with a bare-metal setup as well, but note that we’ll re-create our entire cluster halfway through which may take additional time on bare metal.

    The reference distribution is Ubuntu 22.04 LTS for which the instructions in this lab have been tested against. If you’re looking for a challenge, feel free to follow the lab with a different distribution but beware that some of the instructions may require non-trivial modification.

    For the purposes of this lab, we’ll refer to our master node as master0 and worker node as worker0.

    Evaluating a typical kubeadm cluster against the CIS Kubernetes benchmarks

    Let’s set up a kubeadm cluster following the typical process and test it with kube-bench, which will run automated tests to evaluate our cluster against the CIS Kubernetes benchmarks, the industry standard in determining whether our Kubernetes cluster is secure.

    For the purposes of this lab, we’ll assume this isn’t the first time you’ve provisioned a two-node kubeadm cluster so we’ll skip over the details and provide the commands directly get our cluster set up in minutes.

    The versions of Kubernetes and associated components used in this lab are as follows:

    • Kubernetes 1.28.0
    • containerd 1.7.3
    • runc 1.1.9
    • CNI plugins 1.3.0
    • Calico 3.26.1

    Setting up master0

    Run the following commands on master0 to perform preliminary setup and avoid issues on installing and initializing Kubernetes. Make sure to replace x.x.x.x below with the private IP address of master0.

    sudo hostnamectl set-hostname master0
    echo "export PATH=\"/opt/cni/bin:/usr/local/sbin:/usr/local/bin:\$PATH\"" >> "$HOME/.bashrc" && \
    source "$HOME/.bashrc"
    sudo sed -i 's#Defaults secure_path = /sbin:/bin:/usr/sbin:/usr/bin#Defaults secure_path = /opt/cni/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin#' /etc/sudoers
    export K8S_CONTROL_PLANE="x.x.x.x"
    echo "$K8S_CONTROL_PLANE k8s-control-plane" | sudo tee -a /etc/hosts
    sudo modprobe br_netfilter
    echo br_netfilter | sudo tee /etc/modules-load.d/kubernetes.conf
    cat << EOF | sudo tee -a /etc/sysctl.conf
    net.ipv4.ip_forward=1
    EOF
    sudo sysctl -p
    sudo systemctl reboot

    After the reboot, run the following commands to install the containerd CRI and associated components:

    wget https://github.com/containerd/containerd/releases/download/v1.7.3/containerd-1.7.3-linux-amd64.tar.gz
    sudo tar Cxzvf /usr/local containerd-1.7.3-linux-amd64.tar.gz
    sudo mkdir -p /usr/local/lib/systemd/system/
    sudo wget -qO /usr/local/lib/systemd/system/containerd.service https://raw.githubusercontent.com/containerd/containerd/main/containerd.service
    sudo systemctl daemon-reload
    sudo systemctl enable --now containerd.service
    sudo mkdir -p /etc/containerd/
    containerd config default | \
    sed 's/SystemdCgroup = false/SystemdCgroup = true/' | \
    sed 's/pause:3.8/pause:3.9/' | \
    sudo tee /etc/containerd/config.toml
    sudo systemctl restart containerd.service
    sudo mkdir -p /usr/local/sbin/
    sudo wget -qO /usr/local/sbin/runc https://github.com/opencontainers/runc/releases/download/v1.1.9/runc.amd64
    sudo chmod +x /usr/local/sbin/runc
    sudo mkdir -p /opt/cni/bin/
    wget https://github.com/containernetworking/plugins/releases/download/v1.3.0/cni-plugins-linux-amd64-v1.3.0.tgz
    sudo tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.3.0.tgz

    Now run the commands below to install Kubernetes and initialize our master node:

    sudo apt update && sudo apt install -y apt-transport-https ca-certificates curl
    curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.28/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.28/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
    sudo apt update && sudo apt install -y \
    kubeadm=1.28.0-1.1 \
    kubelet=1.28.0-1.1 \
    kubectl=1.28.0-1.1
    sudo apt-mark hold kubelet kubeadm kubectl
    sudo systemctl enable --now kubelet.service
    cat > kubeadm-config.yaml << EOF
    kind: ClusterConfiguration
    apiVersion: kubeadm.k8s.io/v1beta3
    kubernetesVersion: v1.28.0
    controlPlaneEndpoint: "k8s-control-plane:6443"
    networking:
    podSubnet: "192.168.0.0/16"
    ---
    kind: KubeletConfiguration
    apiVersion: kubelet.config.k8s.io/v1beta1
    cgroupDriver: systemd
    EOF
    sudo kubeadm init --config kubeadm-config.yaml
    mkdir -p $HOME/.kube
    sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
    sudo chown $(id -u):$(id -g) $HOME/.kube/config
    echo "source <(kubectl completion bash)" >> "$HOME/.bashrc" && \
    source "$HOME/.bashrc"
    wget -qO - https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/calico.yaml | \
    kubectl apply -f -

    Wait a minute or two, then run the command below to verify that our master0 is Ready:

    kubectl get no

    Sample output:

    NAME STATUS ROLES AGE VERSION
    master0 Ready control-plane 72s v1.28.0

    Setting up worker0

    Now set up our worker node worker0.

    Again, the preliminary setup which also reboots our node – replace x.x.x.x again with the private IP address of master0:

    sudo hostnamectl set-hostname worker0
    echo "export PATH=\"/opt/cni/bin:/usr/local/sbin:/usr/local/bin:\$PATH\"" >> "$HOME/.bashrc" && \
    source "$HOME/.bashrc"
    sudo sed -i 's#Defaults secure_path = /sbin:/bin:/usr/sbin:/usr/bin#Defaults secure_path = /opt/cni/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin#' /etc/sudoers
    export K8S_CONTROL_PLANE="x.x.x.x"
    echo "$K8S_CONTROL_PLANE k8s-control-plane" | sudo tee -a /etc/hosts
    sudo modprobe br_netfilter
    echo br_netfilter | sudo tee /etc/modules-load.d/kubernetes.conf
    cat << EOF | sudo tee -a /etc/sysctl.conf
    net.ipv4.ip_forward=1
    EOF
    sudo sysctl -p
    sudo systemctl reboot

    Next, install containerd and associated components:

    wget https://github.com/containerd/containerd/releases/download/v1.7.3/containerd-1.7.3-linux-amd64.tar.gz
    sudo tar Cxzvf /usr/local containerd-1.7.3-linux-amd64.tar.gz
    sudo mkdir -p /usr/local/lib/systemd/system/
    sudo wget -qO /usr/local/lib/systemd/system/containerd.service https://raw.githubusercontent.com/containerd/containerd/main/containerd.service
    sudo systemctl daemon-reload
    sudo systemctl enable --now containerd.service
    sudo mkdir -p /etc/containerd/
    containerd config default | \
    sed 's/SystemdCgroup = false/SystemdCgroup = true/' | \
    sed 's/pause:3.8/pause:3.9/' | \
    sudo tee /etc/containerd/config.toml
    sudo systemctl restart containerd.service
    sudo mkdir -p /usr/local/sbin/
    sudo wget -qO /usr/local/sbin/runc https://github.com/opencontainers/runc/releases/download/v1.1.9/runc.amd64
    sudo chmod +x /usr/local/sbin/runc
    sudo mkdir -p /opt/cni/bin/
    wget https://github.com/containernetworking/plugins/releases/download/v1.3.0/cni-plugins-linux-amd64-v1.3.0.tgz
    sudo tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.3.0.tgz

    Now, install Kubernetes and initialize our worker node – replace the x’s with your Kubernetes token and CA certificate hash as shown in the output of kubeadm init on our master node master0:

    sudo apt update && sudo apt install -y apt-transport-https ca-certificates curl
    curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.28/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.28/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
    sudo apt update && sudo apt install -y \
    kubeadm=1.28.0-1.1 \
    kubelet=1.28.0-1.1
    sudo apt-mark hold kubelet kubeadm
    sudo systemctl enable --now kubelet.service
    export K8S_TOKEN="xxxxxx.xxxxxxxxxxxxxxxx"
    export K8S_CA_CERT_HASH="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    sudo kubeadm join k8s-control-plane:6443 \
    --discovery-token "${K8S_TOKEN}" \
    --discovery-token-ca-cert-hash "sha256:${K8S_CA_CERT_HASH}"

    Again, wait a minute or two after the setup is complete and run the command again to verify that our worker0 node is also Ready:

    kubectl get no

    Sample output:

    NAME STATUS ROLES AGE VERSION
    master0 Ready control-plane 10m v1.28.0
    worker0 Ready <none> 21s v1.28.0

    Verify the basic functionality of our cluster

    Let’s test that our cluster can run basic workloads and that pod networking is functioning as expected.

    Run the following commands on master0.

    First untaint our master node so we can run application pods on it as well:

    kubectl taint no master0 node-role.kubernetes.io/control-plane-

    Now create an NGINX deployment with two replicas and expose it with a ClusterIP service:

    kubectl create deploy nginx --image=nginx --replicas=2 --port=80
    kubectl expose deploy nginx

    Spin up a Pod with curl pre-installed so we can curl our nginx service:

    kubectl run curlpod --image=curlimages/curl -- sleep infinity

    Now curl our nginx service – we should expect to receive a response:

    kubectl exec curlpod -- curl -s nginx

    Sample output:

    <!DOCTYPE html>
    <html>
    <head>
    <title>Welcome to nginx!</title>
    <style>
    html { color-scheme: light dark; }
    body { width: 35em; margin: 0 auto;
    font-family: Tahoma, Verdana, Arial, sans-serif; }
    </style>
    </head>
    <body>
    <h1>Welcome to nginx!</h1>
    <p>If you see this page, the nginx web server is successfully installed and
    working. Further configuration is required.</p>
    <p>For online documentation and support please refer to
    <a href="http://nginx.org/">nginx.org</a>.<br/>
    Commercial support is available at
    <a href="http://nginx.com/">nginx.com</a>.</p>
    <p><em>Thank you for using nginx.</em></p>
    </body>
    </html>

    This confirms that our cluster is functional.

    Running the kube-bench tests and analyzing the log output

    Now fetch the job.yaml file from kube-bench which runs the benchmarking tool as a Kubernetes Job:

    wget https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml

    Before applying this YAML file, let’s add a spec.nodeName field to schedule it to our master0 node:

    echo ' nodeName: master0' >> job.yaml

    Apply the YAML file to create the job:

    kubectl apply -f job.yaml

    Wait about 10-15 seconds for the job to complete:

    kubectl get job

    Once the job completes, fetch the logs for the corresponding kube-bench-xxxxx pod. Replace the xxxxx placeholder as appropriate and remember – kubectl tab completion is your friend 😉

    kubectl logs kube-bench-xxxxx

    Take some time to scroll through and comprehend the output. For the purposes of this lab though, we’ll ignore the numerous warnings emitted by kube-bench and focus on just resolving the failed tests which can be shown succinctly with the command below – again, replcae the xxxxx placeholder as appropriate:

    kubectl logs kube-bench-xxxxx | grep FAIL

    Sample output:

    [FAIL] 1.1.12 Ensure that the etcd data directory ownership is set to etcd:etcd (Automated)
    [FAIL] 1.2.5 Ensure that the --kubelet-certificate-authority argument is set as appropriate (Automated)
    [FAIL] 1.2.17 Ensure that the --profiling argument is set to false (Automated)
    [FAIL] 1.2.18 Ensure that the --audit-log-path argument is set (Automated)
    [FAIL] 1.2.19 Ensure that the --audit-log-maxage argument is set to 30 or as appropriate (Automated)
    [FAIL] 1.2.20 Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate (Automated)
    [FAIL] 1.2.21 Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate (Automated)
    [FAIL] 1.3.2 Ensure that the --profiling argument is set to false (Automated)
    [FAIL] 1.4.1 Ensure that the --profiling argument is set to false (Automated)
    9 checks FAIL
    0 checks FAIL
    0 checks FAIL
    [FAIL] 4.1.1 Ensure that the kubelet service file permissions are set to 600 or more restrictive (Automated)
    1 checks FAIL
    0 checks FAIL
    10 checks FAIL

    Here’s what each failing test means:

    • “Ensure that the etcd data directory ownership is set to etcd:etcd” – the etcd data directory at /var/lib/etcd/ and its contents should be owned by the etcd system account which should be created if not exists
    • “Ensure that the –kubelet-certificate-authority argument is set as appropriate” – by default, the kubelet server certificate is self-signed and the kube-apiserver does not verify the authenticity of the certificate when communicating with the kubelet – this implies that the API server to kubelet connection is insecure and cannot be safely run over a public network. By replacing the kubelet’s server certificate with one signed by a trusted CA (as specified in the API server’s --kubelet-certificate-authority argument), the communication is secure and can then be safely run over a public network – refer to the official documentation for details
    • “Ensure that the –profiling argument is set to false” – the profiling feature of the kube-apiserver, kube-controller-manager and kube-scheduler exposes sensitive information about the cluster which could be exploited by a malicious actor. Setting it to false remediates this issue
    • “Ensure that the –audit-log-path argument is set” – audit logs for the kube-apiserver is off by default. Turning it on and specifying an appropriate path such as /var/log/apiserver/audit.log allows an administrator to periodically review it for potentially indicators of compromise
    • “Ensure that the –audit-log-maxage argument is set to 30 or as appropriate” – ditto
    • “Ensure that the –audit-log-maxbackup argument is set to 10 or as appropriate” – ditto
    • “Ensure that the –audit-log-maxsize argument is set to 100 or as appropriate” – ditto
    • “Ensure that the kubelet service file permissions are set to 600 or more restrictive” – the kubelet service file located at /lib/systemd/system/kubelet.service on Ubuntu has its permissions set to 0644 by default which is insecure – this can be easily remediated by manually setting it to 0600

    If we run the same test on our worker node – run these commands on the master0 node (perhaps counter-intuitively):

    sed -i 's/master0/worker0/' job.yaml
    kubectl replace --force -f job.yaml

    Wait 10-15 seconds for it to finish, then grep our pod logs again for failures:

    kubectl logs kube-bench-xxxxx | grep FAIL

    We’ll see that our worker node only fails the kubelet.service test:

    [FAIL] 4.1.1 Ensure that the kubelet service file permissions are set to 600 or more restrictive (Automated)
    1 checks FAIL
    0 checks FAIL
    1 checks FAIL

    Remediation overview

    Except the failures related to file and directory permissions which can be remediated directly, the rest should be configured through the kubeadm-config.yaml file passed to kubeadm on the master node for cluster initialization – here’s what our updated kubeadm-config.yaml file looks like:

    kind: ClusterConfiguration
    apiVersion: kubeadm.k8s.io/v1beta3
    kubernetesVersion: v1.28.0
    controlPlaneEndpoint: "k8s-control-plane:6443"
    networking:
    podSubnet: "192.168.0.0/16"
    apiServer:
    extraArgs:
    profiling: "false"
    audit-log-path: /var/log/apiserver/audit.log
    audit-log-maxage: "30"
    audit-log-maxbackup: "10"
    audit-log-maxsize: "100"
    kubelet-certificate-authority: /etc/kubernetes/pki/ca.crt
    controllerManager:
    extraArgs:
    profiling: "false"
    scheduler:
    extraArgs:
    profiling: "false"
    ---
    kind: KubeletConfiguration
    apiVersion: kubelet.config.k8s.io/v1beta1
    cgroupDriver: systemd
    serverTLSBootstrap: true

    Apart from the usual fields, notice the following new fields:

    • ClusterConfiguration.apiServer.extraArgs – here is where we pass our extra command-line arguments to the kube-apiserver – the keys are the flag names without the leading double dash -- and the values are the option values
    • ClusterConfiguration.controllerManager.extraArgs – ditto but for kube-controller-manager
    • ClusterConfiguration.scheduler.extraArgs – ditto but for kube-scheduler
    • KubeletConfiguration.serverTLSBootstrap: true – this instructs kubeadm to sign the kubelet server certificates with the Kubernetes cluster CA key instead of using the default of generating a self-signed certificate. In conjunction with the --kubelet-certificate-authority=/etc/kubernetes/pki/ca.crt argument passed to the kube-apiserver, this secures communication between the API server and kubelet so the connection could in theory be served securely over the public Internet

    Refer to the official documentation for details regarding the kubeadm-config.yaml configuration file.

    Provisioning a secure two-node kubeadm cluster

    Armed with this knowledge, let’s delete our cluster and re-create it with security hardening in mind.

    Setting up master0

    Preliminary setup (replace x.x.x.x with the private IP address of master0):

    sudo hostnamectl set-hostname master0
    echo "export PATH=\"/opt/cni/bin:/usr/local/sbin:/usr/local/bin:\$PATH\"" >> "$HOME/.bashrc" && \
    source "$HOME/.bashrc"
    sudo sed -i 's#Defaults secure_path = /sbin:/bin:/usr/sbin:/usr/bin#Defaults secure_path = /opt/cni/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin#' /etc/sudoers
    export K8S_CONTROL_PLANE="x.x.x.x"
    echo "$K8S_CONTROL_PLANE k8s-control-plane" | sudo tee -a /etc/hosts
    sudo modprobe br_netfilter
    echo br_netfilter | sudo tee /etc/modules-load.d/kubernetes.conf
    cat << EOF | sudo tee -a /etc/sysctl.conf
    net.ipv4.ip_forward=1
    EOF
    sudo sysctl -p
    sudo systemctl reboot

    Installing containerd:

    wget https://github.com/containerd/containerd/releases/download/v1.7.3/containerd-1.7.3-linux-amd64.tar.gz
    sudo tar Cxzvf /usr/local containerd-1.7.3-linux-amd64.tar.gz
    sudo mkdir -p /usr/local/lib/systemd/system/
    sudo wget -qO /usr/local/lib/systemd/system/containerd.service https://raw.githubusercontent.com/containerd/containerd/main/containerd.service
    sudo systemctl daemon-reload
    sudo systemctl enable --now containerd.service
    sudo mkdir -p /etc/containerd/
    containerd config default | \
    sed 's/SystemdCgroup = false/SystemdCgroup = true/' | \
    sed 's/pause:3.8/pause:3.9/' | \
    sudo tee /etc/containerd/config.toml
    sudo systemctl restart containerd.service
    sudo mkdir -p /usr/local/sbin/
    sudo wget -qO /usr/local/sbin/runc https://github.com/opencontainers/runc/releases/download/v1.1.9/runc.amd64
    sudo chmod +x /usr/local/sbin/runc
    sudo mkdir -p /opt/cni/bin/
    wget https://github.com/containernetworking/plugins/releases/download/v1.3.0/cni-plugins-linux-amd64-v1.3.0.tgz
    sudo tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.3.0.tgz

    Installing Kubernetes and initializing the control plane:

    sudo apt update && sudo apt install -y apt-transport-https ca-certificates curl
    curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.28/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.28/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
    sudo apt update && sudo apt install -y \
    kubeadm=1.28.0-1.1 \
    kubelet=1.28.0-1.1 \
    kubectl=1.28.0-1.1
    sudo apt-mark hold kubelet kubeadm kubectl
    sudo chmod 600 /lib/systemd/system/kubelet.service
    sudo useradd -r -c "etcd user" -s /sbin/nologin -M etcd -U
    sudo systemctl enable --now kubelet.service
    cat > kubeadm-config.yaml << EOF
    kind: ClusterConfiguration
    apiVersion: kubeadm.k8s.io/v1beta3
    kubernetesVersion: v1.28.0
    controlPlaneEndpoint: "k8s-control-plane:6443"
    networking:
    podSubnet: "192.168.0.0/16"
    apiServer:
    extraArgs:
    profiling: "false"
    audit-log-path: /var/log/apiserver/audit.log
    audit-log-maxage: "30"
    audit-log-maxbackup: "10"
    audit-log-maxsize: "100"
    kubelet-certificate-authority: /etc/kubernetes/pki/ca.crt
    controllerManager:
    extraArgs:
    profiling: "false"
    scheduler:
    extraArgs:
    profiling: "false"
    ---
    kind: KubeletConfiguration
    apiVersion: kubelet.config.k8s.io/v1beta1
    cgroupDriver: systemd
    serverTLSBootstrap: true
    EOF
    sudo kubeadm init --config kubeadm-config.yaml
    mkdir -p $HOME/.kube
    sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
    sudo chown $(id -u):$(id -g) $HOME/.kube/config
    echo "source <(kubectl completion bash)" >> "$HOME/.bashrc" && \
    source "$HOME/.bashrc"
    sudo chmod 700 /var/lib/etcd
    sudo chown -R etcd: /var/lib/etcd
    wget -qO - https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/calico.yaml | \
    kubectl apply -f -

    Verify the master is ready with kubectl get no.

    Setting up worker0

    Preliminary setup (replace x.x.x.x with the private IP for master0):

    sudo hostnamectl set-hostname worker0
    echo "export PATH=\"/opt/cni/bin:/usr/local/sbin:/usr/local/bin:\$PATH\"" >> "$HOME/.bashrc" && \
    source "$HOME/.bashrc"
    sudo sed -i 's#Defaults secure_path = /sbin:/bin:/usr/sbin:/usr/bin#Defaults secure_path = /opt/cni/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin#' /etc/sudoers
    export K8S_CONTROL_PLANE="x.x.x.x"
    echo "$K8S_CONTROL_PLANE k8s-control-plane" | sudo tee -a /etc/hosts
    sudo modprobe br_netfilter
    echo br_netfilter | sudo tee /etc/modules-load.d/kubernetes.conf
    cat << EOF | sudo tee -a /etc/sysctl.conf
    net.ipv4.ip_forward=1
    EOF
    sudo sysctl -p
    sudo systemctl reboot

    Installing containerd:

    wget https://github.com/containerd/containerd/releases/download/v1.7.3/containerd-1.7.3-linux-amd64.tar.gz
    sudo tar Cxzvf /usr/local containerd-1.7.3-linux-amd64.tar.gz
    sudo mkdir -p /usr/local/lib/systemd/system/
    sudo wget -qO /usr/local/lib/systemd/system/containerd.service https://raw.githubusercontent.com/containerd/containerd/main/containerd.service
    sudo systemctl daemon-reload
    sudo systemctl enable --now containerd.service
    sudo mkdir -p /etc/containerd/
    containerd config default | \
    sed 's/SystemdCgroup = false/SystemdCgroup = true/' | \
    sed 's/pause:3.8/pause:3.9/' | \
    sudo tee /etc/containerd/config.toml
    sudo systemctl restart containerd.service
    sudo mkdir -p /usr/local/sbin/
    sudo wget -qO /usr/local/sbin/runc https://github.com/opencontainers/runc/releases/download/v1.1.9/runc.amd64
    sudo chmod +x /usr/local/sbin/runc
    sudo mkdir -p /opt/cni/bin/
    wget https://github.com/containernetworking/plugins/releases/download/v1.3.0/cni-plugins-linux-amd64-v1.3.0.tgz
    sudo tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.3.0.tgz

    Installing Kubernetes and initializing the worker node (replace the placeholders marked with x’s with the corresponding Kubernetes token and CA certificate hash):

    sudo apt update && sudo apt install -y apt-transport-https ca-certificates curl
    curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.28/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
    echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.28/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
    sudo apt update && sudo apt install -y \
    kubeadm=1.28.0-1.1 \
    kubelet=1.28.0-1.1
    sudo apt-mark hold kubelet kubeadm
    sudo chmod 600 /lib/systemd/system/kubelet.service
    sudo systemctl enable --now kubelet.service
    export K8S_TOKEN="xxxxxx.xxxxxxxxxxxxxxxx"
    export K8S_CA_CERT_HASH="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    sudo kubeadm join k8s-control-plane:6443 \
    --discovery-token "${K8S_TOKEN}" \
    --discovery-token-ca-cert-hash "sha256:${K8S_CA_CERT_HASH}"

    Verify that the worker node is ready with kubectl get no.

    Verify the basic functionality of our cluster

    Now follow the same steps as with the original cluster above or devise your own tests to verify that this hardened cluster is indeed functioning as expected. But before that, we need to sign the certificate signing requests (CSR) generated for the kubelet server certificates to complete the configuration for secured communication from the API server to the kubelets, so that our API server can properly manage Pods through the kubelet:

    for csr in $(kubectl get csr | grep Pending | awk '{ print $1 }'); do
    kubectl certificate approve $csr
    done

    Sample output:

    certificatesigningrequest.certificates.k8s.io/csr-96t9n approved
    certificatesigningrequest.certificates.k8s.io/csr-mhfft approved
    certificatesigningrequest.certificates.k8s.io/csr-x5t2m approved

    Running the kube-bench tests to verify our cluster is now secure

    Let’s download the job.yaml from kube-bench once more and assign it to run on the master0 node:

    wget https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
    echo ' nodeName: master0' >> job.yaml
    kubectl apply -f job.yaml

    Wait 10-15 seconds or poll with kubectl get job until complete. Now view the failed tests through the logs (replace the xxxxx placeholder as appropriate):

    kubectl logs kube-bench-xxxxx | grep FAIL

    Sample output:

    [FAIL] 1.1.12 Ensure that the etcd data directory ownership is set to etcd:etcd (Automated)
    1 checks FAIL
    0 checks FAIL
    0 checks FAIL
    0 checks FAIL
    0 checks FAIL
    1 checks FAIL

    Notice the test for etcd data directory ownership is still failing – we’ll get back to that in a moment. But all the other tests have passed!

    Now for our worker node – run these on the master node:

    sed -i 's/master0/worker0/' job.yaml
    kubectl replace --force -f job.yaml

    Wait 10-15 seconds or poll with kubectl get job until complete, then grep the failing tests again:

    kubectl logs kube-bench-xxxxx | grep FAIL

    You should see no failures at all:

    0 checks FAIL
    0 checks FAIL
    0 checks FAIL

    Regarding the failing etcd data directory ownership check

    If you check the /var/lib/etcd/ directory and its contents, you’ll see they’re already owned by etcd:etcd:

    ls -l /var/lib/ | grep etcd

    Sample output:

    drwx------ 3 etcd etcd 4096 Aug 25 15:06 etcd

    The test is erroneously failing since kube-bench is run inside a Pod which does not share the host PID namespace. This is a known issue and not yet resolved at the time of writing – see aquasecurity/kube-bench#1275 on GitHub for details.

    Concluding remarks and going further

    We’ve seen how to run CIS Kubernetes benchmark tests on an existing two-node kubeadm cluster with kube-bench, how to interpret the failing tests and how to address them by augmenting the kubeadm configuration with the appropriate fields and parameters. With the help of kube-bench, we can easily discover and remediate security-related issues at the cluster level which serves as a basis for securing our Kubernetes applications and workloads running atop.

    I hope you enjoyed this article and stay tuned for more content 😉

    + ,
  • The source code for this lab exercise is available on GitHub.

    Consider our typical DevSecOps CI/CD pipeline that triggers automated unit and integration testing, container image building, vulnerability scanning, image pushing and signing, all the way up to deploying to a properly secured production environment on every developer commit to a Git repository.

    DevSecOps CI/CD pipeline

    We’ve seen how to construct a complete DevOps CI/CD pipeline with GitHub Actions, how container image signing and verification can be achieved with Sigstore Cosign and policy-controller, but we’ve yet to explore some of the available tools to perform vulnerability scanning on container images and how to make use of the feedback provided by these tools to start securing our applications and microservices.

    In the lab to follow, we’ll see how vulnerability scanning can be conveniently achieved with Grype and how various systematic techniques can be applied to start securing our microservices at the container image level.

    Lab: Minimizing container image vulnerabilities for a Rocket microservice

    Prerequisites

    Familiarity with Linux, Docker and containers is assumed. If not, consider following through the official Docker guide. You may also wish to enroll in LFS101x: Introduction to Linux, a self-paced online course offered by The Linux Foundation on edX at no cost.

    Setting up your environment

    You’ll need a Linux environment with at least 2 vCPUs and 4G of RAM. The reference distribution is Ubuntu 22.04 LTS, though the lab should work on most other Linux distributions as well with little to no modification.

    We’ll set up the following tools:

    Installing Docker

    Docker (hopefully) needs no introduction – simply install it from the system repositories and add yourself to the docker group:

    sudo apt update && sudo apt install -y docker.io
    sudo usermod -aG docker "${USER}"

    Log out and back in for group membership to take effect.

    Installing Grype

    Grype is an open source vulnerability scanning tool for container images developed and maintained by Anchore, dedicated to improving software supply chain security.

    Install it using the official instructions but we’ll install it under $HOME/.local/bin/ so no sudo is required:

    curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b "$HOME/.local/bin"

    Log out and back in for Grype to appear in your PATH.

    Verifying everything is installed correctly

    Run the following commands to check the version of each tool we just installed:

    docker --version
    grype version

    Sample output:

    Docker version 20.10.25, build 20.10.25-0ubuntu1~22.04.1
    Application: grype
    Version: 0.65.2
    Syft Version: v0.87.1
    BuildDate: 2023-08-17T20:03:30Z
    GitCommit: 51223cd0b1069c7c7bbc27af1deec3e96ad3e07d
    GitDescription: v0.65.2
    Platform: linux/amd64
    GoVersion: go1.19.12
    Compiler: gc
    Supported DB Schema: 5

    Evaluating base image selection and image build strategy from a security perspective using Grype

    Let’s consider a Rocket microservice exposing the following API:

    • GET /date – returns a JSON payload specifying the current date and time, e.g. {"date":"2023-08-19 05:47:00"}
    • GET /version – returns a JSON payload specifying the current application version, e.g. {"version":"0.1.0"}

    The microservice is written in Rust using the Rocket framework, though developing a RESTful microservice with Rocket isn’t the main focus of this lab so we’ll skip over the implementation details – interested Rustaceans may inspect the source code at their own leisure.

    Clone the project locally with Git and navigate to the project root:

    git clone https://github.com/DonaldKellett/rocket-date-server.git
    pushd rocket-date-server/

    Now inspect the contents of the default Dockerfile shown below:

    FROM rustlang/rust:nightly-bookworm
    WORKDIR /app
    COPY src/ /app/src/
    COPY Cargo.toml /app
    COPY Cargo.lock /app
    RUN cargo install --path .
    CMD ["/usr/local/cargo/bin/rocket-date-server"]

    As usual, we select a suitable base image, copy our application source code to the image, build the application using the right tools and specify a suitable default command to run whenever a container is created from our image.

    Let’s analyze our selection of the base image rustlang/rust:nightly-bookworm:

    • We use an image rustlang/rust with the Rust development tools pre-installed since we’re building and shipping a microservice written in Rust
    • From our selected tag nightly-bookworm, we can infer that:
      • The latest nightly version of Rust is used since the Rocket framework itself depends on advanced, unstable language features
      • The image is based on Debian 12 Bookworm, the latest development release of Debian at the time of writing which contains up-to-date software, mitigating the risk of introducing additional vulnerabilities through outdated dependencies

    We expect this image to be reasonably secure for most typical real-world scenarios. But anyway, let’s first build our image:

    docker build -t rocket-date-server:0.1.0 .

    Now spin up a container based on our newly built image:

    docker run \
    --rm \
    -d \
    -p 8000:8000 \
    --name rocket-date-server \
    rocket-date-server:0.1.0

    Our microservice listens on port 8000 locally – let’s give it a test drive to confirm that it is operational:

    curl localhost:8000/date
    curl localhost:8000/version

    Sample output:

    {"date":"2023-08-19 06:22:44"}
    {"version":"0.1.0"}

    Now stop and delete the container to conserve resources:

    docker stop rocket-date-server

    As mentioned above, our image is using up-to-date software so we expect it to be reasonably secure. But how secure is it really? Let’s find out by scanning our image with Grype:

    grype rocket-date-server:0.1.0
    Using a default image with up-to-date software

    Turns out there are 729 known vulnerabilities in total, 2 of which are critical and 45 of which are high. That’s a lot of vulnerabilities!

    Since the default image contains a lot of unneeded software, the attack surface is large and it is easy to discover new vulnerabilities which may not have a known immediate patch. If we really care about security, we’ll have to do better.

    Now consider a modified Dockerfile Dockerfile.slim based on a slimmed down Debian image rustlang/rust:nightly-bookworm-slim with less unneeded software:

    FROM rustlang/rust:nightly-bookworm-slim
    WORKDIR /app
    COPY src/ /app/src/
    COPY Cargo.toml /app
    COPY Cargo.lock /app
    RUN cargo install --path .
    CMD ["/usr/local/cargo/bin/rocket-date-server"]

    Build a new image from our modified Dockerfile, giving the image a tag of 0.1.0-slim this time to distinguish it from our original image:

    docker build -f Dockerfile.slim -t rocket-date-server:0.1.0-slim .

    Now spin up a container from our new image. Other than the updated tag name 0.1.0-slim, the command used should be otherwise identical:

    docker run \
    --rm \
    -d \
    -p 8000:8000 \
    --name rocket-date-server \
    rocket-date-server:0.1.0-slim

    Run the same curl commands again to confirm our modified image is operational. The exact curl commands are elided and not repeated here for brevity.

    Now stop our container again:

    docker stop rocket-date-server

    Give our slimmed-down image a scan:

    grype rocket-date-server:0.1.0-slim
    Using a slimmed-down image with up-to-date software

    Notice how the vulnerability count for our slimmed-down image is reduced to 255 with no critical vulnerabilities and “only” 14 high vulnerabilities. While still far from ideal, this is already a great step forward from our initial condition. So remember – if you care about the security of your applications at all, start by replacing the default bloated base image with a slimmed-down image containing less unneeded software and thus a reduced attack surface.

    But why stop here when we can go further? Let’s explore Google’s distroless images and see how they can help us further reduce the attack surface of our microservices.

    Consider the following Dockerfile Dockerfile.distroless:

    FROM rustlang/rust:nightly AS build-env
    WORKDIR /app
    COPY src/ /app/src/
    COPY Cargo.toml /app
    COPY Cargo.lock /app
    RUN cargo install --path .
    FROM gcr.io/distroless/cc:nonroot
    WORKDIR /app
    COPY --from=build-env /usr/local/cargo/bin/rocket-date-server /app
    CMD ["/app/rocket-date-server"]

    Unlike our previous Dockerfiles, the base gcr.io/distroless/cc:nonroot for our application image is specified on line 8, which contains only a bare minimal Debian base system with a minimal C runtime required to run most typical simple applications. It does not even contain the APT package manager so you cannot install development dependencies and build your application directly from there.

    Notice line 1 of our Dockerfile specified as follows:

    FROM rustlang/rust:nightly AS build-env

    This specifies a different base image rustlang/rust:nightly containing all the usual development dependencies for building our application from source code. Then, once our application is successfully built, we copy only the application binary and possibly its runtime dependencies to our distroless base image to be shipped to production, without including unnecessary artifacts such as the application source code and development dependencies – this ensures we keep the attack surface of our microservice at a minimum. This method of building and preparing our application image is known as a multi-stage build.

    Now build our image from this Dockerfile and tag it 0.1.0-distroless:

    docker build -f Dockerfile.distroless -t rocket-date-server:0.1.0-distroless .

    You should test the image again to confirm it is functional, the exact commands of which are elided for brevity.

    Now scan our distroless image for known vulnerabilities:

    grype rocket-date-server:0.1.0-distroless
    Basing our microservice on a distroless image

    Notice how the vulnerability count is now reduced to just 15, with no critical or high vulnerabilities and just 4 medium vulnerabilities. This is a staggering improvement from our previous “slimmed-down” image! Distroless works especially well for application binaries that do not require a full-fledged language runtime such as Python or Node.js from a security perspective, so keep that in mind when deciding whether to base your application images on Distroless.

    Before we conclude this lab, let’s look at one more option to base our image upon: Alpine Linux.

    Alpine Linux is a security-oriented, lightweight Linux distribution based on musl libc and busybox.

    So basically, it’s designed to be small, fast and secure. The drawback is that its choice of musl libc as opposed to the usual GNU C library glibc implies subtle behavioral differences from most other Linux distributions that might matter for larger, more complex applications and that applications compiled for Alpine often cannot be executed directly on most other Linux distributions and vice-versa.

    Here’s our final Dockerfile Dockerfile.alpine for this lab:

    FROM rustlang/rust:nightly-alpine AS build-env
    RUN apk add -U musl-dev
    WORKDIR /app
    COPY src/ /app/src/
    COPY Cargo.toml /app
    COPY Cargo.lock /app
    RUN cargo install --path .
    FROM alpine
    WORKDIR /app
    COPY --from=build-env /usr/local/cargo/bin/rocket-date-server /app
    CMD ["/app/rocket-date-server"]

    Again, we use a multi-stage build to ensure that our final image contains exactly what is required to run our application.

    Now build our image and give it a tag of 0.1.0-alpine:

    docker build -f Dockerfile.alpine -t rocket-date-server:0.1.0-alpine .

    Give it a test run again to ensure the application works as expected, then scan the image for vulnerabilities:

    grype rocket-date-server:0.1.0-alpine
    Using Alpine as base image for our microservice

    This time, Grype reported no known vulnerabilities – the best we could possibly achieve and a final step up from our distroless-based application image. So remember this folks – use an updated version of Alpine with a multi-stage build for maximal security for your microservices where it really matters 😉

    Conclusion

    We’ve covered how container image vulnerability scanning is an integral component of every typical DevSecOps CI/CD pipeline, how it works in practice by using ready-made open source tools such as Grype available at no cost and ways to minimize microservice vulnerabilities by selecting the correct base image for each workload as well as using a multi-stage build to ensure only the necessary artifacts end up in the final application image thus minimizing its attack surface.

    I hope you enjoyed this article and stay tuned for more content 😉

    + , , ,
  • The source code for this lab exercise is available on GitHub.

    Continuous integration (CI) is the practice of building software from source and performing unit and integration tests in an automated fashion whenever source code changes are committed to a repository, while continuous deployment (CD) goes a step further by automatically deploying the updated software to a development and/or production environment as long as the latest software passes the entire test suite. They are collectively known as CI/CD, both of which aim to shorten the software development lifecycle, reduce the scope of manual operation and reduce the risk of human error through automation.

    CI/CD pipeline

    Continuous delivery, on the other hand, is almost identical to continuous deployment with one key difference – the final step of deploying the tested software to a production environment requires manual approval. While this may seem a step back from continuous deployment, it solves the issue that undesired changes to a software product may not always be caught by unit and integration tests by providing a buffer for management to decide whether a particular version of the software should be released to production even if fully functional.

    GitHub is the top software-as-a-service (SaaS) platform featuring an integrated version control system (VCS) used by developers all around the world for hosting their software projects and code repositories, large and small, whether for public or private use and is home to many of the world’s largest and most influential open source projects forming the backbone of the modern Internet. Not surprisingly, it offers first-class support for fully customizable CI/CD pipelines and DevOps workflows through GitHub Actions, which supports both fully managed SaaS runners and self-managed custom runners for varying degrees of control over the environment used to execute your automated workflows.

    In the lab to follow, we will be setting up an end-to-end DevOps workflow for a Flask microservice with GitHub Actions, using a self-managed custom runner for maximal control over the pipeline execution environment and automating deployments to a local Kubernetes cluster. Furthermore, we will construct separate pipelines for our “development” and “production” environments to further elaborate on the concepts of continuous deployment and delivery.

    Lab: Setting up an end-to-end DevOps workflow for a Flask microservice

    Prerequisites

    A basic awareness of DevOps tools and methodologies, Git and Kubernetes is assumed. If not, consider enrolling in the following self-paced online courses offered by The Linux Foundation on edX at no cost:

    You’ll also need access to an account on both GitHub and Docker Hub so sign up if you haven’t already done so.

    Setting up your environment

    You’ll need a Linux environment with at least 2 vCPUs and 4G of RAM. The reference distribution is Ubuntu 22.04 LTS, though the lab should work on most other Linux distributions as well with little to no modification.

    We’ll generate an SSH key pair and set up the following tools:

    Generating an SSH key pair

    We’ll need an SSH key pair to push commits to GitHub from our local Git repository so generate one if you haven’t done so already:

    ssh-keygen

    Just press Enter a few times to accept the default options.

    Installing Docker

    Docker (hopefully) needs no introduction – simply install it from the system repositories and add yourself to the docker group:

    sudo apt update && sudo apt install -y docker.io
    sudo usermod -aG docker "${USER}"

    Log out and back in for group membership to take effect.

    Installing python3.10-venv

    We’ll need the python3.10-venv package to create a Python virtual environment used for developing our Flask microservice, so install it from the system repositories:

    sudo apt update && sudo apt install -y python3.10-venv

    Installing kind

    kind is a conformant Kubernetes distribution which runs entirely in Docker and is great for development, testing and educational purposes.

    Let’s first create a user-specific directory for storing binaries and add it to our PATH so subsequent installation of software will not require sudo:

    mkdir -p "$HOME/.local/bin/"
    echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"

    Now fetch kind from upstream and make it executable – we’ll be using version 0.20.0:

    wget -qO "$HOME/.local/bin/kind" https://github.com/kubernetes-sigs/kind/releases/download/v0.20.0/kind-linux-amd64
    chmod +x "$HOME/.local/bin/kind"

    Installing kubectl

    kubectl is the official command-line tool for interacting with Kubernetes clusters.

    Let’s fetch kubectl from upstream and make it executable – we’ll be using version 1.27.3:

    wget -qO "$HOME/.local/bin/kubectl" https://dl.k8s.io/release/v1.27.3/bin/linux/amd64/kubectl
    chmod +x "$HOME/.local/bin/kubectl"

    You might also find it useful to enable Bash completion for kubectl, which can save you quite a bit of typing:

    echo "source <(kubectl completion bash)" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"

    Care must be taken to match the kubectl (client) version and Kubernetes cluster (server) version – in particular, the client must not fall behind the server by more than 1 minor version. We chose v1.27.3 for our kubectl client since kind v0.20.0 corresponds to Kubernetes version 1.27.3.

    Verifying everything is installed correctly

    Run the following commands to check the version of each tool we just installed:

    docker --version
    kind --version
    kubectl version --client

    Sample output:

    Docker version 20.10.21, build 20.10.21-0ubuntu1~22.04.3
    kind version 0.20.0
    WARNING: This version information is deprecated and will be replaced with the output from kubectl version --short. Use --output=yaml|json to get the full version.
    Client Version: version.Info{Major:"1", Minor:"27", GitVersion:"v1.27.3", GitCommit:"25b4e43193bcda6c7328a6d147b1fb73a33f1598", GitTreeState:"clean", BuildDate:"2023-06-14T09:53:42Z", GoVersion:"go1.20.5", Compiler:"gc", Platform:"linux/amd64"}
    Kustomize Version: v5.0.1

    You can safely ignore any warnings printed to the console. As long as there are no errors, you should be good to go 🙂

    Creating the cluster

    We’ll need to tweak the default configuration for kind this time, but otherwise creating a Kubernetes cluster with kind is very simple:

    cat > actions-kind.yaml << EOF
    kind: Cluster
    apiVersion: kind.x-k8s.io/v1alpha4
    name: actions-kind
    networking:
    apiServerAddress: "0.0.0.0"
    apiServerPort: 6443
    EOF
    kind create cluster --config=actions-kind.yaml

    Wait a minute or two for the cluster to become ready. In the meantime, let’s explore some of the options we tweaked:

    • networking.apiServerAddress: "0.0.0.0" – kind’s kube-apiserver is configured to listen to the loopback interface by default for security reasons, since it’s meant as an educational tool and shouldn’t be used in production. However, for our use case of constructing a DevOps pipeline to deploy automatically to the cluster, we’ll need it to listen on all interfaces
    • networking.apiServerPort: 6443 – kind’s kube-apiserver is exposed to the host at a high random-numbered port by default to facilitate spinning up multiple kind clusters; however, for the purposes of simplifying our pipeline, we’ll use the standard 6443/tcp port

    Now query the status of the cluster:

    kubectl get no

    Sample output:

    NAME STATUS ROLES AGE VERSION
    actions-kind-control-plane Ready control-plane 27s v1.27.3

    Once the status appears as “Ready”, you should be good to go 🙂

    Setting up our repository and GitHub Actions runner

    Now let’s log in to GitHub and create a private repository – let’s name it flask-ping-server.

    Creating a repository

    We’re making our repository private since with a self-managed runner, setting its visibility to public implies that an attacker could fork your repository and use it to run potentially malicious workflows on your runner.

    Now let’s set up our runner. Note that this lab has three major components, all of which should be configured separately for production:

    • The developer’s laptop / workstation for developing the Flask microservice locally and pushing commits to GitHub via git
    • The self-managed GitHub Actions runner for executing our CI/CD pipelines
    • The Kubernetes cluster to where the development and production workloads will be deployed

    For the purposes of this lab, we’ll keep things simple and merge all of these components into the same Linux environment.

    So we’ll set up the runner on the same machine running our kind Kubernetes cluster. To do this, find the “Settings” tab near the middle to the top of the page:

    Going to the repository Settings tab

    Select “Actions > Runners”:

    Repository settings - Actions - Runners

    Now click on “New self-hosted runner”:

    Creating a new self-hosted runner

    You should see the instructions for setting up the runner for your repository, including the token used for registration. The (slightly modified) commands are shown below for your convenience, minus the registration token (replace the x‘s with your token and JohnDoe with your GitHub username):

    mkdir -p actions-runner/ && pushd actions-runner/
    curl -o actions-runner-linux-x64-2.307.1.tar.gz -L https://github.com/actions/runner/releases/download/v2.307.1/actions-runner-linux-x64-2.307.1.tar.gz
    echo "038c9e98b3912c5fd6d0b277f2e4266b2a10accc1ff8ff981b9971a8e76b5441 actions-runner-linux-x64-2.307.1.tar.gz" | shasum -a 256 -c
    tar xzf ./actions-runner-linux-x64-2.307.1.tar.gz
    export RUNNER_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    echo "export GH_USERNAME=\"JohnDoe\"" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"
    ./config.sh --url "https://github.com/${GH_USERNAME}/flask-ping-server" --token "${RUNNER_TOKEN}"
    popd

    Press Enter a few times to accept the defaults and wait for the configuration to complete. You should now see a runner under “Settings > Actions > Runners” with the status “Offline”:

    kind runner registered

    That’s because we’ve registered the runner but we haven’t actually started it yet. Instead of starting the runner directly, we’ll create a systemd service to run it automatically on systemd startup so the runner can survive across reboots:

    cat << EOF | sudo tee /etc/systemd/system/github-actions-runner.service
    [Unit]
    Description=GitHub Actions runner
    [Service]
    WorkingDirectory=$HOME/actions-runner
    ExecStart=/usr/bin/sudo -u $USER $HOME/actions-runner/run.sh
    [Install]
    WantedBy=multi-user.target
    EOF
    sudo systemctl daemon-reload
    sudo systemctl enable --now github-actions-runner.service

    Check the status of our service:

    systemctl status --no-pager --full github-actions-runner.service

    Sample output:

    ● github-actions-runner.service - GitHub Actions runner
    Loaded: loaded (/etc/systemd/system/github-actions-runner.service; enabled; vendor preset: enabled)
    Active: active (running) since Tue 2023-08-01 13:28:13 UTC; 7s ago
    Main PID: 7162 (sudo)
    Tasks: 17 (limit: 4557)
    Memory: 36.5M
    CPU: 1.814s
    CGroup: /system.slice/github-actions-runner.service
    ├─7162 /usr/bin/sudo -u dsleung /home/dsleung/actions-runner/run.sh
    ├─7163 /bin/bash /home/dsleung/actions-runner/run.sh
    ├─7167 /bin/bash /home/dsleung/actions-runner/run-helper.sh
    └─7171 /home/dsleung/actions-runner/bin/Runner.Listener run
    Aug 01 13:28:13 kind systemd[1]: Started GitHub Actions runner.
    Aug 01 13:28:13 kind sudo[7162]: root : PWD=/home/dsleung/actions-runner ; USER=dsleung ; COMMAND=/home/dsleung/actions-runner/run.sh
    Aug 01 13:28:13 kind sudo[7162]: pam_unix(sudo:session): session opened for user dsleung(uid=1000) by (uid=0)
    Aug 01 13:28:15 kind sudo[7171]: √ Connected to GitHub
    Aug 01 13:28:16 kind sudo[7171]: Current runner version: '2.307.1'
    Aug 01 13:28:16 kind sudo[7171]: 2023-08-01 13:28:16Z: Listening for Jobs

    If you see a status of active (running) then you should be good to go 🙂

    Cloning the repository locally and developing our Flask microservice

    Do you want to develop an app? Because that’s precisely what we’re gonna do next 😉

    Do you want to develop an app? (Rick and Morty reference)

    (Rick and Morty reference courtesy of Adult Swim)

    Our microservice will exhibit the following behavior:

    • It responds to GET /ping/ with the JSON payload {"message": "pong"}
    • It responds to GET /version/ with a JSON payload indicating the version of our app, e.g. {"version": "0.0.1"}

    Clone your GitHub repo locally using Git. But before that, we need to add a deploy key to our repository.

    Go to “Settings” again:

    GitHub repo settings

    This time, select “Deploy keys” under “Security”:

    Security > Deploy keys

    Click “Add deploy key”:

    Add deploy key

    Fill in a suitable title for your deploy key and paste in the contents of your SSH public key then click “Add key” – ensure the “Allow write access” option is checked:

    Paste SSH key

    You can view the contents of your public key by running the following command:

    cat "$HOME/.ssh/id_rsa.pub"

    Now clone the repository locally using Git – replace johndoe below with your actual GitHub username:

    echo "export GH_USERNAME=\"johndoe\"" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"
    git clone "git@github.com:${GH_USERNAME}/flask-ping-server.git"

    Enter the project directory:

    pushd flask-ping-server/

    Now create a virtual environment for developing our Flask microservice:

    python3 -m venv .venv
    . .venv/bin/activate

    You should see (.venv) prepended to your existing prompt.

    Install Flask for developing our microservice and pytest for unit testing:

    pip install Flask
    pip install pytest

    Now write the list of dependencies installed into a file requirements.txt used by pip to reproduce the installation:

    pip freeze > requirements.txt

    Feel free to leave the virtual environment now:

    deactivate

    Also fill in our .gitignore file to ensure we only commit and push the necessary files to our GitHub repository:

    cat > .gitignore << EOF
    .venv
    __pycache__
    EOF

    Now let’s fill in the code for our microservice including all the necessary unit tests. However, since developing a RESTful microservice with Flask isn’t the main point of this lab, we’ll simply skip through the development process with Bash:

    mkdir -p project/ping/
    cat > project/ping/__init__.py << EOF
    from flask import Blueprint
    ping_blueprint = Blueprint('ping', __name__, url_prefix='/ping')
    from . import routes
    EOF
    cat > project/ping/routes.py << EOF
    from . import ping_blueprint
    from flask import Response
    import json
    @ping_blueprint.route('/')
    def pong():
    return Response(json.dumps({ "message": "pong" }), mimetype='application/json')
    EOF
    mkdir -p project/version/
    cat > project/version/__init__.py << EOF
    from flask import Blueprint
    version_blueprint = Blueprint('version', __name__, url_prefix='/version')
    from . import routes
    EOF
    cat > project/version/routes.py << EOF
    from . import version_blueprint
    from flask import Response
    import json
    APP_VERSION='0.0.1'
    @version_blueprint.route('/')
    def get_app_version():
    return Response(json.dumps({ "version": APP_VERSION }), mimetype='application/json')
    EOF
    mkdir -p project/utils/
    cat > project/utils/__init__.py << EOF
    from flask import Flask
    from project.ping import ping_blueprint
    from project.version import version_blueprint
    def create_app():
    app = Flask(__name__)
    app.register_blueprint(ping_blueprint)
    app.register_blueprint(version_blueprint)
    return app
    EOF
    mkdir -p tests/
    touch tests/__init__.py
    cat > tests/conftest.py << EOF
    import pytest
    from project.utils import create_app
    @pytest.fixture()
    def app():
    app = create_app()
    app.config.update({
    "TESTING": True,
    })
    yield app
    @pytest.fixture()
    def client(app):
    return app.test_client()
    EOF
    mkdir -p tests/app/
    cat > tests/app/test_app.py << EOF
    import json
    def test_ping(client):
    response = client.get('/ping/')
    assert response.content_type == 'application/json'
    body = json.loads(response.data)
    assert 'message' in body
    assert body['message'] == 'pong'
    def test_app_version(client):
    response = client.get('/version/')
    assert response.content_type == 'application/json'
    body = json.loads(response.data)
    assert 'version' in body
    assert body['version'] == '0.0.1'
    EOF
    cat > app.py << EOF
    from project.utils import create_app
    app = create_app()
    EOF

    Also fill in our Dockerfile for building the container image for our app:

    cat > Dockerfile << EOF
    FROM python:3.12-rc-bullseye
    COPY requirements.txt /app/
    COPY app.py /app/
    COPY project/ /app/project/
    WORKDIR /app/
    RUN pip install -r requirements.txt
    CMD ["flask", "run", "--host", "0.0.0.0"]
    EOF

    Now let’s commit our changes and push them to GitHub – replace John Doe and johndoe@example.com below with your actual full name and email address respectively:

    git add .gitignore Dockerfile app.py project/ requirements.txt tests/
    export YOUR_FULL_NAME="John Doe"
    export YOUR_EMAIL_ADDRESS="johndoe@example.com"
    git config --global user.name "${YOUR_FULL_NAME}"
    git config --global user.email "${YOUR_EMAIL_ADDRESS}"
    git commit -m "Add project files"
    git push

    Leave our application directory:

    popd

    If you reload the page now, you should see the project files appear in your GitHub repository:

    Project files pushed to GitHub repository

    We now have a modular codebase and unit tests to verify the functional correctness of our microservice as well as a Dockerfile to build a container image from our app, which is good and all, but something is missing – we don’t (yet) have an automated workflow to run the unit tests for us and deploy it to our development and production environments provided the unit tests pass.

    We can do better – let’s add in such a workflow for our development environment 😉

    Constructing a continuous deployment pipeline for our development environment

    We’ll a build a continuous deployment pipeline for our development environment – recall that continuous deployment refers to the practice of automating the entire workflow of running unit and integration tests, building the container image and deploying the app to our environment on every source code commit, all without any form of manual intervention whatsoever.

    But before that, make sure you have a personal access token (PAT) for your Docker Hub account which is required as part of the continuous deployment pipeline – in fact, let’s generate one now.

    Log in to Docker Hub, select your username at the upper right hand corner and click “Account Settings”:

    Docker Hub account settings

    Now select the “Security” option to the left:

    Docker Hub Settings > Security

    Click “New Access Token”:

    Docker Hub - Create a new access token

    Now enter a suitable description for your token and change the access permissions to “Read, Write” since we won’t be deleting any images in our pipeline, then click “Generate”:

    Docker Hub - Generate a new access token

    Keep your access token somewhere safe since we’ll need to refer to it later. You may now log out of Docker Hub.

    Back in our kind cluster, let’s create a dev namespace for hosting our development workloads:

    kubectl create ns dev

    Now we need to create a ServiceAccount with just enough privileges to manage Deployments in our dev namespace (in accordance with the principle of least privilege) for use in our continuous deployment pipeline, along with the other associated Kubernetes objects. In particular, we need to:

    • Create a Role describing the aforementioned privileges
    • Create a ServiceAccount that our continuous deployment pipeline will assume for (re-)creating Deployments in the dev namespace
    • Create a RoleBinding that grants our ServiceAccount the privileges mentioned in our Role

    We’ll also create a Secret for Kubernetes to provide us a long-lived token representing our ServiceAccount so it doesn’t expire too quickly.

    If you’re familiar with access control and management on public cloud platforms such as AWS or GCP, these Kubernetes objects roughly map to the following components in IAM:

    • A Kubernetes Role corresponds to an IAM policy
    • A Kubernetes ServiceAccount corresponds to an IAM role
    • A Kubernetes RoleBinding corresponds to associating an IAM policy to an IAM role

    Let’s go ahead and create them:

    kubectl -n dev create role actions-dev \
    --verb=get \
    --verb=list \
    --verb=watch \
    --verb=create \
    --verb=update \
    --verb=patch \
    --verb=delete \
    --resource=deployments
    kubectl -n dev create sa actions-dev
    kubectl -n dev create rolebinding actions-dev \
    --role=actions-dev \
    --serviceaccount=dev:actions-dev
    kubectl apply -f - << EOF
    apiVersion: v1
    kind: Secret
    metadata:
    name: actions-dev-secret
    namespace: dev
    annotations:
    kubernetes.io/service-account.name: actions-dev
    type: kubernetes.io/service-account-token
    EOF
    export ACTIONS_DEV_TOKEN="$(kubectl -n dev get secret actions-dev-secret -o jsonpath='{.data.token}' | base64 -d -)"

    Now view our service account token and keep it somewhere safe since we’ll refer to it in a moment:

    echo "${ACTIONS_DEV_TOKEN}"

    Time to define our development pipeline:

    1. On every commit to the repository, run the unit tests to ensure the functional correctness of our microservice
    2. Only if all the unit tests pass, build a container image for our microservice and push it to Docker Hub
    3. Only if the image build and push process was successful, (re-)create a Deployment in the dev namespace for our microservice

    The YAML file defining our pipeline (and any other GitHub Actions workflows) resides in the .github/workflows/ directory – on every commit pushed to the upstream repository, GitHub automatically reads in all workflow files in that directory (if exists) and executes them as a collection of jobs.

    Let’s enter our project directory again and create it with the commands below:

    pushd flask-ping-server/
    mkdir -p ./.github/workflows/
    cat > ./.github/workflows/dev.yaml << EOF
    name: Development workflow
    run-name: Development workflow
    on: [push]
    jobs:
    unit-tests:
    runs-on: self-hosted
    container:
    image: python:3.12-rc-bullseye
    steps:
    - name: Check out repository code
    uses: actions/checkout@v3
    - name: Install pip requirements
    run: |
    pip install -r requirements.txt
    - name: Run unit tests
    run: |
    python3 -m pytest -v
    image-build:
    runs-on: self-hosted
    needs: [unit-tests]
    steps:
    - name: Set up QEMU
    uses: docker/setup-qemu-action@v2
    - name: Set up Docker Buildx
    uses: docker/setup-buildx-action@v2
    - name: Login to Docker Hub
    uses: docker/login-action@v2
    with:
    username: \${{ secrets.DOCKERHUB_USERNAME }}
    password: \${{ secrets.DOCKERHUB_TOKEN }}
    - name: Build and push
    uses: docker/build-push-action@v4
    with:
    push: true
    tags: \${{ secrets.DOCKERHUB_USERNAME }}/flask-ping-server:latest
    deploy-to-dev:
    runs-on: self-hosted
    needs: [image-build]
    container:
    image: ubuntu:22.04
    steps:
    - name: Install dependencies
    run: |
    apt-get update && apt-get install -y wget
    wget -qO "/usr/local/bin/kubectl" https://dl.k8s.io/release/v1.27.3/bin/linux/amd64/kubectl
    chmod +x "/usr/local/bin/kubectl"
    kubectl config set-cluster kind-actions-kind --server=https://172.17.0.1:6443/ --insecure-skip-tls-verify=true
    kubectl config set-credentials actions-dev --token="\${{ secrets.ACTIONS_DEV_TOKEN }}"
    kubectl config set-context actions-dev --user=actions-dev --cluster=kind-actions-kind --namespace=dev
    kubectl config use-context actions-dev
    - name: Deploy to dev namespace
    run: |
    kubectl create deploy flask-ping-server --image=\${{ secrets.DOCKERHUB_USERNAME }}/flask-ping-server:latest --replicas=2 --port=5000 --dry-run=client -o yaml > flask-ping-server.yaml
    kubectl replace --force -f flask-ping-server.yaml
    EOF

    Before we commit this file and push it to our upstream repository, we’ll need to visit GitHub and add the following secrets to our repository so GitHub Actions can use them transparently and securely:

    • DOCKERHUB_USERNAME – your username on Docker Hub
    • DOCKERHUB_TOKEN – the Docker Hub PAT we created earlier
    • ACTIONS_DEV_TOKEN – the token allowing our pipeline to assume the ServiceAccount we just created for (re-)deploying the latest revision of our microservice to our Kubernetes cluster

    Ensure you are logged in to GitHub, then navigate to your flask-ping-server repository and select “Settings”:

    Repository settings

    Now select “Secrets and variables > Actions” under “Security” to the left:

    Secrets and variables > Actions

    Click on “New repository secret”:

    New repository secret

    Fill in DOCKERHUB_USERNAME for the name and enter your Docker Hub username for the secret itself and click “Add secret”:

    Filling in a repository secret

    Now repeat the action for the other two secrets we mentioned above. Once you’re done, you should see the following page:

    Repository secrets added for GitHub Actions

    Note that by design, even if you decide to edit your secrets afterwards, you cannot obtain their previous values – you can only update the secret with a completely new value.

    Now we can safely commit our changes and push them to our upstream repository:

    git add .github/
    git commit -m "Add development pipeline"
    git push

    You may now leave our project directory:

    popd

    Now visit the homepage for our project repository again – we should see a yellow circle next to our commit which indicates our continuous deployment pipeline is running:

    Continuous deployment pipeline running

    Wait a few minutes, maybe 10-15 minutes, then refresh the page and you should see the yellow circle replaced with a green checkmark:

    Continuous deployment pipeline successful

    Congratulations! This means that your continuous deployment pipeline has executed to completion and you should now see a Deployment named flask-ping-server created in our dev namespace:

    kubectl -n dev get deploy

    Sample output:

    NAME READY UP-TO-DATE AVAILABLE AGE
    flask-ping-server 2/2 2 2 6m13s

    In fact, let’s see it in action 😉

    Expose our Deployment with a Service:

    kubectl -n dev expose deploy flask-ping-server --port=80 --target-port=5000

    Now spin up a Pod with curl installed for testing our microservices:

    kubectl run curlpod --image=curlimages/curl -- sleep infinity

    And send a GET /ping/ request to our Service:

    kubectl exec curlpod -- curl -s http://flask-ping-server.dev/ping/

    Sample output:

    {"message": "pong"}

    Let’s also confirm that our microservice returns its version number correctly:

    kubectl exec curlpod -- curl -s http://flask-ping-server.dev/version/

    Sample output:

    {"version": "0.0.1"}

    Excellent! Let’s now move on to our production pipeline, which will use continuous delivery instead of continuous deployment.

    Constructing a continuous delivery pipeline for our production environment

    Recall that the main difference between continuous deployment and continuous delivery is that the former is fully automated, but the latter introduces a “manual approval” step which serves as a buffer for management to decide which working version of our microservice to release to production as “stable”.

    Again, we’ll create our prod namespace for production deployments:

    kubectl create ns prod

    Now create a Role, ServiceAccount and RoleBinding like we did for our development environment, and create a long-lived token via a Secret:

    kubectl -n prod create role actions-prod \
    --verb=get \
    --verb=list \
    --verb=watch \
    --verb=create \
    --verb=update \
    --verb=patch \
    --verb=delete \
    --resource=deployments
    kubectl -n prod create sa actions-prod
    kubectl -n prod create rolebinding actions-prod \
    --role=actions-prod \
    --serviceaccount=prod:actions-prod
    kubectl apply -f - << EOF
    apiVersion: v1
    kind: Secret
    metadata:
    name: actions-prod-secret
    namespace: prod
    annotations:
    kubernetes.io/service-account.name: actions-prod
    type: kubernetes.io/service-account-token
    EOF
    export ACTIONS_PROD_TOKEN="$(kubectl -n prod get secret actions-prod-secret -o jsonpath='{.data.token}' | base64 -d -)"

    Again, make note of our ServiceAccount token:

    echo "${ACTIONS_PROD_TOKEN}"

    Now enter our project directory and create our production workflow YAML file:

    pushd flask-ping-server/
    cat > ./.github/workflows/prod.yaml << EOF
    name: Production workflow
    run-name: Production workflow
    on:
    release:
    types: [published]
    jobs:
    unit-tests:
    runs-on: self-hosted
    container:
    image: python:3.12-rc-bullseye
    steps:
    - name: Check out repository code
    uses: actions/checkout@v3
    - name: Install pip requirements
    run: |
    pip install -r requirements.txt
    - name: Run unit tests
    run: |
    python3 -m pytest -v
    image-build:
    runs-on: self-hosted
    needs: [unit-tests]
    steps:
    - name: Set up QEMU
    uses: docker/setup-qemu-action@v2
    - name: Set up Docker Buildx
    uses: docker/setup-buildx-action@v2
    - name: Login to Docker Hub
    uses: docker/login-action@v2
    with:
    username: \${{ secrets.DOCKERHUB_USERNAME }}
    password: \${{ secrets.DOCKERHUB_TOKEN }}
    - name: Build and push
    uses: docker/build-push-action@v4
    with:
    push: true
    tags: \${{ secrets.DOCKERHUB_USERNAME }}/flask-ping-server:\${{ github.event.release.tag_name }}
    deploy-to-prod:
    runs-on: self-hosted
    needs: [image-build]
    container:
    image: ubuntu:22.04
    steps:
    - name: Install dependencies
    run: |
    apt-get update && apt-get install -y wget
    wget -qO "/usr/local/bin/kubectl" https://dl.k8s.io/release/v1.27.3/bin/linux/amd64/kubectl
    chmod +x "/usr/local/bin/kubectl"
    kubectl config set-cluster kind-actions-kind --server=https://172.17.0.1:6443/ --insecure-skip-tls-verify=true
    kubectl config set-credentials actions-prod --token="\${{ secrets.ACTIONS_PROD_TOKEN }}"
    kubectl config set-context actions-prod --user=actions-prod --cluster=kind-actions-kind --namespace=prod
    kubectl config use-context actions-prod
    - name: Deploy to prod namespace
    run: |
    kubectl create deploy flask-ping-server --image=\${{ secrets.DOCKERHUB_USERNAME }}/flask-ping-server:\${{ github.event.release.tag_name }} --replicas=2 --port=5000 --dry-run=client -o yaml > flask-ping-server.yaml
    kubectl apply -f flask-ping-server.yaml
    EOF

    The main differences compared to our development workflow is as follows:

    • Instead of running the workflow on every pushed commit (on: [push]), we will run our production workflow on every published (stable) release on.release.types: [published]. This action of tagging a stable release represents the “manual approval” component of our continuous delivery pipeline
    • This time, we tag our container image with a proper version tag representing our stable release instead of latest
    • Instead of forcefully replacing our Deployment with kubectl replace --force which could lead to service interruption, we use kubectl apply instead to gracefully roll over our production deployment to the new stable version of our microservice

    Again, before committing the changes and pushing them to the remote repository, make sure to add the ACTIONS_PROD_TOKEN secret to your GitHub repository.

    Now commit and push the changes to our remote repository:

    git add .github/
    git commit -m "Add production pipeline"
    git push

    Feel free to leave our project directory for the last time:

    popd

    You should still see the development pipeline triggered but not the production pipeline. That’s because our production pipeline is only triggered when we tag a commit as a stable release.

    Development pipeline triggered but not production pipeline

    Wait for our development pipeline to finish anyway. Once you see a green checkmark, proceed with creating a versioned release for our microservice.

    To the right of the page, select “Create a new release”:

    Create a new release

    For “Choose a tag”, type in 0.0.1 and select “Create new tag: 0.0.1”:

    Create new tag: 0.0.1

    Fill in a suitable name and description for our release, then click “Publish release”:

    Fill in details and publish release

    Now go to the “Actions” tab from the homepage of our repository, and you should see our production pipeline queued for execution along with our development pipeline which was triggered again:

    Production pipeline queued for execution along with development pipeline

    After your production pipeline goes green, verify the results again in our cluster:

    kubectl -n prod get deploy
    kubectl -n prod expose deploy flask-ping-server --port=80 --target-port=5000
    kubectl exec curlpod -- curl -s http://flask-ping-server.prod/ping/
    kubectl exec curlpod -- curl -s http://flask-ping-server.prod/version/

    Concluding remarks and going further

    I hope you enjoyed this introduction to DevOps, CI/CD pipelines and continuous delivery. While setting up this pipeline isn’t exactly trivial, we could do better by integrating security into our DevOps pipeline as a first-class citizen in order to protect your software delivery chain from software supply chain attacks – this concept is covered in detail in another blog post of mine: Securing your Kubernetes workloads with Sigstore

    Stay tuned for more content and I hope to see you again in a future article 😉

    + , ,
  • Popular hosted version control system (VCS) solutions such as GitHub and GitLab are filled with powerful enterprise features and enable large-scale collaboration and rapid iteration on public open source projects that power much of today’s Internet. On the other hand, while they enable the creation of private code repositories for projects not intended to be publicly accessible, often at no cost, they reside on third-party infrastructure which may raise data security, privacy and compliance concerns for businesses and organizations relying on them for internal projects and workflows.

    While GitHub and GitLab do offer self-managed on premise or public cloud deployments of their integrated VCS solutions which alleviate some of those concerns, such offerings are geared towards larger enterprises and require more effort to properly configure, deploy and maintain. Furthermore, they often contain proprietary components and require the purchase of licenses to unlock the full range of features, which may be prohibitively costly for small-to-medium businesses.

    Enter Gitea – a lightweight, cost-effective, open source VCS solution suitable for small-to-medium businesses released under the MIT license:

    Gitea is a lightweight DevOps platform. It brings teams and developers high-efficiency but easy operations from planning to production.

    Being fully open source means that unlike GitHub or GitLab, purchasing a license is not required to unlock the full range of features on your self-managed Gitea instance, though they provide an option for purchasing a support plan for businesses requiring a completely hands-free experience.

    In the lab to follow, we’ll be deploying our very own Gitea instance to Kubernetes with a single command using Helm and exploring the basics of creating an organization, repository (repo) and pushing a commit containing our source code to the repo with Git.

    Lab: Deploying Gitea to Kubernetes

    Prerequisites

    A basic awareness of software development tools and methodologies, Git and Kubernetes is assumed. If not, consider enrolling in the following self-paced online courses offered by The Linux Foundation on edX at no cost:

    Setting up your environment

    You’ll need a Linux environment with at least 2 vCPUs and 4G of RAM. The reference distribution is Ubuntu 22.04 LTS, though the lab should work on most other Linux distributions as well with little to no modification.

    We’ll set up the following tools:

    Installing Docker

    Docker (hopefully) needs no introduction – simply install it from the system repositories and add yourself to the docker group:

    sudo apt update && sudo apt install -y docker.io
    sudo usermod -aG docker "${USER}"

    Log out and back in for group membership to take effect.

    Installing kind

    kind is a conformant Kubernetes distribution which runs entirely in Docker and is great for development, testing and educational purposes.

    Let’s first create a user-specific directory for storing binaries and add it to our PATH so subsequent installation of software will not require sudo:

    mkdir -p "$HOME/.local/bin/"
    echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"

    Now fetch kind from upstream and make it executable – we’ll be using version 0.20.0:

    wget -qO "$HOME/.local/bin/kind" https://github.com/kubernetes-sigs/kind/releases/download/v0.20.0/kind-linux-amd64
    chmod +x "$HOME/.local/bin/kind"

    Installing kubectl

    kubectl is the official command-line tool for interacting with Kubernetes clusters.

    Let’s fetch kubectl from upstream and make it executable – we’ll be using version 1.27.3:

    wget -qO "$HOME/.local/bin/kubectl" https://dl.k8s.io/release/v1.27.3/bin/linux/amd64/kubectl
    chmod +x "$HOME/.local/bin/kubectl"

    You might also find it useful to enable Bash completion for kubectl, which can save you quite a bit of typing:

    echo "source <(kubectl completion bash)" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"

    Care must be taken to match the kubectl (client) version and Kubernetes cluster (server) version – in particular, the client must not fall behind the server by more than 1 minor version. We chose v1.27.3 for our kubectl client since kind v0.20.0 corresponds to Kubernetes version 1.27.3.

    Installing Helm

    Helm is the official package manager for Kubernetes – it is effectively “orchestration for orchestration”.

    Let’s fetch the archive from upstream and move helm to our PATH – we’ll be using version 3.12.2:

    wget https://get.helm.sh/helm-v3.12.2-linux-amd64.tar.gz
    tar xvf helm-v3.12.2-linux-amd64.tar.gz
    mv linux-amd64/helm "$HOME/.local/bin/helm"
    rm -rf helm-v3.12.2-linux-amd64.tar.gz linux-amd64/

    Verifying everything is installed correctly

    Run the following commands to check the version of each tool we just installed:

    docker --version
    kind --version
    kubectl version --client
    helm version

    Sample output:

    Docker version 20.10.21, build 20.10.21-0ubuntu1~22.04.3
    kind version 0.20.0
    WARNING: This version information is deprecated and will be replaced with the output from kubectl version --short. Use --output=yaml|json to get the full version.
    Client Version: version.Info{Major:"1", Minor:"27", GitVersion:"v1.27.3", GitCommit:"25b4e43193bcda6c7328a6d147b1fb73a33f1598", GitTreeState:"clean", BuildDate:"2023-06-14T09:53:42Z", GoVersion:"go1.20.5", Compiler:"gc", Platform:"linux/amd64"}
    Kustomize Version: v5.0.1
    version.BuildInfo{Version:"v3.12.2", GitCommit:"1e210a2c8cc5117d1055bfaa5d40f51bbc2e345e", GitTreeState:"clean", GoVersion:"go1.20.5"}

    You can safely ignore any warnings printed to the console. As long as there are no errors, you should be good to go 🙂

    Creating a cluster

    Creating a Kubernetes cluster with kind is easy – simply run kind create cluster and optionally give it a name, e.g. gitea-kind:

    kind create cluster --name gitea-kind

    Wait a minute or two, then query the status of the cluster:

    kubectl get no

    Sample output:

    NAME STATUS ROLES AGE VERSION
    gitea-kind-control-plane Ready control-plane 22s v1.27.3

    Once you see a status of “Ready” as shown above then you’re good to go 🙂

    Deploying Gitea to the cluster

    We’ll deploy Gitea to our Kubernetes cluster with a single command using Helm. It’s simple, fast, automated and allows us to leverage the various benefits of Kubernetes such as the ease of setting up high availability and scale horizontally as needed.

    First go to Artifact Hub and search for “gitea”:

    Searching for Gitea on Artifact Hub

    Let’s select the first option as it appears to be the official chart:

    Official Helm chart for Gitea

    Feel free to take the time to read through the chart description. Now add the chart repository and trigger an update to fetch the latest metadata:

    helm repo add gitea https://dl.gitea.io/charts
    helm repo update

    Sample output:

    "gitea" has been added to your repositories
    Hang tight while we grab the latest from your chart repositories...
    ...Successfully got an update from the "gitea" chart repository
    Update Complete. ⎈Happy Helming!⎈

    We’ll now deploy Gitea to the gitea namespace in our cluster. To ensure we get a usable instance on the get-go, we’ll need to create an admin user and work around an issue where Gitea submits a write request to a read-only PostgreSQL instance by disabling high availability (HA) for PostgreSQL – this is done by specifying the following chart values:

    • gitea.admin.username: gitea
    • gitea.admin.password: gitea
    • postgresql-ha.enabled: false
    • postgresql.enabled: true

    For this simple case, we can specify the chart values directly on the command line, though for proper customization, you’ll probably want to download the chart instead and edit the appropriate templates and YAML files before deploying the modified chart to your cluster:

    helm -n gitea install my-gitea gitea/gitea \
    --version 9.0.4 \
    --create-namespace \
    --set gitea.admin.username=gitea \
    --set gitea.admin.password=gitea \
    --set postgresql-ha.enabled=false \
    --set postgresql.enabled=true

    Sample output:

    NAME: my-gitea
    LAST DEPLOYED: Thu Jul 27 14:44:42 2023
    NAMESPACE: gitea
    STATUS: deployed
    REVISION: 1
    NOTES:
    1. Get the application URL by running these commands:
    echo "Visit http://127.0.0.1:3000 to use your application"
    kubectl --namespace gitea port-forward svc/my-gitea-http 3000:3000

    Wait a few minutes for our instance to stabilize, then query its status and confirm that everything is ready:

    kubectl -n gitea get svc,deploy,po

    Sample output:

    NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
    service/my-gitea-http ClusterIP None <none> 3000/TCP 4m28s
    service/my-gitea-postgresql ClusterIP 10.96.108.54 <none> 5432/TCP 4m28s
    service/my-gitea-postgresql-hl ClusterIP None <none> 5432/TCP 4m28s
    service/my-gitea-redis-cluster ClusterIP 10.96.203.95 <none> 6379/TCP 4m28s
    service/my-gitea-redis-cluster-headless ClusterIP None <none> 6379/TCP,16379/TCP 4m28s
    service/my-gitea-ssh ClusterIP None <none> 22/TCP 4m28s
    NAME READY UP-TO-DATE AVAILABLE AGE
    deployment.apps/my-gitea 1/1 1 1 4m28s
    NAME READY STATUS RESTARTS AGE
    pod/my-gitea-5b8f845c56-njmmj 1/1 Running 0 4m28s
    pod/my-gitea-postgresql-0 1/1 Running 0 4m28s
    pod/my-gitea-redis-cluster-0 1/1 Running 2 (3m33s ago) 4m28s
    pod/my-gitea-redis-cluster-1 1/1 Running 2 (3m33s ago) 4m28s
    pod/my-gitea-redis-cluster-2 1/1 Running 0 4m28s
    pod/my-gitea-redis-cluster-3 1/1 Running 3 (3m9s ago) 4m28s
    pod/my-gitea-redis-cluster-4 1/1 Running 2 (3m20s ago) 4m28s
    pod/my-gitea-redis-cluster-5 1/1 Running 2 (3m13s ago) 4m28s

    Now expose our web interface with kubectl port-forward:

    kubectl -n gitea port-forward svc/my-gitea-http --address 0.0.0.0 3000:3000 >& /dev/null &

    If you’re running your Linux environment in VirtualBox with the default NAT networking mode as I am, you’ll probably need to configure port forwarding in VirtualBox as well to expose the web interface to the host so it can be opened directly in your web browser:

    Port forwarding in VirtualBox

    Now navigate to http://localhost:3000/ – if everything is working as expected, you should see the landing page of your local Gitea instance:

    Gitea landing page

    Creating your first Gitea organization and repository

    Let’s log in to our Gitea instance as the admin user. The username and password should both be gitea, or whatever you specified on the command line when deploying the chart.

    Gitea landing page with sign in button
    Gitea login page

    You’ll see a rather empty-looking page – that’s because we haven’t yet any activity.

    Gitea dashboard

    Notice to the right that we have “Repository” and “Organization”. Repositories (repos) contain the entire history of “point in time” snapshots (commits) of the source code pertaining to an independent software component or program, while organizations are typically used to group together a collection of related repositories into a large-scale software project or administrative umbrella.

    We’ll be creating an organization and our first repository under that organization, though note that repositories can definitely exist outside of organizations as long as they are tied to a specific user. Let’s name them both hello-gitea 🙂

    Creating an organization, part 1
    Creating an organization, part 2
    Creating an organization, part 3

    When creating the repository, you can optionally specify an open source license to adopt for the project – I’ve chosen Apache 2.0 in this example which is a commonly used permissive open source license.

    Creating a repository, part 1
    Creating a repository, part 2
    Creating a repository, part 3

    Congratulations – you’ve created your first organization and repo within your local Gitea instance!

    hello-gitea/hello-gitea

    Cloning the repo locally and pushing your first (second) Git commit

    Now that we have a repo, let’s clone it locally via the command line with Git, write some code, commit our changes and push the changes back to the upstream repo as our “first” commit, though technically this is our second commit to the repo since the first commit was automatically created when we created the repository itself.

    git clone http://localhost:3000/hello-gitea/hello-gitea.git

    Notice the .git at the end of the URL; it’s actually optional and we could have specified http://localhost:3000/hello-gitea/hello-gitea for the URL instead but the suffix reminds us that this is a Git repository.

    Let’s navigate to the project root of the cloned repository:

    pushd hello-gitea/

    Now let’s write a webpage which just shows “Hello Gitea!” in a large, top-level heading:

    cat > index.html << EOF
    <!DOCTYPE HTML>
    <html>
    <head>
    <meta charset="utf-8" />
    <title>Hello Gitea!</title>
    </head>
    <body>
    <h1>Hello Gitea!</h1>
    </body>
    </html>
    EOF

    View the changes to be staged:

    git status

    Sample output:

    On branch main
    Your branch is up to date with 'origin/main'.
    Untracked files:
    (use "git add <file>..." to include in what will be committed)
    index.html
    nothing added to commit but untracked files present (use "git add" to track)

    Stage our newly added index.html file to the list of changes to be committed to our repository:

    git add index.html

    View the status again and notice how it has changed:

    git status

    Sample output:

    On branch main
    Your branch is up to date with 'origin/main'.
    Changes to be committed:
    (use "git restore --staged <file>..." to unstage)
    new file: index.html

    Before we actually commit the changes, configure Git so it knows our username and email address – let’s just use gitea and gitea@local.domain respectively, though feel free to change them as desired:

    git config --global user.name "gitea"
    git config --global user.email "gitea@local.domain"

    Now commit the changes to our local copy of the repo. We’ll need to provide a message to go with the commit – let’s keep it short and simple, something like “Add index.html”:

    git commit -m "Add index.html"

    Sample output:

    [main c17e479] Add index.html
    1 file changed, 10 insertions(+)
    create mode 100644 index.html

    If you refresh the page at http://localhost:3000/hello-gitea/hello-gitea, notice how our index.html webpage hasn’t shown up yet. That’s because we haven’t pushed the changes from our local copy of the repo back to the upstream repo.

    We’ll do just that right now – enter the admin username and password as prompted:

    git push

    Sample output:

    Username for 'http://localhost:3000': gitea
    Password for 'http://gitea@localhost:3000':
    Enumerating objects: 4, done.
    Counting objects: 100% (4/4), done.
    Delta compression using up to 2 threads
    Compressing objects: 100% (3/3), done.
    Writing objects: 100% (3/3), 411 bytes | 411.00 KiB/s, done.
    Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
    remote: . Processing 1 references
    remote: Processed 1 references in total
    To http://localhost:3000/hello-gitea/hello-gitea.git
    18df10f..c17e479 main -> main

    Refresh the page and voila – our index.html webpage now appears in the upstream repository!

    index.html webpage pushed to upstream repository

    Let’s navigate out of the project root and feel free to delete our local copy of the repo as we won’t be needing it afterwards:

    popd
    rm -rf hello-gitea/

    Concluding remarks and going further

    We’ve seen how Gitea can be deployed to Kubernetes with a single command using Helm and how it could address data security, privacy and compliance concerns of small-to-medium businesses by enabling teams to collaborate on projects and repositories locally without relying on a third-party service such as GitHub.

    While we’ve seen the basic usage of Gitea – how to create an organization, repository and commit code to that repository, there remains a ton of features we haven’t yet covered:

    • Multi-user collaboration using Issues and Pull Requests
    • Versioning and release management with tags and packages
    • Project management and documentation using the Projects board and Wiki
    • Creating a CI/CD pipeline for DevOps workflows with Gitea Actions (disabled by default, needs to be explicitly enabled at chart installation time)

    … and much more.

    I hope you enjoyed this article and stay tuned for more content 😉

    + ,
  • Consider your typical CI/CD pipeline as shown below. What are some of the issues associated with the DevOps workflow below, if any?

    DevOps pipeline

    The main issue is that security measures are not integrated into the pipeline as a first-class citizen. Let’s assume the best case where the Kubernetes cluster hosting the production workloads is reasonably secured as an afterthought. In this case, a malicious actor seeking to compromise the cluster might, failing to gain access to the nodes themselves, attempt to gain access to the Pods running the workloads directly instead as a starting point. However, this vector of attack is not terribly effective – since Pods themselves are ephemeral and are constantly being recreated, the attacker would likely have a hard time maintaining access to the infected workload, let alone navigating their way within the cluster.

    Instead, what an attacker might do is attempt to compromise the production workload(s) straight from the source, by finding ways to inject vulnerabilities and/or malware directly into the source code itself (e.g. by first gaining access to the developer’s workstation or laptop) or infecting the build environment where images are generated – this is commonly known as a software supply chain attack. As a result, the compromised application image makes its way to production as a Kubernetes Deployment which is deployed to the cluster – since the image itself is already infected, all Pods created and re-created from that Deployment are guaranteed to be infected, easing the attacker’s burden of maintaining access to that infected workload and subsequently working their way around the cluster.

    Infected DevOps pipeline

    To prevent software supply chain attacks like the one shown above, security measures should be proactively integrated into the pipeline as a first-class citizen and DevOps professionals should integrate security practices into their day-to-day duties – this practice of integrating security into DevOps is also known as DevSecOps.

    For example, consider the following DevSecOps pipeline with automated vulnerability scanning and image signing built-in. The idea is that the image will be automatically signed and deployed to the cluster only if vulnerability scanning reports that the image does not contain malware and/or high-severity vulnerabilities; otherwise the image is rejected. The cluster also enforces a policy such that only workloads whose images contain the appropriate signature(s) can be deployed to the cluster, to prevent attackers from circumventing the pipeline and attempting to deploy unsigned (potentially infected) images directly to the cluster.

    DevSecOps pipeline

    In the lab that follows, we will introduce an open source project designed for DevSecOps workflows and dedicated to improving software supply chain security – Sigstore.

    Lab: Sign and verify your workloads with Sigstore

    Prerequisites

    A basic understanding of Kubernetes is assumed. If not, consider enrolling in LFS158x: Introduction to Kubernetes, a self-paced online course offered by The Linux Foundation on edX at no cost.

    Setting up your environment

    You’ll need a Linux environment with at least 2 vCPUs and 4G of RAM. The reference distribution is Ubuntu 22.04 LTS, though the lab should work on most other Linux distributions as well with little to no modification.

    We’ll set up the following tools:

    Installing Docker

    Docker (hopefully) needs no introduction – simply install it from the system repositories and add yourself to the docker group:

    sudo apt update && sudo apt install -y docker.io
    sudo usermod -aG docker "${USER}"

    Log out and back in for group membership to take effect.

    Installing Cosign

    Cosign is a command-line tool to sign and verify container images and part of the Sigstore project dedicated to improving software supply chain security.

    Let’s first create a user-specific directory for storing binaries and add it to our PATH so subsequent installation of software will not require sudo:

    mkdir -p "$HOME/.local/bin/"
    echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"

    Now fetch the cosign binary from upstream and make it executable – we’ll be using version 2.1.1:

    wget -qO "$HOME/.local/bin/cosign" https://github.com/sigstore/cosign/releases/download/v2.1.1/cosign-linux-amd64
    chmod +x "$HOME/.local/bin/cosign"

    Installing kind

    kind is a conformant Kubernetes distribution which runs entirely in Docker and is great for development, testing and educational purposes.

    Let’s fetch kind from upstream and make it executable – we’ll be using version 0.20.0:

    wget -qO "$HOME/.local/bin/kind" https://github.com/kubernetes-sigs/kind/releases/download/v0.20.0/kind-linux-amd64
    chmod +x "$HOME/.local/bin/kind"

    Installing kubectl

    kubectl is the official command-line tool for interacting with Kubernetes clusters.

    Let’s fetch kubectl from upstream and make it executable – we’ll be using version 1.27.3:

    wget -qO "$HOME/.local/bin/kubectl" https://dl.k8s.io/release/v1.27.3/bin/linux/amd64/kubectl
    chmod +x "$HOME/.local/bin/kubectl"

    You might also find it useful to enable Bash completion for kubectl, which can save you quite a bit of typing:

    echo "source <(kubectl completion bash)" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"

    Care must be taken to match the kubectl (client) version and Kubernetes cluster (server) version – in particular, the client must not fall behind the server by more than 1 minor version. We chose v1.27.3 for our kubectl client since kind v0.20.0 corresponds to Kubernetes version 1.27.3.

    Installing Helm

    Helm is the official package manager for Kubernetes – it is effectively “orchestration for orchestration”.

    Let’s fetch the archive from upstream and move helm to our PATH – we’ll be using version 3.12.2:

    wget https://get.helm.sh/helm-v3.12.2-linux-amd64.tar.gz
    tar xvf helm-v3.12.2-linux-amd64.tar.gz
    mv linux-amd64/helm "$HOME/.local/bin/helm"
    rm -rf helm-v3.12.2-linux-amd64.tar.gz linux-amd64/

    Verifying everything is installed correctly

    Run the following commands to check the version of each tool we just installed:

    docker --version
    cosign version
    kind --version
    kubectl version --client
    helm version

    Sample output:

    Docker version 20.10.21, build 20.10.21-0ubuntu1~22.04.3
    ______ ______ _______. __ _______ .__ __.
    / | / __ \ / || | / _____|| \ | |
    | ,----'| | | | | (----`| | | | __ | \| |
    | | | | | | \ \ | | | | |_ | | . ` |
    | `----.| `--' | .----) | | | | |__| | | |\ |
    \______| \______/ |_______/ |__| \______| |__| \__|
    cosign: A tool for Container Signing, Verification and Storage in an OCI registry.
    GitVersion: v2.1.1
    GitCommit: baf97ccb4926ed09c8f204b537dc0ee77b60d043
    GitTreeState: clean
    BuildDate: 2023-06-27T06:57:11Z
    GoVersion: go1.20.5
    Compiler: gc
    Platform: linux/amd64
    kind version 0.20.0
    WARNING: This version information is deprecated and will be replaced with the output from kubectl version --short. Use --output=yaml|json to get the full version.
    Client Version: version.Info{Major:"1", Minor:"27", GitVersion:"v1.27.3", GitCommit:"25b4e43193bcda6c7328a6d147b1fb73a33f1598", GitTreeState:"clean", BuildDate:"2023-06-14T09:53:42Z", GoVersion:"go1.20.5", Compiler:"gc", Platform:"linux/amd64"}
    Kustomize Version: v5.0.1
    version.BuildInfo{Version:"v3.12.2", GitCommit:"1e210a2c8cc5117d1055bfaa5d40f51bbc2e345e", GitTreeState:"clean", GoVersion:"go1.20.5"}

    You can safely ignore any warnings printed to the console. As long as there are no errors, you should be good to go 🙂

    Sign and verify your first image with Cosign

    Before we sign and verify our first image, we’ll need to build one and push it to Docker Hub (or another registry of your choice).

    Build and push our app to Docker Hub

    Let’s create a project folder hello-cosign/ for building our image:

    mkdir -p hello-cosign/
    pushd hello-cosign/

    We’ll base our “app” on Apache, a popular web server. Run the command below to fill in our Dockerfile:

    cat > Dockerfile << EOF
    FROM httpd:2.4
    COPY ./public-html/ /usr/local/apache2/htdocs/
    EOF

    Now create a public-html/ folder and write our “Hello Cosign” homepage which is what will make our app “unique”:

    mkdir -p public-html/
    cat > public-html/index.html << EOF
    <!DOCTYPE HTML>
    <html>
    <head>
    <meta charset="utf-8" />
    <title>Hello Cosign!</title>
    </head>
    <body>
    <h1>Hello Cosign!</h1>
    </body>
    </html>
    EOF

    Before we proceed, export your Docker Hub username, replacing johndoe below with your actual username:

    echo "export DOCKERHUB_USERNAME=\"johndoe\"" >> "$HOME/.bashrc"
    source "$HOME/.bashrc"

    Now build our image and name it hello-cosign, giving it a tag of 0.0.1:

    export APP_NAME="hello-cosign"
    export VERSION="0.0.1"
    docker build -t "${DOCKERHUB_USERNAME}/${APP_NAME}:${VERSION}" .

    To confirm our app is working properly, let’s start a container with our app and check the output:

    docker run --rm -d -p 8080:80 --name "${APP_NAME}" "${DOCKERHUB_USERNAME}/${APP_NAME}:${VERSION}"
    curl localhost:8080

    Sample output:

    c959f8bf92921b290785611fbc9f06a1fb912beaedd0fae2adb2c50134658a18
    <!DOCTYPE HTML>
    <html>
    <head>
    <meta charset="utf-8" />
    <title>Hello Cosign!</title>
    </head>
    <body>
    <h1>Hello Cosign!</h1>
    </body>
    </html>

    Stop the container once our app is confirmed operational:

    docker stop "${APP_NAME}"

    Now let’s log in to Docker Hub and push our image:

    docker login -u "${DOCKERHUB_USERNAME}"
    docker push "${DOCKERHUB_USERNAME}/${APP_NAME}:${VERSION}"

    Time to leave our app directory:

    popd

    Hello Cosign!

    From the README in the official repository sigstore/cosign:

    Cosign supports:

    • “Keyless signing” with the Sigstore public good Fulcio certificate authority and Rekor transparency log (default)
    • Hardware and KMS signing
    • Signing with a cosign generated encrypted private/public keypair
    • Container Signing, Verification and Storage in an OCI registry.
    • Bring-your-own PKI

    For simplicity, we’ll go with a cosign generated encrypted private/public keypair in this demo, though one might prefer keyless signing or signing with a public cloud KMS managed key pair in a production environment.

    Let’s generate our key pair:

    cosign generate-key-pair

    Press Enter twice to set an empty password for the private key. You should also see the output below which can be verified with ls:

    Private key written to cosign.key
    Public key written to cosign.pub

    As usual, sign our container image with the private key and verify it later with the public key. But before that, we need to confirm the SHA256 digest of our image and refer to it during the signing process, to ensure that we are indeed signing the correct artifact!

    export APP_DIGEST="$(docker images --digests | grep "${DOCKERHUB_USERNAME}/${APP_NAME}" | cut -d' ' -f9 | cut -d':' -f2)"

    Now sign with cosign sign, specifying our private key cosign.key and referring to our image by SHA256 digest instead of version tag:

    cosign sign --key cosign.key "${DOCKERHUB_USERNAME}/${APP_NAME}@sha256:${APP_DIGEST}"

    Sample output:

    Enter password for private key:
    The sigstore service, hosted by sigstore a Series of LF Projects, LLC, is provided pursuant to the Hosted Project Tools Terms of Use, available at https://lfprojects.org/policies/hosted-project-tools-terms-of-use/.
    Note that if your submission includes personal data associated with this signed artifact, it will be part of an immutable record.
    This may include the email address associated with the account with which you authenticate your contractual Agreement.
    This information will be used for signing this artifact and will be stored in public transparency logs and cannot be removed later, and is subject to the Immutable Record notice at https://lfprojects.org/policies/hosted-project-tools-immutable-records/.
    By typing 'y', you attest that (1) you are not submitting the personal data of any other person; and (2) you understand and agree to the statement and the Agreement terms at the URLs listed above.
    Are you sure you would like to continue? [y/N] y
    tlog entry created with index: 28001047
    Pushing signature to: index.docker.io/donaldsebleung/hello-cosign

    Now log out of Docker Hub:

    docker logout

    To verify, use cosign verify but specify the public key cosign.pub instead and feel free to refer to the image by version tag since a third party verifying your image might not know or care about the SHA256 digest:

    cosign verify --key cosign.pub "${DOCKERHUB_USERNAME}/${APP_NAME}:${VERSION}"

    Sample output:

    
    Verification for index.docker.io/donaldsebleung/hello-cosign:0.0.1 --
    The following checks were performed on each of these signatures:
      - The cosign claims were validated
      - Existence of the claims in the transparency log was verified offline
      - The signatures were verified against the specified public key
    
    [{"critical":{"identity":{"docker-reference":"index.docker.io/donaldsebleung/hello-cosign"},"image":{"docker-manifest-digest":"sha256:507f40e3f0520ef9cae6d05bb3663c298c06d3c968d651536ede5ea11bd1c71a"},"type":"cosign container image signature"},"optional":{"Bundle":{"SignedEntryTimestamp":"MEUCIQDbpsPaKQIekQUAMntuXDFJXFmw2MEgMJ0/PPVcd/qKPwIgFn7qhn3yGjybDKX01rf+1O85ohB7ISVuNbKNdJbGfrg=","Payload":{"body":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJlYWExM2I2Y2ZlNjQwZjg1Y2M3Zjg0Njc1ZmM0YmNhNmQwOWM4MTAxYjIyMmRjYjQzZDZlNzcxNDdmYWJkNDcyIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQzNPSHl5REtOTTAwY0l5bFg5MGwzWU1CbmQ3OUdRZWtrOVhNMGszVGh4SUFpRUFuMDJRNmxhQ25mOGsvZmNDd3ErVVROMXF2dmczdm5CbERhS1lIai96eldZPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCUVZVSk1TVU1nUzBWWkxTMHRMUzBLVFVacmQwVjNXVWhMYjFwSmVtb3dRMEZSV1VsTGIxcEplbW93UkVGUlkwUlJaMEZGU2xaV09GWldha3AwTkZKU1dFbHpOa1J1U0dRNFYzSkZaekE0WndwS1IzRnpMMmhPUVV0eFkyVmtOMHN3VEZnNVdFaGlTa05hTkc5NE56UTBORE5sTVhVMVFqSkxiek5DYVZwWGIwUjJVbEJTVDJoR1EwNW5QVDBLTFMwdExTMUZUa1FnVUZWQ1RFbERJRXRGV1MwdExTMHRDZz09In19fX0=","integratedTime":1689771460,"logIndex":28001047,"logID":"c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"}}}}]
    
    

    Enforce workload integrity with ClusterImagePolicy

    Being able to manually sign and verify images is good and all, but what we really want is for our Kubernetes cluster to automatically verify image signatures at deployment time and refuse to run the (potentially compromised) workload in case a valid signature cannot be found. This is where Sigstore’s policy-controller and ClusterImagePolicy come in – though first we need a Kubernetes cluster 😉

    Let’s get one up and running in no time using kind:

    kind create cluster --name hello-sigstore

    Before we get started, let’s create a few namespaces:

    • dev: “Development” namespace
    • prod: “Production” namespace
    • cosign-system: where our policy-controller will be installed
    kubectl create ns dev
    kubectl create ns prod
    kubectl create ns cosign-system

    Now let’s install policy-controller using Helm. Take a look at the project details on Artifact Hub, then run the following commands to add Sigstore’s Helm repository and install sigstore/policy-controller into our cosign-system namespace:

    helm repo add sigstore https://sigstore.github.io/helm-charts
    helm repo update
    helm -n cosign-system install my-policy-controller sigstore/policy-controller --version 0.6.0

    Now consider the policy described below which might resemble a real-world scenario:

    • To ensure the integrity of our production workloads, we will require valid signatures on all images in the prod namespace
    • However, the dev namespace is really for our developers to test stuff out quickly and won’t be running production workloads, so it might not make sense to enforce image signature and verification there

    To enforce the policy described above, we’ll need to do two things:

    1. Define our ClusterImagePolicy to require a valid signature from our key pair for all images
    2. Using the label policy.sigstore.dev/include=true, enforce the policy in our prod namespace and leave our dev namespace intact

    Let’s write our ClusterImagePolicy and apply it to the cluster:

    cat > my-image-policy.yaml << EOF
    apiVersion: policy.sigstore.dev/v1beta1
    kind: ClusterImagePolicy
    metadata:
    name: my-image-policy
    spec:
    images:
    - glob: "**"
    authorities:
    - key:
    hashAlgorithm: sha256
    data: |
    $(cat cosign.pub | sed 's/^/ /')
    EOF
    kubectl apply -f my-image-policy.yaml

    Now apply the label policy.sigstore.dev/include=true to the prod namespace for the policy to take effect there:

    kubectl label ns prod policy.sigstore.dev/include=true

    Deploy a Pod with curl installed so we can conduct some tests in a moment:

    kubectl run curlpod --image=curlimages/curl -- sleep infinity

    Let’s also generate some common Deployment templates to be applied to both the dev and prod namespaces:

    kubectl create deploy nginx \
    --image=nginx \
    --replicas=2 \
    --port=80 \
    --dry-run=client \
    -o yaml > nginx.yaml
    kubectl create deploy "${APP_NAME}" \
    --image="${DOCKERHUB_USERNAME}/${APP_NAME}:${VERSION}" \
    --replicas=2 \
    --port=80 \
    --dry-run=client \
    -o yaml > "${APP_NAME}.yaml"

    Now compare the behavior of the dev and prod namespaces. We expect the dev namespace to accept any workload (the default) and the prod namespace to accept only signed workloads.

    Verify we can run unsigned workloads in the dev namespace by deploying NGINX:

    kubectl -n dev apply -f nginx.yaml

    Let’s expose the deployment as well, and verify we can reach the service using curl:

    kubectl -n dev expose deploy nginx
    kubectl exec curlpod -- curl -s nginx.dev

    Sample output:

    service/nginx exposed
    <!DOCTYPE html>
    <html>
    <head>
    <title>Welcome to nginx!</title>
    <style>
    html { color-scheme: light dark; }
    body { width: 35em; margin: 0 auto;
    font-family: Tahoma, Verdana, Arial, sans-serif; }
    </style>
    </head>
    <body>
    <h1>Welcome to nginx!</h1>
    <p>If you see this page, the nginx web server is successfully installed and
    working. Further configuration is required.</p>
    <p>For online documentation and support please refer to
    <a href="http://nginx.org/">nginx.org</a>.<br/>
    Commercial support is available at
    <a href="http://nginx.com/">nginx.com</a>.</p>
    <p><em>Thank you for using nginx.</em></p>
    </body>
    </html>

    For sanity, verify we can also run signed workloads in the dev namespace by deploying our app:

    kubectl -n dev apply -f "${APP_NAME}.yaml"
    kubectl -n dev expose deploy "${APP_NAME}"
    kubectl exec curlpod -- curl -s "${APP_NAME}.dev"

    Sample output:

    deployment.apps/hello-cosign created
    service/hello-cosign exposed
    <!DOCTYPE HTML>
    <html>
    <head>
    <meta charset="utf-8" />
    <title>Hello Cosign!</title>
    </head>
    <body>
    <h1>Hello Cosign!</h1>
    </body>
    </html>

    Let’s delete the deployments and associated services to save on resources:

    kubectl -n dev delete svc "${APP_NAME}"
    kubectl -n dev delete deploy "${APP_NAME}"
    kubectl -n dev delete svc nginx
    kubectl -n dev delete deploy nginx

    Now let’s repeat the experiments on our prod namespace.

    Try to deploy our unsigned NGINX workload:

    kubectl -n prod apply -f nginx.yaml

    You should see the following error:

    Error from server (BadRequest): error when creating "nginx.yaml": admission webhook "policy.sigstore.dev" denied the request: validation failed: failed policy: my-image-policy: spec.template.spec.containers[0].image
    index.docker.io/library/nginx@sha256:08bc36ad52474e528cc1ea3426b5e3f4bad8a130318e3140d6cfe29c8892c7ef signature key validation failed for authority authority-0 for index.docker.io/library/nginx@sha256:08bc36ad52474e528cc1ea3426b5e3f4bad8a130318e3140d6cfe29c8892c7ef: no signatures found for image

    This indicates our ClusterImagePolicy is in effect and rejecting Deployments using unsigned images. You can confirm that the Deployment is indeed not created by running the command kubectl -n prod get deploy.

    No resources found in prod namespace.

    Now confirm that we can deploy our signed app:

    kubectl -n prod apply -f "${APP_NAME}.yaml"

    Expose our app and curl it to confirm it is working as expected:

    kubectl -n prod expose deploy "${APP_NAME}"
    kubectl exec curlpod -- curl -s "${APP_NAME}.prod"

    Sample output:

    service/hello-cosign exposed
    <!DOCTYPE HTML>
    <html>
    <head>
    <meta charset="utf-8" />
    <title>Hello Cosign!</title>
    </head>
    <body>
    <h1>Hello Cosign!</h1>
    </body>
    </html>

    Concluding remarks

    We’ve seen how to sign and verify our own images using cosign, and how to configure a Kubernetes cluster to enforce workload signing on the namespace level using policy-controller, both under the Sigstore project dedicated to improving software supply chain security.

    I hope you enjoyed this lab and stay tuned for more content 🙂

    + , , ,
  • Consider the following component in my personal website responsible for serving static web assets from an OSS bucket to users.

    Function assets

    subPath may contain zero or more path components. The bucket donaldsebleung-assets is mounted under /mnt/donaldsebleung-assets/ in the container filesystem within the function assets, which appends the request path subPath to the mount point in order to fetch the associated object from the bucket and return its contents to the user who initiated the request.

    For reference, the original source code of the assets function is shown below.

    import os, re
    def handler(environ, start_response):
    path_info = environ['PATH_INFO']
    local_path = os.path.join('/mnt/donaldsebleung-assets/', path_info[1:])
    local_file_exists = os.path.isfile(local_path)
    if not local_file_exists:
    status = '404 Not Found'
    response_headers = [('Content-Type', 'text/plain')]
    start_response(status, response_headers)
    return [status]
    with open(local_path, 'rb') as local_file:
    contents = local_file.read()
    status = '200 OK'
    content_type = 'application/octet-stream'
    is_css = re.compile(r'\.css$').search(local_path)
    if is_css:
    content_type = 'text/css'
    is_png = re.compile(r'\.png$').search(local_path)
    if is_png:
    content_type = 'image/png'
    is_jpeg = re.compile(r'\.jpe?g$').search(local_path)
    if is_jpeg:
    content_type = 'image/jpeg'
    response_headers = [('Content-Type', content_type)]
    start_response(status, response_headers)
    return [contents]

    The intended behavior of the function is that it should only use the request path subPath to fetch the associated object from the bucket donaldsebleung-assets and return its contents to the requesting user – the function should never return content from other parts of the container filesystem, e.g. the function source code located at /code/index.py which should be kept hidden at all costs.

    Now imagine you are a malicious actor tasked to fetch the source code located at /code/index.py and disclose it to the public. How would you trick the function to return its source code, if at all possible? Hint: it’s in the title 😉

    The directory traversal attack

    Notice in line 5 of the source code that the local filesystem path is constructed by appending the request path to the bucket mount point /mnt/donaldsebleung-assets/ using os.path.join without any input validation or sanitation:

    local_path = os.path.join('/mnt/donaldsebleung-assets/', path_info[1:])

    For most cases, this line of code functions as expected. For example, suppose the request sent to the API gateway is GET /assets/css/bootstrap.min.css. Then the modified request received by the function assets is GET /css/bootstrap.min.css, so path_info becomes /css/bootstrap.min.css. Thus:

        os.path.join('/mnt/donaldsebleung-assets/', path_info[1:])
    ==> os.path.join('/mnt/donaldsebleung-assets/', 'css/bootstrap.min.css')
    ==> '/mnt/donaldsebleung-assets/css/bootstrap.min.css'
    

    So the object with key css/bootstrap.min.css (a copy of Bootstrap CSS) is fetched from the bucket donaldsebleung-assets and returned to the requesting user as expected.

    However, consider the following request sent to the API gateway:

    GET /assets//code/index.py

    The modified request sent to our function is then:

    GET //code/index.py

    So path_info is //code/index.py and we have:

        os.path.join('/mnt/donaldsebleung-assets/', path_info[1:])
    ==> os.path.join('/mnt/donaldsebleung-assets/', '/code/index.py')
    ==> '/code/index.py'
    

    This is because according to the os.path.join specification, if any intermediate path starts with a forward slash / on Unix/Linux, all preceding paths are discarded and the filesystem traversal starts from the root directory again. This means our source code has just been leaked – no wonder the vulnerable code is now available in this blog post for everyone to see (-:

    Now, if you test this in one of the major browsers such as Chrome or Firefox, you’ll realize that filesystem traversal attempts like this are automatically collapsed, so for example entering a link in the search bar like https://www.donaldsebleung.com/assets//code/index.py and pressing Enter gets treated as https://www.donaldsebleung.com/assets/code/index.py and you get a 404 response as expected. However, you can’t expect a malicious actor to play by the rules 😉

    In fact, it’s possible to craft the exact request as shown above using openssl s_client:

    openssl s_client -connect www.donaldsebleung.com:443

    And entering the following request once the SSL connection is established:

    GET /assets//code/index.py HTTP/1.1
    Host: www.donaldsebleung.com

    The patch

    import os, re
    def handler(environ, start_response):
    path_info = environ['PATH_INFO']
    dt_attack = re.compile(r'\.\.|\/\/').search(path_info)
    if dt_attack or path_info == '/homepage.md':
    status = '404 Not Found'
    response_headers = [('Content-Type', 'text/plain')]
    start_response(status, response_headers)
    return [status]
    local_path = os.path.join('/mnt/donaldsebleung-assets/', path_info[1:])
    local_file_exists = os.path.isfile(local_path)
    if not local_file_exists:
    status = '404 Not Found'
    response_headers = [('Content-Type', 'text/plain')]
    start_response(status, response_headers)
    return [status]
    with open(local_path, 'rb') as local_file:
    contents = local_file.read()
    status = '200 OK'
    content_type = 'application/octet-stream'
    is_css = re.compile(r'\.css$').search(local_path)
    if is_css:
    content_type = 'text/css'
    is_png = re.compile(r'\.png$').search(local_path)
    if is_png:
    content_type = 'image/png'
    is_jpeg = re.compile(r'\.jpe?g$').search(local_path)
    if is_jpeg:
    content_type = 'image/jpeg'
    response_headers = [('Content-Type', content_type)]
    start_response(status, response_headers)
    return [contents]

    Notice we added 6 lines of code in lines 5-10 to detect attempts at directory traversal attacks and respond to such requests with a 404 Not Found, since we don’t want to inform the attacker that we detected and actively intercepted the attack. The path_info == '/homepage.md' condition is because the homepage for my personal website is also stored in the same donaldsebleung-assets bucket (it doesn’t make sense to create a new bucket just to store the homepage specifically) but shouldn’t be returned to the user as-is through our assets function – instead, it should be converted to a pretty HTML homepage through another function homepage which is then displayed to the user.

    Architecture with Alibaba Cloud Function Compute

    In fact, the original issue of my assets function unexpectedly returning the raw homepage.md file with the request path /assets/homepage.md was what lead me to discovering this filesystem traversal attack vulnerability which has since been patched – who said I deliberately made my code vulnerable just for the sake of authoring this blog post? 😉

    The moral

    Never trust user input!

    + , ,
  • Over the past two years, my personal website (i.e. this site) was hosted on a self-managed cloud server running a traditional LAMP stack (Linux, Apache, MySQL, PHP) with neither elasticity nor redundancy, functioning solely as a stub for redirecting all requests to my GitHub profile. Despite this functionality being as trivial as it could possibly get, the underlying infrastructure was fragile, rigid, costly and difficult to properly manage due to the grossly suboptimal architecture.

    Despite the glaring shortcomings of the above architecture, the website was basically functioning as expected so there was little incentive to improve it – after all, “if it ain’t broke, don’t fix it” – until I finally decided to re-design the entire infrastructure this week following inspiration from the excellent AWS Technical Essentials course, this time adopting a fully serverless event-driven architecture.

    The current architecture is resilient, secure, elastically scalable, cost-effective and requires no maintenance of infrastructure whatsoever – a complete overhaul of the previous one. So what were some of the motivations and considerations surrounding the adoption of the previous and current architectures? Perhaps a detailed review and analysis of the historical evolution of my personal website is in order.

    2015: my personal website was born

    I was introduced to programming in 2015 through an extra-curricular activity (ECA) offered at my high school by BSD Academy (now BSD Education) on frontend web development with HTML5+CSS3+JS, for which I also picked up PHP as my first server-side programming language shortly thereafter. At the time, I only had a very basic understanding of fundamental programming concepts and had virtually no concept of IT infrastructure – to me, a server was just some machine hosted in a remote location over the Internet as far as I was concerned.

    When I decided to show off demonstrate my newfound programming skills by developing my very own website back in 2015, web hosting through the likes of GoDaddy was all the rage and the go-to option for individual users and small businesses, due to the ease of getting a website up and running without having to manage servers by oneself. Furthermore, the main offering by GoDaddy back then was a LAMP stack where the developer was only responsible for uploading HTML5+CSS3+JS code and static web assets, possibly along with PHP scripts for generating dynamic content and optionally interacting with a MySQL database managed by GoDaddy, all via a user-friendly web interface such as cPanel.

    While this was the most sensible option at the time given my ability and interests back then, there are a few key disadvantages to this model:

    • The software stack is determined entirely by the vendor and developers are limited to the framework(s) and programming language(s) provided therein
    • The architecture is rigid and non-scalable – during a sudden surge in traffic, the server would be overwhelmed. Furthermore, to handle possible spikes in traffic, a larger server needs to be rented and the program code + data may need to be migrated to the new server automatically or manually, depending on whether the vendor supports automatic migration of code + data between servers
    • The rigidity of the architecture implies that over-provisioning is necessary – a sufficiently large server capable of handling peak traffic needs to be rented upfront for a fixed period of time, e.g. 1 year, which is a waste of both money and resources

    Nevertheless, my website would be hosted on GoDaddy for the years to come, until a course offered at my university opened me to the world of cloud computing and I decided to migrate my website to the cloud.

    2021: migrating to the cloud

    My professional interest shifted from software development to primarily operations and maintenance of IT infrastructure when I was first introduced to Linux mid-2020 through an internship at M-Labs for which I swiftly developed a strong passion towards and earned my first professional LFCS certification shortly thereafter in July 2020, now set to expire in July 2023. This enabled me to properly understand the underlying technology stack and infrastructure powering my website on GoDaddy for the first time, though my website still remained on GoDaddy with lack of a better hosting alternative due to a missing piece of the puzzle – cloud computing.

    After my internship ended in late 2020, I returned to HKUST for the final semester of my Computer Science degree where I took a course on cloud computing offered by Prof. Wei WANG which opened me to a whole new world of possibilities – in particular, the concept of spinning up an EC2 instance on AWS and getting a full-blown Linux system up and running in minutes was what really intrigued me at the time. What could possibly be better than getting a Linux system up and running in minutes, then getting my hands dirty by installing, configuring and setting up everything myself?

    With the operational flexibility offered by Amazon EC2 (later migrated to Alibaba Cloud ECS in early 2022 due to non-technical reasons), I was no longer limited to the software stack offered by GoDaddy – instead, I could develop my website using the framework and programming language of my choice, install and manage the necessary dependencies myself, and manage OS and software updates at my own pace as well as SSL certificates using Let’s Encrypt and Certbot at no additional cost. So I stumbled upon Spring Framework written in Java and decided to give that a try.

    It was fun and exciting developing my own website using the framework and programming language of my choice and deploying it to a cloud server fully managed and maintained by myself, while the novelty lasted. Once the novelty wore off, it became a pain, a maintenance nightmare:

    • Both Spring Framework and Java itself were verbose and cumbersome, and I found it difficult to learn beyond the very basics, so I quickly lost interest in it and subsequently the continued development of my website
    • The way I decided to package and distribute my website using Snap meant that every time I updated my website, even if just for updating the static HTML content, I had to re-run all the commands for building the Snap package on my local workstation, upload it to the Snap Store and wait for automatic Snap updates to pull the updated website to the cloud server – a tedious and time-consuming process which further reduced my interest in continued development of my website
    • I had to log into the server every once in a while to apply OS and software patches and updates, or write a cron job to perform updates automatically and reboot the server on a fixed schedule, the latter of which eventually proved to be fragile and lead to manual updates ending up broken for some unknown reason
    • Every once in a while, I would re-install the OS on my laptop (typical of a new Linux user (-: ) without backing up my SSH keys, so I would need to generate a new SSH key pair … and get locked out of my cloud server. This meant that I had to replace the cloud server and repeat the entire setup process manually every time this happened
    • Since my personal website doesn’t receive traffic all that often, the cloud server was idle some 99% of the time, yet I had to keep paying for resources such as CPU, memory and elastic IP address occupied by my cloud server, a serious waste of both money and resources

    Before long, I decided to ditch my website written in Spring entirely, and replaced it with an Apache web server acting solely as a stub for redirecting all HTTP and HTTPS request to my GitHub profile. Stuff that I might as well have achieved using GoDaddy, but with increased operational and monetary overhead. So at best, I was back to square 1:

    • A traditional LAMP stack
    • High operational and maintenance effort required
    • Rigid architecture difficult to scale automatically
    • Tremendous waste of computing resources
    • High operation expenses (OpEx), i.e. wasted money, despite not really doing anything useful

    Nevertheless, I stuck with this grossly suboptimal architecture for way longer than I should have due to inertia, despite having learned about the various potential benefits of managed cloud services such as elastic scalability, built-in fault tolerance and automatic security patching by going through a series of cloud-related courses and certifications since early 2022.

    2023: embracing serverless

    That was until I revisited the concept of serverless functions near the end of the excellent AWS Technical Essentials course just last week. All of a sudden, everything clicked into place – for a personal website with a low average visit rate and possible unexpected spikes in traffic that only needs to respond to stateless, short-lived web requests and doesn’t warrant ongoing maintenance of infrastructure, wouldn’t an event-driven serverless architecture using functions be a perfect fit for this use case?

    So now I have an architecture that is excellent from the development, operational and financial perspective, which also happens to be extensible and future-proof:

    • From a development perspective, developing and maintaining each feature in separate functions promotes modularity and separation of concerns, easing the development and iteration process
    • From an operational perspective, the current architecture is automatically fault-tolerant, secure, elastically scalable and requires no maintenance of infrastructure whatsoever, which is exactly how a personal website should be
    • From a financial perspective, I can finally take complete advantage of managed cloud services by paying only for CPU, memory and network traffic my website uses when serving requests to visitors and only pay for a negligible additional fee for the continued storage of my static web assets the rest of the time

    Also, by utilizing Python-Markdown to convert Markdown templates to HTML on the fly, I can place complete focus on content when authoring long, technical blog posts like this one by writing them in Markdown and uploading them directly to Alibaba Cloud OSS when ready, instead of wasting time wrestling with HTML tags, CSS styling and page layout.

    Another major advantage of utilizing Alibaba Cloud Function Compute (with OSS) as the backend and placing them behind an API Gateway is extensibility – it is simple to implement and expose new functionality for my website whenever desired by writing a new function and attaching it to the gateway via a new API, or even attaching a completely different set of cloud services to the gateway as a new API if required.

    Concluding remarks

    So this was an architectural overview of my personal website and how it was designed to leverage the advantages of modern managed cloud services, but we haven’t really touched on the development perspective in this article.

    I hope you liked this article and stay tuned for my next article focusing on the development aspect of my personal website re-design which should be out in a day or two 🙂

    +