Fargate + Firelensでのログ出力について考える

Fargate + ECSで動かしているバッチが終了した時に、実行結果のサマリーを通知(SNSファンアウト)したいと思ったのですが、色々と方法がありそうなので、つらつらとメモ。

前提としては、こんな感じです。

  • バッチ本体はJavaで実装。ログはLogback + SLF4Jのテンプレ構成。
  • ログの出力量が多いので、CloudWatch LogsにはDEBUGレベルのログは流したくない。
  • AWSのインフラはCDKで構築。

タスク終了時のイベント通知を利用する

一番シンプルで王道な方法は、タスク停止時のイベントアラートを利用する方法。

dev.classmethod.jp

ただ、この方法だと、終了した時に、ジョブの実行結果の詳細を含めた通知ができないんですよね…(まぁそれは別途どこかに記録してそれを利用するべき、という説もありますが…)

アプリケーションのログを利用する

ということで、次の選択肢として、アプリケーションログを拾って通知する方法を考えます。

ログの取り扱いは、Firelensログドライバ(サイドカーコンテナ)を利用しています(これも定石どおりですね)
前述の通り、Javaで実装された場合なので、logback.xmlを修正し、Fargate上で実行されたアプリケーションのログは、標準出力に流れるようにしてあります。

その後の構成としては、 Firelens → Firehose → S3 といった流れで、S3にログが溜まります。
さて、この方法だと、ログを保存するには完全マネージドで申し分ないのですが、バッチの終了を検知して通知することができません。

そこで考えられるのは、以下の方法です。

  1. Firehoseのデータ変換用Lambdaを仕込み、その中で終了ログを拾って通知する。
  2. S3のイベント通知を元にLambdaを発火させ、その中で終了ログを拾って通知する。
  3. Firelens (Fluent Bit)の設定をカスタムし、終了ログはCloudWatch Logsへ、全てのログはFirehoseへ送る。

AとBは手間がかかるのでないな、と思ったのですが、Cをやる場合も、カスタム設定ファイルを使う場合は、AWS公式のイメージだとダメで、自前でFirelens用のイメージをビルドしてあげないとならないみたいで、思ったよりは手間がかかりそう…。
というのも、Fargate 起動タイプを使用するタスクの場合、サポートされる config-file-type の値は file のみだからです。

参考記事

docs.aws.amazon.com

qiita.com

qiita.com

sioncojp.hateblo.jp

S3 SelectのJSON行(JSON Lines)を色々試す

S3 Selectでは、CSV, JSON(行 or ドキュメント), Parquetといったファイル形式がサポートされていますが、このうち、扱いやすさと柔軟性の高そうなJSON行について、調べてみました。

サンプルファイル

今回用意したファイルはこちら。

{ "id": "1", "name": "suzuki", "age": 20 }
{ "id": "2", "name": "tanaka", "birthplace": "Tokyo", "hobby": ["baseball"] }
{ "id": "3", "name": "yamada", "age": 25, "birthplace": "Tokyo", "hobby": ["tennis", "soccer"] }
{ "id": "4", "name": "sato", "birthplace": "Saitama", "hobby": ["baseball"] }

ポイントは、

  • 行ごとに項目の過不足がある
  • 配列型が存在

という点です。

確認はマネジメントコンソールからS3 Selectを直接実行しています(SDK経由だと、出力の形式が少し変わるかもしれません)

  • ファイル形式: JSON
  • JSONタイプ: JSON
  • 圧縮: なし

全件取得 : select * from s3object s

[
    {
        "id": "1",
        "name": "suzuki",
        "age": 20
    },
    {
        "id": "2",
        "name": "tanaka",
        "birthplace": "Tokyo",
        "hobby": [
            "baseball"
        ]
    },
    {
        "id": "3",
        "name": "yamada",
        "age": 25,
        "birthplace": "Tokyo",
        "hobby": [
            "tennis",
            "soccer"
        ]
    },
    {
        "id": "4",
        "name": "sato",
        "birthplace": "Saitama",
        "hobby": [
            "baseball"
        ]
    }
]

