less is more

心のフルスタックエンジニア👨‍💻バイブスでコードを書いています🤘

S3のイベント通知を完全に理解する🧘‍♀️

概要

今回はS3のイベント通知に関して掘り下げます。
よくあるユースケースとしては、S3に画像が置かれたのをトリガーにLambdaを起動してサムネイル画像を作成するとかでしょうか。

アーキテクチャはこんな感じ。

f:id:bluepixel:20200504145655p:plain

ターゲット

イベントの送信先として指定できるのは以下の3つのみです。

  • Lambda
  • SNS
  • SQS

別のものを使いたい場合は、EventBridgeという選択肢があります。
とはいえSNS, SQSを経由すれば大体現実的なユースケースはカバーできると思います。

EventBridgeはCloudTrailを介するのでS3のイベント通知に比べると少し面倒です。

一つ注意点として、SQSで利用できるのは通常キューのみとなります。FIFOキューは2020年5月現在サポートされていません。仕組み的に今後サポートされることはなさそうな印象です。

イベントタイプ

大まかに分けると、オブジェクトの作成削除です。
ただしS3のAPIは豊富なので、どのAPIを介して作成・削除されたのかを細かく絞り込むこともできます。

マネジメントコンソールだとわかりやすいです。

f:id:bluepixel:20200504160918p:plain

また、キーのプレフィックスサフィックスを指定することで対象となるオブジェクトを絞り込むこともできます。

作成

例えば、オブジェクト作成イベントは以下の4つのAPIからトリガーされます。

  • s3:ObjectCreated:Put
  • s3:ObjectCreated:Post
  • s3:ObjectCreated:Copy
  • s3:ObjectCreated:CompleteMultipartUpload

これらを問わずに全ての作成イベントを通知したければワイルドカードを使います s3:ObjectCreated:*

ちなみに通知はリクエストベースではなく、ステータスベースで送信されます。つまり失敗したAPI呼び出しに対してイベントは発行されません。

削除

以下の2つです。

  • s3:ObjectRemoved:Delete
  • s3:ObjectRemoved:DeleteMarkerCreated

バージョニングが有効なオブジェクトの場合、削除マーカーが作成された時に通知を送信するためにはObjectRemoved:DeleteMarkerCreatedを使います。

例によってs3:ObjectRemoved:*で一括指定が可能です。

削除に関して注意しておきたいのは、ライフサイクルポリシーによる自動削除はイベントを発行しない点です。

復元

Glacierにアーカイブしたオブジェクトを復元する時に通知されます。
結果的にバケット内にオブジェクトが作成されるわけですが、使うAPIが異なるので作成のイベントは通知されません。
あくまでAPIベースでイベントが管理されているためです。
ファイルが置かれたら、みたいな直感的な感覚で捉えているとこのあたりハマる可能性があります。

復元が開始された時にs3:ObjectRestore:Postが、完了した時にs3:ObjectRestore:Completedが通知されます。

消失

冗長化ストレージ(RRS)を使用している場合に限るイベントです。
通常のストレージがイレブンナインのデータの耐久性を保証するのに対し、RRSではフォーナイン(99.99%)まで下がるため、1万個のオブジェクトがある場合、1年間に1つくらいのデータロストが発生する可能性があります。

それを捕捉するイベントです。
s3:ReducedRedundancyLostObject

レプリケーション

別のバケットにオブジェクトをレプリケーションする設定が有効になっている場合に限るイベントです。

  • レプリケート失敗時 s3:Replication:OperationFailedReplication
  • 15分の閾値を超えてもレプリケートされていない時s3:Replication:OperationMissedThreshold
  • 閾値を超えた後にレプリケートされた時s3:Replication:OperationReplicatedAfterThreshold
  • メトリクスによって追跡されなくなった時s3:Replication:OperationNotTracked

サンプル

ターゲットにLambdaを指定する場合

サフィックス.lambdaのオブジェクトが作成されたらLambdaを起動してパラメータをログに出力します。

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  Name:
    Description: identifier
    Type: String
    Default: test-s3-event20200504
Resources:
  Lambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref Name
      Handler: index.lambda_handler
      Role: !GetAtt LambdaRole.Arn
      Runtime: nodejs12.x
      Code:
        ZipFile: |
          exports.lambda_handler = async (event, context) => {
            const util = require('util');
            console.log(util.inspect(event,false,null));
            console.log(util.inspect(context,false,null));
            return 'hello';
          }

  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns: 
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt Lambda.Arn
      Principal: s3.amazonaws.com
      SourceArn: !Sub arn:aws:s3:::${Name}

  S3:
    Type: AWS::S3::Bucket
    DependsOn:
      - LambdaPermission
    Properties:
      BucketName: !Ref Name
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: s3:ObjectCreated:*
            Filter:
              S3Key:
                Rules:
                  - Name: suffix
                    Value: .lambda
            Function: !GetAtt Lambda.Arn

ハマりどころとしては2つあって、

1つはLambda::Permissionです。これがないとS3がLambdaを起動できる権限がなくイベントを通知できません。

これはスタックの作成時にすでにバリデーションされます。 権限がない場合は以下のようなエラーメッセージが表示されます。

Unable to validate the following destination configurations

2つ目のハマりどころはリソースの依存関係です。

S3のイベント設定にLambda関数を指定する+権限のバリデーションがあるため、先にLambdaとLambdaPermissonを作る必要があります。

LambdaPermissionの方はDependsOnキーワードで制御できます。 関数の方はRefで参照するので特に依存関係を明示する必要はありません。

ただし、LambdaPermissionのSourceArnRef参照を使用すると循環依存になりスタックの作成に失敗します。 そのため、Parametersなどをうまく使って直接バケット名を埋め込んだArnを指定する必要があります。

実行するとeventにはこんな感じの値が含まれてきます。

{
    "Records": [
        {
            "eventVersion": "2.1",
            "eventSource": "aws:s3",
            "awsRegion": "ap-northeast-1",
            "eventTime": "2020-05-04T11:11:03.944Z",
            "eventName": "ObjectCreated:Put",
            "userIdentity": {
                "principalId": "xxxxxxxxxxxxxxxxxxxx"
            },
            "requestParameters": {
                "sourceIPAddress": "xxx.xxx.xxx.xxx"
            },
            "responseElements": {
                "x-amz-request-id": "xxxxxxxxxxxxxxxxxxxx",
                "x-amz-id-2": "xxxxxxxxxxxxxxxxxxxx"
            },
            "s3": {
                "s3SchemaVersion": "1.0",
                "configurationId": "23982aac-f2ca-40a6-8979-277afab383a6",
                "bucket": {
                    "name": "test-s3-event20200504",
                    "ownerIdentity": {
                        "principalId": "xxxxxxxxxxxxxxxxxxxx"
                    },
                    "arn": "arn:aws:s3:::test-s3-event20200504"
                },
                "object": {
                    "key": "sample.lambda",
                    "size": 18,
                    "eTag": "f0a94df54850f1f740e1c2ed6c78ee49",
                    "sequencer": "005EAFF84B50DBAEE4"
                }
            }
        }
    ]
}

