Backstory

For the last couple of weeks, I’ve been playing with AWS Greengrass for work. At first, I did some manual setups, but eventually, I needed to automate the deployments and subscription setups. After diving into the documentation and a few chats with AWS Support, I’ve managed to do a proper CloudFormation stack and do a deployment.

In this post, I’ll try to give a worthy walkthrough if you ever want to play with AWS Greengrass. If this is your first time hearing Greengrass, I’ll kindly point out the official documentation, here, which obviously does a pretty good job of covering what it is.

Prerequisite

As usual, we would require some setup before we start writing some code. Some of these steps are pretty usual, such as an AWS Account. However, I’ll try to give you some instruction about others, as they are unique to this tutorial.

  1. An AWS account, which you can open easily at aws.amazon.com.
  2. Install and configure AWS CLI on your computer.
  3. Install NodeJS. Version 12.X will suffice.
  4. Install Serverless framework. See serverless.com.
  5. Install Docker.

After step 5, we need to fetch the Docker image of Greengrass as we will run the core on our laptop as if it’s an IoT device.

Let’s pull the Docker image of Greengrass. First, we need to log in to the AWS IoT Greengrass registry in Amazon ECR. If this runs correctly, we’ll see Login Succeeded output in our terminal.

$ aws ecr get-login-password --region us-west-2 | docker login --username AWS --password-stdin https://216483018798.dkr.ecr.us-west-2.amazonaws.com

Then, we can pull the Docker image.

$ docker pull 216483018798.dkr.ecr.us-west-2.amazonaws.com/aws-iot-greengrass:latest

Once it’s finished, we can move on to the code. Don’t worry, we’ll get back to this Docker image eventually, but first, we need to get some credentials and do some work.

Coding

Now, before we start working on all the connections, we need a simple serverless function to send a message to a Greengrass topic. So, let’s start building it.


Lambda Function

OK. To keep things simple, we are once again going to use the Serverless framework.

$ mkdir greengrassLambda && cd $_
$ serverless create --template aws-nodejs

Let’s also init npm and install AWS Greengrass SDK.

$ npm init -y
$ npm install aws-greengrass-core-sdk --save

We are going to update our serverless.yml file as well.

service: greengrasslambda
frameworkVersion: "2"

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  region: eu-west-1

functions:
  counter:
    handler: handler.handler

Now, we can write our function in handler.js which is going to be a simple counter that will push messages to our choice of a topic, sample/counter.

'use-strict';

const ggSdk = require('aws-greengrass-core-sdk');

const iotClient = new ggSdk.IotData();
const util = require('util');

let counter = 0;

module.exports.handler = (event, context, callback) => {
  counter++;

  try {
    iotClient.publish({
      topic: 'sample/counter',
      payload: JSON.stringify({
        message: util.format('Sent from Greengrass Core. Invocation Count: %s', counter)
      }),
      queueFullPolicy: 'AllOrError',
    }, () => {
      callback(null, true);
    });
  } catch (error) {
    console.log(error);
  }

  return;
};

Alright. Looks like this part is done, so let’s deploy this and start our CloudFormation template for our AWS Greengrass group.

$ sls deploy

AWS Greengrass Group

This part is a bit tricky. There is an example CloudFormation template online for Greengrass, but, to understand what we need to create, you might need to play a bit on AWS Console. I did many manual setups, breaking stuff and more to understand the needs here. I’ll try to give you as much insight as possible and break the CloudFormation template into pieces before giving you the whole file.

Also, there is a step which needed to be done manually here. That is, creating a certificate. As far as I can tell, CloudFormation currently doesn’t support this. There is an API to handle this but, we’ll do this part manually for now.

First, we’ll go to the IoT Core service on AWS Console. Navigate to the Certificates menu and click create.

AWS IoT Core Certificates.

Then, progress as recommended and create the certificates. After it’s done, download all three files, activate the certificates and click done. We’ll attach the policy using CloudFormation later in the post.

AWS IoT Core Certificate Create One Click.

AWS IoT Core Certificate Create Done.