全件、全属性を出力。問題ありませんね。

数値型で絞り込み(結果が1件) : select * from s3object s where s.age > 20

{
    "id": "3",
    "name": "yamada",
    "age": 25,
    "birthplace": "Tokyo",
    "hobby": [
        "tennis",
        "soccer"
    ]
}

条件を指定した場合。こちらも想定どおり。

数値型で絞り込み(結果が複数件) : select * from s3object s where s.age >= 20

[
    {
        "id": "1",
        "name": "suzuki",
        "age": 20
    },
    {
        "id": "3",
        "name": "yamada",
        "age": 25,
        "birthplace": "Tokyo",
        "hobby": [
            "tennis",
            "soccer"
        ]
    }
]

1つ上の結果との比較ですが、結果が1件の時はドキュメント型、複数件の時はリスト型で返るみたいですね。

配列型で絞り込み(NGな例) : select * from s3object s where s.hobby = 'baseball'

[]

配列型の項目の検索はこれではダメみたいです。

配列型で絞り込み(OKな例) : select * from s3object s where s.hobby[0] = 'baseball'

[
    {
        "id": "2",
        "name": "tanaka",
        "birthplace": "Tokyo",
        "hobby": [
            "baseball"
        ]
    },
    {
        "id": "4",
        "name": "sato",
        "birthplace": "Saitama",
        "hobby": [
            "baseball"
        ]
    }
]

配列のインデックスを指定してあげればOK。
ちなみに、存在しないインデックスを指定した場合は、エラーにはならず、空のリストが返ります。

ちなみに、インデックスを問わず、contains的な検索をしたい場合は、 'soccer' in s.hobby と書くようです。

文字列で絞り込み(LIKE) : select * from s3object s where s.birthplace like '%o'

[
    {
        "id": "2",
        "name": "tanaka",
        "birthplace": "Tokyo",
        "hobby": [
            "baseball"
        ]
    },
    {
        "id": "3",
        "name": "yamada",
        "age": 25,
        "birthplace": "Tokyo",
        "hobby": [
            "tennis",
            "soccer"
        ]
    }
]

LIKE検索はサポートされています。

文字列で絞り込み(NOT EQUAL) : select * from s3object s where s.birthplace != 'Tokyo'

{
    "id": "4",
    "name": "sato",
    "birthplace": "Saitama",
    "hobby": [
        "baseball"
    ]
}

birthplace項目自体が存在し、かつ、 Tokyo 以外のものが返ります。

項目が存在しないものを探す : select * from s3object s where s.birthplace IS NULL

{
    "id": "1",
    "name": "suzuki",
    "age": 20
}

項目が存在しないものを取得する場合は IS NULL でできます。

ルートレベル以外での検索 : select * from s3object[*].hobby s where s[1] = 'soccer'

{
    "_1": [
        "tennis",
        "soccer"
    ]
}

直近でこれを駆使する機会はなさそうなので触りだけですが、詳しくは公式ドキュメントに記載されています。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/s3-glacier-select-sql-reference-select.html#s3-glacier-select-sql-reference-attribute-access

まとめ

ほぼ予想どおりの動きをしてくれるみたいなので、問題なく使えそうでした。

TypeScriptで配列をn個ずつに分割する

配列をn個ずつの二次元配列にするワンライナーです(Rubyeach_slice 的なやつ) 使い古されたネタなので、いろんな書き方が可能だと思いますが…。

const arraySplit = <T = object>(array: T[], n: number): T[][] =>
  array.reduce((acc: T[][], c, i: number) => (i % n ? acc : [...acc, ...[array.slice(i, i + n)]]), []);

使い方はこんな感じ。

const result = arraySplit<number>([1, 2, 3, 4, 5], 2); // <- [[1, 2], [3, 4], [5]]

JS版として、以下のQiita記事にあるコメントを参考にさせていただきました(実質、それをTypeScript化した形です)

qiita.com

AWS CDKをプロダクションで使うための私見プラクティス