Records[0]["s3"]["bucket"]["name"]バケット名が、
Records[0]["s3"]["object"]["key"]でオブジェクトのキーが取得できます。

ターゲットにSNSを指定する場合

トピックを作成してそこに飛ばします。動作確認のためにメールでのサブスクリプションも作っています。

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  Name:
    Description: identifier
    Type: String
    Default: test-s3-event20200504
  Email:
    Description: email address
    Type: String
Resources:
  Topic:
    Type: AWS::SNS::Topic
    Properties: 
      DisplayName: !Ref Name
      TopicName: !Ref Name

  TopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Properties: 
      PolicyDocument:
        Id: 1
        Version: "2012-10-17"
        Statement:
          - Sid: 1
            Effect: Allow
            Action: 
              - sns:Publish
            Resource: !Ref Topic
            Principal:
              Service: s3.amazonaws.com
            Condition:
              ArnLike:
                aws:SourceArn: !Sub arn:aws:s3:::${Name}
      Topics: 
        - !Ref Topic

  TopicSubscription:  
    Type: AWS::SNS::Subscription
    Properties: 
      Endpoint: !Ref Email
      Protocol: email
      TopicArn: !Ref Topic

  S3:
    Type: AWS::S3::Bucket
    DependsOn:
      - LambdaPermission
      - TopicPolicy
    Properties:
      BucketName: !Ref Name
      NotificationConfiguration:
        LambdaConfigurations:
          # 中略
        TopicConfigurations:
          - Event: s3:ObjectCreated:*
            Filter:
              S3Key:
                Rules:
                  - Name: suffix
                    Value: .sns
            Topic: !Ref Topic 

SNSの場合は、LambdaPermissionにあたるTopicPolicyを作る必要があります。勘所はほとんど一緒です。

唯一異なるのがポリシードキュメントの指定です。
IAMポリシーとかでおなじみのやつですが、 Principal: s3.amazonaws.comと書くことはできません。正しくは以下。

Principal:
  Service: s3.amazonaws.com

あとは例によってバリデーションがあるので、TopicPolicyをS3バケットより先に作るように依存関係を定義します。

ターゲットにSQSを指定する場合

キューの場合です。 SNSとほとんど一緒です。

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  Name:
    Description: identifier
    Type: String
    Default: test-s3-event20200504
Resources:
  Queue:
    Type: AWS::SQS::Queue
    Properties: 
      QueueName: !Ref Name

  QueuePolicy:
    Type: AWS::SQS::QueuePolicy
    Properties: 
      PolicyDocument:
        Id: 1
        Version: "2012-10-17"
        Statement:
          - Sid: 1
            Effect: Allow
            Action: 
              - sqs:SendMessage
            Resource: !GetAtt Queue.Arn
            Principal: 
              Service: s3.amazonaws.com
            Condition:
              ArnLike:
                aws:SourceArn: !Sub arn:aws:s3:::${Name}
      Queues: 
        - !Ref Queue

  S3:
    Type: AWS::S3::Bucket
    DependsOn:
      - LambdaPermission
      - QueuePolicy
      - TopicPolicy
    Properties:
      BucketName: !Ref Name
      NotificationConfiguration:
        LambdaConfigurations:
          # 中略
        QueueConfigurations:
          - Event: s3:ObjectCreated:*
            Filter:
              S3Key:
                Rules:
                  - Name: suffix
                    Value: .sqs
            Queue: !GetAtt Queue.Arn
        TopicConfigurations:
          # 中略

動作確認用のスクリプト

#!/bin/sh
echo "hello from lambda" >> sample.lambda
echo "hello from sns" >> sample.sns
echo "hello from sqs" >> sample.sqs
aws s3 cp ./sample.lambda s3://$BUCKET/
aws s3 cp ./sample.sns s3://$BUCKET/
aws s3 cp ./sample.sqs s3://$BUCKET/

その他覚えておきたいこと

  • 通知は数秒で配信されるが1分以上かかることもある。
  • バージョニングされていない場合、同じオブジェクトを同時に書き込むと1つしかイベントが発行されないことがある。
  • ごくまれにイベントが配信されない、二重で配信されることがある(ググると色々記事が出てきますが、100万回に1回以下とかそのくらいのレベルっぽい)。
  • トリガーされるLambdaで同じバケットにオブジェクトを配置する処理とかを書いているとループする。プレフィックスの設定でうまく処理する。

公式ドキュメント

Amazon S3 イベント通知の設定 - Amazon Simple Storage Service

サンプルのソースコード

github.com

最後まで読んだ人は「SQS完全に理解した🧘‍♀️」って言っていいです。

CloudFormationのスタックのdiffをGithubで参照できるようにする

続きです。 bluepixel.hatenablog.com

自前のGithub Actionを作っていきます。

ワークフローの内容

CloudFormationで作成したスタックを更新する際、変更セットというものを作る必要があります。

この変更セットには、どのリソースの何が変更されるのが、置換が必要な操作なのかなどの情報を含まれているのですが、CLIで確認するには使い勝手が悪く、また、マネジメントコンソール上では確認できますが、変更されるプロパティの詳細までは表示されません。

f:id:bluepixel:20200429221140p:plain

まずこれをGithub上でわかりやすく確認できるようにしたいです。

また、IaC(Infrastructure as Code)においてネックとなるレビュー問題を解決します。CloudFormationの場合は、テンプレートとなるjsonまたはymlファイルを目視で確認する、または実際に変更セットを作ってみて内容を確認するという作業が発生します。これをGithub Actionsでオートメーション化して、プルリクがオープンされたのをトリガーに自動で変更内容を貼り付けるようにしたいと思います。

完成系

f:id:bluepixel:20200429221642p:plain

先に完成のイメージを貼りました。 見た目はマネジメントコンソールをなるべく模すようにしています。

実装

