Late last year, we started looking into embedded analytical dashboard solutions. We concluded that embedding pre-built dashboards would ultimately box us into one tool. That’s when we discovered Cube, a semantic layer or middleware between the data source and our data application. This blog post will detail our journey in deploying Cube on AWS.
*
The header image is taken from GitHub - Cube
What is Cube?
We chose Cube because it is a headless BI tool that allows more freedom in design and won’t lock us into a vendor. We considered other embedded solutions like AWS Quicksight, Tableau, Looker, and Metabase Embedding. While these tools would let us embed dashboards into our application, we sought flexibility in design and data modeling. We were aware that opting for Cube would require additional Engineering resources but we favored this approach over the cost and maintenance of alternative products.
Cube serves as a semantic layer, making it easy to connect data warehouses, data lakes, or render them accessible for your BI tool, dashboard, or any other use case. They expose their API to custom-build a UI around the data. The next step was deploying this in our infrastructure, and for that, we use AWS. We use Terraform by HashiCorp to deploy all of our infrastructure in a codified manner. This allows for PR reviews and having everything in code rather than building it out in the AWS console.
Prerequisites
At Knack, we use Terraform to deploy and manage our infrastructure on AWS. We use the following AWS services, which I’ll delve deeper into later in this article:
I will not go into detail about these tools but rather how they helped us achieve the goal of deploying a production-ready Cube setup. You should have an understanding of what each tool does and is used for. You will also need a data warehouse or database to connect Cube to. In our case, we are using Redshift.
Here is a high-level overview of the architecture utilized for deploying Cube on AWS:
Setting up Your Project in Terraform
module "cube-bi" {
source = "./services/cube-bi"
providers = {
aws.networking = aws.networking
}
environment = local.environment
region = var.region
vpc_id = data.aws_vpc.vpc.id
cube_security_group = data.aws_security_group.ecs_security_group.id
container_env = merge({
cubejs_db_host = data.aws_lb.nlb.dns_name
}, var.cube-bi_config[local.environment].container_env)
ecs_cluster_arn = data.aws_ecs_cluster.ecs_cluster.id
openid_connect_provider_arn = aws_iam_openid_connect_provider.github.arn
initial_ecr_image_cube = "******.dkr.ecr.us-east-1.amazonaws.com/knack/cube:latest-cube"
initial_ecr_image_cubestore = "******.dkr.ecr.us-east-1.amazonaws.com/knack/cubestore:latest-cube"
domain_cube = var.cube-bi_config[local.environment].domain
alb_arn = data.aws_lb.aws_alb.arn
alb_priority = 30
desired_count_cube_api = var.cube-bi_config[local.environment].desired_count_cube_api
desired_count_cube_refresh_worker = var.cube-bi_config[local.environment].desired_count_cube_refresh_worker
service_count_cubestore_router = var.cube-bi_config[local.environment].service_count_cubestore_router
service_count_cubestore_worker = var.cube-bi_config[local.environment].service_count_cubestore_worker
alarm_sns_topic_arn = module.opsgenie_data_cloudwatch.sns_arn
}
The above module sets up our service with the variables needed. We are passing in the information needed to deploy this via Task Definitions on ECS. We are passing in environment
, region
, vpc_id
, security_group
, ecs_cluster_arn
, and some container_environments
, which pass in the Redshift username and database name. We already pushed the Docker Images for Cube and the Cubestore to our ECR repo. These include our data modeling and config files. We pushed these via our GitHub repository, where we keep all the modeling and files needed to run Cube.
Task Definitions
The first step is to set up the task definitions for the Cube services. We need to deploy the cube-api
, cube-refresh-worker
, cubestore-router
, and the cubestore-worker
. To set up these task definitions, we will use a pre-built module. This module does a basic set-up of the task definition with the variables seen. We give it the image
, cpu
, memory
, portMappings
, execution_role_arn
, environment
variables, secrets
, and a CloudWatch configuration.
module "this_task_template_cube_api" {
source = "../../../modules/aws-ecs-task-definition"
family = "${local.combined_name}-api-template"
image = var.initial_ecr_image_cube
cpu = 1024
memory = 512
memoryReservation = 256
name = "cube-api"
essential = true
task_role_arn = module.this_task_execution_role.this_iam_role_arn
execution_role_arn = module.this_task_execution_role.this_iam_role_arn
portMappings = [
{
containerPort = 4000,
protocol = "tcp"
}
]
environment = local.cube_environment_variables
secrets = concat(local.secrets, [
{
"name" = "CUBEJS_JWT_KEY"
"valueFrom" = aws_ssm_parameter.cubejs_jwt_key.arn
}
])
logConfiguration = {
logDriver = "awslogs",
options = {
"awslogs-group" = aws_cloudwatch_log_group.this.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "cube-api"
}
}
}
The CUBEJS_CUBESTORE_HOST
was the first challenge we faced, as we needed a way to pass in the host address of the Cubestore host. We decided to use Service Discovery. Below are the environment variables that we are passing into the container.
cube_environment_variables = [
{
"name" = "CUBEJS_DB_TYPE"
"value" = "clickhouse"
},
{
"name" = "CUBEJS_DB_PORT"
"value" = "8123"
},
{
"name" = "CUBEJS_DB_HOST"
"value" = var.container_env.cubejs_db_host
},
{
"name" = "CUBEJS_DB_NAME"
"value" = "external_analytics"
},
{
"name" = "CUBEJS_DB_USER"
"value" = var.container_env.cubejs_db_user
},
{
"name" = "CUBEJS_CUBESTORE_HOST"
"value" = "cubestore-router.cube-bi.local"
},
{
"name" = "CUBEJS_CACHE_AND_QUEUE_DRIVER"
"value" = "cubestore"
}
]
Service Discovery
Service Discovery is a managed service that enables automatic, dynamic, and highly available discovery of services within your Amazon Virtual Private Cloud (Amazon VPC) network. This helped us pass in the host as environment variables for the services to talk to each other.
To set it up, we will use the aws_service_discovery_private_dns_namespace
and aws_service_discovery_service
resources by Terraform. We created a private DNS namespace called cube.local
for each service to use. Next, we need to set up the service itself, in this case, for the cubestore-router
and cubestore-worker
. For the cubestore-worker
, we set it up so we can deploy multiple workers, hence the ${count.index + 1}
and count
configuration. It is straightforward to set up, as we pass in the DNS namespace from the namespace module and give it a DNS record. This will create a DNS record for the ECS service to be discovered in our VPC. This will be needed when we set up the ECS service, but before that, we’ll need to set up the application load balancer.
resource "aws_service_discovery_private_dns_namespace" "discovery_namespace" {
name = "cube.local"
description = "Discovery service namespace for cube services to use"
vpc = var.vpc_id
}
resource "aws_service_discovery_service" "this_cubestore_router" {
name = "cubestore-router"
dns_config {
namespace_id = aws_service_discovery_private_dns_namespace.discovery_namespace.id
dns_records {
ttl = 10
type = "A"
}
routing_policy = "MULTIVALUE"
}
health_check_custom_config {
failure_threshold = 1
}
}
resource "aws_service_discovery_service" "this_cubestore_worker" {
count = var.service_count_cubestore_worker
name = "cubestore-worker-${count.index + 1}"
dns_config {
namespace_id = aws_service_discovery_private_dns_namespace.discovery_namespace.id
dns_records {
ttl = 10
type = "A"
}
routing_policy = "MULTIVALUE"
}
health_check_custom_config {
failure_threshold = 1
}
}
Elastic Load Balancer or Application Load Balancer
To service API requests from our dashboard, we need an application load balancer to pass these requests to the Cube API service. We will use the aws_alb_target_group
and aws_lb_listener_rule
resources from Terraform. We give it a port, protocol, the VPC ID, and a health_check path. Cube has two health check paths, which resolve in /readyz
and /livez
. /readyz
returns the state of the deployment, and /livez
provides the liveness state of the deployment by testing the data source. We chose the /readyz
path. Next, we’ll set up a listener rule for the load balancer.
resource "aws_alb_target_group" "this_cube_api" {
name = "${local.hyphen_combined_name}-api"
port = 4000
protocol = "HTTP"
vpc_id = var.vpc_id
deregistration_delay = 60
health_check {
path = "/readyz"
protocol = "HTTP"
matcher = 200
healthy_threshold = 2
}
lifecycle {
create_before_destroy = true
}
tags = {
Environment = var.environment
}
}
resource "aws_lb_listener_rule" "this_cube_api" {
listener_arn = data.aws_lb_listener.this.arn
priority = var.alb_priority
action {
type = "forward"
target_group_arn = aws_alb_target_group.this_cube_api.arn
}
condition {
host_header {
values = [var.domain_cube]
}
}
}
EC2 Instances
For our tasks to be deployed, we’ll need to set up some EC2 instances in our VPC. We are setting it up via a custom module that sets up the auto-scaling group, the number of instances, security groups associated with the instances, IAM roles, and cloudwatch alarms. We use this module in all of our infrastructure so we don’t have to copy and paste the same configuration.
module "ecs_primary" {
source = "../modules/aws-ecs-cluster"
name = "data-primary"
environment = local.environment
region = var.region
vpc_id = module.vpc.vpc_id
instance_type = var.ecs_primary_config[local.environment].instance_type
instance_subnet_ids = module.vpc.private_subnets
min_size = var.ecs_primary_config[local.environment].min_size
max_size = var.ecs_primary_config[local.environment].max_size
desired_capacity = var.ecs_primary_config[local.environment].desired_capacity
alarm_sns_topic_arn = module.opsgenie_data_cloudwatch.sns_arn
ingress_with_source_security_group_id = [
{
rule = "all-all"
source_security_group_id = module.alb_primary.this_security_group_id
}
]
}
ECS Service
To piece everything together, we’ll need to deploy each service from the task definition. However, before we can deploy there are a few considerations to take into account. First, we need to determine the network mode for each service, crucial in connecting these services. AWS offers 3 network modes: host
, bridge
, and awsvpc
. The host mode is the most basic network mode; Bridge mode is the default network mode for Docker, allowing the use of a virtual bridge between the networking of the container. In our case, we will use the awsvpc
network mode. One thing to note is the limitations of ENI (Elastic Network Interfaces) in your EC2 instances. For example, they only allow 3 ENIs in a t3.medium
instance.
We will use the aws_ecs_service
resource from Terraform. I chose to show the cubestore-worker
as an example to demonstrate how dynamic and scalable this deployment is. This can be used for other services as well. As we can see, we have a count variable to tell Terraform how many workers it should create. Our current setup is using 1 worker because we don’t need more at the moment. We can see we have to give the task definition to the service. We created a local variable to be able to correctly pass in the task definition we need. We created a list to access the correct task definition like this: cubestore_worker_ids =
[module.this_task_template_cubestore_worker-1.arn, module.this_task_template_cubestore_worker-2.arn]
. This allows Terraform to get the correct task definition from the index.
When employing the awsvpc
network mode, it is essential to provide a network_configuration
that includes specifying a security group and private subnets. This configuration is crucial for associating an Elastic Network Interface (ENI) with a specific subnet. This association is achieved by utilizing a data block named aws_subnets
. Additionally, we need to link the previously created service discovery with the task, and this is facilitated by utilizing the service_registries
configuration value. In this context, we simply pass the Amazon Resource Name (ARN) of the relevant module for seamless integration.
resource "aws_ecs_service" "this_cube_api" {
name = "${local.combined_name}-cube_api"
cluster = var.ecs_cluster_arn
task_definition = module.this_task_template_cube_api.arn
desired_count = var.desired_count_cube_api
deployment_minimum_healthy_percent = var.desired_count_cube_api > 1 ? 50 : 0
deployment_maximum_percent = 100
load_balancer {
target_group_arn = aws_alb_target_group.this_cube_api.arn
container_name = "cube-api"
container_port = 4000
}
deployment_circuit_breaker {
enable = true
rollback = true
}
lifecycle {
ignore_changes = [
task_definition,
capacity_provider_strategy,
]
}
}
resource "aws_ecs_service" "this_cube_refresh_worker" {
name = "${local.combined_name}-refresh-worker"
cluster = var.ecs_cluster_arn
task_definition = module.this_task_template_cube_refresh_worker.arn
desired_count = var.desired_count_cube_refresh_worker
deployment_minimum_healthy_percent = var.desired_count_cube_refresh_worker > 1 ? 50 : 0
deployment_maximum_percent = 100
deployment_circuit_breaker {
enable = true
rollback = true
}
network_configuration {
security_groups = [module.security_group.this_security_group_id]
subnets = data.aws_subnets.private.ids
assign_public_ip = false
}
lifecycle {
ignore_changes = [
task_definition,
capacity_provider_strategy,
]
}
}
Conclusion
To round out this article, we learned a few things about deploying Cube via ECS. First, we had to use service discovery for seamless communication between the cube services. Second, we needed to use awsvpc
network mode in oder for the service to get it’s own ENI. This also played a crucial part for seamless communication. Third, we had to make sure the security groups of each service had the correct permissions set up. Before starting on this journey, we had limited knowledge about AWS and Terraform, and it proved to be a great challenge to get a much better understanding of AWS, networking, ECS, EC2, Terraform, etc.