AWS CDK (Cloud Development Kit)の登場によって、ようやく理想的なInfrastructure as Codeが実現できるようになってきたなと感じています。
素で書くCloudFormationテンプレートやAnsible等と比べて、特に以下のあたりが便利ですね。

  • 型があり、補完が効く
  • ロール、ポリシー周りの記述がgrantXxxだけで直感的に記述できる
  • 変更に伴う変更セット周りの面倒くささが解消される

CDKをプロダクションで使っていくために、「こうするといいんじゃないかな」と思う個人的なプラクティスをまとめてみました。

(注) CDKのバージョンは執筆時点での最新バージョンである1.19.0を対象としています。
また、言語は問わない内容にしたつもりですが、TypeScriptしか触っていませんので、TypeScriptが前提です。

書き方はサンプルリポジトリを参考にする

まず、プロジェクトの構成は cdk init app で作られたものを土台にし、下手に弄らないほうが無難だと思います。
この構成だと、lib 以下にスタックの定義を記述していくことになると思います。

公式のサンプルリポジトリに豊富なサンプルがあるので、近しい構成のものを参考にしながら、膨らませていくとよいと思います。

github.com

書けるものはdescriptionを書く

Stack, Lambda, API等、説明(description)が記載できるものについては横着せずに、書いておくのがよいと思います。

const sampleStack = new SampleStack(app, 'SampleStack', {
  description: 'はじめてのAWS CDKスタックです'
});

マネージメントコンソールで一覧で見た時に用途が明確になりますし、コードのコメントのかわりとしても使えます。

~/.aws/credentialsのデフォルトプロファイルに注意

cdk deploy とすると、あまりに簡単にデプロイができてしまいますが、オプション未指定時は ~/.aws/credentials のデフォルトプロファイルが使われることに注意しましょう(事故の元になります)。

常にプロファイルを指定してデプロイするよう心がけたほうが安全だと思います。

$ cdk deploy --profie staging

コードを短くすることにこだわりすぎない

CFnテンプレートを書くのに比べて短い記述量で済むのがCDKのメリットの一つですが、記述を短くすることを最優先にせずに、バランスを考えた方がよい気がしています。

例えば、3つのLambda関数を作成する場合、以下のようにループを用いて書くことができます。

for (const name of ['fn1', 'fn2', 'fn3']) {
  new lambda.Function(this, name, {
    runtime: lambda.Runtime.NODEJS_12_X,
    code: lambda.Code.asset('src'),
    handler: `${name}.handler`
  });
}

多少冗長でも以下のようにありのままに書いた方がよい気がしています。直感的に内容が分かりますし、例えば、後で一部の関数だけメモリやタイムアウトの値を変えたくなった時に素直に対応ができます。
このあたり、DRYの原則に反しますが、テストコードを書く時の感覚に近いかもしれません。

new lambda.Function(this, 'fn1', {
  runtime: lambda.Runtime.NODEJS_12_X,
  code: lambda.Code.asset('src'),
  handler: 'fn1.handler'
});

new lambda.Function(this, 'fn2', {
  runtime: lambda.Runtime.NODEJS_12_X,
  code: lambda.Code.asset('src'),
  handler: 'fn2.handler'
});

new lambda.Function(this, 'fn3', {
  runtime: lambda.Runtime.NODEJS_12_X,
  code: lambda.Code.asset('src'),
  handler: 'fn3.handler'
});

Stackのコンストラクタだけで記述を済ませず、シンプルにする

独自のStackクラスのコンストラクタ内ですべての処理を記載しようとすると、モノによっては、やはり長くなってしまいます。
1つ1つのリソース定義はメソッドに抽出するなどして、コンストラクタでは、全体像がひと目で把握できるシンプルな構造になっていると、後のメンテナンスがしやすいと思います。

import cdk = require('@aws-cdk/core');
import sns = require('@aws-cdk/aws-sns');
import subs = require('@aws-cdk/aws-sns-subscriptions');
import sqs = require('@aws-cdk/aws-sqs');

export class SqsSnsStack extends cdk.Stack {

  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const queue = this.createQueue();

    const topic = this.createTopic();