最初にワークフローの流れを整理します。

  • チェックアウト
  • AWS認証
  • 変更セットの作成 (creat-change-set)
  • 作成された変更セットの取得(describe-change-set
  • diffの抽出・整形
  • 変更セットの削除(delete-change-set
  • diffをプルリクにコメント

赤字の部分が今回マーケットプレイスにリリースしたGithub Actionsになります。

github.com

なぜ赤字の部分だけかというと、それはGithub Actionsの設計思想に関係しています。

ワークフローとは、個々のタスクをパーツとして柔軟に組み合わせて構築するものです。マーケットプレイスにリリースされているアクションはそのパーツになります。そのため、1つのアクションがいろいろやりすぎているとワークフローに組み込みづらくなり、ユースケースが限定されてしまいます。

それぞれのパーツが入力と出力を介してゆるくつながる、言い換えれば任意につなげられる疎な状態を保つことが、使いやすいアクションの条件となります。

今回で言えば、AWS認証は公式で aws-actions/configure-aws-credentials が提供していますし、ファイルのダウンロード・アップロードはアーティファクト機能を通して実現できます。プルリクへのコメント作成もあらかじめ組み込まれている環境変数GITHUB_TOKENを使えば、別でアクセストークンを用意する必要もありません。

このあたりの境界や責務の範囲を理解すると、うまくワークフローを設計したり、カスタムアクションを作成することができます。

Dockerfile

スクリプトpythonで書いているのでpythonイメージをベースに、aws-cli, jq, less をインストールしてます。

FROM python:3

RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
  && unzip awscliv2.zip \
  && ./aws/install

RUN apt-get update \
  && apt-get install -y less jq

COPY entrypoint.sh /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]

Githubホスティングしているランナー(ex. Ubuntu 18.04.4 LTS)にはすでに基本的な言語とソフトウェアがインストールされているんですが、aws-cliが1系だったり、やっぱりDocker化しないと手元で開発がしにくかったりするのでDockerを選んでいます。

github.com

entrypoint.sh

変更セットの作成

diffが見たいだけなんですが、そういうAPIは用意されていません。
一時的な変更セットを作成して、diffを保存して、削除するという形をとっています。

変更セット名は、ユーザーが別で色々管理しているものがあるかもしれないのでランダムにUUIDを生成しています。

OSによりますがcat /proc/sys/kernel/random/uuidで取得できます。
小ネタですが変更セット名の先頭は英文字である必要があるために"a$(cat /proc/sys/kernel/random/uuid)"としています。

#!/bin/sh -l

uuid="a$(cat /proc/sys/kernel/random/uuid)"

スタック名とテンプレートファイルはパラメータで指定します。
ドキュメントがなくて分かりづらいんですが、ワークフローからwithで受け取ったパラメータは内部で$INPUT_*で参照できるようになっています。
例えばstack_name:$INPUT_STACK_NAMEになります。

aws cloudformation create-change-set --stack-name $INPUT_STACK_NAME --template-body file://$INPUT_TEMPLATE_BODY --change-set-name=$uuid
if [ $? -ne 0 ]; then
  echo "[ERROR] failed to create change set."
  exit 1
fi

AWSの認証に失敗したりスタックが存在しなかったりする場合は、スタックの作成に失敗するので明示的にexitします。0以外のコードを返せばワークフロー側で失敗として認識されます。

変更セットの確認

作成した変更セットからdiffを抽出します。
具体的にはdescribe-change-setのレスポンスの"Changes"に含まれている部分です。

すぐに投げてしまうとステータスがまだ作成中の場合があるので、何回かポーリングします。

for i in `seq 1 5`; do
  aws cloudformation describe-change-set --change-set-name=$uuid --stack-name=$INPUT_STACK_NAME --output=json > $uuid.json 
  status=$(cat $uuid.json | jq -r '.Status')
  if [ ${status} = "CREATE_COMPLETE" ] || [ ${status} = "FAILED" ]; then    
    break
  else
    echo "change set is now creating..."
    sleep 3
  fi
done

ステータスは CREATE_IN_PROGRESS, CREATE_COMPLETE, FAILED の3種類です。

変更点がない場合、ステータスは FAILED となります。
それはそれで変更点なしとして表示したいので FAILEDでも作成完了と見なすようにしました。

変更セットの削除

後片付けです。
レビュー後そのまま適用したいというニーズもあるかもしれないのでここオプションにしようかなあとか考えていたりする🤔

aws cloudformation delete-change-set --change-set-name=$uuid --stack-name=$INPUT_STACK_NAME
if [ $? -ne 0 ]; then
  echo "[ERROR] failed to delete change set."
fi

結果の整形

jsonに保存した結果をpythonスクリプトでごにょごにょして出力します。
::set-outputはGihub Actionの組み込み関数で、ここに値を詰めるとワークフロー内の別のステップから参照できるようになります。

先に述べたように、様々なユースケースに応じて利用できるように整形前の素のjsonも一応突っ込んでいます。

文字列だけでなくファイルもアーティファクトとして使えます。
文字列として扱う場合は、multilineが扱えないのでjq-cオプションで圧縮する必要があります。

result=$(cat $uuid.json | jq -c)
echo "::set-output name=change_set_name::$uuid"
echo "::set-output name=result::$result"
echo "::set-output name=result_file_path::$uuid.json"

python pretty_format.py $uuid $INPUT_STACK_NAME
echo "::set-output name=diff_file_path::$uuid.html"
# pretty_format.py

import json
import sys

class ChangeSet:
  changes = None

  def __init__(self, changes):
    self.changes = changes

  def action(self):
    action = self.changes['ResourceChange']['Action']
    color = ""
    if self.changes['ResourceChange']['Action'] == "Modify":
      color = "<img src=\"https://placehold.it/12/0073bb/0073bb?text=+\" />"
    elif self.changes['ResourceChange']['Action'] == "Add":
      color = "<img src=\"https://placehold.it/12/1d8102/1d8102?text=+\" />"
    elif self.changes['ResourceChange']['Action'] == "Remove":
      color = "<img src=\"https://placehold.it/12/d13212/d13212?text=+\" />"
    return "%s %s" % (color, action)

  def logical_resource_id(self):
    return self.changes['ResourceChange']['LogicalResourceId']

  def physical_resource_id(self):
    if 'PhysicalResourceId' in self.changes['ResourceChange']:
      return self.changes['ResourceChange']['PhysicalResourceId']
    else:
      return "-"

  def resource_type(self):
    return self.changes['ResourceChange']['ResourceType']

  def replacement(self):
    if 'Replacement' in self.changes['ResourceChange']:
      return self.changes['ResourceChange']['Replacement']
    else:
      return "-"

  def details(self):
    arr = []
    for d in self.changes['ResourceChange']['Details']:
      if d['Target']['Attribute'] != 'Properties':
        continue
      arr.append("- %s" % d['Target']['Name'])
    return "<br>".join(arr)


if __name__ == '__main__':
  data = {}
  with open("%s.json" % sys.argv[1]) as f:
    data = json.load(f)

  body = "<h1>Change set</h1><h2>Stack Name: %s</h2><br>" % sys.argv[2]
  
  if len(data['Changes']) > 0:
    body += "<table><tr><th>Action&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</th><th>ID</th><th>Type</th><th>Replacement</th><th>Changed Properties</th></tr>"
    for c in data['Changes']:
      body += "<tr>"
      change_set = ChangeSet(c)
      body += "<td>%s</td>" % change_set.action()
      body += "<td>%s</td>" % change_set.logical_resource_id()
      # cols.append(change_set.physical_resource_id())
      body += "<td>%s</td>" % change_set.resource_type()
      body += "<td>%s</td>" % change_set.replacement()
      body += "<td>%s</td>" % change_set.details()
      body += "</tr>"
    body += "</table>"
  else:
    body += "no change."
  
  with open("%s.html" % sys.argv[1], mode='w') as f:
    f.write(body)

整形部分にはいくつかハックを施しています。

見慣れているマネジメントコンソールっぽく表示するのが見る側にとって負担がないので、まずそれを実現するために色をつけます。

f:id:bluepixel:20200429221642p:plainf:id:bluepixel:20200429221140p:plain

この色を付けるハックはStack Overflowで見つけた方法で、プレースホルダー画像を生成してくれるサイトを外部画像として参照するというものです。
背景色と文字色を同じにして適当なテキストを設定すればぽく見えます。

次に物理リソースIDを消します。長くて表が見づらくなるしそんなに需要はないと判断したためです。

代わりに、変更されるプロパティを追加します。
マネジメントコンソールには表示されないものなんですが、実はAPIでは取得できるのでリストします。本来これが一番重要な情報だったりすると思うんだが...

ちなみに["Target"]["Metadata"]["Target"]["Tag"]は無視してます。
これもあまり需要はないかと思ったので。

最後にこれらを表形式でまとめあげてファイルに書き出すんですが、マークダウンではなくHTMLとしたのには明確な理由があり、

最終的にプルリクにコメントとして投げる時に、JSONのボディに指定する値がmultiline stringだと大いに問題があります。
Githubの仕様としても、改行コードのエンコード・デコードがややこしく、::set-outputを通すとまた変換が行われたり、うまく改行を保ったままポストすることができませんでした。

github.community

最終的にたどり着いたのがHTMLで、さらにファイルの中身をcatxargs echoで参照する方法です(catだと失敗する)

run: |
    curl -X POST \
    -H "Authorization: token ${GITHUB_TOKEN}" \
    -d "{\"body\": \"$(cat ${FILE_PATH} | xargs echo)\"}" \
            ${URL}

テスト

テスト用のワークフローを作ります。

整形したデータはアーティファクトとして利用しています。

help.github.com

コメントのポストに必要なGithubトークンはすでに用意されているものを使います。

help.github.com

on:
  pull_request:
    types: [opened, synchronize]
jobs:
  list-change-stack:
    runs-on: ubuntu-latest
    name: list cfn stack change set
    steps:
      - name: checkout
        uses: actions/checkout@v2
      - name: configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1
      - name: describe change set
        id: describe-change-set
        uses: ./
        with:
          stack_name: omochi
          template_body: after.cf.yml

      - name: archive diff
        uses: actions/upload-artifact@v1
        with:
          name: diff
          path: ${{ steps.describe-change-set.outputs.diff_file_path }}

      - name: Download diff markdown
        uses: actions/download-artifact@v1
        with:
          name: diff

      - name: Post comments
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          URL: ${{ github.event.pull_request.comments_url }}
          FILE_PATH: ${{ steps.describe-change-set.outputs.diff_file_path }}
        run: |
          cat ${FILE_PATH} | xargs echo
          curl -X POST \
            -H "Authorization: token ${GITHUB_TOKEN}" \
            -d "{\"body\": \"$(cat ${FILE_PATH} | xargs echo)\"}" \
            ${URL}

リリース

できたものをマーケットプレイスにリリースします。 特に審査もなくサクッと出せます。

github.com

README.mdとaction.ymlメタデータをしっかり書きましょう。 アイコンはいくつか用意されているものから選べます。

# action.yml
name: 'describe-cfn-change-set'
description: 'describe cfn change set'
author: 'sakuraya (@Blue-Pix)'
branding:
  icon: 'box'  
  color: 'orange'

カテゴリは2つ選べるが、ぶっちゃけ何が適切なのかよくわからない。 f:id:bluepixel:20200429233800p:plain

Publish this Action to the GitHub Marketplace にチェックを入れてリリースタグつければ完成です。

終わり

リリースのハードルも低く驚くほど簡単にできるのでこれはコミュニティの発展が期待できそう。

自前のGithub Actionsを作ってみる

この記事を書いた時に、プラットフォームの発展に結構可能性を感じたので自分でも作ってみる。

bluepixel.hatenablog.com

何を作るかはさておき、まずは作り方を一通り調べてみる。

ドキュメントはここから。 help.github.com

基本的な仕様

About actions - GitHub Help

  • メタデータaction.yml または action.yaml に定義する。
  • アクションはDockerコンテナまたはJavaScriptで実装する。
  • 自前のアクションは.github/actions/*に配置するのが推奨。
  • 公開する場合はタグでセマンティックバージョニングを行うのが推奨。

チュートリアル

Dockerで作る方で進めてみる。

help.github.com

このページでは、hello-world-docker-actionというアクションを作るチュートリアル形式で流れを説明してくれる。

1. Dockerfileを作る

JavaScriptで作る分には実行環境がすでにGithubのランナーに用意されているが、それ以外の言語を使いたい場合はDockerをかます必要がある。

# Container image that runs your code
FROM alpine:3.10

# Copies your code file from your action repository to the filesystem path `/` of the container
COPY entrypoint.sh /entrypoint.sh

# Code file to execute when the docker container starts up (`entrypoint.sh`)
ENTRYPOINT ["/entrypoint.sh"]

2.メタデータaction.ymlに記述する

入力や出力値の定義、マーケットプレイスに公開する際のアイコンや作者名を管理するファイル。

# action.yml
name: 'Hello World'
description: 'Greet someone and record the time'
inputs:
  who-to-greet:  # id of input
    description: 'Who to greet'
    required: true
    default: 'World'
outputs:
  time: # id of output
    description: 'The time we greeted you'
runs:
  using: 'docker'
  image: 'Dockerfile'
  args:
    - ${{ inputs.who-to-greet }}

inputsに定義した値をargsとして注入して、imageに指定したファイルからコンテナをビルドするサンプル。

3. スクリプトを作る

エントリーポイントとなる entrypoint.sh を作成。

#!/bin/sh -l

echo "Hello $1"
time=$(date)
echo "::set-output name=time::$time"

実行権限を付与する。

chmod +x entrypoint.sh

echo "::set-output name=<output name>::<value>"Github Actionsのシンタックスで、後のワークフローで参照することのできる値として出力を行う。

エラーがなくスクリプトの実行が終了すると、ステータスがsuccessとなる。 0以外でexitした場合はfailureとしてマークされる。

4. READMEを書く

こういうサンプルは参考になる。

# Hello world docker action

This action prints "Hello World" or "Hello" + the name of a person to greet to the log.

## Inputs

### `who-to-greet`

**Required** The name of the person to greet. Default `"World"`.

## Outputs

### `time`

The time we greeted you.

## Example usage

uses: actions/hello-world-docker-action@v1
with:
  who-to-greet: 'Mona the Octocat'

5. テストする

ワークフローをテストする。 別のリポジトリの場合はusesを使って自分のリポジトリに参照を向ければよい。同じリポジトリでやる場合はサンプルをそのまま使う。プライベートリポジトリの場合、別のリポジトリからは参照できない。

on: [push]

jobs:
  hello_world_job:
    runs-on: ubuntu-latest
    name: A job to say hello
    steps:
    - name: Hello world action step
      id: hello
      uses: actions/hello-world-docker-action@v1
      with:
        who-to-greet: 'sakuraya'
    # Use the output from the `hello` step
    - name: Get the output time
      run: echo "The time was ${{ steps.hello.outputs.time }}"

チュートリアル終了。

自前のアクションを作ってマーケットプレイスにリリースする

CloudFormationの定義ファイルを監視してスタックの変更内容をプルリクにコメントするBotを作りました。

github.com

マーケットプレイスにリリース済みです。

次回はこれの実装方法とリリースを解説します〜

bluepixel.hatenablog.com

Slackのスラッシュコマンドとダイアログを使ってEC2のセキュリティグループを編集する

経緯

フルリモート体制になっているので、sshの接続元などにオフィス以外の任意のIPアドレスを追加する必要が出てきた。

メンバーの自宅はIPが固定されていないため、IPアドレスが変わるたびにインフラ担当に作業を依頼する必要がある。

作業の手間とヒューマンエラーをなくすために、自動化を試みた。

要件

  • 許可したいIPアドレスと対象のセキュリティグループを入力するとインバウンドルールが追加される。
  • 追加された任意のIPアドレスを削除する機能も用意する。
  • 機能を使用できるユーザーと対象のセキュリティグループをあらかじめ制限する。
  • 誰がいつどんな操作を行ったのか履歴を残す。

使用技術

やっぱりこういうボットはSlackかなあと思ったのでスラッシュコマンドで作ることにしました。

バックエンドはLambda+API Gatewayです。

スラッシュコマンドはGASで作る人が多い気がするんですが、今回はAWSAPIを用いるのでこれ以外の選択肢はまあないですね。

f:id:bluepixel:20200424234524p:plain

f:id:bluepixel:20200424235631p:plain

1. Slackアプリの設定

以下からアプリを新しく作成する。
Slack API: Applications | Slack

[Slash Commands] を選択し、コマンドとURLを入力する。
この時点ではURLが決まっていないので適当に入れておく。
コマンドは/ipとする。

Usageにはパラメータのヒントなどを入れておくのだが、とりあえず空にしておく。

ワークスペースにインストールして一旦作業は終わり。
Verification TokenBot User OAuth Access Tokenがあとで必要になるのでコピーしておく。

2. Lambdaの作成

ロジックの作り込みはちょっと長くなるので、まずは疎通確認用にシンプルに200を返す関数を作る。

ランタイムはNode.js 12.x

exports.handler = async (event, context) => {
    console.log(event)
    return { statusCode: 200 } 
}

3. API Gatewayの作成

POSTリソースを作成する。ここではmenuというリソース名にする。
統合リクエストに先ほどの関数を指定して、マッピングテンプレートを追加する。

f:id:bluepixel:20200425002632p:plain

Slackから送られてくるリクエストはapplication/x-www-foorm-urlencodedなので、これを扱いやすいようにJSONに変換する処理を入れる。

このテンプレートのベストプラクティスについてはAWS Developer Forumでも議論されているようだが、下記の記事のものが良い感じの落とし所だと思う。
公式で提供してほしい。。。

qiita.com

これで適当にステージ作ってデプロイして、生成されたURLをさっき作ったSlackアプリの方に設定します。

これでSlackに/ipとポストすると{ statusCode: 200 }が返ります。

CloudWatch Logsにはパラメータのログが出るのでスキーマを確認しておきます。

{
  token: "**********",
  team_id: "**********",
  team_domain: "**********",
  channel_id: "**********",
  channel_name: "privategroup",
  user_id: "**********",
  user_name: "**********",
  command: "/ip",
  response_url: "https://hooks.slack.com/commands/**********",
  trigger_id: "**********"
}

tokenは最初にメモしたVerification Tokenに相当します。これが一致すると、正しくSlackからリクエストが送られてきているという証明になります。

user_idはコマンドを実行したユーザーです。ここを見れば権限が制御できそうですね。

4. 認証

最初に基本的な認証を入れておきます。
Verfication Tokenはすでに説明した通りです。ちなみにこれすでにDeprecatedになっている方法です。気になる人はsigned secretsを使ってください。

api.slack.com

あとはユーザーに制限をかけておきます。 許可するユーザーのSlackのIDを環境変数ALLOWED_USERSにカンマ区切りで持っておき判定することにします。

ユーザーIDの調べ方ですが、自分のアカウントは設定から、
ワークスペースの管理者であれば管理画面から一括でCSVダウンロードができます。

f:id:bluepixel:20200425010350p:plain

2つの認証をかけた状態です。

if (event["token"] != process.env.VERIFY_TOKEN) {
    return { "statusCode": 400, "body": "invalid verification token" }
}
    
if (!process.env.ALLOWED_USERS.split(",").includes(event['user_id'])) {
    return { "statusCode": 400, "body": "non-allowed user" }
}

5. dialog.open

中身の実装に移っていくわけですが、少しアーキテクチャを考えます。
スラッシュコマンドからパラメータを受け取って処理する場合、ユーザーに手順を覚えてもらう必要があります。引数の順番やフォーマットが正しくないとパースする側でしんどいという問題もあります。

application/x-www-form-urlencodedで送られてくるパラメータは、ユーザーが入力した部分は全てtextというキーに入れられ、スペースが+に置換された一つの文字列となるので、順番を間違えるともう終わりです。

やはりUIが必要になります。

今回はdialog.openというAPIを使って、ダイアログでユーザーの入力を補助することにします。 f:id:bluepixel:20200425011447p:plain

なので本命の追加・削除処理の前に、ダイアログを開くための関数を別で作ります。

6. 追加ダイアログの実装

対象となるセキュリティグループを取得します。
環境変数ALLOWED_SGSにカンマ区切りでグループIDを入れておきます。 取得したセキュリティグループをセレクトボックスで選択式にして、あとはIPアドレスとコメントの入力欄を用意したダイアログを返します。

const aws = require("aws-sdk")
const ec2 = new aws.EC2()
const axios = require("axios")

exports.handler = async (event, context) => {
    /*
     (中略)認証
    */
    
    const headers = {
      "Content-Type": "application/json; charset=utf-8",
      "Authorization": `Bearer ${process.env.SLACK_TOKEN}`
    }
    const body = await build_body(event["trigger_id"])

    const res = await axios.post('https://slack.com/api/dialog.open', body, {"headers":headers})
      .then(function (response) {
        console.log(response.data);
        return response.data;
      }).catch(function (err) {
        console.error(err)
        return
      })

    if(!res || !res["ok"]) return { statusCode: 400, body: "something went wrong." }
    return { statusCode: 200 }
};