Now we can start writing our CloudFormation template. Let’s keep it in the same repository as our lambda function, so, we should create a templates folder and in it, a file named greengrass.json. We’ll take three parameters. LambdaName, LambdaVersion, and CertificateHash. Since Greengrass doesn’t support $LATEST tag, this will make our job a lot easier. For CertificateHash, we’ll take the value as cert/xxxxx....

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "AWS IoT Greengrass template for RustyNeuron tutorial.",
  "Parameters": {
    "LambdaName": {
      "Type": "String"
    },
    "LambdaVersion": {
      "Type": "String"
    },
    "CertificateHash": {
      "Type": "String"
    }
  },
  "Resources": {},
  "Outputs": {}
}

Above is the basis of our template. Let’s start filling up our Resources. Now, in order this to work, we need several resources in order, as listed below.

1.  AWS::IoT::Thing
2.  AWS::Greengrass::CoreDefinition
3.  AWS::Greengrass::CoreDefinitionVersion
4.  AWS::Greengrass::FunctionDefinition
5.  AWS::Greengrass::FunctionDefinitionVersion
6.  AWS::Greengrass::LoggerDefinition
7.  AWS::Greengrass::LoggerDefinitionVersion
8.  AWS::Greengrass::SubscriptionDefinition
9.  AWS::Greengrass::SubscriptionDefinitionVersion
10. AWS::Greengrass::Group
11. AWS::IoT::Policy
12. AWS::IoT::PolicyPrincipalAttachment
13. AWS::IoT::ThingPrincipalAttachment

Let’s start with the top three. We’ll name our thing, Neuron.

{
  ...
  "Resources": {

    // COPY FROM HERE

    "NeuronCore": {
      "Type": "AWS::IoT::Thing",
      "Properties": {
        "ThingName": "NeuronCore"
      }
    },
    "NeuronCoreDefinition": {
      "Type": "AWS::Greengrass::CoreDefinition",
      "Properties": {
        "Name": "NeuronCoreDefinition"
      }
    },
    "NeuronCoreDefinitionVersion": {
      "Type": "AWS::Greengrass::CoreDefinitionVersion",
      "Properties": {
        "CoreDefinitionId": {
          "Ref": "NeuronCoreDefinition"
        },
        "Cores": [
          {
            "Id": "NeuronCore",
            "CertificateArn": {
              "Fn::Join": [
                ":",
                [
                  "arn:aws:iot",
                  {
                    "Ref": "AWS::Region"
                  },
                  {
                    "Ref": "AWS::AccountId"
                  },
                  {
                    "Ref": "CertificateHash"
                  }
                ]
              ]
            },
            "ThingArn": {
              "Fn::Join": [
                ":",
                [
                  "arn:aws:iot",
                  {
                    "Ref": "AWS::Region"
                  },
                  {
                    "Ref": "AWS::AccountId"
                  },
                  "thing/NeuronCore"
                ]
              ]
            }
          }
        ]
      }
    },

    // TO HERE

  },
  ...
}

OK. This looks promising. Let’s add the Function definitions. Here, we need to be careful about a couple of things. First, IsolationMode must be NoContainer as Greengrass is running on our computer. Second, in the FunctionConfiguration, we mark the Pinned value as false. This is because we designed our lambda function to run on-demand.