    topic.addSubscription(new subs.SqsSubscription(queue));
  }

  private createQueue(): sqs.Queue {
    return new sqs.Queue(this, 'HelloCdkQueue', {
      visibilityTimeout: cdk.Duration.seconds(300)
    });
  }

  private createTopic(): sns.Topic {
    return new sns.Topic(this, 'HelloCdkTopic');
  }
}

STABLEなものだけで構築できそうならば、CDKを採用する

CDKは新しいツールであり、2018年8月にDeveloper Previewとして発表された頃からGitHubをウォッチしていますが、アップデートが頻繁にあります。
まぁ、GAになってからは落ち着いた感じもしますが、それでも、今後、破壊的な変更がある可能性はゼロではないと思います。

公式のAPIリファレンスを見ると、各サービスごとに、CDKでの安定性(stability)ステータスが確認できます。例えば、Lambdaの場合は下記URLです。

https://docs.aws.amazon.com/cdk/api/latest/docs/aws-lambda-readme.html

ちょっと乱暴ですが、ステータスが実験的(EXPERIMENTAL)になっているものの利用は避け、安定(STABLE)なものだけで実現可能な場合にのみ、CDKを採用する、というのも手です。
使いたいオプションがサポートされていなかったり、という罠にハマることが少ないと思うので…(まぁIssueなりPullRequestを送ればよい話ではありますが…)。

まぁ、サーバレスなアプリケーションを作る定番サービス(DynamoDB, Lambda, API Gateway, SNS, SQS, etc)はいずれもSTABLEなので、多くのケースでは実戦で使えると思ってます。

Runtime Contextをうまく活用する

CDKにはContextという形で、パラメータ値をスタックに流し込むことができます。

https://docs.aws.amazon.com/cdk/latest/guide/context.html

うまく活用することで、デプロイ時の不測の変更を防いだり、環境(ステージ)間の差をコードから分離して管理することができるようになります。

参考記事

CloudFormationのベストプラクティスに基づいて使う

CDKは新しいAWSのサービスというわけではなく、実態はCloudFormationであり、CFnをプログラム的に扱えるようにうまく抽象化したものです。
そのため、CDKを使っていく上でも、CFnの知識は不可欠であり、CFnには成熟したベストプラクティスが存在するので、まずはそれを身につけた上で使っていきましょう。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/best-practices.html

GradleからbootRunを実行する時にコマンドライン引数でパラメータを渡したい

GradleからbootRun (Spring Bootアプリケーションの起動タスク)を実行する時に、コマンドライン引数でパラメータを渡す方法についてです。

注:古めのバージョン(Spring Boot 1.4.x, Gradle 3.x)で確認しているため、最新バージョンでは動作しない可能性があります。

build.gradleで指定する場合

はじめに、 build.gradle で指定する場合は以下のように書きます。

bootRun {
   jvmArgs  ['-Dspring.profiles.active=local']
}

コマンドライン引数で渡す場合

そもそもSpring Bootでは、環境変数やプロパティファイルなど、多様で柔軟な方式で外部設定値を与えることができます。
コマンドライン引数もそれらの選択肢のうちの一つであり、プロパティファイルを書き換えたり環境変数を指定するまでもないようなケースで便利(お手軽)です。

いきなり結論ですが、コマンド実行の際は以下のようにします。

$ ./gradlew bootRun -PjvmArgs="-Dspring.profiles.active=local"

-P はプロジェクトプロパティを設定するGradleのオプションです。

合わせて、 build.gradle も以下のようにします。

bootRun {
    if (project.hasProperty('jvmArgs')) {
        jvmArgs project.jvmArgs.split('\\s+') as List
    }
}

ハイブリッド版

build.gradle でデフォルト値は決めつつ、コマンドライン引数で追加の設定を与えたいような、上記2つのハイブリッド版です。
コマンド実行方法は同じなので省略します。 build.gradle をちょっと書き換えるだけでOKです。このあたりの柔軟性はやりすぎ注意ですが、やはりGradleは便利ですね。

bootRun {
    def myJvmArgs = ['-Dfoo=aaa']
    if (project.hasProperty('jvmArgs')) {
        myJvmArgs.addAll(project.jvmArgs.split('\\s+') as List)
    }
    jvmArgs myJvmArgs
}

