年中アイス

いろいろつらつら

ECSのコンテナ間でNLBを経由した通信がつながらないことがある

gRPCで作った複数のサービスを、同じECS Cluster上で動かし、他のサービスを呼び出している時に、通信できなくなったことがあったので、その原因のメモです。

直接的な原因は、「NLBは、接続元と接続先が同一のIPへの通信ができない」という仕様に意図せず該当したことでした。

このドキュメントにあるように、ヘアピン、ループバックはタイムアウトしますと記載があります。

Internal load balancers do not support hairpinning or loopback.

EC2で直接動作させている場合は、同一ホストに複数のAPIなどを乗せて他のインスタンスとつなぐことは少ないのかと思います。*1しかし、ECSがBridgeモードの場合に、同じインスタンスの別ポートで、複数のコンテナが動くことがあります。その時にこの問題が発生します。*2

例えば、10.0.0.10のECS node instance上で、以下のコンテナが動いていたとします。

  • APIがport 30000で動作
  • 画像用の内部APIがport 30001で動作

この時に、APIが画像APIに対してリクエストを行うと、10.0.0.10 -> NLB -> 10.0.0.10:30001とリクエストがルーティングされることになり、前述の原因に該当します。この状態がBridgeモードでのネットワークの下で行われるのでとても気づきにくいのです。

せめてタイムアウトじゃなくて何かエラーを出してもらえると気付きやすいのですが、TCPレイヤの話なので明確にこのエラーを伝える術がないのかなと思ったりもします。

回避策はドキュメントの回答にあるように、network modeをbridgeではなく、awsvpcモードにして、コンテナごとにIPアドレスが割り当てられるようにすることです。ただ、awsvpcモードはタスクごとにENIを消費し、node instanceのtype/sizeごとに上限が決まっているので、コンテナの集積度が一気に下がります。

instance typeごとのENI上限awsvpcモードでの考慮点を見るとc4.largeでも3個です。1個はそもそもインスタンス自体が使うので、-1した数が、そのインスタンス上で動くコンテナの上限になります。そうなると、c4.largeだと2コンテナしか動かせず、1コア2GB弱/コンテナの割り当てになり、Goの薄いAPIみたいなのだとそんなにリソースいらないからもっと乗せたい、となるのです。

先日、ECS Service DiscoveryがTokyoにも来たので、それを使ってNLBを介さずに、sidecarとしてclient side balancingをやるのがいいのかなと考えています。

終わりに

こんなことを書いたんですが、一足先にクラスメソッドさんも同じことを書いていて ECSがたくさん使われ始めて顕在化するようになったのかなと思います。1日空けて再度内容確認してから投稿しようとしてたら、かぶることに。。。

*1:動かすことはあっても、同一EC2上instance上で動作させるなら、NLB介さずにlocal port経由する方がシンプルです

*2:ECS Clusterに属するnodeが少ない場合に、発生しやすくなり、数十以上あると、まれに発生する奇妙な状態になって気づきにくそうです。

hakoへのプルリク その2

前回に引き続きcontainer health checkの対応をプルリクしました。

github.com

設定追加するだけだし、簡単だーと思ってましたが、何箇所か指摘頂いたので、備忘録。 簡単かなーと思ってましたが、危うく変な挙動を仕込むところでした。

設定値はオプションのものがある

まず、container health checkは以下の設定項目があります。 HealthCheck - Amazon EC2 Container Service

  • command
  • interval
  • retries
  • timeout
  • start period

これらのうち必須なのは、commandだけで、あとはなければデフォルト値が使われます。ですが、hako上必須扱いになってしまっていて、記述を忘れるとエラーになってしまう状態でした。

これに対しては、記述なしに対処する修正をしました。

TaskDefinition更新の差分確認があり、デフォルト値が必要

前述のオプションを対処して、動作確認していました。 すると実はhakoがTaskDefinitionを更新するか確認している部分で、AWSからはデフォルト値、hakoからは値なしで差分確認してしまい、差分がないのにTaskDefinitionを更新ありと判断して、更新、デプロイされる状態になっていました。

自分で同じ設定の時にデプロイし直さないので、気づかず見落とし。 オプションでデフォルト値があるものは、デフォルト値をhako側にも入れるように修正しました。

とりあえずこれでcontainer health checkの追加自体は完了。