async function getGroup() {
  return ec2.describeSecurityGroups({"GroupIds": process.env.ALLOWED_SGS.split(",")}).promise().then(function(data) {
    return data
  })
}

async function build_body(triggerId) {
  const groups = await getGroup()
  return {
    "trigger_id": triggerId,
    "dialog": {
      "callback_id": "add",
      "title": "ssh接続元許可IPアドレスの追加",
      "notify_on_cancel": false,
      "elements": [
        {
          "type": "select",
          "label": "Security Group",
          "name": "securityGroup",
          "options": groups["SecurityGroups"].map(group => {
            return {
              "value": group["GroupId"],
              "label": group["GroupName"]
            }
          })
        },  
        {
          "type": "text",
          "label": "IP Address",
          "name": "ipAddress"
        },
        {
          "type": "text",
          "label": "Comment",
          "name": "comment"
        }
      ]
    }
  }
}

ダイアログの出し方については dialog.openAPIのドキュメントを参照してください。
dialog.open method | Slack

ユーザーがスラッシュコマンドを実行した時のパラメータに含まれるtrigger_idを使ってダイアログを表示させます。このIDは有効期限が3秒しかないため、時間のかかる処理は組めません。

ダイアログの中身のelementsの仕様はドキュメントがまとまっていないので少しハマりました。BlockKitとも微妙に違うのでいろいろ試す必要があります。