{
  ...
  "Resources": {
    ...

    // COPY FROM HERE

    "NeuronFunctionDefinition": {
      "Type": "AWS::Greengrass::FunctionDefinition",
      "Properties": {
        "Name": "NeuronFunctionDefinition"
      }
    },
    "NeuronFunctionDefinitionVersion": {
      "Type": "AWS::Greengrass::FunctionDefinitionVersion",
      "Properties": {
        "FunctionDefinitionId": {
          "Fn::GetAtt": [
            "NeuronFunctionDefinition",
            "Id"
          ]
        },
        "DefaultConfig": {
          "Execution": {
            "IsolationMode": "NoContainer"
          }
        },
        "Functions": [
          {
            "Id": "NeuronLambda",
            "FunctionArn": {
              "Fn::Join": [
                ":",
                [
                  "arn:aws:lambda",
                  {
                    "Ref": "AWS::Region"
                  },
                  {
                    "Ref": "AWS::AccountId"
                  },
                  "function",
                  {
                    "Ref": "LambdaName"
                  },
                  {
                    "Ref": "LambdaVersion"
                  }
                ]
              ]
            },
            "FunctionConfiguration": {
              "Pinned": false,
              "Timeout": 5,
              "EncodingType": "json",
              "Environment": {
                "Execution": {
                  "IsolationMode": "NoContainer",
                  "RunAs": {
                    "Uid": "1",
                    "Gid": "10"
                  }
                }
              }
            }
          }
        ]
      }
    },

    // TO HERE

  },
  ...
}

For Logger definitions, we need two types of loggers. One for CloudWatch and one for the local logs. If you have a problem of seeing logs on CloudWatch, we might need to alter the Greengrass service policy to allow logging. We’ll get to that at the end of our article.

{
  ...
  "Resources": {
    ...

    // COPY FROM HERE

    "NeuronLoggerDefinition": {
      "Type": "AWS::Greengrass::LoggerDefinition",
      "Properties": {
        "Name": "NeuronLoggerDefinition"
      }
    },
    "NeuronLoggerDefinitionVersion": {
      "Type": "AWS::Greengrass::LoggerDefinitionVersion",
      "Properties": {
        "LoggerDefinitionId": {
          "Ref": "NeuronLoggerDefinition"
        },
        "Loggers": [
          {
            "Id": "NeuronLoggerCWSystem",
            "Type": "AWSCloudWatch",
            "Component": "GreengrassSystem",
            "Level": "INFO"
          },
          {
            "Id": "NeuronLoggerCWLambda",
            "Type": "AWSCloudWatch",
            "Component": "Lambda",
            "Level": "INFO"
          },
          {
            "Id": "NeuronLoggerLocalSystem",
            "Type": "FileSystem",
            "Component": "GreengrassSystem",
            "Level": "INFO",
            "Space": 25600
          },
          {
            "Id": "NeuronLoggerLocalLambda",
            "Type": "FileSystem",
            "Component": "Lambda",
            "Level": "INFO",
            "Space": 25600
          }
        ]
      }
    },

    // TO HERE

  }
  ...
}

Next, we have Subscriptions. Here, we need two subscriptions.

  1. From lambda to IoT cloud. This is to listen to the messages that are being published by our lambda function.
  2. From IoT cloud to our lambda. This is to easily trigger our lambda function, using the MQTT client on IoT Console.
{
  ...
  "Resources": {
    ...

    // COPY FROM HERE

    "NeuronSubscriptionDefinition": {
      "Type": "AWS::Greengrass::SubscriptionDefinition",
      "Properties": {
        "Name": "NeuronSubscriptionDefinition"
      }
    },
    "NeuronSubscriptionDefinitionVersion": {
      "Type": "AWS::Greengrass::SubscriptionDefinitionVersion",
      "Properties": {
        "SubscriptionDefinitionId": {
          "Ref": "NeuronSubscriptionDefinition"
        },
        "Subscriptions": [
          {
            "Id": "Subscription1",
            "Source": {
              "Fn::Join": [
                ":",
                [
                  "arn:aws:lambda",
                  {
                    "Ref": "AWS::Region"
                  },
                  {
                    "Ref": "AWS::AccountId"
                  },
                  "function",
                  {
                    "Ref": "LambdaName"
                  },
                  {
                    "Ref": "LambdaVersion"
                  }
                ]
              ]
            },
            "Subject": "sample/counter",
            "Target": "cloud"
          },
          {
            "Id": "Subscription2",
            "Source": "cloud",
            "Subject": "sample/trigger",
            "Target": {
              "Fn::Join": [
                ":",
                [
                  "arn:aws:lambda",
                  {
                    "Ref": "AWS::Region"
                  },
                  {
                    "Ref": "AWS::AccountId"
                  },
                  "function",
                  {
                    "Ref": "LambdaName"
                  },
                  {
                    "Ref": "LambdaVersion"
                  }
                ]
              ]
            }
          }
        ]
      }
    },

    // TO HERE

  }
  ...
}

