Outline

Weekly Schedule

WeekModule
1Terraform Brush-up
2Terraform Backends
3Event-Based Architecture on AWS
4AWS Batch Jobs
5Getting Started with Kubernetes
6Kubernetes Pods
7Kubernetes Namespaces
8Kubernetes Configs & Secrets
9Reading Week
10Kubernetes Labels
11Kubernetes Deployments
12Kubernetes Deployments
13Kubernetes Jobs
14Kubernetes Services
15Final

Assesment

ModuleWeight
In-class Activities20%
Assignment 0120%
Assignment 0220%
Project40%

Terraform (brush-up)

Setting up an AWS Account

  • Refer to this document to set up your AWS account.

  • Refer to this document to create an IAM user. For simplicity, attach the AdministratorAccess IAM policy to the user so we won't run into permission issues. Just as an FYI, this is against security best practices, but we will fix this later.

  • Generate access key ID and secret access key for the user you created in the previous step. Refer to this [document] (https://docs.aws.amazon.com/powershell/latest/userguide/pstools-appendix-sign-up.html).

  • Install AWS CLI. Refer to this document.

  • Install AWS Vault from here.

Install Terraform

For MacOS:

brew tap hashicorp/tap brew install hashicorp/tap/terraform

For Windows:

choco install terraform

Checkout the official documentation for other platforms.

Make sure terraform is installed:

terraform Usage: terraform [global options] <subcommand> [args] The available commands for execution are listed below. The primary workflow commands are given first, followed by less common or more advanced commands. Main commands: init Prepare your working directory for other commands validate Check whether the configuration is valid plan Show changes required by the current configuration apply Create or update infrastructure destroy Destroy previously-created infrastructure ...

Terraform needs to have access to your AWS credentials (the ones you created in the previous step). There are several ways to do this and we will cover most of them as we go forward.

For now, you can have your credentials as environment variables for Terraform to access them:

export AWS_ACCESS_KEY_ID=xxxxxxx export AWS_SECRET_ACCESS_KEY=xxxxxx

Note that this approach only works in your current shell. If you open up a new shell, they'll be gone.

Another way is to use aws-vault which you installed in the previous step.

aws-vault exec dev -- terraform apply

Creating a Simple Server

Terraform code is written in the HashiCorp Configuration Language (HCL) in files with the extension .tf. It is a declarative language, so your goal is to describe the infrastructure you want, and Terraform will figure out how to create it. We'll start by deploying a simple server on AWS.

Start by creating a file named main.tf:

terraform { required_providers { aws = { version = ">= 4.0.0" source = "hashicorp/aws" } } }

Download the AWS plugin for Terraform:

terraform init

This will download the required plugin and put it in the .terraform folder. We don't commit this folder to version control. So, let's make the current directory a git repo and add a .gitignore file to exclude the .terraform folder (along with other files and folders).

Initialize a git repo:

git init

Create a .gitignore file and set it up as discribed on this page.

Add a provider block to your main.tf to specify an AWS region:

provider "aws" { region = "ca-central-1" }

Now it's time to add a resource. Resources in Terraform are declared as below:

resource "<PROVIDER>_<TYPE>" "<NAME>" { [CONFIG ...] }

Where the PROVIDER is the plugin you're using (in this case, AWS); TYPE is the type of resource you want to create (in this case, EC2); NAME is the local name you give to the resource; and CONFIG is the arguments specific to the resource. Here's an example for an Ubuntu server in the ca-central-1 region.

resource "aws_instance" "example" { ami = "ami-0b6937ac543fe96d7" instance_type = "t2.micro" tags = { Name = "bvc" } }

Terraform binary contains a formatting tool to format your tool. It is highly recommended to do so, as it will improve readibility.

terraform fmt

Validate your code to catch any syntax errors withing your Terraform code:

terraform validate

If you get this message, you're good to go:

Success! The configuration is valid.

Before creating the instance, it's recommended to see what Terraform calls the Plan. It shows the changes to be made before actually making them. This is a great way to make sure we're not accidentally making a change we're not supposed to. See the plan with this command:

terraform plan

You should see an output similar to this:

Terraform will perform the following actions: # aws_instance.example will be created + resource "aws_instance" "example" { + ami = "ami-0b6937ac543fe96d7" + arn = (known after apply) + associate_public_ip_address = (known after apply) + availability_zone = (known after apply) + cpu_core_count = (known after apply) + cpu_threads_per_core = (known after apply) + disable_api_stop = (known after apply) + disable_api_termination = (known after apply) + ebs_optimized = (known after apply) + get_password_data = false + host_id = (known after apply) + host_resource_group_arn = (known after apply) + id = (known after apply) + instance_initiated_shutdown_behavior = (known after apply) + instance_state = (known after apply) + instance_type = "t2.micro" + ipv6_address_count = (known after apply) ... Plan: 1 to add, 0 to change, 0 to destroy.

Once we're ok with the changes, we can apply them using terraform apply. This command will prompt you for one last time. In case you want to skip this prompt, run terraform apply --auto-approve.

After about 30 seconds, the instance is up and running.

instance

Next, we want to creat an AWS key pair so that we can SSH into the instance:

resource "tls_private_key" "key" { algorithm = "RSA" rsa_bits = 4096 provisioner "local-exec" { interpreter = ["bash", "-c"] command = "echo '${self.private_key_pem}' > ${pathexpand("~/Downloads/canada.pem")}" } }

The local-exec provisioner enables us to run a custom script on the machine that runs the Terraform code (in this case, our own machine). The script makes sure that we store the private key somewhere on our local file system so we can log in to the instance.

We need to also associate the key with the instance we create earlier:

resource "aws_instance" "example" { ami = "ami-0b6937ac543fe96d7" instance_type = "t2.micro" key_name = aws_key_pair.key_pair.key_name }

key-pair

After applying the changes, the key is created, yet we can't still SSH into the instance. The reason is that there's currently no security group attached to the instance that opens the SSH port for us. Let's add one:

data "aws_vpc" "default" { default = true } resource "aws_security_group" "allow_ssh_http" { name = "allow_ssh_http" description = "Allows SSH and HTTP" vpc_id = data.aws_vpc.default.id ingress { description = "SSH" from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { description = "Allows HTTP" from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "allow_ssh" } }

The security group allows both SSH and HTTP (later we're installing a web server on the instance, so it's necessary to have HTTP as well).

As security groups belong to a VPC on AWS, we also need to grab the id of the default VPC that AWS creates for us in each region. The data block in Terraform enables us to do that. Note that the information we can get from a provider (in this case, AWS) depends on the provider itself and can be different from one provider to another. Don't forget to associate the security group with your instance:

resource "aws_instance" "example" { ami = "ami-0b6937ac543fe96d7" instance_type = "t2.micro" key_name = aws_key_pair.key_pair.key_name vpc_security_group_ids = [aws_security_group.allow_ssh_http.id] ... }

After SSHing to the instance, we can now install an Apache web server:

sudo apt update sudo apt install apache2 sudo systemctl start apache2 sudo systemctl enable apache2

Apache should now be running on the port 80 of the instance. In order to see the default Apache page, we would need the public IP of the instance. Although you can get that from the AWS console, let's output that using Terraform:

output "public_ip" { value = aws_instance.example.public_ip }

You can now apply the changes and see the public IP address of the instance printed out in the console. Head to the IP address and you should be able to see the default Apache page.

Terraform Backend

Using a Terraform Backend

terraform { backend "s3" { bucket = "bvc-terraform-state" key = "live/main.tfstate" region = "ca-central-1" dynamodb_table = "terraform-locks" encrypt = true } required_providers { aws = { version = ">= 4.0.0" source = "hashicorp/aws" } } }

Create IAM Role for GitHub Actions

main.tf

# Create an IAM OIDC identity provider that trusts GitHub resource "aws_iam_openid_connect_provider" "github_actions" { url = "https://token.actions.githubusercontent.com" client_id_list = ["sts.amazonaws.com"] thumbprint_list = [ data.tls_certificate.github.certificates[0].sha1_fingerprint ] } # Fetch GitHub's OIDC thumbprint data "tls_certificate" "github" { url = "https://token.actions.githubusercontent.com" } # Create role for the action data "aws_iam_policy_document" "assume_role" { statement { actions = ["sts:AssumeRoleWithWebIdentity"] effect = "Allow" principals { identifiers = [aws_iam_openid_connect_provider.github_actions.arn] type = "Federated" } condition { test = "StringLike" variable = "token.actions.githubusercontent.com:sub" # The repos and branches defined in var.allowed_repos_branches # will be able to assume this IAM role values = [ "repo:masoudkarimif/*:*" ] } } } # Assign policy to the role resource "aws_iam_role" "github_actions_role" { name_prefix = var.role_name_prefix assume_role_policy = data.aws_iam_policy_document.assume_role.json } resource "aws_iam_role_policy" "this" { role = aws_iam_role.github_actions_role.name policy = <<POLICY { "Version": "2012-10-17", "Statement": [ { "Action": [ "s3:PutObject", "s3:Get*" ], "Resource": "arn:aws:s3:::${var.devops_bucket_name}/*", "Effect": "Allow" }, { "Action": [ "lambda:UpdateFunctionCode" ], "Resource": "*", "Effect": "Allow" } ] } POLICY } data "aws_iam_policy" "readonlyaccess" { arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" } resource "aws_iam_role_policy_attachment" "attachment" { role = aws_iam_role.github_actions_role.name policy_arn = data.aws_iam_policy.readonlyaccess.arn }

variables.tf

variable "role_name_prefix" { type = string } variable "devops_bucket_name" { type = string }

outputs.tf

output "role_name" { value = aws_iam_role.github_actions_role.name } output "role_arn" { value = aws_iam_role.github_actions_role.arn }

Using Different Roles for Different Environments

In GitHub workflow:

# other stuff ... name: deploy # ... - name: Configuration for master branch if: ${{ github.ref == 'refs/heads/master' }} run: | echo "ROLE_ARN=${{ secrets.PROD_DEVOPS_ROLE }}" >> $GITHUB_ENV echo "DEVOPS_BUCKET=PROD-BUCKET" >> $GITHUB_ENV echo "ENV=PROD" >> $GITHUB_ENV - name: Configuration for stage branch if: ${{ github.ref == 'refs/heads/stage' }} run: | echo "ROLE_ARN=${{ secrets.STAGE_DEVOPS_ROLE }}" >> $GITHUB_ENV echo "DEVOPS_BUCKET=STAGE-BUCKET" >> $GITHUB_ENV echo "ENV=STAGE" >> $GITHUB_ENV - name: AWS uses: aws-actions/configure-aws-credentials@master with: role-to-assume: ${{ env.ROLE_ARN }} role-session-name: samplerolesession aws-region: us-east-1 # other stuff ...

Event-based Architecture

Trigger Lambda with EventBridge

resource "aws_lambda_function" "check_foo" { ... } resource "aws_cloudwatch_event_rule" "every_five_minutes" { name = "every-five-minutes" description = "Fires every five minutes" schedule_expression = "rate(5 minutes)" } resource "aws_cloudwatch_event_target" "check_foo_every_five_minutes" { rule = aws_cloudwatch_event_rule.every_five_minutes.name target_id = "some_id" arn = aws_lambda_function.check_foo.arn } resource "aws_lambda_permission" "allow_cloudwatch_to_call_check_foo" { statement_id = "AllowExecutionFromCloudWatch" action = "lambda:InvokeFunction" function_name = aws_lambda_function.check_foo.function_name principal = "events.amazonaws.com" source_arn = aws_cloudwatch_event_rule.every_five_minutes.arn }

Read CSV files with Python

with open("file.csv", mode="r", encoding="utf-8", errors="ignore") as file: csv_reader = csv.reader(file, delimiter=",") for row in csv_reader: print(row)

Create S3 Bucket Notification w/ Lambda as Target

resource "aws_s3_bucket_notification" "bucket_notification" { bucket = aws_s3_bucket.name.id lambda_function { lambda_function_arn = aws_lambda_function.lambda_func.arn events = ["s3:ObjectCreated:*"] filter_suffix = ".csv" } } resource "aws_lambda_permission" "allow_bucket" { statement_id = "AllowExecutionFromS3Bucket" action = "lambda:InvokeFunction" function_name = aws_lambda_function.lambda_func.arn principal = "s3.amazonaws.com" source_arn = aws_s3_bucket.name.arn } resource "aws_lambda_function_event_invoke_config" "invoke_config" { function_name = aws_lambda_function.lambda_func.function_name maximum_retry_attempts = 0 depends_on = [ aws_lambda_permission.allow_bucket ] }

Batch Jobs

Create a Batch Job with EventBridge Trigger

# get default vpc data "aws_vpc" "default" { default = true } # get all the subnets data "aws_subnets" "all" { filter { name = "vpc-id" values = [data.aws_vpc.default.id] } } # role that the instance could assume resource "aws_iam_role" "ecs_instance_role" { name = "ecs_instance_role" assume_role_policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Action": "sts:AssumeRole", "Effect": "Allow", "Principal": { "Service": "ec2.amazonaws.com" } } ] } EOF } # attach a managed policy to the role resource "aws_iam_role_policy_attachment" "ecs_instance_role" { role = aws_iam_role.ecs_instance_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" } # create an instance profile from the role resource "aws_iam_instance_profile" "ecs_instance_role" { name = "ecs_instance_role" role = aws_iam_role.ecs_instance_role.name } # create role for the batch service to assume resource "aws_iam_role" "aws_batch_service_role" { name = "aws_batch_service_role" assume_role_policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Action": "sts:AssumeRole", "Effect": "Allow", "Principal": { "Service": "batch.amazonaws.com" } } ] } EOF } # attach a managed policy to the batch role resource "aws_iam_role_policy_attachment" "aws_batch_service_role" { role = aws_iam_role.aws_batch_service_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBatchServiceRole" } # ec2 instances need to have a security group resource "aws_security_group" "sg" { name = "aws_batch_compute_environment_security_group" # open up egress egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } # create batch compute environment resource "aws_batch_compute_environment" "ce" { compute_environment_name = "batch-ce" compute_resources { instance_role = aws_iam_instance_profile.ecs_instance_role.arn subnets = data.aws_subnets.all.ids instance_type = ["optimal"] max_vcpus = 3 min_vcpus = 0 bid_percentage = 100 security_group_ids = [ aws_security_group.sg.id, ] type = "SPOT" allocation_strategy = "BEST_FIT" # needs to be created first # check out https://docs.aws.amazon.com/batch/latest/userguide/spot_fleet_IAM_role.html spot_iam_fleet_role = "arn:aws:iam::690521436457:role/AmazonEC2SpotFleetTaggingRole" } type = "MANAGED" service_role = aws_iam_role.aws_batch_service_role.arn depends_on = [aws_iam_role_policy_attachment.aws_batch_service_role] } # create batch job definition resource "aws_batch_job_definition" "jd" { name = "batch-jd" type = "container" container_properties = <<CONTAINER_PROPERTIES { "image": "${aws_ecr_repository.my_awesome_repo.repository_url}:latest", "resourceRequirements": [ {"type": "VCPU", "value": "1"}, {"type": "MEMORY", "value": "1024"} ] } CONTAINER_PROPERTIES } # create ecr repo to push docker image too resource "aws_ecr_repository" "repo" { name = "my-awesome-repo" } # create batch job queue resource "aws_batch_job_queue" "queue" { name = "job-queue" state = "ENABLED" priority = 1 compute_environments = [ aws_batch_compute_environment.ce.arn, ] } # create event rule to run the batch job resource "aws_cloudwatch_event_rule" "sample" { name = "sample" description = "Sample Batch Job" schedule_expression = "rate(10 minutes)" } # create target for the event rule resource "aws_cloudwatch_event_target" "target" { rule = aws_cloudwatch_event_rule.sample.name target_id = "to-batch" arn = aws_batch_job_queue.queue.arn role_arn = aws_iam_role.eb_role.arn batch_target { job_definition = aws_batch_job_definition.jd.arn job_name = "schedule" job_attempts = 1 } depends_on = [ aws_iam_role_policy_attachment.eb_attachment ] } # create role for eventbridge to assume resource "aws_iam_role" "eb_role" { name = "eb_role" assume_role_policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Sid": "", "Effect": "Allow", "Principal": { "Service": "events.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } EOF } # create policy for event eventbridge role resource "aws_iam_policy" "policy" { name = "eb_policy" description = "eb_policy" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = [ "batch:SubmitJob", ] Effect = "Allow" Resource = "*" }, ] }) } # attach policy to role resource "aws_iam_role_policy_attachment" "eb_attachment" { role = aws_iam_role.eb_role.name policy_arn = aws_iam_policy.policy.arn }

Getting Started with Kubernetes

Kubernetes object structure

API version

The Kubernetes API version defines the structure of a primitive and uses it to validate the correctness of the data. The API version serves a similar purpose as XML schemas to a XML document or JSON schemas to a JSON document. The version usually undergoes a maturity process—e.g., from alpha to beta to final. Sometimes you see different prefixes separated by a slash (e.g., apps). You can list the API versions compatible with your cluster version by running the command kubectl api-versions.

Kind

The kind defines the type of primitive—e.g., a Pod or a Service. It ultimately answers the question, “What type of object are we dealing with here?”

Metadata

Metadata describes higher-level information about the object—e.g., its name, what namespace it lives in, or whether it defines labels and annotations. This section also defines the UID.

Spec

The specification (“spec” for short) declares the desired state—e.g., how should this object look after it has been created? Which image should run in the container, or which environment variables should be set for?

Status

The status describes the actual state of an object. The Kubernetes controllers and their reconcilliation loops constantly try to transition a Kubernetes object from the desired state into the actual state. The object has not yet been materialized if the YAML status shows the value {}.

k-obj-instruction

Using kubectl to Interact with the Kubernetes Cluster

kubectl is the primary tool to interact with the Kubernetes clusters from the command line. The CKAD exam is exclusively focused on the use of kubectl. Therefore, it’s paramount to understand its ins and outs and practice its use heavily.

In this section, I want to provide you with a brief overview of its typical usage pattern. Let’s start by looking at the syntax for running commands first. A kubectl execution consists of a command, a resource type, a resource name, and optional command line flags:

kubectl [command] [TYPE] [NAME] [flags]

The command specifies the operation you’re planning to run. Typical commands are verbs like create, get, describe, or delete. Next, you’ll need to provide the resource type you’re working on, either as a full resource type or its short form. For example, you could work on a service here, or use the short form, svc. The name of the resource identifies the user-facing object identifier, effectively the value of metadata.name in the YAML representation. Be aware that the object name is not the same as the UID. The UID is an autogenerated, Kubernetes-internal object reference that you usually don’t have to interact with. The name of an object has to be unique across all objects of the same resource type within a namespace. Finally, you can provide zero to many command line flags to describe additional configuration behavior. A typical example of a command-line flag is the --port flag, which exposes a Pod’s container port.

Imperative Approach

The imperative method for object creation does not require a manifest definition. You would use the kubectl run or kubectl create command to create an object on the fly. Any configuration needed at runtime is provided by command-line options. The benefit of this approach is the fast turnaround time without the need to wrestle with YAML structures:

kubectl run frontend --image=nginx --restart=Never --port=80

Declarative Approach

The declarative approach creates objects from a manifest file (in most cases, a YAML file) using the kubectl create or kubectl apply command. The benefit of using the declarative method is reproducibility and improved maintenance, as the file is checked into version control in most cases. The declarative approach is the recommended way to create objects in production environments:

kubectl create -f pod.yaml

Hybrid Approach

Sometimes, you may want to go with a hybrid approach. You can start by using the imperative method to produce a manifest file without actually creating an object. You do so by executing the kubectl run command with the command-line options -o yaml and --dry-run=client:

kubectl run frontend --image=nginx --restart=Never --port=80 -o yaml --dry-run=client > pod.yaml

Deleting an object

At any given time, you can delete a Kubernetes object. During the exam, the need may arise if you made a mistake while solving a problem and want to start from scratch to ensure a clean slate. In a work environment, you’ll want to delete objects that are not needed anymore. The delete command offers two options: deleting an object by providing the name or deleting an object by pointing to the YAML manifest that created it:

kubectl delete pod frontend # pod "frontend" deleted kubectl delete -f pod.yaml

Updating a live object

Finally, I want to briefly explain the apply command and the main difference to the create command. The create command instantiates a new object. Trying to execute the create command for an existing object will produce an error. The apply command is meant to update an existing object in its entirety or just incrementally. That’s why the provided YAML manifest may be a full definition of an object or a partial definition (e.g., just the number of replicas for a Deployment). Please note that the apply command behaves like the create command if the object doesn’t exist yet, however, the YAML manifest will need to contain a full definition of the object

Kubernetes Pods & Namespaces

Creating a Pod

apiVersion: v1 kind: Pod metadata: name: hazelcast labels: app: hazelcast env: prod spec: containers: - env: - name: DNS_DOMAIN value: cluster image: hazelcast/hazelcast name: hazelcast ports: - containerPort: 5701 restartPolicy: Never
kubectl create -f pod.yaml # list pods kubectl get pods # get pod kubectl get pods hazelcast # rendering pod details kubectl describe pods hazelcast # accessing logs of a pod kubectl logs hazelcast # executin command in a container kubectl exec -it hazelcast -- /bin/sh kubectl exec hazelcast -- env

Defining a Command for a Pod

apiVersion: v1 kind: Pod metadata: name: mypod spec: containers: - args: - /bin/sh - -c - while true; do date; sleep 10; done image: busybox name: mypod restartPolicy: Never

Same result with:

apiVersion: v1 kind: Pod metadata: name: mypod spec: containers: - command: ["/bin/sh"] args: ["-c", "while true; do date; sleep 10; done"] image: busybox name: mypod restartPolicy: Never

Understanding Namespaces

Namespaces are an API construct to avoid naming collisions and represent a scope for object names. A good use case for namespaces is to isolate the objects by team or responsibility. Most questions in the CKAD exam will ask you to execute the command in a specific namespace which has been set up for you. The following sections briefly touch on the basic operations needed to deal with a namespace.

The default namespace hosts object that haven’t been assigned to an explicit namespace. Namespaces starting with the prefix kube- are not considered end user-namespaces. They have been created by the Kubernetes system. You will not have to interact with them as an application developer.

Listing namespaces:

kubectl get namespaces

Creating and Using a Namespace

To create a new namespace, use the create namespace command. The following command uses the name code-red:

kubectl create namespace code-red kubectl get namespace code-red

The corresponding representation as a YAML manifest would look as follows:

apiVersion: v1 kind: Namespace metadata: name: code-red

Once the namespace is in place, you can create objects within it. You can do so with the command line option --namespace or its short-form -n. The following commands create a new Pod in the namespace code-red and then lists the available Pods in the namespace

kubectl run pod --image=nginx --restart=Never -n code-red kubectl get pods -n code-red

Deleting a Namespace

Deleting a namespace has a cascading effect on the object existing in it. Deleting a namespace will automatically delete its objects:

kubectl delete namespace code-red kubectl get pods -n code-red

Kubernetes Configs

Creating a ConfigMap

You can create a ConfigMap imperatively with a single command: kubectl create configmap. As part of the command, you have to provide a mandatory command-line flag that points to the source of the data. Kubernetes distinguishes four different options:

  • Literal values, which are key-value pairs as plain text.
  • A file that contains key-value pairs and expects them to be environment variables.
  • A file with arbitrary contents.
  • A directory with one or many files.

The following commands show all options in action. You will find that a file and directory use the same command-line option, --from-file. Later, we’ll revisit how those key-value pairs are parsed and stored in a ConfigMap.

Literal values

kubectl create configmap db-config --from-literal=db=staging

Single file with environment variables

configmap/db-config created

ConfigMap YAML file manifest:

apiVersion: v1 kind: ConfigMap metadata: name: backend-config data: database_url: jdbc:postgresql://localhost/test user: fred

Consuming a ConfigMap as Environment Variables

apiVersion: v1 kind: Pod metadata: name: configured-pod spec: containers: - image: nginx:1.19.0 name: app envFrom: - configMapRef: name: backend-config

Sometimes, key-value pairs do not conform to typical naming conventions for environment variables or can’t be changed without impacting running services. You can redefine the keys used to inject an environment variable into a Pod with the valueFrom attribute.

apiVersion: v1 kind: Pod metadata: name: configured-pod spec: containers: - image: nginx:1.19.0 name: app env: - name: DATABASE_URL valueFrom: configMapKeyRef: name: backend-config key: database_url - name: USERNAME valueFrom: configMapKeyRef: name: backend-config key: user

Mounting a ConfigMap as Volume

Most programming languages can resolve and use environment variables to control the runtime behavior of an application. Especially when dealing with a long list of configuration data, it might be preferable to access the key-value pairs from the filesystem of the container.

apiVersion: v1 kind: Pod metadata: name: configured-pod spec: containers: - image: nginx:1.19.0 name: app volumeMounts: - name: config-volume mountPath: /etc/config volumes: - name: config-volume configMap: name: backend-config

Creating a Secret

You can create a Secret imperatively with a single command: kubectl create secret. Similar to the command for creating a ConfigMap, you will have to provide an additional subcommand and a configuration option. It’s mandatory to spell out the subcommand right after the Kubernetes resource type secret.

In most cases, you will likely deal with the type generic, which provides the same command-line options to point to the source of the configuration data as kubectl create configmap:

  • Literal values, which are key-value pairs as plain text.
  • A file that contains key-value pairs and expects them to be environment variables.
  • A file with arbitrary contents.
  • A directory with one or many files.
kubectl create secret generic db-creds --from-literal=pwd=s3cre!

Of course, you can always take the declarative route, but there’s a little catch. You have to Base64-encode the configuration data value yourself when using the type Opaque. How can you do so? One way to encode and decode a value is the Unix command-line tool base64.

Declarative way:

apiVersion: v1 kind: Secret metadata: name: db-creds type: Opaque data: pwd: czNjcmUh

Consuming a Secret as Environment Variables

Consuming the key-value pairs of a Secret as environment variables from a container works almost exactly the same way as it does for a ConfigMap. There’s only one difference: instead of using envFrom.configMapRef, you’d use envFrom.secretRef:

apiVersion: v1 kind: Pod metadata: name: configured-pod spec: containers: - image: nginx:1.19.0 name: app envFrom: - secretRef: name: db-creds

It’s important to understand that the container will make the environment variable available in a Base64-decoded value. In turn, your application running in the container will not have to implement Base64-decoding logic.

Creating a ResourceQuota

The Kubernetes primitive ResourceQuota establishes the usable, maximum amount of resources per namespace. Once put in place, the Kubernetes scheduler will take care of enforcing those rules.

Creating a ResourceQuota object is usually a task a Kubernetes administrator would take on, but it’s relatively easy to define and create such an object. First, create the namespace the quota should apply to:

kubectl create namespace team-awesome kubectl get namespace
apiVersion: v1 kind: ResourceQuota metadata: name: awesome-quota spec: hard: pods: 2 requests.cpu: "1" requests.memory: 1024m limits.cpu: "4" limits.memory: 4096m
kubectl create -f awesome-quota.yaml --namespace=team-awesome kubectl describe resourcequota awesome-quota --namespace=team-awesome

Test Pod Manifest:

apiVersion: v1 kind: Pod metadata: name: nginx spec: containers: - image: nginx:1.18.0 name: nginx resources: requests: cpu: "0.5" memory: "512m" limits: cpu: "1" memory: "1024m"

Kubernetes Labels & Deployments

Understanding Labels

Kubernetes lets you assign key-value pairs to objects so that you can use them later within a search query. Those key-value pairs are called labels. To draw an analogy, you can think of labels as tags for a blog post. A label describes a Kubernetes object in distinct terms (e.g., a category like “frontend” or “backend”) but is not meant for elaborate, multi-word descriptions of its functionality. As part of the specification, Kubernetes limits the length of a label to a maximum of 63 characters and a range of allowed alphanumeric and separator characters.

labels

It’s common practice to assign one or many labels to an object at creation time; however, you can modify them as needed for a live object. When confronted with labels for the first time, they might seem like an insignificant feature—but their importance cannot be overstated. They’re essential for understanding the runtime behavior of more advanced Kubernetes objects like a Deployment and a Service.

Declaring Labels

Labels can be declared imperatively with the run command or declaratively in the metadata.labels section in the YAML manifest. The command-line option --labels (or -l in its short form) defines a comma-separated list of labels when creating a Pod. The following command creates a new Pod with two labels from the command line:

kubectl run labeled-pod --image=nginx --restart=Never --labels=tier=backend,env=dev

Assigning labels to Kubernetes objects by editing the manifest requires a change to the metadata section.

apiVersion: v1 kind: Pod metadata: name: labeled-pod labels: env: dev tier: backend spec: containers: - image: nginx name: nginx

Inspecting Labels

You can inspect the labels assigned to a Kubernetes object from different angles. Here, we’ll want to look at the most common ways to identify the labels of a Pod. As with any other runtime information, you can use the describe or get commands to retrieve the labels:

kubectl describe pod labeled-pod | grep -C 2 Labels: kubectl get pod labeled-pod -o yaml | grep -C 1 labels:

If you want to list the labels for all object types or a specific object type, use the --show-labels command-line option. This option is convenient if you need to sift through a longer list of objects. The output automatically adds a new column named LABELS:

kubectl get pods --show-labels NAME READY STATUS RESTARTS AGE LABELS labeled-pod 1/1 Running 0 38m env=dev,tier=backend

Understanding Deployments

At the beginning, we discussed labels and label selection from all angles. We did so for a good reason. A Deployment is one of the Kubernetes primitives that uses labels as a foundational concept. If you didn’t have a chance to fully brush up on labels, I’d urge you to review the content.

Running an application inside of a Pod is powerful, but scalability and reliability can become a problem. Say the load on your application increases during peak times or because of a growing user base, and you still have only one Pod instance that can serve up the application’s functionality. With increased traffic comes higher resource consumption. Depending on the resource requirements of the container, the application may come to a grinding halt.

That’s where a Deployment comes in. Within a Deployment, you can specify the number of Pods running your application with the exact same setup. Need to scale up? Simply bump up the number of replicas, and Kubernetes will take care of creating the Pods. Furthermore, the Deployment ensures that failing Pods are restarted so that the actual state matches with the desired state. Under the hood, a Deployment uses the Kubernetes primitive ReplicaSet, as shown below.

The ReplicaSet’s sole purpose is to replicate a guaranteed number of Pods with the same configuration.

replica-set

Creating Deployments

Deployments can be created imperatively with the create deployment command. The options you can provide to configure the Deployment are somewhat limited and do not resemble the ones you know from the run command. The following command creates a new Deployment that uses the image nginx:1.14.2 for a single replica:

kubectl create deployment my-deploy --image=nginx:1.14.2

Often, you will find yourself generating and further modifying the YAML manifest. The following manifest creates a Deployment with a single replica. If you look closely, you will see label selection in action. The selector spec.selector.matchLabels matches on the key-value pair app=my-deploy with the label defined under the template section, as shown below:

apiVersion: apps/v1 kind: Deployment metadata: name: my-deploy labels: app: my-deploy spec: replicas: 1 selector: matchLabels: app: my-deploy template: metadata: labels: app: my-deploy spec: containers: - name: nginx image: nginx:1.14.2

Listing Deployments

Once created, a Deployment and all of its corresponding objects can be listed. The following get command lists all Deployments, Pods, and ReplicaSets. If a Pod or ReplicaSet is managed by a Deployment, the name of the object will reflect that connection. For the Deployment named my-deploy, you will find at least one Pod and one ReplicaSet with the prefix my-deploy- plus a random hash:

kubectl get deployments,pods,replicasets

Rendering Deployment Details

You can inspect the details of a Deployment using the describe command. Not only does the output provide information on the number and availability of replicas, it also presents you with the reference to the ReplicaSet. Inspecting the ReplicaSet or the replicated Pods renders references to the parent object managing it:

kubectl describe deployment.apps/my-deploy kubectl describe replicaset.apps/my-deploy-8448c488b5 kubectl describe pod/my-deploy-8448c488b5-mzx5g

Rolling Out a New Revision

Application development is usually not stagnant. As part of the software development lifecycle, you build a new feature or create a bug fix and deploy the changes to the Kubernetes cluster as part of the release process. In practice, you’d push a new Docker image to the registry bundling the changes so that they can be run in a container. By default, a Deployment rolls out a new container image using a zero-downtime strategy by updating Pods one by one. The following figure shows the rolling update process for a Deployment controlling two replicas from version 1.2.3 to 2.0.0.

rollout

Every Deployment keeps a record of the rollout history. Within the history, a new version of a rollout is called a revision. Before experiencing the rollout of a new revision in practice, let’s inspect the initial state of the Deployment named my-deploy. The rollout command shows revision 1, which represents the creation of the Deployment with all its settings:

# should show only one revision kubectl rollout history deployment my-deploy

In the next step, we will update the container image used on the Deployment from nginx:1.14.2 to nginx:1.19.2. To do so, either edit the live object or run the set image command:

kubectl set image deployment my-deploy nginx=nginx:1.19.2

Looking at the rollout history again now shows revision 1 and 2. When changing the Pod template of a Deployment—for example, by updating the image—a new ReplicaSet is created. The Deployment will gradually migrate the Pods from the old ReplicaSet to the new one. Inspecting the Deployment details reveals a different name—in this case, my-deploy-775ccfcbc8:

$ kubectl rollout history deployment my-deploy deployment.apps/my-deploy REVISION CHANGE-CAUSE 1 <none> 2 <none> $ kubectl describe deployment.apps/my-deploy ... NewReplicaSet: my-deploy-775ccfcbc8 (1/1 replicas created) ... $ kubectl rollout status deployment my-deploy deployment "my-deploy" successfully rolled out

NOTE

By default, a Deployment persists a maximum of 10 revisions in its history. You can change the limit by assigning a different value to spec.revisionHistoryLimit.

You can also retrieve detailed information about a revision with the rollout history command by providing the revision number using the --revision command-line option. The details of a revision can give you an indication of what exactly changed between revisions:

$ kubectl rollout history deployments my-deploy --revision=2 deployment.apps/my-deploy with revision #2 Pod Template: Labels: app=my-deploy pod-template-hash=9df7d9c6 Containers: nginx: Image: nginx:1.19.2 Port: <none> Host Port: <none> Environment: <none> Mounts: <none> Volumes: <none>

The rolling update strategy ensures that the application is always available to end users. This approach implies that two versions of the same application are available during the update process. As an application developer, you have to be aware that convenience doesn’t come without potential side effects. If you happen to introduce a breaking change to the public API of your application, you might temporarily break consumers, as they could hit revision 1 or 2 of the application. You can change the default update strategy of a Deployment by providing a different value to the attribute strategy.type; however, consider the trade-offs. For example, the value Recreate kills all Pods first, then creates new Pods with the latest revision, causing a potential downtime for consumers.

Rolling Back to a Previous Revision

Despite the best efforts to avoid them by writing extensive test suites, bugs happen. Not only can the rollout command deploy a new version of an application, you can also roll back to an earlier revision. In the previous section, we rolled out revisions 1 and 2. Assume revision 2 contains a bug and we need to quickly revert to revision 1. The following command demonstrates the process:

kubectl rollout undo deployment my-deploy --to-revision=1

If you look at the rollout history, you’ll find revisions 2 and 3. Kubernetes recognizes that revisions 1 and 3 are exactly the same. For that reason, the rollout history deduplicates revision 1 effectively; revision 1 became revision 3:

kubectl rollout history deployment my-deploy

The rollback process works pretty much the same way as rolling out a new revision. Kubernetes switches back to the “old” ReplicaSet, drains the Pods with the image nginx:1.19.2, and starts new Pods with the image nginx:1.14.2.

Manually Scaling a Deployment

The scaling process is completely abstracted from the end user. You just have to tell the Deployment that you want to scale to a specified number of replicas. Kubernetes will take care of the rest.

Say we wanted to scale from one replica to five replicas, as shown below:

scale

We have two options: using the scale command or changing the value of the replicas attribute for the live object. The following command show the effect of scaling up a Deployment:

kubectl scale deployment my-deploy --replicas=5

A Deployment records scaling activities in its events, which we can view using the describe deployment command:

kubectl describe deployment.apps/my-deploy

Horizontal Pod Autoscaler

The Horizontal Pod Autoscaler doesn't come pre-installed with Docker Desktop. Follow this tutorial on how to install it.

A Deployment can be autoscaled using the autoscale deployment command. Provide the name and the thresholds you’d like the autoscaler to act upon. In the following example, we’re specifying a minimum of 2 replicas at any given time, a maximum number of 8 replicas the HPA can scale up to, and the CPU utilization threshold of 70%. Listing the HPAs in the namespace reflects those numbers. You can use the primitive name horizontalpodautoscalers for the command; however, I prefer the short-form notation hpa:

kubectl autoscale deployment my-deploy --cpu-percent=70 --min=2 --max=8

The current status of the HPA shows the upper CPU threshold limit but renders <unknown> for the current CPU consumption. That’s usually the case if the metrics server is not running, is misconfigured, or if the Pod template of the Deployment doesn’t define any resource requirements. Check the events of the HPA using the command kubectl describe hpa my-deploy.

Here's the YAML manifest for an HPA:

apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: autoscaler spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: nginx-deployment minReplicas: 1 maxReplicas: 5 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 50

Summary

Labels are one of the central concepts that control the runtime behavior of more advanced Kubernetes objects. For example, in the context of a Deployment, label selection is used to select the Pods the Deployment manages. You can use labels to select objects based on a query from the command line or within a manifest if supported by the primitive’s API. Annotations serve a different purpose—they are only meant for providing human-readable, informative metadata and can be used for querying objects.

The Deployment is an essential primitive for scaling an application by running it in multiple replicas. The heavy lifting of managing those replicas is performed by a ReplicaSet. Application developers do not have to interact directly with the ReplicaSet; a Deployment handles it under the hood. Deployments come with the capability to easily roll out and roll back revisions of the application represented by an image running in the container. You learned about the commands for controlling the revision history and its operations. Scaling a Deployment manually requires deep insight into the requirements and the load of an application. A Horizontal Pod Autoscaler can automatically scale the number of replicas based on CPU and memory thresholds observed at runtime.

Kubernetes Jobs

Understanding Jobs

A Pod is meant for the continuous operation of an application. You will want to deploy the application with a specific version and then keep it running without interrupts if possible.

A Job is a Kubernetes primitive with a different goal. It runs functionality until a specified number of completions has been reached, making it a good fit for one-time operations like import/export data processes or I/O-intensive processes with a finite end. The actual work managed by a Job is still running inside of a Pod. Therefore, you can think of a Job as a higher-level coordination instance for Pods executing the workload.

Upon completion of a Job and its Pods, Kubernetes does not automatically delete the objects—they will stay until they’re explicity deleted. Keeping those objects helps with debugging the command run inside of the Pod and gives you a chance to inspect the logs.

Kubernetes supports an auto-cleanup mechanism for Jobs and their controlled Pods by specifying the attribute spec.ttlSecondsAfterFinished.

apiVersion: batch/v1 kind: Job metadata: name: counter spec: template: spec: containers: - name: counter image: nginx command: - /bin/sh - -c - counter=0; while [ $counter -lt 3 ]; do counter=$((counter+1)); \ echo "$counter"; sleep 3; done; restartPolicy: Never

Job Operation Types

The default behavior of a Job is to run the workload in a single Pod and expect one successful completion. That’s what Kubernetes calls a non-parallel Job. Internally, those parameters are tracked by the attributes spec.template.spec.completions and spec.template.spec.parallelism, each with the assigned value 1.

You can tweak any of those parameters to fit the needs of your use case. Say you’d expect the workload to complete successfully multiple times; then you’d increase the value of spec.template.spec.completions to at least 2. Sometimes, you’ll want to execute the workload by multiple pods in parallel. In those cases, you’d bump up the value assigned to spec.template.spec.parallelism. This is referred to as a parallel job.

Restart Behavior

The spec.backoffLimit attribute determines the number of retries a Job attempts to successfully complete the workload until the executed command finishes with an exit code 0. The default is 6, which means it will execute the workload 6 times before the Job is considered unsuccessful.

The Job manifest needs to explicitly declare the restart policy by using spec.template.spec.restartPolicy. The default restart policy of a Pod is Always, which tells the Kubernetes scheduler to always restart the Pod even if the container exits with a zero exit code. The restart policy of a Job can only be OnFailure or Never.

Restarting the Container on Failure

The following figure shows the behavior of a Job that uses the restart policy OnFailure. Upon a container failure, this policy will simply rerun the container.

restart

Starting a New Pod on Failure

The following figure shows the behavior of a Job that uses the restart policy Never. This policy does not restart the container upon a failure. It starts a new Pod instead.

never-restart

Understanding CronJobs

A Job represents a finite operation. Once the operation could be executed successfully, the work is done and the Job will create no more Pods. A CronJob is essentially a Job, but it’s run periodically based a schedule; however, it will continue to create a new Pod when it’s time to run the task. The schedule can be defined with a cron-expression you may already know from Unix cron jobs. The following figure shows a CronJob that executes every hour. For every execution, the CronJob creates a new Pod that runs the task and finishes with a zero or non-zero exit code.

cron

Creating and Inspecting CronJobs

apiVersion: batch/v1beta1 kind: CronJob metadata: name: current-date spec: schedule: "* * * * *" jobTemplate: spec: template: spec: containers: - name: current-date image: nginx args: - /bin/sh - -c - 'echo "Current date: $(date)"' restartPolicy: OnFailure

If you list the existing CronJob with the get cronjobs command, you will see the schedule, the last scheduled execution, and whether the CronJob is currently active. It’s easy to match Pods managed by a CronJob. You can simply identify them by the name prefix. In this case, the prefix is current-date-:

kubectl get cronjobs kubectl get pods

Summary

Jobs are well suited for implementing batch processes run in one or many Pods as a finite operation. Both objects, the Job and the Pod, will not be deleted after the work is completed in order to support inspection and troubleshooting activities. A CronJob is very similar to a Job, but executes on a schedule, defined as a Unix cron expression.

Kubernetes Services

Understanding Services

Services are one of the central concepts in Kubernetes. Without a Service, you won’t be able to expose your application to consumers in a stable and predictable fashion. In a nutshell, Services provide discoverable names and load balancing to Pod replicas. The services and Pods remain agnostic from IPs with the help of the Kubernetes DNS control plane component. Similar to a Deployment, the Service determines the Pods it works on with the help of label selection.

The following figure shows functionality. Pod 1 and Pod 2 receive traffic, as their assigned labels match with the label selection defined in the Service. Pod 3 does not receive traffic, as it defines non-matching labels. Note that it is possible to create a Service without a label selector for less-common scenarios.

services

Service Types

Every Service needs to define a type. The type determines how the Service exposes the matching Pods, as listed in the following table.

TypeDescription
ClusterIPExposes the Service on a cluster-internal IP. Only reachable from within the cluster.
NodePortExposes the Service on each node’s IP address at a static port. Accessible from outside of the cluster.
LoadBalancerExposes the Service externally using a cloud provider’s load balancer.

Creating Services

As usual, we’ll look at creating a Service from both the imperative and declarative approach angles. In fact, there are two ways to create a Service imperatively.

The command create service instantiates a new Service. You have to provide the type of the Service as the third, mandatory command-line option. That’s also the case for the default type, ClusterIP. In addition, you can optionally provide the port mapping, which we’ll discuss a little later.

kubectl create service clusterip nginx-service --tcp=80:80

Instead of creating a Service as a standalone object, you can also expose a Pod or Deployment with a single command. The run command provides an optional --expose command-line option, which creates a new Pod and a corresponding Service with the correct label selection in place:

kubectl run nginx --image=nginx --restart=Never --port=80 --expose

For an existing Deployment, you can expose the underlying Pods with a Service using the expose deployment command:

kubectl expose deployment my-deploy --port=80 --target-port=80

Using the declarative approach:

apiVersion: v1 kind: Service metadata: name: nginx-service spec: type: ClusterIP selector: app: nginx-service ports: - port: 80 targetPort: 80

Port Mapping

We briefly touched on the topic of port mapping. The correct port mapping determines if the incoming traffic actually reaches the application running inside of the Pods that match the label selection criteria of the Service. A Service always defines two different ports: the incoming port accepting traffic and the outgoing port, also called the target port. Their functionality is best illustrated by example.

The following figure shows a Service that accepts incoming traffic on port 3000. That’s the port defined by the attribute ports.port in the manifest. Any incoming traffic is then routed toward the target port, represented by ports.targetPort. The target port is the same port as defined by the container running inside of the label-selected Pod. In this case, that’s port 80.

port-mapping

Accessing a Service with Type ClusterIP

ClusterIP is the default type of Service. It exposes the Service on a cluster-internal IP address. The following figure shows how to reach a Pod exposed by the ClusterIP type from another Pod from within the cluster. You can also create a proxy from outside of the cluster using the kubectl proxy command.

cluster-ip

The proxy command can establish a direct connection to the Kubernetes API server from your localhost. With the following command, we are opening port 9999 on which to run the proxy:

kubectl proxy --port=9999

After running the command, you will notice that the shell is going to wait until you break out of the operation. To try talking to the proxy, you will have to open another terminal window. Say you have the curl command-line tool installed on your machine to make a call to an endpoint of the API server. The following example uses localhost:9999—that’s the proxy entry point. As part of the URL, you’re providing the endpoint to the Service named nginx running in the default namespace:

curl -L localhost:9999/api/v1/namespaces/default/services/nginx/proxy

Accessing a Service with Type NodePort

Declaring a Service with type NodePort exposes access through the node’s IP address and can be resolved from outside of the Kubernetes cluster. The node’s IP address can be reached in combination with a port number in the range of 30000 and 32767, assigned automatically upon the creation of the Service. The following figure shows the routing of traffic to Pods via a NodePort-typed Service.

node-port

We’ll change the existing Service named nginx to use the type NodePort instead of ClusterIP:

kubectl edit service nginx curl localhost:<port-number>

Deployments and Services

A Deployment manages Pods and their replication. A Service routes network requests to a set of Pods. Both primitives use label selection to connect with an associated set of Pods.

deployment-and-services

Assignment 01

Part 01

Assume an application stack that defines three different layers: a frontend, a backend, and a database. Each of the layers runs in a Pod. You can find the definition in the YAML file app.yaml:

kind: Pod apiVersion: v1 metadata: name: frontend namespace: app-stack labels: app: todo tier: frontend spec: containers: - name: frontend image: nginx --- kind: Pod apiVersion: v1 metadata: name: backend namespace: app-stack labels: app: todo tier: backend spec: containers: - name: backend image: nginx --- kind: Pod apiVersion: v1 metadata: name: database namespace: app-stack labels: app: todo tier: database spec: containers: - name: database image: mysql env: - name: MYSQL_ROOT_PASSWORD value: example

Create the namespace and the Pods using the file app.yaml.

Part 02

Create a network policy in the file app-network-policy.yaml. The network policy should allow incoming traffic from the backend to the database but disallow incoming traffic from the frontend.

Part 03

Reconfigure the network policy to only allow incoming traffic to the database on TCP port 3306 and no other port.

Assignment 02

Part 01

Create a Pod YAML file with two containers that use the image alpine:3.12.0. Provide a command for both containers that keep them running forever.

Part 02

Define a Volume of type emptyDir for the Pod. Container 1 should mount the Volume to path /etc/a, and container 2 should mount the Volume to path /etc/b.

Part 03

Open an interactive shell for container 1 and create the directory data in the mount path. Navigate to the directory and create the file hello.txt with the contents “Hello World.” Exit out of the container.

Part 04

Open an interactive shell for container 2 and navigate to the directory /etc/b/data. Inspect the contents of file hello.txt. Exit out of the container.

Part 05

Create a PersistentVolume named logs-pv that maps to the hostPath /var/logs. The access mode should be ReadWriteOnce and ReadOnlyMany.

Final

In this assignment, you will create a full stack application with React and AWS that generates obituaries for people (fictional or otherwise). You will use ChatGPT to generate an obituary, Amazon Polly to turn the obituary into speech, and Cloudinary to store the speech and a picture of the deceased (may they rest in peace).

Architecture Overview


the-last-show-architecture


Steps

  • Make sure to see the demo video on D2L
  • Clone the repo
  • Make sure you're inside the root directory of the repo and then run npm install to install all the necessary packages
  • Run npm start and you should be able to see the page open up on your default browser
  • Add your infrastructure code in the main.tf file
  • Add your function code for the get-obituaries function in the functions/get-obituaries/main.py file
  • Add your function code for the create-obituary function in the functions/create-obituary/main.py file
  • Push your changes to the main branch before the deadline to be graded
  • This assignment has a DEMO component that's 50% of the grade

Notes

  • You must create all your resources on AWS with Terraform. Put all your configuration in the main.tf file

  • You must use AWS DynamoDB for the database

  • You must use Lambda Function URLs for this assignment to connect your backend to the frontend

  • You must create 2 Lambda functions for this assignment:

    • get-obituaries: to retrieve all the obituaries. Function URL only allows GET requests
    • create-obituary: to create a new obituary. The function reads all the data (including the picture) from the body of the request. Function URL only allows POST requests
  • You must use Python to write the functions

  • The only external libraries allowed to be used in the functions are requests for sending HTTPS requests to ChatGPT and Cloudinary, and requests-toolbelt for decoding the body of the request received from the front-end. No other external libraries are allowed

  • You must use the Cloudinary Upload API and not the SDK to interact with Cloudinary. You will need to generate a signature for your requests. See how you can do it here

  • You must use the ChatGPT API and not the SDK to interact with ChatGPT

  • To interact with Cloudinary and ChatGPT, you need keys. You must use AWS Systems Manager Parameter Store and the SecureString data type to store the keys for your Cloudinary and ChatGPT accounts. The create-obituary Lambda function will read the keys from the Parameter Store using the boto3 library. The keys must not be present in your application or infra code in the repo. You can create these keys using the AWS CLI, or manually on the AWS Console. Read more about the AWS Systems Manager API here. Depending on your solution, you need one of these two methods: get_parameters or get_parameters_by_path. The Demo uses the last one

  • You must use Amazon Polly to turn the obituary written by ChatGPT to speech and then upload the mp3 version of that to Cloudinary. Read more about the Polly API here. What you need is the synthesize_speech method

  • The Demo uses the text-curie-001 model from ChatGPT. Feel free to use another model. Be careful about the cost of the model you choose!

  • The Demo sets the max_tokens to 600. However, you could change this number as long as it still works properly. You don't have to go over 600. Bigger numbers are more expensive!

  • The Demo uses the Completions API and not the Chat API, as the application doesn't need to have a continuous discussion with the model. This makes the application less expensive. However, feel free to use the Chat API instead

  • The Demo uses the following prompt to interact with the model: write an obituary about a fictional character named {name} who was born on {born_year} and died on {died_year}. You may use a different prompt as long as it still works

  • The Demo uses FlexBox for the layout. However, feel free to use another approach or framework

  • The Demo uses Joanna as the voice id for the speech. Feel free to use another voice id

  • The Demo uses the e_art:zorro enhancement from Cloudinary to create black edges for the picture. Read more here. You only need to add e_art:zorro in a certain part of the image URL

  • You probably need to set a bigger timeout for your Lambda function, as it takes ChatGPT a few seconds to generate an obituary. The default timeout is 3 seconds. You can set the timeout to 20

  • The Demo uses a font from the Google Fonts library named Satisfy to show the obituaries on the front-end,

  • The Demo doesn't use any external library for the front-end

  • In order to get the full mark, you need to DEMO your work in PERSON. You could either demo it to a TA or the instructor. Demo is 50% of the assignment

Things you may change

  • You may use a CSS framework to build the UI. The Demo only uses FlexBox
  • You may use a different voice id with Amazon Polly. The Demo uses Joanna
  • You may use a different model for your ChatGPT integration. The Demo uses text-curie-001
  • You may use a different prompt for the model as long as the final result is the same. The Demo uses write an obituary about a fictional character named {name} who was born on {born_year} and died on {died_year}.
  • You may use a different font. The Demo uses Satisfy

Cost

  • This assignment won't cost you anything if you follow the best practices discussed in the class
  • Use the PROVISIONED billing mode, and only 1 RCU and WCU for your DynamoDB table as shown in the lecture notes
  • Don't use a memory configuration bigger than 128MB for your Lambdas. Use the lecture notes to create Lambda functions. When you don't specify the memory_size field, it will default to 128MB which is the minimum amount
  • Don't send a huge number of requests to your Lambdas using automated scripts. You have 1 Million free requests, and up to 3.2 million seconds of free compute time under the Free Tier
  • Remember to enable Free Tier notifications on your account before building anything as shown in the videos on D2L
  • If you go over the Free Tier (you would have to screw it up really bad, honestly), you can create a new Free AWS account and build your infrastructure there instead. Thankfully, Terraform makes that process super easy for you
  • Cloudinary has a generous free tier. If you don't send thousands of requests for large files, you won't have to pay anything. If you're over the free tier, you could create a new free account (will give you new keys)
  • ChatGPT has a free tier. If you're careful, you won't have to pay anything. If you're over the free tier, you could create a new free account (will give you new API Key)

Bonus Marks

  • You can grab 10 extra points if you orchestrate different steps of the create-obituary Lambda function with AWS Step Functions. You would have to orchestrate 4 Lambda functions:
    • generate-obituary that uses ChatGPT
    • read-obituary that uses Amazon Polly
    • store-files that uses Cloudinary to store both the picture and speech
    • save-item that uses DynamoDB to store a new item
  • You must create all the infra using Terraform
  • Step Functions won't send you a response right away, but the app still needs to find out when the workflow is finished and add a new obituary to the front-end
  • No partial implementation is considered. If the implementation doesn't work properly from beginning to end, there will be no bonus marks for the assignment