f:id:bluepixel:20200425145016p:plain

セレクトボックス にはセキュリティグループの一覧が表示されます。
あらかじめ分かりやすいDescriptionをつけておく必要があります。
GroupNameにしていないのは、値が必須ではないからです。

f:id:bluepixel:20200425145028p:plain

あとは、SlackAPIの仕様としてヘッダーにcharsetを指定しないとWarningが出るのでつけています。また、AuthorizationヘッダーでBearerトークン形式で認証をします。Lambdaの環境変数トークンを追加するのを忘れずに。

const headers = {
  "Content-Type": "application/json; charset=utf-8",
  "Authorization": `Bearer ${process.env.SLACK_TOKEN}`
}

実装は終わりで、あとはセキュリティグループを取得するためにLambdaの実行ロールに以下のインラインポリシーを追加します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "0",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeSecurityGroups"
            ],
            "Resource": "*"
        }
    ]
}

7. 追加APIの実装

ダイアログのsubmitをトリガーにして発火する関数を作ります。
リソースを/addとしてAPI Gatewayに登録し、URLを[Interactivity]の[Request Url]に設定します。

f:id:bluepixel:20200425200326p:plain

const aws = require("aws-sdk")
const ec2 = new aws.EC2()
const axios = require("axios")

exports.handler = async (event, context) => {
  /*
    (中略)認証
  */
    
  const res = await addGroup(
    event["submission"]["securityGroup"], 
    event["submission"]["ipAddress"], 
    event["submission"]["comment"]
  )

  const res2 = await postResult(event, res)

  return { }
}