We are very close. Now that every component definition is done, we can write the group’s definition and link the components we wrote.

{
  ...
  "Resources": {
    ...

    // COPY FROM HERE

    "NeuronGroup": {
      "Type": "AWS::Greengrass::Group",
      "Properties": {
        "Name": "NeuronGroup",
        "RoleArn": {
          "Fn::Join": [
            ":",
            [
              "arn:aws:iam:",
              {
                "Ref": "AWS::AccountId"
              },
              "role/service-role/Greengrass_ServiceRole"
            ]
          ]
        },
        "InitialVersion": {
          "CoreDefinitionVersionArn": {
            "Ref": "NeuronCoreDefinitionVersion"
          },
          "FunctionDefinitionVersionArn": {
            "Ref": "NeuronFunctionDefinitionVersion"
          },
          "SubscriptionDefinitionVersionArn": {
            "Ref": "NeuronSubscriptionDefinitionVersion"
          },
          "LoggerDefinitionVersionArn": {
            "Ref": "NeuronLoggerDefinitionVersion"
          }
        }
      }
    },

    // TO HERE

  }
  ...
}

All good. Let’s add the policy definitions now.

{
  ...
  "Resources": {
    ...

    // COPY FROM HERE

    "NeuronPolicy": {
      "Type": "AWS::IoT::Policy",
      "Properties": {
        "PolicyName": "NeuronPolicy",
        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": [
                "iot:Publish",
                "iot:Subscribe",
                "iot:Connect",
                "iot:Receive"
              ],
              "Resource": [
                "*"
              ]
            },
            {
              "Effect": "Allow",
              "Action": [
                "iot:GetThingShadow",
                "iot:UpdateThingShadow",
                "iot:DeleteThingShadow"
              ],
              "Resource": [
                "*"
              ]
            },
            {
              "Effect": "Allow",
              "Action": [
                "greengrass:*"
              ],
              "Resource": [
                "*"
              ]
            }
          ]
        }
      }
    },
    "NeuronPolicyAttachmet": {
      "Type": "AWS::IoT::PolicyPrincipalAttachment",
      "Properties": {
        "PolicyName": {
          "Ref": "NeuronPolicy"
        },
        "Principal": {
          "Fn::Join": [
            ":",
            [
              "arn:aws:iot",
              {
                "Ref": "AWS::Region"
              },
              {
                "Ref": "AWS::AccountId"
              },
              {
                "Ref": "CertificateHash"
              }
            ]
          ]
        }
      },
      "DependsOn": "NeuronPolicy"
    },
    "NeuronBaseCertificateAttachment": {
      "Type": "AWS::IoT::ThingPrincipalAttachment",
      "Properties": {
        "ThingName": "NeuronCore",
        "Principal": {
          "Fn::Join": [
            ":",
            [
              "arn:aws:iot",
              {
                "Ref": "AWS::Region"
              },
              {
                "Ref": "AWS::AccountId"
              },
              {
                "Ref": "CertificateHash"
              }
            ]
          ]
        }
      },
      "DependsOn": "NeuronCore"
    }

    // TO HERE

  }
  ...
}

At this point, our Resources are done. However, we need one more piece to make it right. We need the command to make a new deployment of our group. To do this, we are going to define an output.

{
  ...
  "Resources": {
    ...
  },
  "Outputs": {

    // COPY FROM HERE

    "CommandToDeployGroup": {
      "Value": {
        "Fn::Join": [
          " ",
          [
            "groupVersion=$(cut -d'/' -f6 <<<",
            {
              "Fn::GetAtt": [
                "NeuronGroup",
                "LatestVersionArn"
              ]
            },
            ");",
            "aws --region",
            {
              "Ref": "AWS::Region"
            },
            "greengrass create-deployment --group-id",
            {
              "Ref": "NeuronGroup"
            },
            "--deployment-type NewDeployment --group-version-id",
            "$groupVersion"
          ]
        ]
      }
    }

    // TO HERE

  }
}