exampleをどうするかで少し悩む

最初一番のexampleのhello.jsonnetに追加してましたが、container health checkはオプションだし、他のsidecarとかあるとわかりにくいかもだし、分けるかなーと夜中プルリク書きながら考えてました。(その日は眠くてexampleの書き直しは未実施)

翌日、実際分離してみると、HTTPサーバなのに外からのアクセス設定がなく、container health checkだけあるという奇妙なexampleになってしまったので、hello.jsonnetに残すままにしました。

所感など

設定値の必須/任意のところは単純なミスでしたが、TaskDefinitionの差分のところは運用面で影響がありえる*1ものだったので、見つけてもらってよかったです。

v3の話もissueに載っていてうちで少し変更を要するのはyamlの廃止予定。と言ってもCodeBuildでテンプレートから生成しているので、そこ変えればいいかなというぐらいですが。あんまりjsonnetで何かするような設定がないので、まだyamlのまま移行はしてないです。

あと未プルリクなのは、既存のALB/NLBに別ポートでぶら下げる機能。複数サービスを同一のALB/NLBで扱えるようにするもの。今は単にあったらそれにattachしていて、ALB/NLBの設定を反映させてなかったりするので、その辺りどういう感じにするべきかもう一度考えてみようと思います。

参考など

*1:厳密にはデプロイされても平気な構成であるべきですが、全く必要なくコンテナの入れ替えが走るのは良くないので防ぐべきです。

Goでファイルの特定位置から読む

bashで組んでたログの検知スクリプトを、メンテナンス性と拡張性考えてGoで書き直すことにしました。 ファイルを定期的にtailして一定行数読み込んで処理する方式を取っていましたが、最後に読んだとこから後にしたいと調べていたら、Mackerelが似たようなことをやっていたので参考にしました。

go-check-plugins/check-log.go at master · mackerelio/go-check-plugins · GitHub

File.Seek()を使って、ファイルの特定バイトから読むようにできます。 前回読み込んだバイト数を記録しておけば、次はその後から追加されたデータのみを読むことができます。

whence*1が、ぱっと見何かわからなかったのですが、この値によってoffsetの使われ方が変わります。

whence 意味
0 ファイルの先頭からのoffset(先頭からスキップするバイト数)
1 今のSeek位置からのoffset(前回Seekした位置からスキップするバイト数)
2 ファイルの末尾からのoffset(末尾から読む。この場合は負数にしないと読めません)

サンプル

Readだけ動作試してみました。

一部抜粋

data

File seek in Go.

コード

fp, err := os.Open("./data")
if err != nil {
    panic(err)
}
defer fp.Close()

fp.Seek(5, 0)
b, err := ioutil.ReadAll(fp)
if err != nil {
    panic(err)
}
fmt.Printf("offset 5, whence 0: %s\n", string(b))

出力

offset 5, whence 0: seek in Go.

whence=2の時にoffsetをマイナスにしてなくて、読めないなーと勘違いしてました。今回試したのはReadですがWriteも同様に位置指定して書き込めるようです。その際は、os.Open()はReadOnlyなので、os.OpenFile(), os.Create()あたりで書き込める状態で作る必要があります。

*1:whenceあんまり聞かない単語でGoogleに投げると「そこから」と訳されました。どこから?の区分値と捉えると納得

elasticsearch bulkAPIで地味にはまる

elasticsearch 6.3が出たので、データ突っ込んで試してみるかと、index作ってデータを投入することに。 curl叩きながら、確認を進めてましたが、BulkAPIを使って、データを入れるときに、以下のようなエラーが出ました。

{"error":"ActionRequestValidationException[Validation Failed: 1: no requests added;]","status":400}

400でvalidationなので、投げてる側の問題と判断して、BulkAPI用のJSONを眺めてみても、特にJSONとしてもelasticsearchとしても問題なさそう。 判明した原因は、curl-dを使っていたことでした。ドキュメントにも

If you’re providing text file input to curl, you must use the --data-binary flag instead of plain -d. The latter doesn’t preserve newlines.

curlなら--data-binaryを使ってくださいと書いてあり、-dがこの省略形かと勘違いしていました。 create indexや検索など他のエンドポイントだと-dでも通るのでなんでだろうと。

elasticsearchのドキュメントちゃんと書いてあってすごいですね。