async function addGroup(groupId, ipAddress, comment) {
  const params = {
    GroupId: groupId, 
    IpPermissions: [
      {
        FromPort: 22,
        ToPort: 22,
        IpProtocol: "tcp", 
        IpRanges: [
          {
            CidrIp: `${ipAddress}/32`, 
            Description: comment
          }
        ]
      }
    ]
  }
  return ec2.authorizeSecurityGroupIngress(params).promise()
    .then(function(data) {
      return true
    }).catch(function (err) {
      console.error(err)
      return
    })
}

async function getGroup(groupId) {
  return ec2.describeSecurityGroups({"GroupIds": [groupId]}).promise().then(function(data) {
    return data
  })
}

async function postResult(event, res) {
  const headers = {
    "Content-Type": "application/json; charset=utf-8",
    "Authorization": `Bearer ${process.env.SLACK_TOKEN}`
  }
  const groups = await getGroup(event["submission"]["securityGroup"])
  let body = {
    "channel": "#ip_address_changer",
    "icon_emoji": ":hammer:",
    "text": "",
    "attachments": [
      {
        "color": "#36a64f",
        "author_name": "IPアドレスの追加/削除",
        "fields": [
          {
            "title": "■ Executed By",
            "value": event["user"]["name"],
            "short": false
          },
          {
            "title": "■ Security Group",
            "value": groups["SecurityGroups"][0]["GroupName"],
            "short": false
          },
          {
            "title": "■ Ip Address",
            "value": event["submission"]["ipAddress"],
            "short": false
          },
          {
            "title": "■ Comment",
            "value": event["submission"]["comment"],
            "short": false
          }
        ]
      }
    ]
  }
  if (res) body["text"] = "Success."
  else body["text"] = "Something went wrong."
  
  return axios.post("https://slack.com/api/chat.postMessage", body, {"headers":headers})
    .then(function (response) {
      return response.data;
    }).catch(function (err) {
      console.error(err)
      return
    }) 
}

まずフォームの受信ですが、スラッシュコマンドの時とSlackから送られてくるペイロードの形式が異なります。同じapplication/x-www-form-urlencodedなんですが、中途半端にJSON文字列化されたフォームがパーセントエンコードされた状態になっています。

payload%3D%7B%22type%22%3A%22dialog_submission%22%2C%22token%22%3A%22xxxxxx%22%2C%22action_ts%22%3A%221587799384.191353%22%2C%22team%22%3A%7B%22id%22%3A%22xxxxxxx%22%2C%22domain%22%3A%22xxxxxx%22%7D%2C%22user%22%3A%7B%22id%22%3A%22xxxxxx%22%2C%22name%22%3A%22xxxxxx%22%7D%2C%22channel%22%3A%7B%22id%22%3A%22xxxxxx%22%2C%22name%22%3A%22xxxxxx%22%7D%2C%22submission%22%3A%7B%22securityGroup%22%3A%22xxxxxx%22%2C%22ipAddress%22%3A%221.1.1.1%22%2C%22comment%22%3A%22test%22%7D%2C%22callback_id%22%3A%22add%22%2C%22response_url%22%3A%22xxxxxx%22%2C%22state%22%3A%22%22%7D