CodeBuildの標準イメージ(マネージド)上でdocker-composeを使ってRDBやRedisを利用する

CodeBuildの標準イメージで、docker-composeが使えるようになっていました。

docs.aws.amazon.com

でそれぞれ使えそうです(リンク先はGitHub上のDockerfile)。

これまでは、MySQLやRedisなどが絡んだテストを実行したい場合、ビルドのフェーズ内でパッケージをインストールする or 独自のイメージを用意する等の対応が必要でした(よね?)。
今では、日常的な開発環境構築に利用する docker-compose.yml をそのまま使って docker-compose up -d するだけで、使い捨てのビルド環境が構築できるということですね。

試してみて気づいた細かい注意点などをメモしておきます。

UbuntuAmazon Linuxの違い

次に挙げるように、パッケージマネージャやJavaのランタイムの指定の違いがあり、共通の buildspec.yml でどちらのOSにも対応するのはちょっと難しいかもしれません。
もっとも、どちらかのOSに対応していればほとんどのケースでは十分だと思いますが…。

パッケージインストール用のコマンド

CodeBuildの buildspec.yml を書く上では、パッケージマネージャが異なることは気をつける必要があります。

Ubuntu の場合は apt / apt-get, Amazon Linux の場合は yum を利用します。

runtime-versions で指定できるjavaの種類

aws/codebuild/standard:2.0 を使っている場合、 buildspec.yml 中で runtime-versions を指定する必要があります。

docs.aws.amazon.com

未指定の場合、以下のようなエラーが発生します。

YAML_FILE_ERROR Message: This build image requires selecting at least one runtime version.

例えば、ランタイムにJavaを使いたい場合は、以下のように指定します。

phases:
  install:
    runtime-versions:
      docker: 18
      java: openjdk8

ここで指定できるJavaの値ですが、OSによって次のような違いがあるみたいです(correttoはAWSの OpenJDKディストリビューションですね)

OS 指定可能な値
Ubuntu openjdk8, openjdk11
Amazon Linux corretto8, corretto11

coretto の指定について、(この記事を書いている時点で)日本語のドキュメントには書かれていませんが、英語のドキュメントには書かれています。

Runtime Versions in Buildspec File Sample for CodeBuild - AWS CodeBuild

高速化のためのローカルキャッシュの利用

ビルド時間の短縮は常に重要な関心事です。素直に作ったビルドジョブにおいては、ビルドの時間を大きく占めるのが、Docker イメージや依存関係のあるライブラリのダウンロードでしょう。
2017年の末頃からS3上へのキャッシュはサポートされていましたが、今ではローカルキャッシュも利用できます。ネットワーク越しでないため、2回目以降のビルドがさらに短縮できることが期待できます。

docs.aws.amazon.com

JavaでGradleを使っている場合は、

cache:
  paths:
    - '/root/.gradle/**/*'

と指定しておけばOKだと思います。
ちなみに、ローカルキャッシュに関するサイズの上限や有効期間については、AWSドキュメント上では言及がありません。キャッシュがあってもなくても、ビルドが安定するような作りにしておくことが大切ですね。

あとは、Docker18.09.0からサポートされたBuildKitなどを有効化してみるのも効果があると思います。

GitHubで2FAを有効にした時にgit pushでAuthentication failedや403エラーになった時の対応

GitHubで途中でアカウントの2FAを有効にした場合、今まで使っていたリポジトリgit push origin xxx を実行すると、

remote: Invalid username or password.
fatal: Authentication failed for 'https://github.com/hoge/some-repo.git/'

とか、

fatal: unable to access 'https://github.com/hoge/some-repo.git/': The requested URL returned error: 403

といったエラーに遭遇します。
その時の対応法です。

解決法

  1. 新しくPersonal access tokensを発行する
  2. .git/configに記載されている urlhttps://{account name}@github.com/hoge/some-repo.git の形式にする
  3. 初回push時にパスワードを聞かれるので、1.で発行したPersonal access tokenを入力(ペースト)する

参考