参考

hakoに機能追加

ECSを本格利用し始め、デプロイの管理にhakoを使うことにしました。最近ECSに機能追加があり、hako未対応のものを使いたくなったこともあり、hakoに追加してみることにしました。

作った機能

いくつか機能を追加して、プルリク投げてマージされたもの、プルリク中のもの、まだ確認中のものと4つ作っています。必要に応じて順次追加していて、投げれそうなものはPRにしています。主に、internalなmicro serviceを動かすユースケースでECSを使っているので、それに応じたものがあります。

health check grace periodサポート

ECS導入する時に使いたかったので、追加してプルリクしました。さすがにこれは単純な設定値の追加なのですぐマージされました。

github.com

この変更で初めてhakoいじったんですが、Rubocopのエラーに悩まされて、自分で変更した部分がルールに合っていなかったものと、Rubocop自体のアップデートのせいなのかエラーになっていたものとありました。

NLBサポート

gRPCをコンテナにして、ECSで動かす予定で、NLB(Network Load Balancer)をサポートしました。PR反応がないので、とりあえず置いたままです。

github.com

一つのALBに複数のECSサービスを紐付ける

hakoはデフォルトで、設定を書いたymlファイル名のALBを作ります。ただ、開発環境などで、hakoファイルごと(サービスごとに)ALBできるとお金かかるので、ポートを変えて、ALBを指定して同一のALBを使うようにしたものです。ポートが変わるので、internal ALB前提です。

GitHub - reiki4040/hako at feature/add-service-to-exists-elbv2

NLBサポートを先にPR出してるのは、これをNLBにも適用したいためですが、これは方針が本家の人が考えるのと合うのかという点があります。一応自分の環境でもしばらく使ってみてからにしようかなという状態です。

ECSのcontainer health checkのサポート

2018/03にECSに追加されたばかりの機能で、とりあえず使ってみようとのことで実装しました。設定値は反映されることを確認しました。AWS SDKのバージョン上がってます。

GitHub - reiki4040/hako at feature/container_healthcheck

機能追加したhakoを使うためのブランチ

上記をまとめて動作確認する用に、featuresと言うブランチを作って、そこに上記未マージ分をまとめて、バージョン番号変えてgemを作って試しています。自分の変更を入れたgemを作る方法は後述しています。

hakoをいじるにあたってのメモ

Rubyは普段使っていない言語なので、とりあえず自分でhakoのコードいじったときのメモです。

まず、変更したコードの実行について。exe/hakoがあったので、lib/hako/version.rbを変えてとりあえずexe/hako --versionしてみましたが、変更したバージョンは出てきませんでした。

調べてみるとbundlerを使って関連ライブラリを入れて、bundle exec hakoといった形でやる必要があったようで、それをやったらローカルの変更が反映されて実行することができました。

  • bundlerのインストール
gem install bundler
  • 関連ライブラリをローカルの環境(プロジェクト?以下に)インストール
bundle install --path=vendor/bundle --binstubs=bundle_bin

—binstubs=bundle_binはrbenv用っぽいので使わなければ不要かも。

  • bundle execで実行するとローカルの変更分が実行されます。
bundle exec hako —version

これでとりあえず自分でhakoのコードを変更したものをhakoコマンドとして実行できます。 また、hakoはrspecでのテストとrubocopでのコードスタイルチェックが実行できます。

bundle exec rake

最初これやってなくて、テスト未実行状態でした。

自分でgemを作って使う

hakoは、bundle exec rake installでpkg/以下にgemファイルが作られます。 それを対象サーバでgem install --local <gem>とすれば、インストールでき、使用することができます。

これで、自分の環境で変更を入れたhakoを使うことができます。

RubyAWS SDKのリファレンス

Ruby SDKのdocumentがよくわからなくても、aws cliで同様の機能(API)の調べて動かすと、項目とか、実際の値も見れてわかりやすかったです。Rubyに限らず、分からなかったらwas cliで動かしてみるのわかりやすいです。

所感など

普段はGoを書くことが多く、Rubyはツール使ってるぐらいで、作法がよくわからない感じでコード書きましたが、とりあえず欲しい機能は実現はできています。rakeで足回りのことができるようになっていたので、実現したいことのコードを書くぐらいで済んだのかなと思います。