Alright. Whew… Let’s apply this, shall we? First, we validate the template we created.

$ aws cloudformation validate-template --template-body file://templates/greengrass.json --region eu-west-1

It should give us the following output.

{
    "Parameters": [
        {
            "ParameterKey": "LambdaName",
            "NoEcho": false
        },
        {
            "ParameterKey": "CertificateHash",
            "NoEcho": false
        },
        {
            "ParameterKey": "LambdaVersion",
            "NoEcho": false
        }
    ],
    "Description": "AWS IoT Greengrass template for RustyNeuron tutorial."
}
(END)

Right now, we are ready to create our stack on AWS. So, let’s go. This will take a while to complete, but hopefully, it will succeed. Make sure to replace the xxxxx... part with your certificate hash.

$ aws cloudformation create-stack --stack-name RustyNeuronTutorial \
  --template-body file://templates/greengrass.json --region eu-west-1 \
  --parameters ParameterKey=LambdaName,ParameterValue=greengrasslambda-dev-counter \
               ParameterKey=LambdaVersion,ParameterValue=1 \
               ParameterKey=CertificateHash,ParameterValue=cert/xxxxx...

AWS IoT Stack Complete.


Running IoT Core on Docker

Do you remember that we downloaded some keys and certificates when we started this tutorial? Right, we need them now. To keep things in one place, let’s create a greengrass folder in the home root, the ~. However, this is up to you. Just remember to change the paths when we run docker.

$ cd ~
$ mkdir -p Development/greengrass && cd $_
$ mkdir certs
$ mkdir config

Now, we’ll copy the three files we got when we created the certificate in the certs folder. Additionally, we need the root.ca.pem file, which is distributed by AWS. To get that, the following command must be run in the certs folder.

$ cd certs                              # /Users/$USER/Development/greengrass/certs
$ sudo wget -O root.ca.pem https://www.amazontrust.com/repository/AmazonRootCA1.pem

Now, we’ll write our config.json file. This file, if you create your Greengrass group, will be included in the certificate tar file. However, it’s not present if you create your certificate stand-alone.

In the config folder, we create a config.json file with the following content.

{
  "coreThing": {
    "caPath": "root.ca.pem",
    "certPath": "xxxxxxxxxx-certificate.pem.crt",
    "keyPath": "xxxxxxxxxx-private.pem.key",
    "thingArn": "arn:aws:iot:eu-west-1:AWS_ACCOUNT_ID:thing/NeuronCore",
    "iotHost": "xxxxxxxxxxxxxx-ats.iot.eu-west-1.amazonaws.com",
    "ggHost": "greengrass-ats.iot.eu-west-1.amazonaws.com",
    "keepAlive": 600
  },
  "runtime": {
    "cgroup": {
      "useSystemd": "yes"
    }
  },
  "managedRespawn": false,
  "crypto": {
    "principals": {
      "SecretsManager": {
        "privateKeyPath": "file:///greengrass/certs/xxxxxxxxxx-private.pem.key"
      },
      "IoTCertificate": {
        "privateKeyPath": "file:///greengrass/certs/xxxxxxxxxx-private.pem.key",
        "certificatePath": "file:///greengrass/certs/xxxxxxxxxx-certificate.pem.crt"
      }
    },
    "caPath": "file:///greengrass/certs/root.ca.pem"
  }
}

You can get your AWS_ACCOUNT_ID by running aws sts get-caller-identity in your terminal. The masked parts of certPath, keyPath, privateKeyPath, and certificatePath are the hash of the files we downloaded. You can find the iotHost in the thing’s Interract menu as shown in the image below.

AWS IoT Host.

In the end, your folder structure should look like this.

$ tree .
.
├── certs
│   ├── xxxxxxxxxx-certificate.pem.crt
│   ├── xxxxxxxxxx-private.pem.key
│   ├── xxxxxxxxxx-public.pem.key
│   └── root.ca.pem
└── config
    └── config.json

