koudenpaのブログ

趣味のブログです。株式会社はてなでWebアプリケーションエンジニアをやっています。職業柄IT関連の記事が多いと思います。

AWS IoT Greengrass備忘2020初春

Greengrassなクリリンが太陽拳するまで - koudenpaのブログ では AWS IoT GreengrassGPIOコネクター を使った。AWSリソースの管理にはAWS CDKをC#で使ってみた。

この記事にはCDKでのGreengrass管理をしてみた際の備忘を書く。そんなに気合いは入っていないので箇条書きと乱文だ!

C#でCDKを使ってみた触感はClassmethodさんの記事(WindowsでAWS CDK(C#)の開発環境を整えてみた | Developers.IO)の末尾に書かれている感想と大体同じで、C#慣れしているなら普通に使えるけれど大体の場合でTypeScriptの方が良さそうだ、といった感じだった。

CDKとGreengrass

大前提として、2020初春現在ではこの組み合わせは遊びでしか使えない。

頑張れば商売にも使えるといった要素は一切ない。

aws-greengrass module · AWS CDK

This is a developer preview (public beta) module. Releases might lack important features and might have future breaking changes.

そもそもパブリックベータであるということもあるのだが、

AWS::Greengrass::CoreDefinitionVersion.Cores

The Greengrass core in this version. Currently, the Cores property for a core definition version can contain only one core.

無数のモノをCoreとして管理したいだろうところで1つだけしかCoreを定義できないようではどうにもならない。

CDKでGreengrass備忘

  • InitialVersion は指定しないで、別途個別のバージョンのリソースを定義しておくのが良さそう

    • GreengrassはGroupという単位で関連リソースを管理する。また、それらのリソースは不変で、リソースに変更を行う場合は別のバージョンのリソースを作成する形になる。これは、リソースをAWSの外(Coreデバイス)にデプロイする都合であると思う。何時、どのバージョンを、どのCoreデバイスにデプロイしたのか? を管理する必要があるからだ。
    • GroupのInitialVersionを指定していると、InitialVersionの内容を変更はすなわちGroupの変更、つまり置換となってGroupが破棄されてしまう。Group毎置換しない場面ではInitialVersionは指定しない方が取り回しが良さそうだ。
    • 逆に遊びとして常に破棄でいいならInitialVersionだけ使う割り切りも良さそう。
  • CDKのデプロイ前にGreengrassのデプロイをリセットする

    • リソースの変更はできないので、あるリソースの内容を変えると置換になる。この際、それらのリソースがCoreにデプロイされていると破棄できない。
    • デプロイをリセットしておくことで破棄できるようにある(はず)。
    • 自分は AWS CLI でやったり、AWSコンソールでやったりしていた。
    • デプロイしていると破棄できない(これはぐグループ自体の破棄に関するメッセージだけれど)。
      • MackerelAlertLampGroup This group is currently deployed, so it cannot be deleted. (Se null; Status Code: 400; Error Code: InvalidDeleteGroupRequest; Request ID: null; Gre Trace ID: Root=xxx) (Service: null; Status Code: 40r Code: InvalidDeleteGroupRequest; Request ID: null)
  • Lambda関数の内容を変更したら関連するリソースのバージョンも変えるようにする

    • Lambda関数にはエイリアスを付与してCoreにデプロイできるようにするが、エイリアスの先のLambda関数だけを編集してもGreengrass上のリソースでのバージョンが同一だと再デプロイされない様子(そりゃそうだろう)。

所感

この1年は完全にWebアプリケーションの世界で生きていたのでIoT関連の要素技術にはまったっく触れていなかったのだけれど、久しぶりに触れてなかなか楽しかった。

Raspberry Pi(Zero)をGreengrassのCoreとして構成さえしておけば、インターネット上からLambda関数をデプロイできるのはなかなか良い体験だった。

コネクタ(今回は特定のMQTTトピックにメッセージを送るとGPIOの状態を変更したりしてくれるコネクタを使った)もなかなか良い感じで、コネクタさえ構成しておけば様々なリソースと平易に接続できそう。典型的には「様々なソース -> 加工用のLamda関数 -> コネクタ」のような形になるのだろう。

CDK(CFn)に関しては宣言型である都合上相性はよくなさそうで、現状でGreengrassを使い込むのであれば、それを内包するアプリケーションでAPIを使って関連リソースを管理するのが無難なところであるように感じた。

試行コード抜粋

リポジトリ公開には若干センシティブな内容が含まれているので、Greengrassの管理を試行していた部分のみ参考として貼っておく。

using Amazon.CDK;
using Amazon.CDK.AWS.Events;
using Amazon.CDK.AWS.Events.Targets;
using Amazon.CDK.AWS.Greengrass;
using Amazon.CDK.AWS.IoT;
using Amazon.CDK.AWS.Lambda;
using System.Collections.Generic;
using System.Linq;

