Auto-Scaling Nodes On Amazon Web Services

Docker Scaler includes endpoints to scale nodes on AWS. In this tutorial, we will construct a system that will scale up worker nodes based on memory usage. This tutorial uses AWS CLI to communicate with AWS and jq to parse json responses from the CLI.

Info

If you are a Windows user, please run all the examples from Git Bash (installed through Docker for Windows). Also, make sure that your Git client is configured to check out the code AS-IS. Otherwise, Windows might change carriage returns to the Windows format.

Setting up Current Environment

We will be using Slack webhooks to notify us. First, we create a Slack workspace and setup a webhook by consulting Slack's Incoming Webhook page. After obtaining a webhook URL set it as an environment variable:

export SLACK_WEBHOOK_URL=[...]

The AWS CLI is configured by setting the following environment variables:

export AWS_ACCESS_KEY_ID=[...]
export AWS_SECRET_ACCESS_KEY=[...]
export AWS_DEFAULT_REGION=us-east-1

The IAM Policies required for this tutorial are cloudformation:*, sqs:*, iam:*, ec2:*, lambda:*, dynamodb:*, "autoscaling:*, and elasticfilesystem:*.

For convenience, we define the STACK_NAME to be the name of our AWS stack, KEY_FILE to be the path to the ssh AWS identity file, and KEY_NAME as the key's name on AWS.

export STACK_NAME=devops22
export KEY_FILE=devops22.pem # Location of pem file
export KEY_NAME=devops22

Setting Up An AWS Cluster

Using AWS Cloudformation, we will create a cluster of three master ndoes:

aws cloudformation create-stack \
    --template-url https://editions-us-east-1.s3.amazonaws.com/aws/stable/Docker.tmpl \
    --capabilities CAPABILITY_IAM \
    --stack-name $STACK_NAME \
    --parameters \
    ParameterKey=ManagerSize,ParameterValue=3 \
    ParameterKey=ClusterSize,ParameterValue=0 \
    ParameterKey=KeyName,ParameterValue=$KEY_NAME \
    ParameterKey=EnableSystemPrune,ParameterValue=yes \
    ParameterKey=EnableCloudWatchLogs,ParameterValue=no \
    ParameterKey=EnableCloudStorEfs,ParameterValue=yes \
    ParameterKey=ManagerInstanceType,ParameterValue=t2.micro \
    ParameterKey=InstanceType,ParameterValue=t2.micro

We can check if the cluster came online by running:

aws cloudformation describe-stacks \
    --stack-name $STACK_NAME | \
    jq -r ".Stacks[0].StackStatus"

Please wait till the output of this command is CREATE_COMPLETE before continuing.

Setting up the AWS Environment

We need to log into a manager node to issue Docker commands and interact with our Docker swarm. To setup the manager shell environmental, we will define variables in our current shell and transfer them to the manager node.

We save the cluster dns to an environment variable CLUSTER_DNS:

CLUSTER_DNS=$(aws cloudformation \
    describe-stacks \
    --stack-name $STACK_NAME | \
    jq -r ".Stacks[0].Outputs[] | \
    select(.OutputKey==\"DefaultDNSTarget\")\
    .OutputValue")

We set the environment variable CLUSTER_IP to the public ip of one of the manager nodes:

CLUSTER_IP=$(aws ec2 describe-instances \
    | jq -r ".Reservations[] \
    .Instances[] \
    | select(.SecurityGroups[].GroupName \
    | contains(\"$STACK_NAME-ManagerVpcSG\"))\
    .PublicIpAddress" \
    | tail -n 1)

We save the the manager and worker autoscaling group names:

WORKER_ASG=$(aws autoscaling \
    describe-auto-scaling-groups \
    | jq -r ".AutoScalingGroups[] \
    | select(.AutoScalingGroupName \
    | startswith(\"$STACK_NAME-NodeAsg-\"))\
    .AutoScalingGroupName")

MANAGER_ASG=$(aws autoscaling \
    describe-auto-scaling-groups \
    | jq -r ".AutoScalingGroups[] \
    | select(.AutoScalingGroupName \
    | startswith(\"$STACK_NAME-ManagerAsg-\"))\
    .AutoScalingGroupName")

We clone the Docker Scaler repo and transfer the stacks folder

git clone https://github.com/thomasjpfan/docker-scaler.git

scp -i $KEY_FILE -rp docker-scaler/stacks docker@$CLUSTER_IP:~

Using ssh, we can transfer the environment variables into a file on the manager node:

echo "
export CLUSTER_DNS=$CLUSTER_DNS
export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION
export WORKER_ASG=$WORKER_ASG
export MANAGER_ASG=$MANAGER_ASG
export SLACK_WEBHOOK_URL=$SLACK_WEBHOOK_URL
" | ssh -i $KEY_FILE docker@$CLUSTER_IP "cat > env"

Finally, we can log into the manager node and source the environment variables:

ssh -i $KEY_FILE docker@$CLUSTER_IP

source env

Deploying Docker Flow Proxy (DFP) and Docker Flow Swarm Listener (DFSL)

For convenience, we will use Docker Flow Proxy and Docker Flow Swarm Listener to get a single access point to the cluster.

echo "admin:admin" | docker secret \
    create dfp_users_admin -

docker network create -d overlay proxy

docker stack deploy \
    -c stacks/docker-flow-proxy-aws.yml \
    proxy

Please visit proxy.dockerflow.com and swarmlistener.dockerflow.com for details on the Docker Flow stack.

Deploying Docker Scaler

To allow Docker Scaler to access AWS, the credientials are stored in a Docker secret:

echo "
export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
" | docker secret create aws -

We can now deploy the Docker Scaler stack:

docker network create -d overlay scaler

docker stack deploy \
    -c stacks/docker-scaler-aws-tutorial.yml \
    scaler

This stack defines a single Docker Scaler service. Focusing on the environment variables set by the compose file:

...
  services:
    scaler:
      image: thomasjpfan/scaler
      environment:
        - ALERTMANAGER_ADDRESS=http://alert-manager:9093
        - NODE_SCALER_BACKEND=aws
        - AWS_MANAGER_ASG=${MANAGER_ASG}
        - AWS_WORKER_ASG=${WORKER_ASG}
        - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION}
        - SERVER_PREFIX=/scaler
      labels:
        - com.df.notify=true
        - com.df.distribute=true
        - com.df.servicePath=/scaler
        - com.df.port=8080
      secrets:
        - aws
...

The NODE_SCALER_BACKEND must be set to aws to configure Docker Scaler to scale nodes on AWS. The label com.df.servicePath=/scaler and environment variable SERVER_PREFIX opens up the scaler service to public REST calls. For this tutorial, we open this path to explore manually scaling nodes.

Deploying Docker Flow Monitor and Alertmanager

The next stack defines the Docker Flow Monitor and Alertmanager services. Before we deploy the stack, we defined our Alertmanager configuration as a Docker secret:

echo "global:
  slack_api_url: '$SLACK_WEBHOOK_URL'
route:
  group_interval: 30m
  repeat_interval: 30m
  receiver: 'slack'
  group_by: [service, scale, type]
  routes:
    - match_re:
        scale: up|down
        type: node
      receiver: 'scale-nodes'
    - match_re:
        alertname: scale_service|reschedule_service|scale_nodes
      group_by: [alertname, service, status]
      repeat_interval: 10s
      group_interval: 1s
      group_wait: 0s
      receiver: 'slack-scaler'

receivers:
  - name: 'slack'
    slack_configs:
      - send_resolved: true
        title: '[{{ .Status | toUpper }}] {{ .GroupLabels.service }} service is in danger!'
        title_link: 'http://$CLUSTER_DNS/monitor/alerts'
        text: '{{ .CommonAnnotations.summary }}'
  - name: 'slack-scaler'
    slack_configs:
      - title: '{{ .GroupLabels.alertname }}: {{ .CommonAnnotations.request }}'
        color: '{{ if eq .GroupLabels.status \"error\" }}danger{{ else }}good{{ end }}'
        title_link: 'http://$CLUSTER_DNS/monitor/alerts'
        text: '{{ .CommonAnnotations.summary }}'
  - name: 'scale-nodes'
    webhook_configs:
      - send_resolved: false
        url: 'http://scaler:8080/v1/scale-nodes?by=1&type=worker'
" | docker secret create alert_manager_config -

This configuration groups alerts by their service, scale, and type labels. The routes section defines a match_re entry, that directs scale alerts to the scale-nodes reciever. The second route is configured to direct alerts from the scaler service to the slack-scaler receiver. The scale-nodes receivers url is given parameters by=1 to denote how many nodes to scale down or up by, and type=worker to only scale worker nodes.

docker network create -d overlay monitor

DOMAIN=$CLUSTER_DNS \
    docker stack deploy \
    -c stacks/docker-flow-monitor-aws.yml \
    monitor

Let us confirm that the monitor stack is up and running:

docker stack ps monitor

Please wait a few moments for all the replicas to have the status running. After the monitor stack is up and running, we can test out manual node scaling!

Manually Scaling Nodes

Before node scaling, we will deploy a simple sleeping service:

docker service create -d --replicas 6 \
  -l com.df.reschedule=true \
  --name demo \
  alpine:3.6 sleep 100000000000

The com.df.reschedule=true label signals to Docker Scaler that this service is allowed for rescheduling after node scaling.

The original cluster started out with three manager nodes. We now scale up the worker nodes by one be issuing a POST request:

curl -X POST http://$CLUSTER_DNS/scaler/v1/scale-nodes\?by\=1\&type\=worker -d \
'{"groupLabels": {"scale": "up"}}'