デコードするとこんな感じです。

payload={"type":"dialog_submission","token":"xxxxxx","action_ts":"1587799384.191353","team":{"id":"xxxxxxx","domain":"xxxxxx"},"user":{"id":"xxxxxx","name":"xxxxxx"},"channel":{"id":"xxxxxx","name":"xxxxxx"},"submission":{"securityGroup":"xxxxxx","ipAddress":"1.1.1.1","comment":"test"},"callback_id":"add","response_url":"xxxxxx","state":""}

これをJSONマッピングするためにまたVTLを書きます。
あまりない形式っぽく、探してもネットに落ちてなかったので適当に自作します。

#set($raw = $input.body)
#set($payload = $raw.replace("payload=",""))
#set($jsonPayload = $util.urlDecode($payload))
$jsonPayload

これでLambda側で普通のJSONとして受け取れます。

ユーザーが選択・入力した値はsubmissionに入っているので、それを使ってAWSAPIを呼びます。試しにプロトコルtcpの22番ポートを許可してみます。
サブネットマスク/32に限定します。

Lambdaの実行ロールにec2:AuthorizeSecurityGroupIngressを追加しておいてください。

次にレスポンスです。

submit後にダイアログを閉じるためには、ボディが空のレスポンスを返す必要があります。{ statusCode: 200 }だとダイアログが残り続けます。

監査のために実行したコマンドと結果を投稿します。
ここで、リクエストに含まれるresponse_url は使いません。
なぜかというとこのresponse_urlに対してポストしたメッセージはephemeral、つまりそのユーザーにしか見えないメッセージになるので監査になりません。

普通にchat.postMessageAPIを使います。 Slackアプリの設定でスコープを追加してください。

これでひとまず完成です。

f:id:bluepixel:20200425222643p:plain

f:id:bluepixel:20200425202321p:plain

8. 削除用ダイアログの実装

次は削除の方を実装します。 ダイアログの返却に条件分岐を入れます。

スラッシュコマンドで引数を取るようにして/ip add または /ip remove で条件分岐します。ここの引数は空白スペースが+で置換されるので、余計なパラメータは取り除きます。

const command = event["text"] === undefined ? "" : event["text"].split("+")[0]
let res
if (command == "add") res = await openAddMenu(event)
else if (command == "remove") res = await openRemoveMenu(event)
else return { statusCode: 400, body: "type `/ip add` or `/ip remove`" }

削除のときはDescriptionがいらなくなるのでフィールドを削除します。
IPアドレスは依然、手入力してもらいます。
理想は、セキュリティグループが選択されたら、そのグループにあるルールのIPアドレスを列挙して動的にセレクトボックスを構築したいんですが、仕組みが結構面倒なのでさぼります。

9. 削除APIの実装

ダイアログを構築する際に callback_id を指定していましたが、これはsubmit時に送られてきます。ここの値を見て、追加のダイアログか削除のダイアログかを判定します。

[Interactivity] の [Request URL]って複数設定できないんですかね 🤔

if (event["callback_id"] == "add") {
 res = await addRule(
    event["submission"]["securityGroup"], 
    event["submission"]["ipAddress"], 
    event["submission"]["comment"]
  )
} else {
  res = await removeRule(
    event["submission"]["securityGroup"], 
    event["submission"]["ipAddress"]
  )
}

インバウンドルールの削除は revokeSecurityGroupIngressポリシーが必要です。例によって実行IAMロールに追加してください。

終わり

思いの外時間がかかってしまった。
ハマりどころはVTLのマッピングテンプレートやSlackAPIのドキュメントですね。
結構このドキュメントは足りないし嘘つきます。

コードは全部置いておくので使ってください。 github.com

yumパッケージの依存関係がぐちゃぐちゃになってしまったら

途中でネットワークが切れてyum updateが中断されたりすると、中途半端な状態でパッケージの更新が終わり、直せなくなることがあります。

この状態で再度yum updateを実行すると以下のようなエラーが出る。

setup-2.8.71-10.amzn2.0.1.noarch は次のインストール済みと衝突しています:  ebtables < ('0', '2.0.10', '16.amzn2.0.1'): ebtables-2.0.10-16.amzn2.x86_64
setup-2.8.71-10.amzn2.0.1.noarch は setup-2.8.71-10.amzn2.noarch の複製です
sqlite-3.7.17-8.amzn2.1.1.x86_64 は sqlite-3.7.17-8.amzn2.0.2.x86_64 の複製です
sudo-1.8.23-4.amzn2.2.x86_64 は sudo-1.8.19p2-14.amzn2.x86_64 の複製です
1:system-release-2-11.amzn2.x86_64 は 1:system-release-2-7.amzn2.x86_64 の複製です
systemd-219-57.amzn2.0.12.x86_64 は systemd-219-57.amzn2.0.9.x86_64 の複製です
...

これをクリーンアップするコマンドがこれ。

sudo package-cleanup --cleandupes

コマンドが見つからない時はyum-utilsをインストールする。

yum install yum-utils

EC2のジャーナルログを手動で削除する

長いこと起動しっぱなしのEC2インスタンスのストレージがかなり圧迫されていた。
調べてみると/var/log/journalが2GBくらいになっていたので、手動で削除する。

こういうことです。 f:id:bluepixel:20200423000027p:plain

ちゃんとローテートしてあげるのがベストなんでしょうが、サクッと対処します。

現在のサイズを確認

$ journalctl --disk-usage
Archived and active journals take up 288.0M on disk.

直近7日間分だけを残して削除する

$ journalctl --vacuum-time=7d

または、直近100MB分だけを残して削除する

$ journalctl --vacuum-size=100M

削除ログ

Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/system@fe5dd5541c404809973dd67d4a6495c9-0000000000000001-0005969545edb7d5.journal (56.0M).
Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/user-1000@ef2a851afb0244fb8dfdfda8703782ec-0000000000001260-0005969666109d5a.journal (8.0M).
Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/system@fe5dd5541c404809973dd67d4a6495c9-000000000000fbf5-000598f998be885a.journal (48.0M).
Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/user-1000@ef2a851afb0244fb8dfdfda8703782ec-00000000000149bb-00059952422b0a92.journal (8.0M).
Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/system@fe5dd5541c404809973dd67d4a6495c9-000000000001c9c7-00059b5de5bc53aa.journal (40.0M).
Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/user-1000@ef2a851afb0244fb8dfdfda8703782ec-000000000001d940-00059b9bb0c87d3a.journal (8.0M).
Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/system@fe5dd5541c404809973dd67d4a6495c9-0000000000025e8c-00059dc237183766.journal (40.0M).
Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/system@fe5dd5541c404809973dd67d4a6495c9-000000000002f292-0005a026852ca017.journal (40.0M).
Deleted archived journal /var/log/journal/ec266b643751b53b678b414987b2d77e/user-1000@ef2a851afb0244fb8dfdfda8703782ec-00000000000301c7-0005a06407be2729.journal (8.0M).
Vacuuming done, freed 256.0M of archived journals on disk.

