Django etag caching

Response time is of utmost importance for any application. It not only makes the application more responsive but also enhances the user experience. In one of my previous post we utilized memcache to cache view response and improve the response time (performance) of the site by almost 10X. This was basically at server-side. However we can further improve this by making some smart client side (browser) caching. Django’s Conditional View Processing is very apt for this scenario.

Django http decorator (django.views.decorators.http) functions provide an easy way to set cache for conditional requests. Etags or content based caching can be very useful for cases where setting time based caching can be a challenge. When etag is set, an If-None-Match request header is sent with the etag value of the last requested version of the resource for all subsequent requests. If the current version has the same etag value, indicating its value is the same as the browser’s cached copy, then an HTTP status of 304 is returned and content is served from the browser cache, boosting the response time as content does not need to be fetched from server/server cache.

The advantage with etag is that, caching can be done based on the response content. Although this involves in generating the etag based on the content which can be a hash or any other string identifier. Web servers (like nginx) can also do this nowadays, however we will see in this post how to set this from application end in Django.

The @etag decorator really makes things simple in Django. You can read more about this decorator in the Django official doc, but here I’ll focus on the implementation.

In my scenario, I am caching the etag as well so that the same etag can be used and also it provides me a manual way to clear the cache and reset the etag (django-clearcache) if required without making any code change/server restarts.

The first thing is to write the get_etag function which returns the etag.

# Get etag (for client side caching)
def get_etag(request,  **kwargs):
    etag_key = request.path.split('/')[2]
    return cache.get(etag_key, None)

The snippet above returns the etag from cache. The cache key is based on the request path, so that the etag decorator can use the same get_etag function for multiple view functions. Once this is done we just need to specify the decorator in our view function.

@etag(get_etag)
def server_data(request, **kwargs):    
    url = reverse('reinvent_rest_api:server_api-list') + '?format=datatables'

    # Check cache & get response
    response = cache_response(ServerViewSet.as_view({'get': 'list'}), url, 'server_cache', 'server_data')
                      
    return  HttpResponse(response)

@etag(get_etag)
def uri_data(request, **kwargs):
    url = reverse('reinvent_rest_api:uri_api-list') + '?format=datatables'

    # Check cache & get response
    response = cache_response(UriViewSet.as_view({'get': 'list'}), url, 'uri_cache', 'url_data')
            
    return  HttpResponse(response)

It’s as simple as shown above. Now the etag is set along with the view’s response cache as below:

# Handle caching
def cache_response(view, url, cache_name, etag_cache_name):
   # Get response from cache
   response = cache.get(cache_name)
        
   # Invoke POST call to get data from DRF if cache is not set
   if not response:   
      status, response = send_api_request(url, view, None, None)
      if status != 200:
         raise Exception('Error fetching data from API: ' + response.content)
      else:
         # Set cache
         cache.set(cache_name, response, None)
         cache.set(etag_cache_name, str(datetime.datetime.now()), None)  
   return response

This would set the response as well as the etag in cache. Here am using the datetime stamp as the etag value.

Now, for etag cache invalidation, it should be the same process as followed for the response cache invalidation, i.e. whenever the model is changed the cache should be invalidated. It is not time/user based. Thus once the cache is set, all users would benefit from it and would get invalidated only when the model has been changed. Cache invalidation is a very crucial factor and is totally based on your application/requirements and how static/dynamic your content is. Be sure to spend some time understanding which approach would best fit before finalizing the design.

In my case, the post save signal of the model is where I put the invalidation logic, which would basically be triggered whenever the model is changed.

# Signal to handle cache invalidation
@receiver(post_create_historical_record)
def invalidate_cache(sender, **kwargs):
    model_name = kwargs.get('instance').__class__.__name__

    if model_name == 'Server' and cache.has_key('server_cache') and cache.has_key('server_data'):
        cache.delete('server_cache')
        cache.delete('server_data')
        
    if model_name == 'Uri' and cache.has_key('uri_cache') and cache.has_key('uri_data'):
        cache.delete('uri_cache')
        cache.delete('uri_data')