The parameters by=1 and type=worker tell the service to scale worker nodes up by 1. Inside the json request body, the scale value is set to up to denote scaling up. To scale nodes down just set the value to down. We can check the number of nodes by running:

docker node ls

The output should be similar to the following (node ids are discarded):

HOSTNAME                        STATUS              AVAILABILITY        MANAGER STATUS
ip-172-31-4-44.ec2.internal     Ready               Active              Reachable
ip-172-31-17-200.ec2.internal   Ready               Active              Reachable
ip-172-31-20-95.ec2.internal    Ready               Active
ip-172-31-44-49.ec2.internal    Ready               Active              Leader

If there are still three nodes, wait a few more minutes and try the command again. Docker Scaler waits for the new node to come up and reschedules services that are labeled com.df.reschedule=true. We look at the processes running on the new worker node:

docker node ps $(docker node ls -f role=worker -q)

The output should include some instances of the demo service, showing that the some of the instances has been place on the new node. We will now move on to implementing a system for automatic scaling!

Deploying Node Exporters

The node exporters are used to display metrics about each nodes for Docker Flow Monitor to scrap. To deploy the exporters stack run:

docker stack deploy \
  -c stacks/exporters-aws.yml \
  exporter

We will focus on the service labels for the node-exporter-manager and node-exporter-worker services:

...
services:
  ...
  node-exporter-manager:
    ...
    deploy:
      labels:
        ...
        - com.df.alertName.1=node_mem_limit_total_above
        - com.df.alertIf.1=@node_mem_limit_total_above:0.95
        - com.df.alertLabels.1=receiver=system,scale=no,service=exporter_node-exporter-manager,type=node
        - com.df.alertFor.1=30s
        ...
  node-exporter-worker:
    ...
    deploy:
      labels:
        ...
        - com.df.alertName.1=node_mem_limit_total_above
        - com.df.alertIf.1=@node_mem_limit_total_above:0.95
        - com.df.alertFor.1=30s
        - com.df.alertName.2=node_mem_limit_total_below
        - com.df.alertIf.2=@node_mem_limit_total_below:0.05
        - com.df.alertFor.2=30s
...

These labels use AlertIf Parameter Shortcuts for creating Prometheus expressions that firing alerts.

The node-exporter-manager has an alertIf label of node_mem_limit_total_above:0.95, which will fire when the total fractional memory of the all manager nodes is above 95%. Setting one of the alertLabels to scale=no prevents autoscaling and sends a notification to Slack. The node-exproter-worker has an alertIf label of node_mem_limit_total_above:0.95 which will fire when the total fractional memory of all worker nodes is above 95%. Similiary, the node_mem_limit_total_below:0.01 fires when the total fractional memory is below 5%. These values for memory alerts are extreme to prevent the alerts from firing. We will change these labels to explore what happens when they fire.

For example, we trigger alert 1 on node-exporter-manager by changing its alert label:

docker service update -d \
  --label-add "com.df.alertIf.1=@node_mem_limit_total_above:0.05" \
  exporter_node-exporter-manager

After the alert is fired, we can see a Slack notification stating Total memory of the nodes is over 0.05. Before we continue, we return the alert back to before:

docker service update -d \
  --label-add "com.df.alertIf.1=@node_mem_limit_total_above:0.95" \
  exporter_node-exporter-manager

Automaticall Scaling Nodes

We trigger alert 1 on node-exporter-worker by setting the node_mem_limit_total_above limit to 5%:

docker service update -d \
  --label-add "com.df.alertIf.1=@node_mem_limit_total_above:0.05" \
  exporter_node-exporter-worker

After a few minutes, the alert will fire and trigger scaler to scale worker nodes up by one. Slack will send a notification stating: Changing the number of worker nodes on aws from 1 to 2. We confirm that there is now five nodes by running:

docker node ls

After the node comes up, scaler will also reschedule services with label, com.df.reschedule=true. During this process, Slack notifications were sent to inform us of each step. Before triggering the alert 2, we return alert 1 back to before:

docker service update -d \
  --label-add "com.df.alertIf.1=@node_mem_limit_total_above:0.95" \
  exporter_node-exporter-worker

We trigger the condition for scaling down a node by setting node_mem_limit_total_below limit to 95%:

docker service update -d \
  --label-add "com.df.alertIf.2=@node_mem_limit_total_below:0.95" \
  exporter_node-exporter-worker

Slack will send a notification stating: Changing the number of worker nodes on aws from 2 to 1. After a few minutes, the alert will fire and trigger scaler to scale worker nodes down by one. We confirm that there is now four nodes by running:

docker node ls

What Now?

We just went through a simple example of a system that automatically scales and de-scales nodes. Feel free to add additional metrics and services to this self-adapting system to customize it to your needs.

Please remove the AWS cluster we created and free your resources:

 aws cloudformation delete-stack \
    --stack-name $STACK_NAME

You can navigate to AWS Cloudformation to confirm that your stack has been removed.