One of the biggest challenges in IoT Edge is Data Visualization. A standard approach is to move the data to the upstream and use various storage, querying and rendering tools to visualize the data. This can become particularly challenging when dealing with high-frequency data in a low-bandwidth connection. Here, we examine how to setup the TICK stack on Azure IoT Edge for storing, querying, processing, and visualizing your data on the edge.

The TICK stack consists of four individual services that have been developed to function as a complete framework, but are abstract enough to be interchangeable by third party services of similar capabilities.

According to Influx Data, the company that makes and supports the stack, TICK "is a loosely coupled yet tightly integrated set of open source projects designed to handle massive amounts of time-stamped information to support your metrics analysis needs".

TICK consists of four services:
1. Telegraf, a telemetry and metrics collection mechanism,
2. InfuxDB, a time series database,
3. Chronograf , a time series visualization engine, and
4. Kapacitor, a time series data processing engine.

Although many variances of this stack exist, these combined four services cover most of the typical scenarios of the edge compute space.

The easiest way to get started with the TICK stack is by using the TICK sandbox repo. This repo is a quick way to get the entire TICK Stack spun up and working together using docker containers through docker-compose.

The docker-compose file structure is somewhat similar to the Azure IoT Edge deployment manifest. The TICK sandbox converted for Azure IoT Edge is here, the rest of the post deal with how to convert the original sandbox, or any docker-compose application to run in Azure IoT Edge.

The Azure IoT Edge TICK stack has been published in this repo.

Modules

The docker-compose services correspond to the manifest modules, with two prerequisite modules defined as system modules, the edgeAgent and the edgeHub. The former is responsible for reading the manifest and starting up the containers on the edge, and the latter is there to facilitate any intra-module communication between the custom modules. In fact, if your modules do not use this communication scheme, then you can even omit the edgeHub system module.

Let's take a look on the docker-compose chronograf service. This service definition is based on the original sandbox chronograf service, with a few minor modifications:

  • we've renamed the images folder to modules
  • changed the way the image tagging schema
  • added a registry name on the images
chronograf:
  # Full tag list: https://hub.docker.com/r/library/chronograf/tags/
  build:
    context: ./modules/chronograf
    dockerfile: ./Dockerfile
    args:
      CHRONOGRAF_TAG: ${CHRONOGRAF_TAG}
  image: "${CONTAINER_REGISTRY_ADDRESS}/chrono_config:${VER}.${BLD}-${ARCH}"
  environment:
    RESOURCES_PATH: "/usr/share/chronograf/resources"
  volumes:
    # Mount for chronograf database
    - /tmp/chronograf/data/:/var/lib/chronograf/
  links:
    # Chronograf requires network access to InfluxDB and Kapacitor
    - influxdb
    - kapacitor
  ports:
    # The WebUI for Chronograf is served on port 8888
    - "8888:8888"
  depends_on:
    - kapacitor
    - influxdb
    - telegraf

Here is the corresponding module in the deployment manifest. Azure IoT Edge has a few fundamental differences compared to docker compose. The first is that all modules operate in the same network, and as such, no special configuration is required for two modules to connect. Also, there is no startup ordering, the modules need to be resilient enough for every possible startup order.

"chronograf": {
  "version": "1.0",
  "type": "docker",
  "status": "running",
  "restartPolicy": "always",
  "settings": {
    "image": "${MODULES.chronograf}",
    "createOptions": {
      "Hostname": "chronograf",
      "Env": [
        "RESOURCES_PATH=/usr/share/chronograf/resources"
      ],
      "HostConfig": {
        "Binds": [
          "/tmp/chronograf/data/:/var/lib/chronograf/"
        ],
        "PortBindings": {
          "8888/tcp": [
            {
              "HostPort": "8888"
            }
          ]
        }
      }
    }
  }
}

Module definition files

We can see that the docker image creation configuration is missing completely from the manifest. Nevertheless, the VS Code Azure IoT Edge extension helps with managing a container build solution. This extension assumes there is a template manifest that points to module definition files, like this one:

{
    "$schema-version": "0.0.1",
    "description": "",
    "image": {
        "repository": "${CONTAINER_REGISTRY_ADDRESS}/chronograf",
        "tag": {
            "version": "${VER}.${BLD}",
            "platforms": {
                "amd64": "./Dockerfile"
            }
        },
        "buildOptions": ["--build-arg CHRONOGRAF_TAG=${CHRONOGRAF_TAG}" ],
        "contextPath": "./"
    },
    "language": "other"
}

The above  module.json file exists inside the modules\chronograf folder, and the manifest "points" to it by this line:

"image": "${MODULES.chronograf}"

Parallel docker-compose

Although VS Code has commands to easily build and push the module container images, it is a good idea to keep a parallel docker-compose definition, primarily for experimenting outside the Edge runtime.

Here's the complete parallel docker-compose file for the TICK stack:

version: '3'
services:
  influxdb:
    # Full tag list: https://hub.docker.com/r/library/influxdb/tags/
    build:
      context: ./modules/influxdb/
      dockerfile: ./Dockerfile
      args:
        INFLUXDB_TAG: ${INFLUXDB_TAG}
    image: "${CONTAINER_REGISTRY_ADDRESS}/influxdb:${VER}.${BLD}-${ARCH}"
    volumes:
      # Mount for influxdb data directory
      - /tmp/influxdb/data:/var/lib/influxdb
    ports:
      # The API for InfluxDB is served on port 8086
      - "8086:8086"
      - "8082:8082"
      # UDP Port
      - "8089:8089/udp"

  telegraf:
    # Full tag list: https://hub.docker.com/r/library/telegraf/tags/
    build:
      context: ./modules/telegraf/
      dockerfile: ./Dockerfile
      args:
        TELEGRAF_TAG: ${TELEGRAF_TAG}
    image: "${CONTAINER_REGISTRY_ADDRESS}/telegraf:${VER}.${BLD}-${ARCH}"
    environment:
      HOSTNAME: "telegraf-getting-started"
    # Telegraf requires network access to InfluxDB
    links:
      - influxdb
    volumes:
      # Mount for Docker API access
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - influxdb

  kapacitor:
  # Full tag list: https://hub.docker.com/r/library/kapacitor/tags/
    build:
      context: ./modules/kapacitor/
      dockerfile: ./Dockerfile
      args:
        KAPACITOR_TAG: ${KAPACITOR_TAG}
    image: "${CONTAINER_REGISTRY_ADDRESS}/kapacitor:${VER}.${BLD}-${ARCH}"
    volumes:
      # Mount for kapacitor data directory
      - /tmp/kapacitor/data/:/var/lib/kapacitor
    # Kapacitor requires network access to Influxdb
    links:
      - influxdb
    ports:
      # The API for Kapacitor is served on port 9092
      - "9092:9092"

  chronograf:
    # Full tag list: https://hub.docker.com/r/library/chronograf/tags/
    build:
      context: ./modules/chronograf
      dockerfile: ./Dockerfile
      args:
        CHRONOGRAF_TAG: ${CHRONOGRAF_TAG}
    image: "${CONTAINER_REGISTRY_ADDRESS}/chrono_config:${VER}.${BLD}-${ARCH}"
    environment:
      RESOURCES_PATH: "/usr/share/chronograf/resources"
    volumes:
      # Mount for chronograf database
      - /tmp/chronograf/data/:/var/lib/chronograf/
    links:
      # Chronograf requires network access to InfluxDB and Kapacitor
      - influxdb
      - kapacitor
    ports:
      # The WebUI for Chronograf is served on port 8888
      - "8888:8888"
    depends_on:
      - kapacitor
      - influxdb
      - telegraf

And here is the Azure IoT Edge template manifest:

{
  "$schema-template": "2.0.0",
  "modulesContent": {
    "$edgeAgent": {
      "properties.desired": {
        "schemaVersion": "1.0",
        "runtime": {
          "type": "docker",
          "settings": {
            "minDockerVersion": "v1.25",
            "loggingOptions": "",
            "registryCredentials": {
              "${CONTAINER_REGISTRY_GROUP}": {
                "username": "${CONTAINER_REGISTRY_USER_NAME}",
                "password": "${CONTAINER_REGISTRY_PASSWORD}",
                "address": "${CONTAINER_REGISTRY_ADDRESS}"
              }
            }
          }
        },
        "systemModules": {
          "edgeAgent": {
            "type": "docker",
            "settings": {
              "image": "mcr.microsoft.com/azureiotedge-agent:1.0",
              "createOptions": {}
            }
          },
          "edgeHub": {
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "mcr.microsoft.com/azureiotedge-hub:1.0",
              "createOptions": {
                "HostConfig": {
                  "PortBindings": {
                    "5671/tcp": [
                      {
                        "HostPort": "5671"
                      }
                    ],
                    "8883/tcp": [
                      {
                        "HostPort": "8883"
                      }
                    ],
                    "443/tcp": [
                      {
                        "HostPort": "443"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "modules": {
          "chronograf": {
            "version": "1.0",
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "${MODULES.chronograf}",
              "createOptions": {
                "Hostname": "chronograf",
                "Env": [
                  "RESOURCES_PATH=/usr/share/chronograf/resources"
                ],
                "HostConfig": {
                  "Binds": [
                    "/tmp/chronograf/data/:/var/lib/chronograf/"
                  ],
                  "PortBindings": {
                    "8888/tcp": [
                      {
                        "HostPort": "8888"
                      }
                    ]
                  }
                }
              }
            }
          },
          "influxdb": {
            "version": "1.0",
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "${MODULES.influxdb}",
              "createOptions": {
                "Hostname": "influxdb",
                "Env": [],
                "HostConfig": {
                  "Binds": [
                    "/tmp/influxdb/data:/var/lib/influxdb"
                  ],
                  "PortBindings": {
                    "8086/tcp": [
                      {
                        "HostPort": "8086"
                      }
                    ],
                    "8082/tcp": [
                      {
                        "HostPort": "8082"
                      }
                    ],
                    "8089/udp": [
                      {
                        "HostPort": "8089"
                      }
                    ]
                  }
                }
              }
            }
          },
          "kapacitor": {
            "version": "1.0",
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "${MODULES.kapacitor}",
              "createOptions": {
                "Hostname": "kapacitor",
                "Env": [],
                "HostConfig": {
                  "Binds": [
                    "/tmp/kapacitor/data:/var/lib/kapacitor"
                  ],
                  "PortBindings": {
                    "9092/tcp": [
                      {
                        "HostPort": "9092"
                      }
                    ]
                  }
                }
              }
            }
          },
          "telegraf": {
            "version": "1.0",
            "type": "docker",
            "status": "running",
            "restartPolicy": "always",
            "settings": {
              "image": "${MODULES.telegraf}",
              "createOptions": {
                "Hostname": "telegraf",
                "Env": [
                  "HOSTNAME=telegraf-getting-started"
                ],
                "HostConfig": {
                  "Binds": [
                    "/var/run/docker.sock:/var/run/docker.sock"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "$edgeHub": {
      "properties.desired": {
        "schemaVersion": "1.0",
        "routes": {
          "allToIoTHub": "FROM /messages/modules/* INTO $upstream"
        },
        "storeAndForwardConfiguration": {
          "timeToLiveSecs": 7200
        }
      }
    }
  }
}

Assuming all other modules are properly defined with a module.json definition file, running the Build and Push IoT Edge Solution to build your module containers and push them in the specified registry.

Make sure you specify your registry and the registry access credentials in the .env file first.

Similarly, running the Generate IoT Edge Deployment Manifest command in VS Code, will generate the actual manifest to use on a device. In the explorer view of VS Code, locate the Azure IoT Hub pane, right click on an edge enabled device and run Create deployment for Single Device and select the generated manifest. This manifest will be pushed down to the running device. If you wait a few minutes to make sure all the modules have been downloaded and visit the 8888 http device port, you'll see the Chronograf UI:

Chronograf running on the Edge

Conclusion

We saw how to transform any docker-compose file to an Azure IoT Edge solution, and how to run the TICK stack on Azure IoT Edge. Using this as starting point, we will see next how we can read and process high frequency device telemetry data.