一応本家に取り込んでもらいたいなーとは考えつつ*1自分の環境で使って、使い勝手を確認中です。個人的には、Goでワンバイナリで配布できるようなものを作りたくはありますが、まずhakoを使って運用してみて、どうあるのが自分としては好みなのか(使いやすいのか)考えていこうかなと思ってます。

参考など

*1:あまり環境固有の機能実装はしない

API Gateway+LambdaのSlack通知APIをCloudFormationで作る

以前、三編に分けて、SlackへPostするWebAPIを、API Gateway+Lambda+node.jsで作りました。

しかしその後、AWS ConsoleのLambdaのUIが変わったので、一部のキャプチャが参考になりづらくなってしまいました。キャプチャし直すのも手間なので、今度はCloudFormationを使って自動で構築します。

CloudFormationで自動構築する

CloudFormation templateを作ったのであとは実行するだけです。READMEにも書いてますが、以下3段階です。

  • SlackのWebhook URLを発行
  • CloudFormationを実行
  • CloudFormationの結果をもとに、slackへのpostを実行

SlackのWebhook URLを発行

前編を参考に発行します。 公式のWebhookドキュメントも一応添えておきます。

発行できたら、apigateway.json"SLACK_WEBHOOK_URL":"none"を取得したURLで置き換えます。(noneを置き換えます。)

CloudFormationを実行

awsコマンドを使って、stackを作成します。stackの構築が終わるまで、数分かかります。

aws cloudformation create-stack --stack-name PostSlackCfn --template-body file://apigateway.json --region ap-northeast-1 --capabilities CAPABILITY_IAM

CloudFormationの結果をもとに、slackへのpostを実行

結果を取得します。

aws cloudformation describe-stacks --stack-name PostSlackCfn

この結果のOutputの部分のapi keyとpost urlをコピーしておきます。

API keyは別途取得する必要があり、前述のapi keyを使って取得します

aws apigateway get-api-key --api-key <your api key id> --include-value

--include-valueオプションがないと、API keyは表示されません。

curlで投げて、HiとSlackに届けばOKです。

curl -XPOST <your post url> -H "x-api-key: <your api key>" -H 'Content-Type: application/json' -d '{"message":"Hi"}'

届かない場合は、CloudWatchLogsでLambdaのログを確認したり、後編に書いているように、API Gatewayのテスト実行をやるなどで調べてみてください。

まとめ

API Gatewayは手で設定していくのは面倒なので、CloudFormationの方が向いてますね。今回、CloudFormation初めて扱いましたが、基本的な構成要素を理解するまで、作成、削除を繰り返したので時間がかかりました。

課題としては、WebHookURLみたいなリポジトリに入れたくないような値はどう扱うのがいいのか。また、もっと大きなくくりでは、CloudFormationが失敗した時に、全部消して最初からになったので、実運用で失敗した時に、うまくリカバリできるのかが不安要素。今回はそこまで調べてないです。

主にCloudFormation化について書いたので、元々の構成詳細については、三編それぞれ見てもらえればと思います。

ここからは、つまづいた点などのメモ。

最初参考にした記事

AWS CloudFormation が Amazon API Gateway をサポートしたので使ってみた | Developers.IO

ここを参考にcloudformationでAPI gatewayのサンプルを作成してみました。 APIを定義して、Resource(URL Pathになるところ)を定義して、Methodを定義する形。とりあえずすぐできました。

DeploymentとMethodで起きる順序問題も、ここにあるDependsOnで理解しました。

/に対してMethodを定義したい

参考にしたのは、Resourceを作る前提でしたが、もともと/に直接POSTで動かしていたので、どうやるのかなと。 結果Resource定義なしでMethodのResourceには、{ "Fn::GetAtt": [“PostSlackCfn”, "RootResourceId"] } を指定すればよかったです。

Lambda関連を追加したら、create stackでエラー

An error occurred (InsufficientCapabilitiesException) when calling the CreateStack operation: Requires capabilities : [CAPABILITY_IAM]

調べると以下のようなものが出てきました。

IAMが絡むと必要になるようで、LambdaとそのRoleを追加したタイミングで発生したようです。 —capabilities CAPABILITY_IAMを追加したら動くようになりました。*1

Lambdaの環境変数を定義したい

これはすぐ見つかりました。