namespace MackerelAlertToAwsIot
{
    public class MackerelAlertLampProps : StackProps
    {
        public IEventBus AlertBus { get; set; }
        public string GroupRoleArn { get; set; }
        public string[] ThingCerts { get; set; }
    }

    // Ref: https://dev.classmethod.jp/cloud/aws/aws-cdk-greengrass-rasberrypi/
    public class MackerelAlertLampStack : Stack
    {
        internal MackerelAlertLampStack(Construct scope, string id, MackerelAlertLampProps props) : base(scope, id, props)
        {
            // クラウドで動かすLambda関数
            // アラートの通知ハンドラ
            var cloudReceiveAlertFunction = new Function(this, "CloudReceiveAlert", new FunctionProps()
            {
                Runtime = Runtime.PYTHON_3_7,
                Code = Code.FromAsset("handlers/cloud"),
                Handler = "ReceiveAlert.handler",
            });

            // Coreで動かすLamda関数
            // アラートの通知ハンドラ
            var deviceReceiveAlert = new Function(this, "DeviceReceiveAlert", new FunctionProps()
            {
                Runtime = Runtime.PYTHON_3_7,
                Code = Code.FromAsset("handlers/device"),
                Handler = "ReceiveAlert.handler",
            });
            var deviceReceiveAlertVersion = deviceReceiveAlert.AddVersion("v1");
            var deviceReceiveAlertAlias = new Alias(this, "DeviceReceiveAlertAlias", new AliasProps()
            {
                AliasName = "v" + deviceReceiveAlertVersion.Version,
                Version = deviceReceiveAlertVersion,
            });

            // テスト用のGPIO操作関数
            var toggleGpio = new Function(this, "DeviceToggleGpio", new FunctionProps()
            {
                Runtime = Runtime.PYTHON_3_7,
                Code = Code.FromAsset("handlers/device"),
                Handler = "ToggleGpio.handler",
            });
            var toggleGpioVersion = toggleGpio.AddVersion("v1");
            var toggleGpioAlias = new Alias(this, "DeviceToggleGpioAlias", new AliasProps()
            {
                AliasName = "v" + toggleGpioVersion.Version,
                Version = toggleGpioVersion,
            });

            var thingPolicy = new CfnPolicy(this, "MackerelAlertLampThingPoilcy", new CfnPolicyProps()
            {
                PolicyName = "MackerelAlertLampThingPoilcy",
                PolicyDocument = new Dictionary<string, object>
                {
                    ["Version"] = "2012-10-17",
                    ["Statement"] = new object[] {
                        new Dictionary<string, object>
                        {
                            ["Effect"] = "Allow",
                            ["Action"] = new string[] {
                                "iot:*",
                                "greengrass:*",
                            },
                            ["Resource"] = new string[] {
                                "*"
                            },
                        }
                    }
                }
            });

            // IDなどに使うために証明書のARNを加工しておく。
            var certs = props.ThingCerts
                   .Select(x => new
                   {
                       Arn = x,
                       Hash = Utils.ToHash(x),
                   }).ToList();
            // 証明書へのポリシーアタッチ
            var certAttaches = certs.Select(x =>
            {
                var attach = new CfnPolicyPrincipalAttachment(this, "MackerelAlertLampCertAttach-" + x.Hash, new CfnPolicyPrincipalAttachmentProps()
                {
                    PolicyName = thingPolicy.PolicyName,
                    Principal = x.Arn,
                });
                attach.AddDependsOn(thingPolicy);
                return attach;
            }).ToList();

            // Greengrass Coreとするモノを作って証明書をアタッチ
            var things = certs.Select(x => new
            {
                CertArn = x.Arn,
                Thing = new CfnThing(this, "MackerelAlertLampThing-" + x.Hash, new CfnThingProps()
                {
                    ThingName = "MackerelAlertLamp-" + x.Hash,
                })
            }).ToList();
            var thingAttaches = things.Select(x =>
            {
                var attach = new CfnThingPrincipalAttachment(this, x.Thing.ThingName + "Attach", new CfnThingPrincipalAttachmentProps()
                {
                    ThingName = x.Thing.ThingName,
                    Principal = x.CertArn,
                });
                attach.AddDependsOn(x.Thing);
                return attach;
            }).ToList();

            // モノをCoreデバイスにする
            var ggCore = new CfnCoreDefinition(this, "MackerelAlertLampCore", new CfnCoreDefinitionProps()
            {
                Name = "MackerelAlertLampCore",
                InitialVersion = new CfnCoreDefinition.CoreDefinitionVersionProperty()
                {
                    Cores = things.Select(x => new CfnCoreDefinition.CoreProperty()
                    {
                        Id = x.Thing.ThingName,
                        CertificateArn = x.CertArn,
                        // XXX ARN参照できないの?
                        //ThingArn = x.Thing.GetAtt("Arn").Reference.ToString(),
                        //ThingArn = x.Thing.GetAtt("resource.arn").Reference.ToString(),
                        ThingArn = $"arn:aws:iot:{this.Region}:{this.Account}:thing/{x.Thing.ThingName}",
                    }).ToArray(),
                }
            });
            things.ForEach(x =>
            {
                ggCore.AddDependsOn(x.Thing);
            });

            // Coreで使うリソース
            // Raspberry Pi のGPIO操作用リソース
            var gpioRw = new CfnResourceDefinition.ResourceInstanceProperty()
            {
                Id = "gpio-rw",
                Name = "RaspberryPiGpioRw",
                ResourceDataContainer = new CfnResourceDefinition.ResourceDataContainerProperty()
                {
                    LocalDeviceResourceData = new CfnResourceDefinition.LocalDeviceResourceDataProperty()
                    {
                        SourcePath = "/dev/gpiomem",
                        GroupOwnerSetting = new CfnResourceDefinition.GroupOwnerSettingProperty()
                        {
                            AutoAddGroupOwner = true,
                        },
                    }
                },
            };
            var ggResource = new CfnResourceDefinition(this, "MackerelAlertLampResource", new CfnResourceDefinitionProps()
            {
                Name = "MackerelAlertLampResource",
                InitialVersion = new CfnResourceDefinition.ResourceDefinitionVersionProperty()
                {
                    Resources = new CfnResourceDefinition.ResourceInstanceProperty[]
                    {
                        gpioRw,
                    }
                },
            });

            // Coreへ配信する関数
            var ggFunction = new CfnFunctionDefinition(this, "MackerelAlertLampFunction", new CfnFunctionDefinitionProps()
            {
                Name = "MackerelAlertLampFunction",
                InitialVersion = new CfnFunctionDefinition.FunctionDefinitionVersionProperty()
                {
                    Functions = new CfnFunctionDefinition.FunctionProperty[]
                    {
                        new CfnFunctionDefinition.FunctionProperty(){
                            Id = deviceReceiveAlert.FunctionName + "-" + deviceReceiveAlertAlias.AliasName,
                            FunctionArn = deviceReceiveAlertAlias.FunctionArn,
                            FunctionConfiguration = new CfnFunctionDefinition.FunctionConfigurationProperty()
                            {
                                // MemorySize と Timeout は必須である様子
                                MemorySize = 65535,
                                Timeout = 10,   // 秒
                            },
                        },
                        new CfnFunctionDefinition.FunctionProperty(){
                            Id = toggleGpio.FunctionName + "-" + toggleGpioAlias.AliasName,
                            FunctionArn = toggleGpioAlias.FunctionArn,
                            FunctionConfiguration = new CfnFunctionDefinition.FunctionConfigurationProperty()
                            {
                                // MemorySize と Timeout は必須である様子
                                MemorySize = 65535,
                                Timeout = 10,   // 秒
                            },
                        },
                    },
                },
            });

            // Coreで使うコネクタ
            // GPIOコネクタ、GPIOはこちらのコネクタ経由で操作することにする
            // https://docs.aws.amazon.com/ja_jp/greengrass/latest/developerguide/raspberrypi-gpio-connector.html
            var gpioConnector = new CfnConnectorDefinition.ConnectorProperty()
            {
                Id = "gpio-connector",
                ConnectorArn = $"arn:aws:greengrass:{this.Region}::/connectors/RaspberryPiGPIO/versions/1",
                Parameters = new Dictionary<string, object>()
                {
                    ["GpioMem-ResourceId"] = gpioRw.Id,
                    // 入力はさしあたってなし
                    //["InputGpios"] = "5,6U,7D",
                    //["InputPollPeriod"] = 50,
                    // 10, 9, 11番は配置が連続しているのでとりあえずそれを使う
                    ["OutputGpios"] = "9L,10L,11L",
                }
            };
            var ggConnector = new CfnConnectorDefinition(this, "MackerelAlertLampConnector", new CfnConnectorDefinitionProps()
            {
                Name = "MackerelAlertLampConnector",
                InitialVersion = new CfnConnectorDefinition.ConnectorDefinitionVersionProperty()
                {
                    Connectors = new CfnConnectorDefinition.ConnectorProperty[]{
                        gpioConnector,
                    },
                }
            });

            // Coreで使うサブスクリプション
            // XXX うっそだろ? ってほどに量があるけれどマジか?
            var ggSubscription = new CfnSubscriptionDefinition(this, "MackerelAlertLampSubscription", new CfnSubscriptionDefinitionProps()
            {
                Name = "MackerelAlertLampSubscription",
            });
            var ggSubscriptions = new CfnSubscriptionDefinitionVersion.SubscriptionProperty[]
                {
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-error",
                        Source = gpioConnector.ConnectorArn,
                        Target = "cloud",
                        Subject ="gpio/+/error",
                    },
                    // XXX Currently, when you create a subscription that uses the Raspberry Pi GPIO connector, you must specify a value for at least one of the + wildcards in the topic.
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-read-9",
                        Source = "cloud",
                        Target = gpioConnector.ConnectorArn,
                        Subject ="gpio/+/9/read",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-write-9",
                        Source = "cloud",
                        Target = gpioConnector.ConnectorArn,
                        Subject ="gpio/+/9/write",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-state-9",
                        Source = gpioConnector.ConnectorArn,
                        Target = "cloud",
                        Subject ="gpio/+/9/state",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-read-10",
                        Source = "cloud",
                        Target = gpioConnector.ConnectorArn,
                        Subject ="gpio/+/10/read",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-write-10",
                        Source = "cloud",
                        Target = gpioConnector.ConnectorArn,
                        Subject ="gpio/+/10/write",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-state-10",
                        Source = gpioConnector.ConnectorArn,
                        Target = "cloud",
                        Subject ="gpio/+/10/state",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-read-11",
                        Source = "cloud",
                        Target = gpioConnector.ConnectorArn,
                        Subject ="gpio/+/11/read",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-write-11",
                        Source = "cloud",
                        Target = gpioConnector.ConnectorArn,
                        Subject ="gpio/+/11/write",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-state-11",
                        Source = gpioConnector.ConnectorArn,
                        Target = "cloud",
                        Subject ="gpio/+/11/state",
                    },
                    //
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-test-write",
                        Source = toggleGpioAlias.FunctionArn,
                        Target = gpioConnector.ConnectorArn,
                        Subject ="gpio/+/11/write",
                    },
                    new CfnSubscriptionDefinitionVersion.SubscriptionProperty()
                    {
                        Id = "gpio-test",
                        Source = "cloud",
                        Target = toggleGpioAlias.FunctionArn,
                        Subject ="gpio/test",
                    },
                };
            var ggLatestSubscription = new CfnSubscriptionDefinitionVersion(this,
                "MackerelAlertLampSubscriptionVersion-" + Utils.ToHash(string.Join("-", ggSubscriptions.Select(x => x.Id))),
                new CfnSubscriptionDefinitionVersionProps()
                {
                    SubscriptionDefinitionId = ggSubscription.AttrId,
                    Subscriptions = ggSubscriptions,
                });

            // Greengras Groupを作る
            var ggGroup = new CfnGroup(this, "MackerelAlertLampGroup", new CfnGroupProps()
            {
                Name = "MackerelAlertLamp",
                RoleArn = props.GroupRoleArn,
            });
            var ggVersionHash = Utils.ToHash(string.Join("-",
                    ggCore.AttrLatestVersionArn,
                    ggFunction.AttrLatestVersionArn,
                    ggResource.AttrLatestVersionArn,
                    ggConnector.AttrLatestVersionArn,
                    ggSubscription.AttrLatestVersionArn));
            var ggLatestVersion = new CfnGroupVersion(this, "MackerelAlertLampGroupVersion-" + ggVersionHash, new CfnGroupVersionProps()
            {
                GroupId = ggGroup.AttrId,
                CoreDefinitionVersionArn = ggCore.AttrLatestVersionArn,
                FunctionDefinitionVersionArn = ggFunction.AttrLatestVersionArn,
                ResourceDefinitionVersionArn = ggResource.AttrLatestVersionArn,
                ConnectorDefinitionVersionArn = ggConnector.AttrLatestVersionArn,
                SubscriptionDefinitionVersionArn = ggSubscription.AttrLatestVersionArn,
            });
            ggLatestVersion.AddDependsOn(ggGroup);
            ggLatestVersion.AddDependsOn(ggCore);
            ggLatestVersion.AddDependsOn(ggResource);
            ggLatestVersion.AddDependsOn(ggFunction);
            ggLatestVersion.AddDependsOn(ggConnector);
            ggLatestVersion.AddDependsOn(ggSubscription);

            // TODO わかったら書く
            var mackerelAlertRule = new Rule(this, "mackerel-alert-rule", new RuleProps()
            {
                EventBus = props.AlertBus,
                EventPattern = new EventPattern()
                {
                    Source = new string[]{
                        "aws.partner/mackerel.io",
                    },
                },
                Targets = new IRuleTarget[] {
                    new LambdaFunction(cloudReceiveAlertFunction),
                    new LambdaFunction(deviceReceiveAlert),
                },
            });
        }
    }
}