2 directories, 5 files

We seem to be ready. Let’s start our IoT core using Docker. Make sure to change the $USER in the command.

$ docker run --rm --init -it --name aws-iot-greengrass \
    --entrypoint /greengrass-entrypoint.sh \
    -v /Users/$USER/Development/greengrass/certs:/greengrass/certs \
    -v /Users/$USER/Development/greengrass/config:/greengrass/config \
    -p 8883:8883 \
    216483018798.dkr.ecr.us-west-2.amazonaws.com/aws-iot-greengrass:latest

We should get something like this.

Greengrass successfully started with PID: 13

Once we see that our container is running, we can make a group deployment and test it.


AWS Greengrass Group Deployment

We have two options here. One, we can go to the IoT console and click deploy or we can use AWS CLI. Remember that we defined an Output in our CloudFormation template. Let’s get that command.

Navigate to the AWS CloudFormation and select RustyNeuronTutorial stack. Go to the Outputs tab.

AWS IoT Command.

$ groupVersion=$(cut -d'/' -f6 <<< arn:aws:greengrass:eu-west-1:AWS_ACCOUNT_ID:/greengrass/groups/GREENGRASS_GROUP_ID/versions/GREENGRASS_GROUP_VERSION_ID ); \
    aws --region eu-west-1 greengrass create-deployment \
    --group-id GREENGRASS_GROUP_ID \
    --deployment-type NewDeployment \
    --group-version-id $groupVersion

This should be done in no time and we should see the successful deployment on IoT Console.

AWS IoT Deployment Done.

Testing

Alright. We are in the home stretch. Let’s test this whole setup. Go to the Test page in the IoT Console. Then, subscribe to sample/counter topic to listen to our lambda function.

AWS IoT Test.

AWS IoT Test Sub.

And now, publish to sample/trigger topic. Remember, we set two subscriptions for both ways. We should successfully get the invocation count message published by our lambda function.

AWS IoT Test Pub.

Logging

Logs will stream to two places. CloudWatch and local file system. We configured our group for both, but probably you won’t be able to see the logs on CloudWatch. So, let’s see the logs in the local file system first.

First, we should connect to our Docker container.

$ docker exec -it aws-iot-greengrass /bin/bash

Logs are kept under /greengrass/ggc/var/log/ as two parts: system and user. To follow the user logs, you can run the following command in your container.

$ tail -f /greengrass/ggc/var/log/user/eu-west-1/AWS_ACCOUNT_ID/LAMBDA_NAME.log

When it comes to CloudWatch logs, we need to give the Greengrass_ServiceRole access to CloudWatch. You could give full access or if you want more granular access, you can attach the following policy to the service role.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:PutMetricFilter",
        "logs:PutRetentionPolicy",
        "logs:DescribeLogStreams"
      ],
      "Resource": [
        "arn:aws:logs:*:*:*"
      ]
    }
  ]
}

Go to IAM console and find the Greengrass_ServiceRole. Then, in the Permissions tab, click the Attach policies. Here, you can either select CloudWatchLogsFullAccess or click Create policy and paste the above summary. It’s up to you. I’ll choose CloudWatchLogsFullAccess. When you trigger your function, like in the Testing section, you’ll be able to see the log groups with the /aws/greengrass/ prefix in CloudWatch.

Deleting

To remove the CloudFormation stack properly, first, you need to reset the deployments of your group. You can do that with the following command.

$ aws greengrass reset-deployments --group-id GREENGRASS_GROUP_ID --region eu-west-1 --force

Then, to remove the CloudFormation stack, you’d need the following command.

$ aws cloudformation delete-stack --stack-name RustyNeuronTutorial --region eu-west-1

Source Code and Troubleshooting

The whole thing, except for the certificates and configs, is in this repository; rwxdash/greengrass-example.

If you encounter any error or problem, I suggest you go to the AWS Documentation. If you find any errata or any suggestion to do things more efficiently, please contact me.

See you in another post!