S3のイベント通知を完全に理解する🧘♀️
概要
今回はS3のイベント通知に関して掘り下げます。
よくあるユースケースとしては、S3に画像が置かれたのをトリガーにLambdaを起動してサムネイル画像を作成するとかでしょうか。
アーキテクチャはこんな感じ。
ターゲット
イベントの送信先として指定できるのは以下の3つのみです。
- Lambda
- SNS
- SQS
別のものを使いたい場合は、EventBridgeという選択肢があります。
とはいえSNS, SQSを経由すれば大体現実的なユースケースはカバーできると思います。
EventBridgeはCloudTrailを介するのでS3のイベント通知に比べると少し面倒です。
一つ注意点として、SQSで利用できるのは通常キューのみとなります。FIFOキューは2020年5月現在サポートされていません。仕組み的に今後サポートされることはなさそうな印象です。
イベントタイプ
大まかに分けると、オブジェクトの作成と削除です。
ただしS3のAPIは豊富なので、どのAPIを介して作成・削除されたのかを細かく絞り込むこともできます。
マネジメントコンソールだとわかりやすいです。
また、キーのプレフィックス・サフィックスを指定することで対象となるオブジェクトを絞り込むこともできます。
作成
例えば、オブジェクト作成イベントは以下の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のSourceArn
にRef
参照を使用すると循環依存になりスタックの作成に失敗します。
そのため、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
サンプルのソースコード
最後まで読んだ人は「SQS完全に理解した🧘♀️」って言っていいです。
CloudFormationのスタックのdiffをGithubで参照できるようにする
続きです。 bluepixel.hatenablog.com
自前のGithub Actionを作っていきます。
ワークフローの内容
CloudFormationで作成したスタックを更新する際、変更セットというものを作る必要があります。
この変更セットには、どのリソースの何が変更されるのが、置換が必要な操作なのかなどの情報を含まれているのですが、CLIで確認するには使い勝手が悪く、また、マネジメントコンソール上では確認できますが、変更されるプロパティの詳細までは表示されません。
まずこれをGithub上でわかりやすく確認できるようにしたいです。
また、IaC(Infrastructure as Code)においてネックとなるレビュー問題を解決します。CloudFormationの場合は、テンプレートとなるjsonまたはymlファイルを目視で確認する、または実際に変更セットを作ってみて内容を確認するという作業が発生します。これをGithub Actionsでオートメーション化して、プルリクがオープンされたのをトリガーに自動で変更内容を貼り付けるようにしたいと思います。
完成系
先に完成のイメージを貼りました。 見た目はマネジメントコンソールをなるべく模すようにしています。
実装
最初にワークフローの流れを整理します。
- チェックアウト
- AWS認証
- 変更セットの作成 (
creat-change-set
) - 作成された変更セットの取得(
describe-change-set
) - diffの抽出・整形
- 変更セットの削除(
delete-change-set
) - diffをプルリクにコメント
赤字の部分が今回マーケットプレイスにリリースしたGithub Actionsになります。
なぜ赤字の部分だけかというと、それは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を選んでいます。
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 </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)
整形部分にはいくつかハックを施しています。
見慣れているマネジメントコンソールっぽく表示するのが見る側にとって負担がないので、まずそれを実現するために色をつけます。
この色を付けるハックはStack Overflowで見つけた方法で、プレースホルダー画像を生成してくれるサイトを外部画像として参照するというものです。
背景色と文字色を同じにして適当なテキストを設定すればぽく見えます。
次に物理リソースIDを消します。長くて表が見づらくなるしそんなに需要はないと判断したためです。
代わりに、変更されるプロパティを追加します。
マネジメントコンソールには表示されないものなんですが、実はAPIでは取得できるのでリストします。本来これが一番重要な情報だったりすると思うんだが...
ちなみに["Target"]["Metadata"]
と["Target"]["Tag"]
は無視してます。
これもあまり需要はないかと思ったので。
最後にこれらを表形式でまとめあげてファイルに書き出すんですが、マークダウンではなくHTMLとしたのには明確な理由があり、
最終的にプルリクにコメントとして投げる時に、JSONのボディに指定する値がmultiline stringだと大いに問題があります。
Githubの仕様としても、改行コードのエンコード・デコードがややこしく、::set-output
を通すとまた変換が行われたり、うまく改行を保ったままポストすることができませんでした。
最終的にたどり着いたのがHTMLで、さらにファイルの中身をcat
とxargs echo
で参照する方法です(catだと失敗する)
run: | curl -X POST \ -H "Authorization: token ${GITHUB_TOKEN}" \ -d "{\"body\": \"$(cat ${FILE_PATH} | xargs echo)\"}" \ ${URL}
自分で実装しておいてあれなんだが、なんで ` curl -d "{\"body\": \"$(cat ${FILE_PATH})\"}" ` だとだめで、 `
— 青いエンジニア🦋 (@itmono_sakuraya) 2020年4月28日
curl -d "{\"body\": \"$(cat ${FILE_PATH} | xargs echo)\"}" ` だとうまくいくのかわからん
テスト
テスト用のワークフローを作ります。
整形したデータはアーティファクトとして利用しています。
コメントのポストに必要なGithubトークンはすでに用意されているものを使います。
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}
リリース
できたものをマーケットプレイスにリリースします。 特に審査もなくサクッと出せます。
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つ選べるが、ぶっちゃけ何が適切なのかよくわからない。
Publish this Action to the GitHub Marketplace
にチェックを入れてリリースタグつければ完成です。
終わり
リリースのハードルも低く驚くほど簡単にできるのでこれはコミュニティの発展が期待できそう。
自前のGithub Actionsを作ってみる
この記事を書いた時に、プラットフォームの発展に結構可能性を感じたので自分でも作ってみる。
何を作るかはさておき、まずは作り方を一通り調べてみる。
ドキュメントはここから。 help.github.com
基本的な仕様
- メタデータは
action.yml
またはaction.yaml
に定義する。 - アクションはDockerコンテナまたはJavaScriptで実装する。
- 自前のアクションは
.github/actions/*
に配置するのが推奨。 - 公開する場合はタグでセマンティックバージョニングを行うのが推奨。
チュートリアル
Dockerで作る方で進めてみる。
このページでは、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を作りました。
マーケットプレイスにリリース済みです。
次回はこれの実装方法とリリースを解説します〜
Slackのスラッシュコマンドとダイアログを使ってEC2のセキュリティグループを編集する
経緯
フルリモート体制になっているので、sshの接続元などにオフィス以外の任意のIPアドレスを追加する必要が出てきた。
メンバーの自宅はIPが固定されていないため、IPアドレスが変わるたびにインフラ担当に作業を依頼する必要がある。
作業の手間とヒューマンエラーをなくすために、自動化を試みた。
要件
- 許可したいIPアドレスと対象のセキュリティグループを入力するとインバウンドルールが追加される。
- 追加された任意のIPアドレスを削除する機能も用意する。
- 機能を使用できるユーザーと対象のセキュリティグループをあらかじめ制限する。
- 誰がいつどんな操作を行ったのか履歴を残す。
使用技術
やっぱりこういうボットはSlackかなあと思ったのでスラッシュコマンドで作ることにしました。
スラッシュコマンドはGASで作る人が多い気がするんですが、今回はAWSのAPIを用いるのでこれ以外の選択肢はまあないですね。
1. Slackアプリの設定
以下からアプリを新しく作成する。
Slack API: Applications | Slack
[Slash Commands] を選択し、コマンドとURLを入力する。
この時点ではURLが決まっていないので適当に入れておく。
コマンドは/ip
とする。
Usageにはパラメータのヒントなどを入れておくのだが、とりあえず空にしておく。
ワークスペースにインストールして一旦作業は終わり。
Verification Token
とBot 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
というリソース名にする。
統合リクエストに先ほどの関数を指定して、マッピングテンプレートを追加する。
Slackから送られてくるリクエストはapplication/x-www-foorm-urlencoded
なので、これを扱いやすいようにJSONに変換する処理を入れる。
このテンプレートのベストプラクティスについてはAWS Developer Forumでも議論されているようだが、下記の記事のものが良い感じの落とし所だと思う。
公式で提供してほしい。。。
これで適当にステージ作ってデプロイして、生成された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
を使ってください。
あとはユーザーに制限をかけておきます。
許可するユーザーのSlackのIDを環境変数ALLOWED_USERS
にカンマ区切りで持っておき判定することにします。
ユーザーIDの調べ方ですが、自分のアカウントは設定から、
ワークスペースの管理者であれば管理画面から一括でCSVダウンロードができます。
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を使って、ダイアログでユーザーの入力を補助することにします。
なので本命の追加・削除処理の前に、ダイアログを開くための関数を別で作ります。
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.open
APIのドキュメントを参照してください。
dialog.open method | Slack
ユーザーがスラッシュコマンドを実行した時のパラメータに含まれるtrigger_id
を使ってダイアログを表示させます。このIDは有効期限が3秒しかないため、時間のかかる処理は組めません。
ダイアログの中身のelements
の仕様はドキュメントがまとまっていないので少しハマりました。BlockKitとも微妙に違うのでいろいろ試す必要があります。
セレクトボックス にはセキュリティグループの一覧が表示されます。
あらかじめ分かりやすいDescription
をつけておく必要があります。
GroupName
にしていないのは、値が必須ではないからです。
あとは、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]に設定します。
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
に入っているので、それを使ってAWSのAPIを呼びます。試しにプロトコルtcp
の22番ポートを許可してみます。
サブネットマスクも/32
に限定します。
Lambdaの実行ロールにec2:AuthorizeSecurityGroupIngress
を追加しておいてください。
次にレスポンスです。
submit後にダイアログを閉じるためには、ボディが空のレスポンスを返す必要があります。{ statusCode: 200 }
だとダイアログが残り続けます。
監査のために実行したコマンドと結果を投稿します。
ここで、リクエストに含まれるresponse_url
は使いません。
なぜかというとこのresponse_url
に対してポストしたメッセージはephemeral
、つまりそのユーザーにしか見えないメッセージになるので監査になりません。
普通にchat.postMessage
APIを使います。
Slackアプリの設定でスコープを追加してください。
これでひとまず完成です。
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くらいになっていたので、手動で削除する。
こういうことです。
ちゃんとローテートしてあげるのがベストなんでしょうが、サクッと対処します。
現在のサイズを確認
$ 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のソケット通信のやり方
やること
- pumaサーバーのアプリをFargateにデプロイする。
- リクエストはNginxで受ける。
- Nginxとpumaの通信はソケットを用いて行う。
- pumaとNginxは同じFargateタスクの別コンテナとして扱う。
こちらの記事にある下図の右側の部分のイメージです。 bluepixel.hatenablog.com
今回はNATなしで、パブリックサブネットに作ります。
アプリケーション
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でも使える方法がまた異なるので、ドキュメントをよく読んで考える必要があります。
docker -v
と、Dockerfile内のVOLUME
と、docker-composeのvolumes
と、ECSのDockerボリュームとバインドマウントの対応関係がとても複雑。