Alpine Linux で Rails 6 で rails-erd を使おうとするとつらい
概要
rails向けのER図出力ツールとしてメジャーな rails-erdを Alpineベースの Docker で使おうとしたら結構面倒だったのでその備忘録。
すでに1年以上メンテされておらず、Rails 6 がサポートされる気配もないのであくまで暫定的なワークアラウンドという方向で。
Gemfile
group :development do gem 'rails-erd' end
Dockerfile
グラフを描画するためのパッケージ graphbiz が必要となるのでインストールする。
また、alpineの場合対応するフォントを入れてやらないと出力されたグラフの文字部分が豆腐になるので色々入れる。
RUN apk add --update --no-cache \ graphviz \ msttcorefonts-installer \ fontconfig \ font-bitstream-type1 \ ghostscript-fonts \ ttf-freefont RUN update-ms-fonts \ && fc-cache -f \ && rm -rf /var/cache/*
フォントは darwin 以外では Arial が使われるようになっているので、msttcorefonts-installer
でインストールする。
また、これだけだと NOT NULL を表す *
(U+2217) が正しく表示されなかったりするので他にもいくつか入れている。
実行
bundle exec erd
以下のWarningが出る。
Rails 6 未サポートだからか。
Warning: Ignoring invalid model ActionText::RichText (table action_text_rich_texts does not exist)
Warning: Ignoring invalid model ActionMailbox::InboundEmail (table action_mailbox_inbound_emails does not exist)
Warning: Ignoring invalid model ActiveStorage::Blob (table active_storage_blobs does not exist)
Warning: Ignoring invalid model ActiveStorage::Attachment (table active_storage_attachments does not exist)
通常のユーザーが作ったモデルであれば、--exclude
オプションをつけることで出力しないようにすることができるが、これらは色々試しても除外できなかった。
--exclude="ActionText::RichText"
--exclude="RichText"
--exclude="action_text_rich_texts
全部効かない。
特に影響ない Warning なので無視するしかない。
ちなみに --warn
オプションつければ出力を制御できる。
eager_load
こんなエラーが出ることもある。
Failed: RuntimeError: No entities found; create your models first!
モデルクラスが認識されていないため。
これに関するワークアラウンドはいくつかある。
1. v6.0.3 以上にする
内部的にはRails.application.eager_load!
で読み込んでいるのだが、ZeitwerkではZeitwerk::Loader.eager_load_all
をする必要がある。
だがこのあたりの変更は結構活発に進んでいるようで、6.0.3からRails.application.eager_load!
が後方互換性のために復活した。
なのでバージョンアップすれば問題なく動く。
2. config.eager_load = true
以下バージョンアップできない環境向け。
eager_loadの設定を変更すれば動く。
3. eager_load!
のオーバーライド
結構香ばしいですね。
module Rails class Application def eager_load! Zeitwerk::Loader.eager_load_all end end end
4. タスクのオーバーライド
3を局所的に解決する例
FP_Reservation/auto_generate_diagram.rake at master · MurakiSari/FP_Reservation · GitHub
cloudformation:CreateStack のオプションを整理する
create-stack — AWS CLI 1.18.78 Command Reference
--stack-name
スタック名。必須。
英数字またはハイフンのみ。
最大128文字で、先頭は英文字でなければならない。
また、リージョン内でユニークという制約がある。
--template-body | --template-url
テンプレートファイルの指定。
どちらか片方でよい。
S3に配置されたファイルを使う場合は、--template-url
にURLを指定する。
ローカルのファイルを使う場合は、--template-body file://{path}
のような形で指定する。
--parameters
パラメータの受け渡し。 JSON文字列を指定するか、以下のようなショートハンドでも書ける。
ParameterKey=string,ParameterValue=string,UsePreviousValue=boolean,ResolvedValue=string ...
UsePreviousValue
は作成時は使えない。更新時に、前回渡したパラメータを引き継ぐかどうか。
ResolvedValue
はパラメータストア(ssm)の参照っぽいんですが、ドキュメントが薄くサンプルも見つからないため具体的な挙動は確認できていません。
Read-only. The value that corresponds to a Systems Manager parameter key. This field is returned only for SSM parameter types in the template.
--disable-rollback | --no-disable-rollback
スタックの作成に失敗した時ロールバックするかどうか。
デフォルトではするので、それを無効にしたい場合に--disable-rollback
を渡すような使い方しかないと思う。
また、このオプションは次の--on-failure
と競合する。
--on-failure
スタックの作成に失敗した時の挙動を決める。
デフォルトではROLLBACK
で、以下の3つから指定できる。
- DO_NOTHING
- ROLLBACK
- DELETE
--disable-rollback
とは併用できない。
明記はないが--disable-rollback
はDO_NOTHING
と同じ挙動っぽい。
DELETE
はスタックを自動で削除してしまう。削除されるとなんでエラーになったのかイベントが見れなくなるのでおすすめしない。
--rollback-configuration
ロールバックをトリガーするための詳細条件を設定できる。
CloudWatchと連携して各リソースごとに閾値を設定し、アラーム状態になったらオペレーションを中止してロールバックする、みたいなことを実現する。
使ったことがないので想定するケースがよくわからない。
--timeout-in-minutes
スタックの作成ステータスを失敗とするまでのタイムアウト値
--notification-arns
Cloudformationのイベントの通知先のSNSのトピックのARN。
オペレーションの実行 IAM がSNS:Publish
権限を有している必要がある。
--capabilities
スタックに特定のIAM関連のリソースがある場合、確認用に渡す必要があるちょっと特殊なオプション。
「IAMに関する操作あるけど大丈夫?認識してる?意図通り?」っていう確認。
値としては CAPABILITY_IAM
,CAPABILITY_NAMED_IAM
,CAPABILITY_AUTO_EXPAND
の3種類がある。
基本的にはCAPABILITY_IAM
, 名前付きのIAMリソースがある場合はCAPABILITY_NAMED_IAM
, マクロでテンプレートを生成する場合はCAPABILITY_AUTO_EXPAND
が必要になる。
とりあえず全部わっとまとめて渡しておけば通せる。
--resource-types
これもcapabilities
っぽい動きをするオプションで、意図せぬリソースが含まれていないかを確認するような仕組み。
AWS::EC2::Instance, AWS::EC2::*,
のような値を渡して、このリストに含まれていないリソースを作ろうとしていたらオペレーションを失敗させるみたいな。
これも使ったことない。
--role-arn
オペレーションの実行IAMロールを指定できる。
サービスロールを使う場合ですね。
なんでサービスロールを使ったほうがいいの?という点に関しては拙著ですが以下をお読みください。 bluepixel.hatenablog.com
--stack-policy-body | --stack-policy-url
作成されたスタックの更新許可を制御する仕組み。
デフォルトでは全てのリソースが更新可能になっているが、ポリシーベースで柔軟に制御をかけられる。
例えばデータベースの更新など物理的なリソースの置換を伴いデータが吹っ飛ぶような危険な操作をできないようにするとかだろうか。
テンプレートの時と同じく、S3のURLを指定してファイルを読み込ませることもできる。
--tags
解説不要ですね。
スタックに対してタグを指定すると管理下のリソースに対して一括でタグを付与してくれるのでとても便利。
--client-request-token
一意の値を指定してオペレーションを識別できるようにするトークン。
マネコンではデフォルト非表示っぽい?けど歯車から表示するように設定できる。
--enable-termination-protection | --no-enable-termination-protection
削除保護。
デフォルトは無効。
以上です。
CloudFormationを扱う際のIAMの考え方
あるスタックを作成するケースを考える
SQSのキューを1つだけ作るテンプレートを用意する。
# sample.cf.yml AWSTemplateFormatVersion: "2010-09-09" Resources: SampleQueue: Type: AWS::SQS::Queue
とあるIAMユーザーのプロファイルを指定してcreate-stack
を実行。
このIAMユーザーはcloudformation:*
のみを許可されているものとする。
$ aws cloudformation create-stack --stack-name sample --template-body file://./sample.cf.yml --profile sample-user { "StackId": "arn:aws:cloudformation:ap-northeast-1:xxxxxx:stack/sample/b579cc80-aaf1-11ea-bafc-0a62df4444de" }
マネコンからステータスを確認すると、キューの作成に失敗している。
これはIAMポリシーの不足が原因。
基本的にcreate-stack
を実行するユーザーは、スタックに含まれるリソースそれぞれに対して作成・取得・削除等の権限を有している必要がある。
※*:create*
だけを与えればいいかというとそうでもない。例のIAMユーザーにsqs:CreateQueue
のポリシーを付与して再度実行すると今度は以下のエラーが発生する。
API: sqs:GetQueueAttributes Access to the resource https://sqs.ap-northeast-1.amazonaws.com/xxxx/sample-SampleQueue-G5X7EGA2HGMA is denied.
ステータスの取得やロールバック時のリソースの削除などにも対応するために、結局はフルアクセスを与えておくのが早かったりする。
ただここで問題となるのがメンバーのIAM管理の話。
スタックに併せてリソースの作成・削除権限をやみくもに与えていくのはセキュリティ上好ましくない。
リソースで制限をかけるとしても、まだ作成される前のスタックに関してはリソース名が不定なことが多いのでそもそも不可能。
じゃあどうするのか
結論: サービスロールを使いましょう
What is サービスロール
AWS の多くのサービスでは、ロールを使用して、ユーザーに代わって該当サービスが他のサービスのリソースにアクセスすることを許可する必要があります。サービスがお客様に代わってアクションを実行するために引き受けるロールは、サービスロールと呼ばれます。
IAMユーザーがユーザーに対してAWS APIのアクセスを許可するように、サービスロールはAWSのサービスに対して別のサービスへの操作権限を許可します。
AWS サービスにアクセス許可を委任するロールの作成 - AWS Identity and Access Management
例えば Lambda なんかは CloudWatch Logs にログを送信するためにlog:PutLogEvents
を持っている必要があったりしますが、この権限をLambdaサービスに対して許可するのがサービスロールです。
Lambdaの場合関数作成時にデフォルトのLambdaBasicExecutionRole
で諸々付与してくれるのでマネコンで作ってるとあまり意識しないかもしれません。
ロールの引受先としてLambdaサービス(lambda.amazonaws.com
)が設定されていることがわかります。
CloudFormationで作る場合はこんな感じでロールを自分で作ります。
IAMRoleForLambda: 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/AmazonDynamoDBFullAccess
そんな感じで、これをCloudFormationのスタック作成時に応用するのがベストプラクティスになります。
つまり、CloudFormationサービスにサービスロールを引き受けさせて、そのロールの権限のもとスタックに含まれるリソースの作成を行うというわけです。
閑話休題:Service Linked Role
てなわけでサービスロールの作成に移りたいんですが、ここでService Linked Role
についても少し触れておきます。
日本語にするとサービスにリンクされたロール
ですが、
サービスにリンクされたロールは、AWS サービスに直接リンクされた一意のタイプの IAM ロールです。サービスにリンクされたロールは、サービスによって事前定義されており、お客様の代わりにサービスから他の AWS サービスを呼び出す必要のあるアクセス権限がすべて含まれています。
サービスにリンクされたロールの使用 - AWS Identity and Access Management
AWS側で事前に定義してくれているもの、という理解でよさそうです。
例えばECSの場合、AWSServiceRoleForECS
というものが定義されています。
ECSが必要とするロードバランサー、Route53、オートスケーリングなどの各種操作権限が含まれています。
こういうテンプレートがあると aws iam create-service-linked-role
--aws-service-name <value>
で簡単にロールが作成できるのですが、このService Linked Role
、全てのサービスに定義されているわけではありません。
こちらに一覧がありますが、 CloudFormation は No
となっていますね。
閑話休題でした。
サービスロールの作成
CloudFormationサービスを引き受け先として、SQSのフルアクセスを許可したサービスロールを作成します。
AWSTemplateFormatVersion: "2010-09-09" Resources: CloudFormationServiceRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - cloudformation.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: create-sqs-resource PolicyDocument: Statement: - Effect: Allow Action: - sqs:* Resource: - "*"
サービスロールの使用
create-stack
のオプションとして--role-arn
があります。
これを指定すると、CloudFormationがそのロールを引き受けてスタックの操作を行います。ちなみに、指定していない場合はAPIを実行したユーザーの一時的なセッションが使われます。
IAMユーザーにはiam:PassRole
権限が必要になります。サービスに対してロールを渡す操作の許可です。
$ aws cloudformation create-stack --stack-name sample --template-body file://./sample.cf.yml --role-arn arn:aws:iam::xxxxxxx:role/xxxxxx
これで、IAMユーザーにSQSリソースに対する権限がなくてもスタックの作成を実行することができるようになりました。
直接ユーザーに権限を付与していくのではなく、サービスロールを使おうねという話でした。
Lambdaの関数をランタイムごとに数える
完成イメージ
$ abc lambda stats | RUNTIME | COUNT | |--------------------------|-------| | nodejs12.x | 2 | | nodejs8.10(Deprecated) | 1 | | python3.6 | 2 | | ruby2.5 | 8 | | ruby2.7 | 2 |
--verbose
オプションで関数名も表示
$ abc lambda stats --verbose | RUNTIME | COUNT | FUNCTIONS | |--------------------------|-------|-------------------------------| | nodejs12.x | 2 | sample-nodejs-12-function-1 | | | | sample-nodejs-12-function-2 | | nodejs8.10(Deprecated) | 1 | sample-nodejs-8-function | | python3.6 | 2 | sample-python-36-function-1, | | | | sample-pytohn-36-function-2 | | ruby2.5 | 8 | sample-ruby-25-function-1, | | | | sample-ruby-25-function-2, | | | | sample-ruby-25-function-3, | | | | sample-ruby-25-function-4, | | | | sample-ruby-25-function-5, | | | | sample-ruby-25-function-6, | | | | sample-ruby-25-function-7, | | | | sample-ruby-25-function-8 | | ruby2.7 | 2 | sample-ruby-27-function-1, | | | | sample-ruby-27-function-2 |
--format
オプションでJSONフォーマットで出力
$ abc lambda stats --format json | jq "." [ { "runtime": "nodejs12.x", "count": 2, "deprecated": false }, { "runtime": "nodejs8.10", "count": 1, "deprecated": true }, { "runtime": "python3.6", "count": 2, "deprecated": false }, { "runtime": "ruby2.5", "count": 8, "deprecated": false }, { "runtime": "ruby2.7", "count": 2, "deprecated": false } ]
CLIツール
もうブログで何回も紹介しているgo製のCLIツールabc
のサブコマンドとして実装する。
ランタイム一覧
現在サポートされているランタイムは以下。
- nodejs12.x
- nodejs10.x
- python3.8
- python3.7
- python3.6
- python2.7
- ruby2.7
- ruby2.5
- java11
- java8
- go1.x
- dotnetcore3.1
- dotnetcore2.1
- provided(カスタムランタイム)
ランタイムサポートポリシー
Lambdaのランタイムにはランタイムサポートポリシーというものが設定されていて、要はセキュリティパッチなどちゃんと実行環境がメンテナンスされているかを表すもの。
最新でないバージョンの言語のランタイムは随時廃止されて行く模様。
廃止は段階的に行われる。
まずはそのランタイムで新しい関数が作成できなくなり、続いて既存の関数の更新ができなくなる。 その後は、既存の関数は廃止されることなくそのまま使い続けることはできるが、実行環境のメンテナンスは行われないので移行が推奨される。
すでに廃止されているランタイムは以下。
- dotnetcore1.0
- dotnetcore2.0
- nodejs(Node.js 0.10)
- nodejs4.3
- nodejs4.3-edge
- nodejs6.10
- nodejs8.10
現在(2020/06/08)時点で廃止予定のランタイムはないが、Python2.7まわりはざわざわしている。 2020/12/31までセキュリティパッチは当てられるようだが、いつ廃止されるという明記はない。
実装
lambda:ListFunctions
APIで関数が列挙できる。
再帰的に取得して結果を返す部分の抜粋。
func listFunctions() ([]*lambda.FunctionConfiguration, error) { var result []*lambda.FunctionConfiguration var nextMarker *string for { params := &lambda.ListFunctionsInput{ MaxItems: aws.Int64(1000), Marker: nextMarker, } resp, err := LambdaClient.ListFunctions(params) if err != nil { return nil, err } result = append(result, resp.Functions...) if resp.NextMarker == nil { break } nextMarker = resp.NextMarker } return result, nil }
パラメータは、なるべく1回で取得するためにMaxItems
を大きめにとっている。次ページがある場合はMarker
を指定して再帰的に取得する。他のAPIではNextToken
という実装をよく見るがMarker
は初めて見た🤔
他にもバージョンの指定やLambda@Edge用にリージョンの指定もできるのだが今回は必要ないので割愛。
集計はランタイムごとにmap
に突っ込んでいて、ソートされたキー順に出力するのだが、一点微妙なところがある。Node.jsのようにバージョンが2桁になっているランタイムでは、リリース順にきれいに並ばないみたいなことが起きる。
(例) 昇順にソートしているが、一番上に最新のnodejs12.xがきてしまう
RUNTIME | COUNT |
---|---|
nodejs12.x | 2 |
nodejs10.x | 2 |
nodejs8.10(Deprecated) | 1 |
python3.6 | 1 |
python3.7 | 1 |
python3.8 | 2 |
ruby2.5 | 8 |
ruby2.7 | 2 |
これを考慮したソートを実装するのはちょっと骨が折れるので今回はやらなかった。
出力のフォーマットはマークダウンのtableかJSONを選択できるようにした。
これまではJSON縛りで作ることが多かったが、今回のユースケースではマークダウンにペッと貼り付けたいこともあるかなと思ったので。
func Output(count map[string][]string) (string, error) { if format == "json" { return jsonOutput(count) } else if format == "table" { return tableOutput(count), nil } return "", errors.New("invalid format.") }
JSONは代わり映えしないので省略するとして、テーブル形式でのフォーマットの実装に関して。
最初は標準のtext/tabwriter
を試した。
最近リリースされたAWS謹製のec2-instance-selector
ではこれを使ってテーブル出力を実装している。
使い方もわかりやすく標準で賄えるのはいいのだが、凝ったことをしようとするとなかなか厳しい。それに、色をつけようとするとタブ幅が崩れるという問題があった。
代わりに以下のライブラリを導入した。
こちらではマークダウン形式のテーブルがサポートされていて、表崩れもなかったのでめでたく採用。
柔軟な色付けもできるっぽい。
(柔軟と言いつつ今回は実装が煩雑になるので自前でカラーコードを埋め込んだが)
func tableOutput(count map[string][]string) string { keys := sortKey(count) tableString := &strings.Builder{} table := tablewriter.NewWriter(tableString) table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) table.SetCenterSeparator("|") if verbose { verboseTableOutput(keys, table, count) } else { normalTableOutput(keys, table, count) } return tableString.String() } func normalTableOutput(keys []string, table *tablewriter.Table, count map[string][]string) { table.SetHeader([]string{"Runtime", "Count"}) for _, k := range keys { runtime := k if isDeprecatedRuntime(runtime) { runtime = fmt.Sprintf("\x1b[91m%s(Deprecated)\x1b[0m", runtime) } table.Append([]string{runtime, strconv.Itoa(len(count[k]))}) } table.Render() }
廃止済みのランタイムを使っている場合は赤くして警告する。
v0.5.0
としてリリース
イメージが残っているECRを含んだCloudformationスタックは削除できない。なのでできるようにした。
できない
ECR単体の削除であれば、例えばCLIだったらecr delete-repository --force
とすればイメージごとリポジトリを削除できるのだが、Cloudformationのスタックとして作成されている場合、イメージが残っているとスタックの削除に失敗する。このケースにおいてイメージごと抹消する方法は存在しない。
いろいろ試してる時にイメージが入ってて消せないみたいなことがよくあり、個人的には結構需要を感じている。
できるようにした
最近Goの練習がてら作っているCLIツールのサブコマンドとして実装。
事前に中のイメージを全削除してからdelete-stack
を呼び出す様な設計でいこうと思う。
サブコマンドの追加
cobraをベースとしているので、サブコマンドの追加はcobra add
で行えるのだが、今回はサブコマンドのサブコマンドとなるので、手動でファイルを追加する。
abcというツールで、aws-cliを模してサービス名をサブコマンドとしている。
今回作るコマンドはabc cfn purge-stack --stack-name xxx
とする。
ディレクトリ構成はAWSのサービスごと、コマンドごとにフォルダが切ってあるので、/lib/cfn/purge_stack/purge_stack.go
というファイルを置く。
(エントリポイントとなる親コマンドはすでにあるので今回は割愛。
cfn unused-exports
というコマンドがすでに実装済みなのでcmd/cfn.go
が存在する。)
// /lib/cfn/purge_stack/purge_stack.go package purge_stack var CfnClient cloudformationiface.CloudFormationAPI var EcrClient ecriface.ECRAPI var ( stackName string ) func NewCmd() *cobra.Command { cmd := &cobra.Command{ Use: "purge-stack", Short: "Delete stack completely including resource contents.", Long: ` [abc cfn purge-stack] This command delete CloudFormation's stack, which delete-stack api provided by AWS officcially cannot to perform. For example, a stack which includes non-empty ECR repository. Internally it uses aws cloudformation api. Please configure your aws credentials with following policies. - cloudformation:DeleteStack - cloudformation:ListStackResources - ecr:BatchDeleteImages - ecr:DescribeImages`, RunE: func(cmd *cobra.Command, args []string) error { err := run(cmd, args) return err }, } cmd.Flags().StringVar(&stackName, "stack-name", "", "stack name to delete") cmd.MarkFlagRequired("stack-name") return cmd } func run(cmd *cobra.Command, args []string) error { if err := ExecPurgeStack(cmd, args); err != nil { return err } cmd.Println("Perform delete-stack is in progress asynchronously.\nPlease check deletion status by yourself.") return nil } func ExecPurgeStack(cmd *cobra.Command, args []string) error { // }
cobraデフォルトのジェネレータではコマンドがハードコーティングされて単体でテストできないため、コマンドを生成するNewCmd()
をメソッドとして定義する。
エントリポイントは、エラーハンドリングを細かくやるためにRun
ではなくRunE
を使用し、runメソッドに委譲する。
そこからさらに、単体でテストしやすい単位で内部実装を別のパブリックなメソッドに委譲する。run
はなるべく薄く保つ様にしているが、このあたりの境界をどうするのがベストなのかはまだ答えがない。
例えばAWS APIをたたいてデータを取得して、それをJSON形式で標準出力に流すという処理の場合、フローは以下の様なステップに分割できる。
フラグのパースはNewCmd()
に含めて透過的に扱うことになる。
ただし親コマンドを経由しないでパッケージ単体でテストする場合は明示的にcmd.Flags().Set()
を呼ぶ必要がある。
AWS認証はテストでサービスクライアントをモックするために、少しトリックを使う必要があるが、基本的にはパブリックメソッド内に含む。
APIを使用するデータ取得部分がパブリックメソッドの主な処理内容。
パースされたフラグ、初期化済みのサービスクライアントを用いてAPIをコールして結果を返す。
データ型は独自の構造体のスライスにすることが多い。
JSON変換はここでは行わない。JSON文字列はテスト書きにくいので。
JSON変換と出力は薄く切ったrun
で行う。
これはグルーコードなのでテストはせず、rootコマンドのテストでE2Eとして出力を確認することにしている。
細かいパターンの網羅はパッケージ単体のUTで行い、E2EではフラグがちゃんとパースされてJSONフォーマットで標準出力に出力されることを確認する。
デフォルトのcobraのジェネレータではこのあたりがベッタリ密結合してしまう🤔
サブコマンドの登録
サブサブコマンドとなるので、親であるcfn
サブコマンドに登録を行う。
(cfn unused-exports
というサブサブコマンドがすでに登録済み)
// /cmd/cfn.go package cmd import ( "github.com/Blue-Pix/abc/lib/cfn" "github.com/Blue-Pix/abc/lib/cfn/purge_stack" "github.com/Blue-Pix/abc/lib/cfn/unused_exports" ) var cfnCmd = cfn.NewCmd() var unusedExportsCmd = unused_exports.NewCmd() // 追加 var purgeStackCmd = purge_stack.NewCmd() func init() { cfnCmd.SetOut(rootCmd.OutOrStdout()) rootCmd.AddCommand(cfnCmd) cfnCmd.AddCommand(unusedExportsCmd) // 追加 cfnCmd.AddCommand(purgeStackCmd) }
ロジックの実装
以下の4つのAPIを使います。
- cloudformation:ListStackResources(スタックに含まれるリソースを取得)
- ecr:DescribeImages(リポジトリに含まれるイメージを取得)
- ecr:BatchDeleteImage(イメージの削除)
- cloudformation:DeleteStack(スタックの削除)
フローとしては、
- スタック内のリソースを走査
- ECRが含まれていた場合、イメージが含まれているか確認
- 取得したイメージをバッチで全削除
- スタックを削除
という流れになります。
イメージはタグかダイジェストを指定して削除することになります。
タグだとややこしいのでダイジェストで決め打ちで消していきます。
最後のメインとなるスタックの削除処理ですが、戻り値はありません。
というのもスタックの削除は非同期なので、削除されたかどうかは list-stacks API などでポーリングして確認する必要があります。
なのでとりあえずコールが成功したら「削除実行中だよ、ステータスは自分で確認してね」というメッセージを出すことにします。
package purge_stack import ( "errors" "fmt" "github.com/Blue-Pix/abc/lib/util" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudformation" "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" "github.com/aws/aws-sdk-go/service/ecr" "github.com/aws/aws-sdk-go/service/ecr/ecriface" "github.com/spf13/cobra" ) var CfnClient cloudformationiface.CloudFormationAPI var EcrClient ecriface.ECRAPI var ( stackName string ) func NewCmd() *cobra.Command { cmd := &cobra.Command{ Use: "purge-stack", Short: "Delete stack completely including resource contents.", Long: ` [abc cfn purge-stack] This command delete CloudFormation's stack, which delete-stack api provided by AWS officcially cannot to perform. For example, a stack which includes non-empty ECR repository. Internally it uses aws cloudformation api. Please configure your aws credentials with following policies. - cloudformation:DeleteStack - cloudformation:ListStackResources - ecr:BatchDeleteImages - ecr:DescribeImages`, RunE: func(cmd *cobra.Command, args []string) error { err := run(cmd, args) return err }, } cmd.Flags().StringVar(&stackName, "stack-name", "", "stack name to delete") cmd.MarkFlagRequired("stack-name") return cmd } func run(cmd *cobra.Command, args []string) error { if err := ExecPurgeStack(cmd, args); err != nil { return err } cmd.Println("Perform delete-stack is in progress asynchronously.\nPlease check deletion status by yourself.") return nil } func ExecPurgeStack(cmd *cobra.Command, args []string) error { initClient(cmd) resources, err := listEcrResources(nil, []*cloudformation.StackResourceSummary{}) if err != nil { return err } for _, resource := range resources { repositoryName := resource.PhysicalResourceId images, err := listImageDigests(nil, repositoryName, []*ecr.ImageIdentifier{}) if err != nil { return err } if len(images) == 0 { continue } failures, err := deleteImages(images, repositoryName) if err != nil { return err } if len(failures) > 0 { cmd.Println(failures) return errors.New(fmt.Sprintf("failed to delete images of %s", aws.StringValue(repositoryName))) } cmd.Println(fmt.Sprintf("All images in %s successfully deleted.", aws.StringValue(repositoryName))) } if err = deleteStack(stackName); err != nil { return err } return nil } func initClient(cmd *cobra.Command) { profile, _ := cmd.Flags().GetString("profile") region, _ := cmd.Flags().GetString("region") if CfnClient == nil { sess := util.CreateSession(profile, region) CfnClient = cloudformation.New(sess) } if EcrClient == nil { sess := util.CreateSession(profile, region) EcrClient = ecr.New(sess) } } func listEcrResources(token *string, ecrs []*cloudformation.StackResourceSummary) ([]*cloudformation.StackResourceSummary, error) { params := &cloudformation.ListStackResourcesInput{ NextToken: token, StackName: aws.String(stackName), } resp, err := CfnClient.ListStackResources(params) if err != nil { return nil, err } for _, r := range resp.StackResourceSummaries { if aws.StringValue(r.ResourceType) == "AWS::ECR::Repository" { ecrs = append(ecrs, r) } } if resp.NextToken != nil { ecrs, err = listEcrResources(resp.NextToken, ecrs) if err != nil { return nil, err } } return ecrs, nil } func listImageDigests(token *string, repositoryName *string, images []*ecr.ImageIdentifier) ([]*ecr.ImageIdentifier, error) { params := &ecr.DescribeImagesInput{ NextToken: token, MaxResults: aws.Int64(1000), RepositoryName: repositoryName, } resp, err := EcrClient.DescribeImages(params) if err != nil { return nil, err } for _, i := range resp.ImageDetails { images = append(images, &ecr.ImageIdentifier{ ImageDigest: i.ImageDigest, }) } if resp.NextToken != nil { images, err = listImageDigests(resp.NextToken, repositoryName, images) if err != nil { return nil, err } } return images, nil } func deleteImages(images []*ecr.ImageIdentifier, repositoryName *string) ([]*ecr.ImageFailure, error) { params := &ecr.BatchDeleteImageInput{ ImageIds: images, RepositoryName: repositoryName, } resp, err := EcrClient.BatchDeleteImage(params) if err != nil { return nil, err } return resp.Failures, nil } func deleteStack(stackName string) error { params := &cloudformation.DeleteStackInput{ StackName: aws.String(stackName), } _, err := CfnClient.DeleteStack(params) if err != nil { return err } return nil }
サービスクライアントはテストでモックを差し込めるようにインターフェースにしてパブリックに出しています。
aws-sdk-goがインターフェースを用意してくれているので便利です。
あとはAWS全般に言えることですが、ページングの実装に気をつける必要があります。レスポンスにNextToken
が含まれている場合、まだ結果があるので再帰的に呼び出しを行います。
テスト書く
パッケージとしてはExecPurgeStack
でAPIが呼び出されていることを確認します。
rootのテストでフラグのパース、標準出力をテストします。
// purge_stack_test.go package purge_stack_test import ( "errors" "fmt" "os" "testing" "github.com/Blue-Pix/abc/lib/cfn/purge_stack" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/cloudformation" "github.com/aws/aws-sdk-go/service/ecr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func initMockClient(cm *purge_stack.MockCfnClient, em *purge_stack.MockEcrClient) { purge_stack.SetMockDefaultBehaviour(cm, em) purge_stack.CfnClient = cm purge_stack.EcrClient = em } func TestExecPurgeStack(t *testing.T) { // include two ecr resources, one with images, other with no image. t.Run("success", func(t *testing.T) { stackName := "foo" cm := &purge_stack.MockCfnClient{} em := &purge_stack.MockEcrClient{} initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Nil(t, err) cm.AssertNumberOfCalls(t, "ListStackResources", 2) em.AssertNumberOfCalls(t, "DescribeImages", 3) em.AssertNumberOfCalls(t, "BatchDeleteImage", 1) cm.AssertNumberOfCalls(t, "DeleteStack", 1) }) t.Run("stack without ecr", func(t *testing.T) { stackName := "foo" cm := &purge_stack.MockCfnClient{} cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{ StackName: aws.String(stackName), }).Return( &cloudformation.ListStackResourcesOutput{ NextToken: nil, StackResourceSummaries: []*cloudformation.StackResourceSummary{ {PhysicalResourceId: aws.String("cluster"), ResourceType: aws.String("AWS::ECS::Cluster")}, }, }, nil, ) em := &purge_stack.MockEcrClient{} initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Nil(t, err) cm.AssertNumberOfCalls(t, "ListStackResources", 1) em.AssertNumberOfCalls(t, "DescribeImages", 0) em.AssertNumberOfCalls(t, "BatchDeleteImage", 0) cm.AssertNumberOfCalls(t, "DeleteStack", 1) }) /************************************ Authorization Error ************************************/ t.Run("authorization error for ListStackResources", func(t *testing.T) { stackName := "foo" const errorCode = "AccessDeniedException" const errorMsg = "An error occurred (AccessDeniedException) when calling the ListStackResources operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: cloudformation:ListStackResources" cm := &purge_stack.MockCfnClient{} cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{StackName: aws.String(stackName)}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge"))) em := &purge_stack.MockEcrClient{} initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Equal(t, errorCode, err.(awserr.Error).Code()) assert.Equal(t, errorMsg, err.(awserr.Error).Message()) cm.AssertNumberOfCalls(t, "ListStackResources", 1) em.AssertNumberOfCalls(t, "DescribeImages", 0) em.AssertNumberOfCalls(t, "BatchDeleteImage", 0) cm.AssertNumberOfCalls(t, "DeleteStack", 0) }) t.Run("authorization error for DescribeImages", func(t *testing.T) { stackName := "foo" const errorCode = "AccessDeniedException" const errorMsg = "An error occurred (AccessDeniedException) when calling the DescribeImages operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: ecr:DescribeImages" cm := &purge_stack.MockCfnClient{} em := &purge_stack.MockEcrClient{} em.On("DescribeImages", &ecr.DescribeImagesInput{NextToken: nil, MaxResults: aws.Int64(1000), RepositoryName: aws.String("ecr1")}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge"))) initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Equal(t, errorCode, err.(awserr.Error).Code()) assert.Equal(t, errorMsg, err.(awserr.Error).Message()) cm.AssertNumberOfCalls(t, "ListStackResources", 2) em.AssertNumberOfCalls(t, "DescribeImages", 1) em.AssertNumberOfCalls(t, "BatchDeleteImage", 0) cm.AssertNumberOfCalls(t, "DeleteStack", 0) }) t.Run("authorization error for BatchDeleteImage", func(t *testing.T) { stackName := "foo" const errorCode = "AccessDeniedException" const errorMsg = "An error occurred (AccessDeniedException) when calling the BatchDeleteImage operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: ecr:BatchDeleteImage" cm := &purge_stack.MockCfnClient{} em := &purge_stack.MockEcrClient{} em.On("BatchDeleteImage", &ecr.BatchDeleteImageInput{ ImageIds: []*ecr.ImageIdentifier{ {ImageDigest: aws.String("foofoofoo")}, {ImageDigest: aws.String("barbarbar")}, {ImageDigest: aws.String("foobarfoobar")}, {ImageDigest: aws.String("barfoobarfoo")}, }, RepositoryName: aws.String("ecr1"), }).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge"))) initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Equal(t, errorCode, err.(awserr.Error).Code()) assert.Equal(t, errorMsg, err.(awserr.Error).Message()) cm.AssertNumberOfCalls(t, "ListStackResources", 2) em.AssertNumberOfCalls(t, "DescribeImages", 2) em.AssertNumberOfCalls(t, "BatchDeleteImage", 1) cm.AssertNumberOfCalls(t, "DeleteStack", 0) }) t.Run("authorization error for DeleteStack", func(t *testing.T) { stackName := "foo" const errorCode = "AccessDeniedException" const errorMsg = "An error occurred (AccessDeniedException) when calling the ListStacks operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: cloudformation:ListStacks" cm := &purge_stack.MockCfnClient{} cm.On("DeleteStack", &cloudformation.DeleteStackInput{StackName: aws.String(stackName)}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge"))) em := &purge_stack.MockEcrClient{} initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Equal(t, errorCode, err.(awserr.Error).Code()) assert.Equal(t, errorMsg, err.(awserr.Error).Message()) cm.AssertNumberOfCalls(t, "ListStackResources", 2) em.AssertNumberOfCalls(t, "DescribeImages", 3) em.AssertNumberOfCalls(t, "BatchDeleteImage", 1) cm.AssertNumberOfCalls(t, "DeleteStack", 1) }) t.Run("authorization error for ListStackResources", func(t *testing.T) { stackName := "foo" const errorCode = "AccessDeniedException" const errorMsg = "An error occurred (AccessDeniedException) when calling the ListStackResources operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: cloudformation:ListStackResources" cm := &purge_stack.MockCfnClient{} cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{StackName: aws.String(stackName)}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge"))) em := &purge_stack.MockEcrClient{} initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Equal(t, errorCode, err.(awserr.Error).Code()) assert.Equal(t, errorMsg, err.(awserr.Error).Message()) cm.AssertNumberOfCalls(t, "ListStackResources", 1) em.AssertNumberOfCalls(t, "DescribeImages", 0) em.AssertNumberOfCalls(t, "BatchDeleteImage", 0) cm.AssertNumberOfCalls(t, "DeleteStack", 0) }) /************************************ Validation Error ************************************/ t.Run("no such stack name", func(t *testing.T) { stackName := "no_such_stack_name" const errorCode = "ValidationError" const errorMsg = "Stack with id no_such_stack_name does not exist" cm := &purge_stack.MockCfnClient{} cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{StackName: aws.String(stackName)}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge"))) em := &purge_stack.MockEcrClient{} initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Equal(t, errorCode, err.(awserr.Error).Code()) assert.Equal(t, errorMsg, err.(awserr.Error).Message()) cm.AssertNumberOfCalls(t, "ListStackResources", 1) em.AssertNumberOfCalls(t, "DescribeImages", 0) em.AssertNumberOfCalls(t, "BatchDeleteImage", 0) cm.AssertNumberOfCalls(t, "DeleteStack", 0) }) /************************************ Others ************************************/ t.Run("failed on BatchDeleteImage", func(t *testing.T) { stackName := "foo" cm := &purge_stack.MockCfnClient{} em := &purge_stack.MockEcrClient{} em.On("BatchDeleteImage", mock.AnythingOfType("*ecr.BatchDeleteImageInput")).Return( &ecr.BatchDeleteImageOutput{ ImageIds: []*ecr.ImageIdentifier{}, Failures: []*ecr.ImageFailure{ {FailureCode: aws.String("hoge"), FailureReason: aws.String("fuga"), ImageId: &ecr.ImageIdentifier{}}, }, }, nil, ) initMockClient(cm, em) cmd := purge_stack.NewCmd() cmd.Flags().Set("stack-name", stackName) var args []string err := purge_stack.ExecPurgeStack(cmd, args) assert.Equal(t, fmt.Sprintf("failed to delete images of %s", "ecr1"), err.Error()) cm.AssertNumberOfCalls(t, "ListStackResources", 2) em.AssertNumberOfCalls(t, "DescribeImages", 2) em.AssertNumberOfCalls(t, "BatchDeleteImage", 1) cm.AssertNumberOfCalls(t, "DeleteStack", 0) }) }
モックはtestify/mockで作っています。
aws-sdk-goのインターフェースがあるのでライブラリなしで簡単にモックは作れるのですが、APIのコール回数を確認するのがしんどいの、今回はtestify/mock
を利用することにしました。
gomockでも実装してみましたが、こちらの方が書きやすかったです。
// mock.go package purge_stack import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudformation" "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" "github.com/aws/aws-sdk-go/service/ecr" "github.com/aws/aws-sdk-go/service/ecr/ecriface" "github.com/stretchr/testify/mock" ) type MockCfnClient struct { mock.Mock cloudformationiface.CloudFormationAPI } func (client *MockCfnClient) ListStackResources(params *cloudformation.ListStackResourcesInput) (*cloudformation.ListStackResourcesOutput, error) { args := client.Called(params) if args.Get(0) != nil { return args.Get(0).(*cloudformation.ListStackResourcesOutput), args.Error(1) } else { return nil, args.Error(1) } } func (client *MockCfnClient) DeleteStack(params *cloudformation.DeleteStackInput) (*cloudformation.DeleteStackOutput, error) { args := client.Called(params) if args.Get(0) != nil { return args.Get(0).(*cloudformation.DeleteStackOutput), args.Error(1) } else { return nil, args.Error(1) } } type MockEcrClient struct { mock.Mock ecriface.ECRAPI } func (client *MockEcrClient) DescribeImages(params *ecr.DescribeImagesInput) (*ecr.DescribeImagesOutput, error) { args := client.Called(params) if args.Get(0) != nil { return args.Get(0).(*ecr.DescribeImagesOutput), args.Error(1) } else { return nil, args.Error(1) } } func (client *MockEcrClient) BatchDeleteImage(params *ecr.BatchDeleteImageInput) (*ecr.BatchDeleteImageOutput, error) { args := client.Called(params) if args.Get(0) != nil { return args.Get(0).(*ecr.BatchDeleteImageOutput), args.Error(1) } else { return nil, args.Error(1) } } func SetMockDefaultBehaviour(cm *MockCfnClient, em *MockEcrClient) { stackName := "foo" cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{ StackName: aws.String(stackName), }).Return( &cloudformation.ListStackResourcesOutput{ NextToken: aws.String("next_token"), StackResourceSummaries: []*cloudformation.StackResourceSummary{ {PhysicalResourceId: aws.String("cluster"), ResourceType: aws.String("AWS::ECS::Cluster")}, {PhysicalResourceId: aws.String("ecr1"), ResourceType: aws.String("AWS::ECR::Repository")}, }, }, nil, ) cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{ NextToken: aws.String("next_token"), StackName: aws.String(stackName), }).Return( &cloudformation.ListStackResourcesOutput{ NextToken: nil, StackResourceSummaries: []*cloudformation.StackResourceSummary{ {PhysicalResourceId: aws.String("ecr2"), ResourceType: aws.String("AWS::ECR::Repository")}, {PhysicalResourceId: aws.String("queue"), ResourceType: aws.String("AWS::SQS::Queue")}, }, }, nil, ) em.On("DescribeImages", &ecr.DescribeImagesInput{ NextToken: nil, MaxResults: aws.Int64(1000), RepositoryName: aws.String("ecr1"), }).Return( &ecr.DescribeImagesOutput{ NextToken: aws.String("next_token"), ImageDetails: []*ecr.ImageDetail{ {ImageDigest: aws.String("foofoofoo"), ImageTags: []*string{aws.String("foo")}}, {ImageDigest: aws.String("barbarbar"), ImageTags: []*string{aws.String("bar")}}, }, }, nil, ) em.On("DescribeImages", &ecr.DescribeImagesInput{ NextToken: aws.String("next_token"), MaxResults: aws.Int64(1000), RepositoryName: aws.String("ecr1"), }).Return( &ecr.DescribeImagesOutput{ NextToken: nil, ImageDetails: []*ecr.ImageDetail{ {ImageDigest: aws.String("foobarfoobar"), ImageTags: []*string{aws.String("foobar")}}, {ImageDigest: aws.String("barfoobarfoo"), ImageTags: []*string{aws.String("barfoo")}}, }, }, nil, ) em.On("DescribeImages", &ecr.DescribeImagesInput{ NextToken: nil, MaxResults: aws.Int64(1000), RepositoryName: aws.String("ecr2"), }).Return( &ecr.DescribeImagesOutput{ NextToken: nil, ImageDetails: []*ecr.ImageDetail{}, }, nil, ) em.On("BatchDeleteImage", &ecr.BatchDeleteImageInput{ ImageIds: []*ecr.ImageIdentifier{ {ImageDigest: aws.String("foofoofoo")}, {ImageDigest: aws.String("barbarbar")}, {ImageDigest: aws.String("foobarfoobar")}, {ImageDigest: aws.String("barfoobarfoo")}, }, RepositoryName: aws.String("ecr1"), }).Return( &ecr.BatchDeleteImageOutput{ ImageIds: []*ecr.ImageIdentifier{}, Failures: []*ecr.ImageFailure{}, }, nil, ) cm.On("DeleteStack", &cloudformation.DeleteStackInput{ StackName: aws.String(stackName), }).Return( &cloudformation.DeleteStackOutput{}, nil, ) }
4つもAPIを使っているので結構長くなってしまった。
rootの方のテストはE2Eっぽい感じです。
t.Run("success", func(t *testing.T) { stackName := "foo" args := []string{"cfn", "purge-stack", "--stack-name", stackName} cmd := NewCmd() cmd.SetArgs(args) cfnCmd := cfn.NewCmd() purgeStackCmd := purge_stack.NewCmd() cfnCmd.AddCommand(purgeStackCmd) cmd.AddCommand(cfnCmd) cm := &purge_stack.MockCfnClient{} em := &purge_stack.MockEcrClient{} purge_stack.SetMockDefaultBehaviour(cm, em) purge_stack.CfnClient = cm purge_stack.EcrClient = em b := bytes.NewBufferString("") cmd.SetOut(b) cmd.Execute() out, err := ioutil.ReadAll(b) if err != nil { t.Fatal(err) } expected := "All images in ecr1 successfully deleted.\nPerform delete-stack is in progress asynchronously.\nPlease check deletion status by yourself.\n" assert.Equal(t, expected, string(out)) assert.Nil(t, err) })
まとめ
cobraはデフォルトの使い方をするとかなりテストがしづらいです。
ただでさえCLIというインターフェースの上に、AWS APIのモック、認証(profile, region)の使い分けなどが載っているため、テストの境界も設定しづらいです。
モックについてはかなり試行錯誤しましたがまあ及第点は出せたかなと思います。
規模が大きくなるにつれて手を入れないとしんどそうな未来は見えていますがまたその時考えることにします。
あとこれ公式でサポートして欲しいですAWSさん(小声)
ロードマップありませんか
ECS/EKSまわりのロードマップはあるけどこういうのやってるサービスの方が稀なのかな🤥https://t.co/OMXeEmgUE7
— 青いエンジニア🦋 (@itmono_sakuraya) 2020年6月5日
GoでAWS APIをモックする
開発中のCLIツールのテストで、AWS API の呼び出しをモックする仕組みを試行錯誤したのでメモ。
ツールでは aws-sdk-go を用いてAPIを呼び出している。
例として、CloudFormation の ListStacks API をモックする。
サービスクライアントを差し替え可能にする
before
import ( "github.com/aws/aws-sdk-go/service/cloudformation" ) service := cloudformation.New(sess) params := &cloudformation.ListStacksInput{ NextToken: token, } resp, err := service.ListStacks(params)
サービスごとにaws-sdk-go/service/*
にサービスクライアントの構造体が定義されていて、通常はそれを使う。
が、そのままハードコーディングしてしまうとテストで差し替えることができなくなってしまう。
このサービスクライアントだが、便利なことにgithub.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface.CloudFormationAPI
というインターフェースを実装しているので、インターフェースを経由すれば実装を気にすることなく各APIを呼び出すことができる。
この仕組みを利用して、サービスクライアントを差し替え可能にする。
after
var Client cloudformationiface.CloudFormationAPI if Client == nil { Client = cloudformation.New(sess) } params := &cloudformation.ListStacksInput{ NextToken: token, } resp, err := Client.ListStacks(params)
Client
をグローバルに出して(複数のプライベートメソッドから呼ばれるため)、呼び出し時にClient
が設定されていなければaws-sdk-goの実装を使う、という仕組みにする。
これによって、テストでは事前にClientにモックオブジェクトを差し込んで、任意の処理をさせることができるようになる。
モックの実装
次に、テストで使うモックの実装。
type mockClient struct { cloudformationiface.CloudFormationAPI ListStacksMock func(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error) ListExportsMock func(params *cloudformation.ListExportsInput) (*cloudformation.ListExportsOutput, error) ListImportsMock func(params *cloudformation.ListImportsInput) (*cloudformation.ListImportsOutput, error) }
以下の記事が詳しいが、Goでは構造体に直接インターフェースを埋め込むDIの仕組みがあるのでそれを活用する。 qiita.com
cloudformationiface.CloudFormationAPI
を埋め込んだモックの構造体を作り、各メソッドの実装をケースごとに差し替えられる様に、関数オブジェクトとして定義する。
ケースごとにオーバライドできる & デフォルトの実装をしておく。
func (client *mockClient) ListStacks(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error) { if client.ListStacksMock != nil { return client.ListStacksMock(params) } switch aws.StringValue(params.NextToken) { case "": return &cloudformation.ListStacksOutput{ NextToken: aws.String("next_token"), StackSummaries: []*cloudformation.StackSummary{ {StackId: aws.String("aaa"), StackName: aws.String("foo")}, {StackId: aws.String("bbb"), StackName: aws.String("bar")}, }, }, nil case "next_token": return &cloudformation.ListStacksOutput{ NextToken: nil, StackSummaries: []*cloudformation.StackSummary{ {StackId: aws.String("ccc"), StackName: aws.String("foobar")}, }, }, nil default: return nil, nil } }
モックすればページングの挙動とかも簡単に確認できますね...!
例えばパーミッションエラー時のテストをしたい場合は、エラーのレスポンスを返す実装を差し込む。
t.Run("authorization error for ListStacks", func(t *testing.T) { cmd := NewCmd() var args []string const errorCode = "AccessDeniedException" const errorMsg = "An error occurred (AccessDeniedException) when calling the ListStacks operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: cloudformation:ListStacks" Client = &mockClient{ ListStacksMock: func(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error) { return nil, awserr.New(errorCode, errorMsg, errors.New("hoge")) }, } actual, err := FetchData(cmd, args) assert.Empty(t, actual) assert.Equal(t, errorCode, err.(awserr.Error).Code()) assert.Equal(t, errorMsg, err.(awserr.Error).Message()) })
awserrの実装を見て、エラーレスポンスを組み立てる。 docs.aws.amazon.com
OrigError()
はあんまり使う気がしないので、該当のCode()
とMessage()
だけで十分再現性あると思う。
type Error interface { // Satisfy the generic error interface. error // Returns the short phrase depicting the classification of the error. Code() string // Returns the error details message. Message() string // Returns the original error if one was set. Nil is returned if not set. OrigErr() error }
エラーコードはこの辺りで確認する。
Redashにつながらなくなった時の調査メモ
Redashが落ちてたのでその時の調査と対応の記録。
前提
- EC2で運用
- 冗長化はしていない
- 公式のAMIを使用(つまりdocker-composeで動かすスタイル)
事象
ブラウザでRedashの固定IPアドレスを叩くと画面が真っ白。
原因のあたりをつける
真っ白な画面は返ってくるので、サーバーが落ちてるわけではなさそう🤔
事実、curlで確認するとステータスは200。
サーバーは生きてるがコンテンツがないレスポンスを返している?
この辺りの事情はこれ以上あたりがつけられないが、なんとなくどうせNo space left on device
だろうなと思った。
というのも、起動時間が。
sshして調査
公式イメージはUbuntuベースなので、ec2-user@x.x.x.x
ではなく、ubuntu@x.x.x.x
になる。
ディスクの容量を確認。
ubuntu@ip-192-168-170-114:~$ df -h Filesystem Size Used Avail Use% Mounted on /dev/nvme0n1p1 7.7G 7.7G 0 100% /
やっぱりというお気持ち。
ちなみに8GBのEBSがアタッチされている。
何が占めているのか調査。
抜粋すると
$ sudo du -h -d 1 / 4.8G /var 2.4G /usr 8.4G /
/var/log
あたりだろうなと思っていたのでまあvar
はよしとして(実際は/var/lib/docker/だったが)、/usr/src
が結構占めていた。
$ ls /usr/src linux-aws-5.3-headers-5.3.0-1017 linux-aws-headers-4.15.0-1021 linux-aws-headers-4.15.0-1047 linux-aws-headers-4.15.0-1048 linux-aws-headers-4.15.0-1050 linux-aws-headers-4.15.0-1051 linux-aws-headers-4.15.0-1052 linux-aws-headers-4.15.0-1054 linux-aws-headers-4.15.0-1056 linux-aws-headers-4.15.0-1057 linux-aws-headers-4.15.0-1058 linux-aws-headers-4.15.0-1060 linux-aws-headers-4.15.0-1063 linux-aws-headers-4.15.0-1065 linux-headers-4.15.0-1021-aws linux-headers-4.15.0-1065-aws linux-headers-5.3.0-1017-aws linux-headers-5.3.0-1019-aws
カーネルの自動アップデートによるゴミが残ってしまっているので削除する。
sudo apt-get -y autoremove sudo apt-get -y autoclean
が、空き容量が全くないために以上のコマンドはtmpファイルすら作れずに失敗する。
仕方がないので一時的にEBSを拡張する。
マネジメントコンソールから10GBに拡張を行う。
しばらく待って、適用が完了したらファイルシステムの拡張を行う。
$ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT loop0 7:0 0 18M 1 loop /snap/amazon loop1 7:1 0 93.9M 1 loop /snap/core/9 loop2 7:2 0 93.8M 1 loop /snap/core/8 loop3 7:3 0 18M 1 loop /snap/amazon nvme0n1 259:0 0 10G 0 disk └─nvme0n1p1 259:1 0 8G 0 part /
今回はパーティション(nvme0n1p1
)があるので、先にパーティションの拡張が必要。
$ sudo growpart /dev/nvme0n1 1 mkdir: cannot create directory ‘/tmp/growpart.2200’: No space left on device FAILED: failed to make temp dir
そうかあああああこれも空き容量必要なのかあああああ。
仕方ないので先に空き領域を適当に確保する。
ジャーナルログをローテートしてしまおう。
$ journalctl --vacuum-time=7d
これで300MBくらい空きができた。
(EBS拡張する必要なかったね)
戻すのも面倒なのでEBSはそのままで10GBで使います。
$ sudo growpart /dev/nvme0n1 1 CHANGED: partition=1 start=2048 old: size=16775135 end=16777183 new: size=20969439,end=20971487 $ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT loop0 7:0 0 18M 1 loop /snap/amazon loop1 7:1 0 93.9M 1 loop /snap/core/9 loop2 7:2 0 93.8M 1 loop /snap/core/8 loop3 7:3 0 18M 1 loop /snap/amazon nvme0n1 259:0 0 10G 0 disk └─nvme0n1p1 259:1 0 10G 0 part /
$ sudo resize2fs /dev/nvme0n1p1 resize2fs 1.44.1 (24-Mar-2018) Filesystem at /dev/nvme0n1p1 is mounted on /; on-line resizing required old_desc_blocks = 1, new_desc_blocks = 2 The filesystem on /dev/nvme0n1p1 is now 2621179 (4k) blocks long. $ sudo df -h Filesystem Size Used Avail Use% Mounted on /dev/nvme0n1p1 9.7G 7.4G 2.3G 77% /
あとは/var/
の方を整理します。
/var/lib/docker/overlay2
が肥大化していたので、docker system prune -a
してdocker再起動して終了。
4.4GBまで減りました。