以上です。

Fargateにおけるpuma+Nginxのソケット通信のやり方

f:id:bluepixel:20200415190526p:plain

やること

  • pumaサーバーのアプリをFargateにデプロイする。
  • リクエストはNginxで受ける。
  • Nginxとpumaの通信はソケットを用いて行う。
  • pumaとNginxは同じFargateタスクの別コンテナとして扱う。

こちらの記事にある下図の右側の部分のイメージです。 bluepixel.hatenablog.com

f:id:bluepixel:20200405232213p:plain

今回はNATなしで、パブリックサブネットに作ります。

github.com

アプリケーション

Sinatraで手早く作ります。
本質的な部分ではないので特に解説はしません。

app.rb

require "logger"
require "sinatra"

class App < Sinatra::Base
  set :server, :puma
  set :logging, Logger.new(STDOUT)

  get "/" do
    "Hello world"
  end
end

config/puma.rb

app_path = File.expand_path("..", __dir__)
directory app_path
pidfile "#{app_path}/tmp/pids/puma.pid"
state_path "#{app_path}/tmp/pids/puma.state"
threads 0, 16
bind "unix://#{app_path}/tmp/sockets/puma.sock"
activate_control_app

コンテナ間のソケット通信を行うので、bindにソケットファイルの位置を指定します。アプリをusr/src/app/に配置し、pumaのソケットをtmp/sockets/puma.sockとします。

Dockerfile

アプリケーションコンテナはruby:2.7.1をベースにします。

Dockerfile

FROM ruby:2.7.1

ENV LANG C.UTF-8
ENV TZ Asia/Tokyo
ENV EDITOR vim

RUN apt-get update

RUN gem update --system
RUN gem install bundler

ENV APP_HOME /usr/src/app
RUN mkdir -p $APP_HOME
WORKDIR $APP_HOME
ADD . $APP_HOME

RUN mkdir -p $APP_HOME/tmp/pids
RUN mkdir -p $APP_HOME/tmp/sockets

VOLUME $APP_HOME/tmp

ARG DEPLOYMENT
RUN bundle config deployment $DEPLOYMENT
RUN bundle

大事なのはここです。

VOLUME $APP_HOME/tmp

他のコンテナでマウントできるように、tmp/をボリュームとして作成します。

Nginx

Nginxの方のDockerfileです。

nginx/Dockerfile

FROM nginx:latest
# for health check
RUN apt-get update && apt-get install -y curl 
ADD custom.conf /etc/nginx/conf.d
CMD /usr/sbin/nginx -g 'daemon off;'
EXPOSE 80

ECSのヘルスチェックでcurlが必要になるのでインストールしています。

custom.confにソケット通信の設定を書いてマウントします。

nginx/custom.conf

server {
  listen 80 default_server;

  root /usr/src/app/public;

  location / {
    try_files $uri $uri/index.html $uri.html @puma;
  }

  location @puma {
    proxy_set_header    Host $http_host;
    proxy_set_header    X-Real-IP $remote_addr;
    proxy_set_header    X-Forwarded-Host $host;
    proxy_set_header    X-Forwarded-Server $host;
    proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://my_app;
  }
}

upstream my_app {
  server unix:///usr/src/app/tmp/sockets/puma.sock;
}

デプロイ

ECSにデプロイします。
諸々の設定は前述のリポジトリにCloudFormationのテンプレートがあるので参照してください。VPC含めて全部のリソースを丸ごと作っています。

重要なのはタスク定義のところだなので、そこだけ抜粋します。

ECSTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties: 
      Cpu: !Ref TaskDefinitionCpu
      Memory: !Ref TaskDefinitionMemory
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !Ref ECSTaskRole
      TaskRoleArn: !Ref ECSTaskRole
      NetworkMode: awsvpc
      ContainerDefinitions:
        - Name: app
          Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECR}:latest
          MemoryReservation: !Ref AppMemory
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-create-group: true
              awslogs-group: !Ref ECSLogsGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: !Sub ${AppName}-app
          Command:
            - bundle
            - exec 
            - pumactl
            - start
          HealthCheck:
            Command:
              - CMD-SHELL
              - curl --unix-socket /usr/src/app/tmp/sockets/puma.sock ./
              - "|| exit 1"
            StartPeriod: 15
          Essential: true
        - Name: nginx
          Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECR}:nginx_latest
          MemoryReservation: !Ref NginxMemory
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-create-group: true
              awslogs-group: !Ref ECSLogsGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: !Sub ${AppName}-nginx
          PortMappings:
            - ContainerPort: !Ref Port
          HealthCheck:
            Command:
              - CMD-SHELL
              - curl -f http://localhost/
              - "|| exit 1"
            StartPeriod: 30
          Essential: true
          DependsOn:
            - Condition: HEALTHY
              ContainerName: app
          VolumesFrom:
            - ReadOnly: true
              SourceContainer: app
  • pumaを先に起動する必要があるので、DependsOnでappコンテナがヘルスチェックに成功してからNginxコンテナを起動するようにしています。
  • pumaのヘルスチェックはcurlで行います。curl 7.40.0からUNIXソケットがサポートされているので--unix-socketオプションを付けます。
  • appコンテナのボリュームの設定はDockerfileで行なっているため、ECSでの設定は特に不要です。
  • Nginxコンテナの方でappコンテナのボリュームをマウントする設定をVolumesFromで行います。

開発環境では

docker-compose を使っているのでこんな感じの設定になっています。

version: '3'
services:
  app:
    environment:
      TZ: Asia/Tokyo
    build: 
      context: ./
      args:
        - DEPLOYMENT=false
    command: bundle exec pumactl start
    volumes:
      - .:/usr/src/app
      - bundle:/usr/local/bundle
      - tmp:/usr/src/app/tmp
  nginx:
    build: ./nginx/
    image: nginx:latest
    ports:
      - 8080:80
    volumes: 
      - tmp:/usr/src/app/tmp
    depends_on:
      - app
volumes:
  bundle:
  tmp:

まとめ

いろいろやり方はあると思いますが、これに落ちつきました。

FargateとEC2でも使える方法がまた異なるので、ドキュメントをよく読んで考える必要があります。

docs.aws.amazon.com

docs.docker.jp

docker -v と、Dockerfile内のVOLUMEと、docker-composeのvolumesと、ECSのDockerボリュームとバインドマウントの対応関係がとても複雑。