でも秘匿情報はコミットしたくないので、どう別管理するのがいいのかは未解決。今回はサンプルで自分のローカルで変更して実行で済ませています。

Stageを定義しないとUsagePlanにStageを指定できないが、DeploymentとStageを両方書くとStageがすでにあるエラーが出る

DeploymentでStageが作られるので、それをUsagePlanに指定しようとしましたが、うまく指定の仕方がわからず。Deploymentで作成させるStageの名前を入れても、そんなものはないと怒られる始末。さらにDeploymentとStageで同じ名前にすると、すでにあるからダメと。

結局解決策わからず、Stageを定義しないと指定できないので、以下を参考に、Deploymentではダミーを作って、Stageを別定義して、それをUsagePlanに指定しました。

参考

*1:CAPABILITY_IAM、はっきり理解はできてません。権限いるから明示的に許可するってことでしょうか

GoでJSONの数字/数値を扱う

JSONはデータの表現形式です。表現はできますが、項目があるかや、型が何であるかを検査する仕組みはありません。*1

そのため、システムや実装が分かれている場合に、数値を期待しているが、JSONの表現上、数値か数字かがずれていることが起き得ます。どういうことかというと、"numeric":1234 (数値)なのか、"numeric":"1234" (数字)なのかが違う、または不定ということ。仕様の齟齬や、実装ミスなどで発生します。

動的型付け言語の場合、よしなに扱ってくれたりしますが、Goにそれをやられると型エラーが発生してしまいます。GoでJSONを読み込む場合、encoding/jsonパッケージjson.Unmarshal()を使って、structに読み込む方法があります。しかし、stringで定義していたら、数値がNG、int系で定義していたら、数字がNGです。

stringは数値がダメ(playground)

type Num struct {
    Number string
}

num := Num{}
err := json.Unmarshal([]byte(`{"number":1234}`), &num)
    
// json: cannot unmarshal number into Go struct field Num.Number of type string

int64は数字がダメ(playground)

type Num struct {
    Number int64
}

num := Num{}
err := json.Unmarshal([]byte(`{"number":"1234"}`), &num)

// json: cannot unmarshal string into Go struct field Num.Number of type int64  

Goの場合、Unmarshalで読み込めないと、黒魔術的にJSONを解析する必要がある*2ので、Unmarshalで済ませたいのです。その際に、json.Numberを使います。

使い方は簡単で、数値数字不定になっている属性の型をjson.Numberにして、そのstructを使ってUnmarshalします。そうすると数字でも数値でもabcdeなどの文字列でも、Unmarshal時点ではエラーになりません。型の不一致で、JSON全体の読み込みを止めなくて済みます。もちろん使う時に数値変換は必要で、Int64()を使いますが、数字以外の文字列の場合はエラーになります。(ParseInt失敗するのと同様)

type Num struct {
    Number1 json.Number
    Number2 json.Number
    Number3 json.Number
}

num := Num{}
// Unmarshalではエラーにならない
err := json.Unmarshal([]byte(`{"number1":1111,"number2":"2222","number3":"abcde"}`), &num)
// 数値はエラーにならない
num1, err := num.Number1.Int64()

// 数字はエラーにならない
num2, err := num.Number2.Int64()
    
// 数字以外の文字はエラー
// strconv.ParseInt: parsing "abcde": invalid syntax
_, err = num.Number3.Int64()

json.Number使ったUnmarshal(playground)

補足:JSON出力する時のjson.Number

json.Numberは、Marshalすると、数値として出力されます。数字、数値どちらで読み込んでいても数値になります。数以外の文字列の場合は、Marshal時にエラーになります。このjson.Numberを使って読み込んで、そのままMarshalする場合は、この数値になる動きを意識しておく必要があります。

json.NumberのMarshal

type Num struct {
    Number1 json.Number `json:"number1"`
    Number2 json.Number `json:"number2"`
}

num := Num{}
// 数値、数字を読み込む
err := json.Unmarshal([]byte(`{"number1":1111,"number2":"2222"}`), &num)

// JSONに出力
j, err := json.Marshal(num)

// 数値として出力される
// {"number1":1111,"number2":2222}
fmt.Println(string(j))

*1:JSON Schemaなど別の仕組みを使用するとチェックしたりできるようです

*2:map[string]interface{}で延々名前探して型確認して変換の繰り返し。。。