I used the django_simple_history post save signal as I have history enabled for multiple models. Thus instead of using different signals for different models, the django_simple_history’s post save signal can be used.

Note – Incase you have deployed your application in Kubernetes and use Nginx Ingress Controller, ensure to set gzip to off, else Nginx will discard etag from response header. This can be done by the below annotation in your Ingress manifest:

kind: Ingress
apiVersion: extensions/v1beta1
metadata:
  name: reinvent-ingress
  annotations:
    ingress.kubernetes.io/configuration-snippet: "gzip off;"
spec:
  ...
  ...

And that’s about it. Please find below the response time screenshots before and after this change 🙂

  1. First Request (No caching at all)

Timing – 17.1 s

2. Second Request (Memcached / without etag)

Timing – 474 ms

3. Third Request (Memcached / with etag)

Timing – 3 ms

Deploy Kafka on K8s using Helm

Kafka is one of the most popular open source distributed streaming platform used very often for multiple requirements. However its deployment can get complicated given its architecture especially for HA setups.

Deployment in Kubernetes is simplified a lot using Helm, however in-case of customizations required for project specific needs, it can be tricky if we do-not understand how the helm charts are configured.

So while working for Kakfa setup in one of my current projects, we had some custom requirements, with mTLS and exposing the services externally. Have tried to state and changes done to make the deployment seamless with these custom requirements.

Let’s start with the basic setup first. It’s pretty simple and well documented (at least for the Bitnami Helm, which we use). So just adding the chart repo and executing:

helm install kafka . -f values-production.yaml -n <namespace>

should get the Kafka up and running as a stateful set. You can check the options that can be overridden in the readme file. However, for further customizations (if required), it’s better to download the chart and use it from a custom repo.

Some customizations that I would be discussing are:

  1. External access setup using LoadBalancers with custom DNS
  2. External access setup using NodePort and external load balancer
  3. mTLS (mutual TLS or 2 way TLS) setup with wildcard cert (i.e. one cert used by multiple brokers)

  1. External access with LoadBalancer service type and custom DNS

Now, the bitnami helm supports setting up LoadBalancers for external access. You can refer the configuration here. What I would focus on, is specifying custom domain for these LoadBalancers which is not an option by default, as the helm depends on the LoadBalancer service to provide the DNS as output once created (which does not happen with all providers).

If we see the file – scripts-configmap.yaml in the helm template:

k8s_svc_lb_ip() {
local namespace=${1:?namespace is missing}
local service=${2:?service is missing}
local service_ip=$(kubectl get svc "$service" -n "$namespace" -o jsonpath="{.status.loadBalancer.ingress[0].ip}")
local service_hostname=$(kubectl get svc "$service" -n "$namespace" -o jsonpath="{.status.loadBalancer.ingress[0].hostname}")

if [[ -n ${service_ip} ]]; then
echo "${service_ip}"
else
echo "${service_hostname}"
fi
k8s_svc_node_port "{{ $releaseNamespace }}" "$SVC_NAME" | tee "$SHARED_FILE"

...
...
export EXTERNAL_ACCESS_IP=$(echo '{{ .Values.externalAccess.service.loadBalancerIPs }}' | tr -d '[]' | cut -d ' ' -f "$(($ID + 1))")

As you can see above, the hostname is set only when the Loadbalancer IP is not set. This needs to be updated for supporting our requirement to specify the domain as below:

# Change to specify custom LoadBalancer DNS instead of IP
export EXTERNAL_ACCESS_IP="broker${ID}.{{ .Values.externalAccess.service.loadBalancerDomain }}"

This basically allows to set the domain from Values file rather than populating it from the LoadBalancer response. Each broker will thus have the external IP set to the DNS name: broker{id}.<domain>, (where id is the broker id – e.g. broker0.example.com) which will be further added in the ADVERTISED_LISTENER property. Note, that it is important to set the EXTERNAL_ACCESS_IP as this is set in the ADVERTISED_LISTENER property:

export KAFKA_CFG_ADVERTISED_LISTENERS="INTERNAL://${MY_POD_NAME}.{{ $fullname }}-headless.{{ $releaseNamespace }}.svc.{{ $clusterDomain }}:{{ $interBrokerPort }},CLIENT://${MY_POD_NAME}.{{ $fullname }}-headless.{{ $releaseNamespace }}.svc.{{ $clusterDomain }}:{{ $clientPort }},EXTERNAL://${EXTERNAL_ACCESS_IP}:${EXTERNAL_ACCESS_PORT}"

If this is not set correctly, although you might be able to connect to Kafka, however you will get errors while producing/consuming messages.

Once this change is done, you can add the domain entry in values.yaml as below:

externalAccess:
  service:
    ## Ensure the LoadBalancer IP created has a DNS entry added as broker<ID>.domain.com (e.g. broker0.example.com)
    loadBalancerDomain: example.com

This works seamlessly, and you will be able to access Kafka using the DNS name now. However, note that the A record for the DNS pointing to the LoadBalancer IP needs to be set manually for each broker at your DNS provider end.

2. External access setup using NodePort and external load balancer

The process is quite similar for NodePort setup, the only difference being that LoadBalancer setup needs to be handled totally at the provider end and will not be taken care by Kubernetes. So just update the service type to NodePort and there is already a property (externalAccess.service.domain) which is specifically to set domain for NodePort supported out of the box by the bitnami helm. Just ensure you add the K8s nodes with correct port to the LoadBalancer pool members for each broker.

3. mTLS setup with wildcard cert (used by multiple brokers)

Ok, now we come to a really interesting part, which can give you headaches and often not work due to incorrect configurations. I’ll divide this section into 2 parts:

  1. How to generate a self signed wildcard cert to be used
  2. Customizations required in the helm to support this wildcard cert

  1. Generate SSL certs

While there are tons of resources in the net to do this, I’ll point the exact steps to generate a self-signed SSL cert which can be used for enabling mTLS in Kafka. Before that, mutual TLS is basically a 2 way TLS, which requires a client cert to achieve AuthN. Thus once Kafka has mTLS, enabled, a client side cert will be required to connect to it, which is much safer than using a password.

Steps to generate self-signed SSL cert and import it to keystore and truststore (will be using java keytool and openssl):

1. Create a certificate authority (CA)
openssl req -new -x509 -keyout ca-key -out ca-cert -days 1200 -passin pass:kafkapassword -passout pass:kafkapassword -subj "/CN=*.example.com/OU=<UNIT>/O=<ORG>/L=<LOCATION>/C=<COUNTRY>"

# Client Certs
2. Create client keystore
keytool -noprompt -keystore kafka.client.keystore.jks -genkey -alias localhost -keyalg RSA -keysize 2048 -dname "CN=*.example.com/OU=<UNIT>/O=<ORG>/L=<LOCATION>/C=<COUNTRY>" -storepass kafkapassword -keypass kafkapassword

3. Create client certificate signing request
keytool -noprompt -keystore kafka.client.keystore.jks -alias localhost -certreq -file cert-client-unsigned -storepass kafkapassword 

4. Sign the client certificate with the CA created
openssl x509 -req -CA ca-cert -CAkey ca-key -in cert-client-unsigned -out cert-client-signed -days 1200 -CAcreateserial

5. Import CA and signed client certificate into client keystore
keytool -import -keystore kafka.client.keystore.jks -file ca-cert -alias theCARoot
keytool -noprompt -keystore kafka.client.keystore.jks -alias localhost -import -file cert-client-signed -storepass kafkapassword

# Server Certs
6. Import CA into server truststore
keytool -noprompt -keystore kafka.server.truststore.jks -alias theCARoot -import -file ca-cert -storepass kafkapassword

7. Create server keystore
keytool -noprompt -keystore kafka.server.keystore.jks -genkey -alias buildserver -keyalg RSA -keysize 2048 -dname "CN=*.example.com/OU=<UNIT>/O=<ORG>/L=<LOCATION>/C=<COUNTRY>" -storepass kafkapassword -keypass kafkapassword

8. Sign server certificate
keytool -noprompt -keystore kafka.server.keystore.jks -alias buildserver -certreq -file cert-server-unsigned -storepass kafkapassword

9. Sign the server certificate with the CA created
openssl x509 -req -CA ca-cert -CAkey ca-key -in cert-server-unsigned  -out cert-server-signed -days 1200 -CAcreateserial

10. Import CA and signed server certificate into server keystore
keytool -import -keystore kafka.server.keystore.jks -file ca-cert -alias theCARoot
keytool -noprompt -keystore kafka.server.keystore.jks -alias buildserver -import -file cert-server-signed -storepass kafkapassword

The above would, generate the required client and server certs, keystore and truststore required to setup mTLS.

Note, you may specify SAN/IP’s additionally in the certs if you have such requirements or want to make the mTLS work with IP addresses/other sub domains. (using -ext SAN flag). You can google up this flag to read more.

Incase you have a valid CA, you can sign the generated certs using the CA, instead of using the self created CA.

2. Customizations required in the helm to support this wildcard cert

Once the certs are generated and in place, we need to make some modifications in the helm, as by default it expects each broker to have an individual cert. Firstly, set the required parameters correctly as specified here. To use the wildcard cert for all broker, update the file scripts-configmap.yaml as below:

# Support wildcard cert (one keystore for all brokers)
    if [[ -f "/certs/kafka.truststore.jks" ]] && [[ -f "/certs/kafka.keystore.jks" ]]; then
        mkdir -p /opt/bitnami/kafka/config/certs
        cp "/certs/kafka.truststore.jks" "/opt/bitnami/kafka/config/certs/kafka.truststore.jks"
        cp "/certs/kafka.keystore.jks" "/opt/bitnami/kafka/config/certs/kafka.keystore.jks"
    else
        echo "Couldn't find the expected Java Key Stores (JKS) files! They are mandatory when encryption via TLS is enabled."
        exit 1
    fi

The original file will have the {ID} configured, which basically needs to be removed to make it a global cert to be used by all brokers. The server keystore and trustore needs to be copied to the folder: files>jks. And thats about it. 🙂

You should have a HA stateful Kafka cluster up and running in no time…

CKAD – My take!

I passed the CKAD (Certified Kubernetes Application Developer) exam earlier this month and was since asked by many regarding my experience and tips. So here it goes…

There are so many tips and suggestions in net regarding vim setup, tackle easy ones first, time management, use the notepad etc.

IMO the most important thing is you should know to use imperative commands to create/modify resources. (Basically the kubectl create/run/label/annotate/set commands).

Secondly you should bookmark all important links from the documentation as for everything we can’t use imperative commands, so the copy-paste from documentation should be fast and less time should be spent to find what you need. Thus bookmarks.

If you are proficient with these 2 aspects it’s really simple. I attempted all questions sequentially, no custom vim settings, did not use notepad. I was able to complete the test with 15 mins remaining. Although I kept verification for the end.

Another important thing is the namespace. Ensure to set the context before each question without fail. It’s very important and don’t ignore it.

For me the above worked 🙂

Now regarding the prep courses and materials I used.

I referred the KodeKloud labs (https://kodekloud.com/p/kubernetes-certification-course-labs) There are 2 lightning labs and 2 mock exams.

I did these 3-4 times till I could complete all of them in time. The lightning labs are really good and if you can solve them within the time I feel you are ready. Even after knowing the answers, I could not complete these initially within time. So the usage of bookmarks, copy-pasting everything was put to test and it really helped a lot.

One good thing about CKAD is the syllabus isn’t vast. There are just 10-15 imperative commands you should know IMO. I also glanced once at the CKAD exercise (just read through once) – https://github.com/dgkanatsios/CKAD-exercises

Again, if you know K8s basics, 1 week of preparation and the labs practise is more than enough to clear this exam.

All the best with your exam 🙂

Design a site like this with WordPress.com
Get started