出力を入力へ

プログラミングに関する自分が考えた事を中心にまとめます

GitHub PRにRedmine issueへのリンクを記載したらコメント通知するactionを書いた

初めてのGitHub Actionsの開発。

github.com

Actions開発の背景

タスク管理にGitHub issueではなくRedmine issueを利用しているのだけれど、プルリクを作成したときにRedmine issueへのリンクを記載することがよくある。 git コミットにissue番号を記載して連携させれる方法もあるけど、コミットレベルではなくプルリクレベルで連携させたかった。

あまりこういった取り組みは行われていないようなので、GitHub Actionsでプルリクの内容を解析してリンク先のRedmine issueにコメントを残すActionを開発した。 これによってGitHub側の開発状況がRedmineにコメントとして自動反映されるので、わざわざRedmine issueを手動で更新しなくて済むようにした。

開発の取り組み

GitHub Actionsについて

基本的には公式ドキュメントの アクションの開発を読み進めていった。

最初は環境変数を通して必要な情報が渡されないかなと期待したけど、欲しい情報はなかったのでしっかりと作り込むことにした。 Actionを開発する上で、JavaScriptに慣れておらずDockerに慣れていることから、Dockerアクションで実装しようかと思っていた。ただし、RedmineのAPI叩くためだけにDockerイメージのプルするのはやり過ぎかなと思ったのと、DockerアクションはJavaScriptアクションより遅いとうことだったので、JavaScriptアクションで実装してみることにした。JavaScriptを使う良い機会とも思ったので。 ただし、プルリクを作成したタイミングでしか実行されず、コメント通知が少し速くてもまったく嬉しくないので、JavaScriptアクションを選択した意味はあまりなかったかもしれない。

Action開発する上では、どのようにGitHubから必要な情報を取得するかが悩みどころだった。パッケージとしてはtoolkitが公開されているけど、細分化されておりどのパッケージが必要なものかわからなかった。 結局はGitHub Actionsに関する情報取得は @actions/coreを、プルリクに関する情報は @actions/githubを利用した。ただし、@actions/github を使う上で欲しい情報がどのように取得できるかは最後までよくわからなかった。一応 ドキュメントはしっかりあるけど、各APIを叩くと具体的にどのような情報が取得できるのか判断できなかった。結局は実際にAPIをいくつか叩いてみたりしてあたりをつけた。

Node.js アプリ開発

Node.jsアプリ開発はほとんど経験がなかったので、結構基本的なところから試行錯誤していたので思ったより時間を掛けてしまった。 基本的には javascript-action-templateの通り。 迷ったのは以下の通り。

パッケージマネージャとしてのnpmについて。 てっきりyarnを利用するのかと思ったけど、サンプルにはnpmしかなかったので、そのままnpmを利用し続けた。yarnの方が良いらしいくらいにしか理解していないのと、npmで困っていないので現状もnpmのまま。

パッケージの配置について。 動作検証として開発中のActionsをGitHub上で動かしてみたけど、node_modulesが見付からないというエラーで動作しなかった。原因は全部ドキュメントに書いてあるのだけれど node_modulesごとコミットする必要があるとこのと。 ただし、他のプロジェクトを見てもnode_modulesをコミットしているプロジェクトはほとんど見あたらず、テンプレートすらnode_modulesをコミットする方針は採用していなかった。また、コミットするにしてもmainブランチにはコミットせずリリースブランチ/タグでのみコミットしないと開発がやりずらくなる、みたいな情報もあった。node_modulesの中身もきちんと理解していないのでコミットしたくないという思いもあり、テンプレートは他のActionsと同じように@vercel/nccを利用する方針を採用した。 ただし、nccをどのように実行すればよいか解決していない。現状はアプリを修正したときやパッケージを更新したときに手動でコマンドを叩いている。リリースプロセスの中で自動実行する仕組みにしないと間違えそうだなという不安が残っている。

テストについて。 node.jsのテストフレームワークとしてjestは前々から使ってみたいなという思いはあったので喜んで採用した。単体テスト自体は慣れているし、違和感なくテストを書くことができた。 ただしテストを書くためには対象関数を別ファイルに分割する必要があったり、テストのためだけに? module.exportsしたりと、本当にこのやり方でいいのかなという違和感が残っている。 Redmine APIもよくわからない事が多いので、できればRedmine APIをモックにしてテストを書きたい気持ちもあったけど、そこは面倒そうなので諦め。

Redmine API用のライブラリについて。 探すと node-redmineがあったので特に悩まず採用した。実際問題なく開発を進めることはできた。 ただし、後から確認してみるとまったくメンテされておらず、開発者もその気がなさそう。今後の事を考えると別のライブラリ(例えば axios-redmineなど)を採用しておけばよかったかもしれない。

TypeScriptについて。 TypeScriptでの開発はまったく検討していなかったけど、開発中の調査等でTypeScriptという選択肢があることに気が付いた。また、TypeScriptのテンプレートも用意されている。 TypeScriptもどこかで使ってみたいという思いはあるけれど、まあ別の機会ということでスルーした。

アプリの実装について。 あまり複雑な処理はないが、唯一困ったのがプルリクの本文からRedmine issueへのリンクを取得する部分。特にissue番号の一覧だけが欲しいのをどうするかが悩みどころで結構時間を使ってしまった。 結局は RegExp.exec を使えばシンプルに実装できるとわかり無事解決。

リリース

リリースをどうするかはまだ解決していない。 特に利用において、現状では @main のようにメインブランチを指定する利用方法を想定しているが、できればよくある@v1のようなバージョン指定をサーポートしたい。しかし、これを実現するにはv1タグをリリースの度に打ち直す必要がある。できればタグは一度打ったら別のコミットに打ち直したくないので、このリリース方法には違和感がある。 解決策としてv1ブランチを作成することにした。これで@v1.0.0 指定と@v1指定の両方がサポートされ、v1タグをリリースの度に打ち直す必要はない。ただし、v1.0のようなマイナーバージョンまでの指定はサポートできない。最新機能に追従するv1指定か、バージョンを固定するv1.0.0指定の2つがサポートされていればまずはOKだろうか。

今後の展望

まだまだやりたい事はたくさんあるがどこまで対応できるか不明。しばらく使ってみてから考える。

機能としては、Redmineのissueステータス更新やメッセージのカスタマイズなんかはやりたい。 非機能としても、リリースプロセスは整備したいし、テストまわりももう少しきちんと書きたい。あとはREADMEもえいやで書いたので英文を見直したい。

それ以外にも、だいたい実装が完了した時点で、GitHub Actions実践入門の書籍にActions開発についても触れられていることを知った。てっきりActions利用のみだと勘違いしていた。Actions開発については分量はそこまで多くないし、Actions側のアップデートもあるので全部そのままは利用できないかもしれないが、今回自分がActionsまわりで悩んだ点くらいは楽に解決できたかもしれない。

lambda関数のコンテナイメージサポートをterraformで構築する

AWS Lambda関数をコンテナイメージでデプロイする環境をterraformで構築したい。 特にterraformでは対象のコンテナイメージを管理から外したい場合における 初期構築方法について検証する。

検証コードはこちら。

github.com

lambda関数によるイメージ指定

Lambda関数を package_type = Image で作成する場合、 コンテナイメージを指定する必要があり、ECRだけでは作成できない。 イメージをpushせずに構築しようとすると、以下のようなエラーになる。

resource "aws_lambda_function" "sample" {
  function_name = "sample-container"
  role          = aws_iam_role.lambda.arn

  package_type = "Image"
  image_uri    = "${aws_ecr_repository.sample_prepared.repository_url}:latest"
}
aws_lambda_function.sample_prepared: Creating...
╷
│ Error: error creating Lambda Function (1): InvalidParameterValueException: Source image xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/sample-prepared:latest does not exist. Provide a valid source image.
│ {
│   RespMetadata: {
│     StatusCode: 400,
│     RequestID: "4c6a36d1-1ed8-4c2a-959a-50f8b066d21d"
│   },
│   Message_: "Source image xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/sample-prepared:latest does not exist. Provide a valid source image.",
│   Type: "User"
│ }
│
│   with aws_lambda_function.sample_prepared,
│   on external_registry.tf line 3, in resource "aws_lambda_function" "sample_prepared":
│    3: resource "aws_lambda_function" "sample_prepared" {
│

このため、有効なコンテナイメージを準備しておきLambda関数を構築可能とする必要がある。 このコンテナイメージにはAWSが提供してくれるイメージなどは利用できず、 対象のAWSアカウントに自分で準備する必要がある。

docs.aws.amazon.com

Lambda 関数の作成は、Amazon ECR のコンテナレジストリと同じアカウントから実行する必要があることにご注意ください

以上から、対策方針としては以下の3つがある。

  • ダミーECRリポジトリおよびコンテナイメージを準備しておく
  • local-exec でECRレジストリ作成と同時にイメージを作成する
  • docker providerでイメージまで作成する

方針1. 外部のダミーECRレジストリおよびコンテナイメージを準備しておく

一番シンプルなのは事前に専用のダミーコンテナイメージを準備しておく方法。 ダミーイメージをデプロイ時に合わせて構築することは難しく、 lambdaの仕様上別AWSアカウントのイメージ等を利用することはできない。 このため自分で事前にイメージを構築しておく。

resource "aws_lambda_function" "sample_prepared" {
  function_name = "sample-container-prepared"
  role          = aws_iam_role.lambda.arn

  package_type = "Image"
  # 初期構築時はダミーイメージを指定しておく
  image_uri = "${data.aws_ecr_repository.prepared.repository_url}:latest"

  lifecycle {
    ignore_changes = [image_uri]
  }
}

# 実際にlambda関数で実行するコンテナイメージを格納するレジストリ
resource "aws_ecr_repository" "sample_prepared" {
  name                 = "sample-prepared"
  image_tag_mutability = "MUTABLE"
}

# 以下は事前に手動で構築しておいたECRレジストリおよびコンテナイメージ
data "aws_ecr_repository" "prepared" {
  name = "prepared"
}

手作業でECRレジストリを作成し、適当なイメージをlatestタグでプッシュしておく。 初回構築時のみLambda関数がこのコンテナイメージをロードするが、 以降はlifecycleで指定している通り無視されるので、 別のイメージタグを指定しようが異なるリポジトリのイメージを指定しようが terraform上では差分は発生しない。

この方法はシンプルで手軽に対応可能な一方で、 手動でECRリポジトリの作成とイメージのpushが必要であること、 初回構築のためだけに別のECRリポジトリへの依存を明記する必要があるといった気持ち悪さがある。 実際に利用するECRリポジトリが対象lambda関数から指定されないことは誤解を生じやすいのでできれば避けたい。

方針2. local-exec でECRレジストリ作成と同時にイメージを作成する

初回構築のためだけに別ECRリポジトリを準備・利用するのは気持ち悪いので 初回構築時にECRリポトリにダミーイメージを格納する。 こういった操作にはlocal-execが便利なのでこれで実現する。

resource "aws_lambda_function" "sample_localexec" {
  function_name = "sample-container-localexec"
  role          = aws_iam_role.lambda.arn

  package_type = "Image"
  image_uri    = "${aws_ecr_repository.sample_localexec.repository_url}:latest"

  depends_on = [null_resource.generate_dummy_image]
}

# 実際にlambda関数で実行するコンテナイメージを格納するレジストリ
resource "aws_ecr_repository" "sample_localexec" {
  name                 = "sample-localexec"
  image_tag_mutability = "MUTABLE"
}

# ダミーコンテナイメージの保存
resource "null_resource" "generate_dummy_image" {
  provisioner "local-exec" {
    command = "aws ecr get-login-password | docker login --username AWS --password-stdin ${data.aws_caller_identity.current.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com"
  }

  # ダミーイメージとしてalpineを利用する
  provisioner "local-exec" {
    command = "docker pull alpine:latest"
  }

  provisioner "local-exec" {
    command = "docker tag alpine:latest ${aws_ecr_repository.sample_localexec.repository_url}"
  }

  provisioner "local-exec" {
    command = "docker push ${aws_ecr_repository.sample_localexec.repository_url}"
  }
}

この方法を利用することで lambda関数のイメージ指定先として実際に利用するECRリポジトリを指定できる。 事前の操作も不要であり構築も容易である。

課題として、動作環境にawscliおよびdocker cliが必要なことが挙げられる。 通常の開発環境であれば存在を仮定しても問題なさそうだが、これが問題になるのがTerraform Cloud環境である。 Terraform Cloud への追加ソフトウェアインストールについてはドキュメントにまとめられているが、 基本的に非推奨でありさまざまな制約も存在する。 やはりAWS CLIが欲しいという意見は挙がっているようだが、 Terraform Cloud環境でaws cli や docker cli を利用する方針は避けた方がよさそう。

※別途確認したところ、Terarform Cloud環境にaws cliは存在したがdocker cliは存在しなかった。

方針3. docker providerでイメージまで作成する

local-execを利用すればLambda関数の構築に必要なダミーdockerイメージを生成・格納できるが Terraformの実行環境に追加ソフトウェアの依存が生まれてしまう。 これを解決する方法として、terraformでdockerイメージを制御する terraform-provider-docker を利用する。 docker providerを利用することで、追加でのawscliやdocker cliなしにdockerイメージのpushが可能になる。

resource "aws_lambda_function" "sample_dockerprovider" {
  function_name = "sample-container-dockerprovider"
  role          = aws_iam_role.lambda.arn

  package_type = "Image"
  image_uri = "${aws_ecr_repository.sample_dockerprovider.repository_url}:latest"
}

resource "aws_ecr_repository" "sample_dockerprovider" {
  name                 = "sample-dockerprovider"
  image_tag_mutability = "MUTABLE"
}

# Dockerレジストリにダミーイメージを格納しておく
resource "docker_registry_image" "sample_dockerprovider" {
  name = "${aws_ecr_repository.sample_dockerprovider.repository_url}:latest"

  build {
    context = "dummy"
  }
}

# docker-providerでECRを利用するための認証設定
data "aws_ecr_authorization_token" "token" {
}

provider "docker" {
  registry_auth {
    address  = "${data.aws_caller_identity.current.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com"
    username = data.aws_ecr_authorization_token.token.user_name
    password = data.aws_ecr_authorization_token.token.password
  }
}

ここで一番実現したいことは、DockerHub上のalpineイメージを対象のECRリポジトリにコピーすること。 しかし、docker providerではこのような機能は議論されているが実装さていない。 このため、dummyディレクトリにあるダミー用のDockerfileにてコンテナイメージビルドした上で、そのイメージをECRリポジトリにpushすることで実現する。

FROM alpine

この方法であれば追加でaws cliや docker cliは不要でdocker providerを追加すれば解決できる。 ただし、hashicorp管理ではない別のproviderへ依存することになるのでこれを許容できるかが鍵となる。

まとめ

  • lambda関数でコンテナイメージサポートを利用する場合、初期構築時からコンテナイメージが必要となる
  • Terraformで上記lambda関数を構築する場合は構築時用のコンテナイメージをどのように準備するかが課題になる。
  • 別ECRリポジトリを参照する方法、local-execで構築する方法、docker providerで構築する方法が考えられる。
  • どの方針も一長一短なのでどれを選択するかは状況次第

参考

Terraform Cloudでリソースインポート時の変数参照

TerraformのバックエンドにTerraform Cloudを利用しているときに リソースインポートでエラーが発生して困っていた。 これについて調べていたら、リソースインポート時の変数参照についていろいろわかったのでそのまとめ。

Terraform 0.15.5で検証。

発生したエラー

再現手順

まずは発生したエラーについて。 特定の条件でリソースのインポートを行うとエラーが発生するというもので、再現手順は以下の通り。

  1. サンプルリポジトリの通りリソースを構築(terraform apply)する
  2. リソースをstateファイルから削除する
  3. Terraformをリモート実行モードに切り替える
  4. Terraform Cloud上で未定義の変数(例: undefined_variable = "test")を設定する
  5. Terraformをローカル実行モードに切り替える
  6. 2で削除したリソースをインポートする

以上の手順で実行したとき、リソースが正常通りインポートされることを期待するが、 実際には以下の通りエラーになる。

$ terraform import aws_s3_bucket.sample sample-import-tf
Acquiring state lock. This may take a few moments...
╷
│ Error: Value for undeclared variable
│ 
│ A variable named "undefined_variable" was assigned a value, but the root module does not declare a variable of that name. To use this value, add a "variable" block to
│ the configuration.
╵

エラー原因

当然ながら、原因は手順4で未定義変数に値を設定しようとしたこと。 リソースインポート時に未定義変数への値の割り当てが実行できずにエラーとなった。

未定義変数への値の代入があると、terraform planでも警告はあるがエラーにはならないので インポート時はエラー扱いになるという挙動の違いに混乱した。

$ terraform plan
...
╷
│ Warning: Value for undeclared variable
│ 
│ The root module does not declare a variable named "undefined_variable" but a value was found in file "terraform.tfvars". If you meant to use this value, add a
│ "variable" block to the configuration.
│ 
│ To silence these warnings, use TF_VAR_... environment variables to provide certain "global" settings to all configurations in your organization. To reduce the
│ verbosity of these warnings, use the -compact-warnings option.

また、一番驚いたのはローカル実行モードなのにTerraform Cloud上の変数を参照したところ。 ローカル実行モードでは変数のタブも参照できなくなるので、利用されないだけでなく、利用できないという認識だった。 ドキュメントを確認したが、インポートの章でもremoteの章でも変数を参照するという記載は見付からなかった。 リソースインポート作業が発生する場合は、過去に設定した変数はもう使わないからと放置せず、予め削除しておいた方がよさそう。

追加検証

この挙動について理解するために、以下のような追加検証を行った。

ローカル変数定義ファイルの参照

Terraform Cloud上の変数定義で、未定義変数に値を設定しているとエラーになることはわかった。 ではローカルの変数定義ファイル(terraform.tfvars)で未定義変数に値を設定しているとどうなるか検証した。

手順は以下の通り

  1. サンプルリポジトリの通りリソースを構築(terraform apply)する
  2. リソースをstateファイルから削除する
  3. ローカルのterraform変数定義ファイル(terraform.tfvars)に未定義変数(例: undefined_variable = "value")を設定する
  4. 2で削除したリソースをインポートする

以上の検証の結果、リソースインポートはエラーにはならず警告だけ出力してインポートに成功した。 ローカルの変数定義ファイルも参照はされるものの、Terraform Cloud上の変数定義とは異なりエラー扱いにならないというのは挙動の違いに驚かされる。

$ terraform import aws_s3_bucket.sample sample-import-tf
Acquiring state lock. This may take a few moments...

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

╷
│ Warning: Value for undeclared variable
│ 
│ The root module does not declare a variable named "undefined_variable" but a value was found in file "terraform.tfvars". If you meant to use this value, add a
│ "variable" block to the configuration.
│ 
│ To silence these warnings, use TF_VAR_... environment variables to provide certain "global" settings to all configurations in your organization. To reduce the
│ verbosity of these warnings, use the -compact-warnings option.
╵

ローカルとリモート両方での未定義変数設定

リソースインポート時にローカルとリモート両方の変数定義が参照されていることがわかった。 では、ローカルとリモート両方で変数定義が行われているときの挙動を確認した。

手順は以下の通り。

  1. サンプルリポジトリの通りリソースを構築(terraform apply)する
  2. リソースをstateファイルから削除する
  3. Terraformをリモート実行モードに切り替える
  4. Terraform Cloud上で未定義の変数(例: undefined_variable = "test")を設定する
  5. ローカルのterraform変数定義ファイル(terraform.tfvars)に、4とは別の未定義変数(例: another_undefined_variable = "value")を設定する
  6. Terraformをローカル実行モードに切り替える
  7. 2で削除したリソースをインポートする

以上の検証の結果、2つの未定義変数の設定で警告とエラーが出力されることを確認した。 このため、どちらかで変数定義を行っていればもう1方の変数定義が無視されるということはなく、両方の変数定義が参照される様子。

$ terraform import aws_s3_bucket.sample sample-import-tf
Acquiring state lock. This may take a few moments...
Releasing state lock. This may take a few moments...
╷
│ Warning: Value for undeclared variable
│ 
│ The root module does not declare a variable named "another_undefined_variable" but a value was found in file "terraform.tfvars". If you meant to use this value, add a
│ "variable" block to the configuration.
│ 
│ To silence these warnings, use TF_VAR_... environment variables to provide certain "global" settings to all configurations in your organization. To reduce the
│ verbosity of these warnings, use the -compact-warnings option.
╵

╷
│ Error: Value for undeclared variable
│ 
│ A variable named "undefined_variable" was assigned a value, but the root module does not declare a variable of that name. To use this value, add a "variable" block to
│ the configuration.
╵

リソース削除時の挙動

リソースインポート時にエラーは発生することは理解したが、 逆にリソースをstateから除外するときにどうなるか検証した。

手順は以下の通り

  1. サンプルリポジトリの通りリソースを構築(terraform apply)する
  2. Terraformをリモート実行モードに切り替える
  3. Terraform Cloud上で未定義の変数(例: undefined_variable = "test")を設定する
  4. ローカルのterraform変数(terraform.tfvars)に、3とは別の未定義変数(例: undef = "value")を設定する
  5. Terraformをローカル実行モードに切り替える
  6. リソースをstateファイルから削除する

結果は以下の通り、警告もエラーも生じずにリソースを除外することができた。 state操作において変数を参照するのはインポート時だけの挙動の様子。

$ terraform state rm aws_s3_bucket.sample
Acquiring state lock. This may take a few moments...
Removed aws_s3_bucket.sample
Successfully removed 1 resource instance(s).

まとめ

Terraform Cloud利用時におけるリソースインポート時には ローカル実行モードであってもTerraform Cloudおよびローカルで設定した変数定義が参照される。 特にローカルでは警告止まりだった未定義変数の警告が、Terraform Cloudだとエラー扱いになる。 ローカル実行モードで利用する場合はTerraform Cloud上の変数定義はすべて削除しておいた方がよい。

ansibleのEC2 dynamic inventoryで異なるタグがまとめられる不具合

Ansibleの AWS EC2 dynamic inventoryにおけるタグ管理はあまり賢くないなと思ったのでそのまとめ。 タグで変なことやるとどこかでバグらせそう。

ちなみに、ansibleはバージョン体系等を大きく変更しようとしているので ansibleのバージョンによって挙動が大きく変化しうる。 ここでは安定板のansible 2.9を対応とする。

AWS EC2 dynamic inventory

ansibleで対象のホストを動的に変化させたい場合は dynamic inventoryを利用する。 AWS EC2用としても専用のスクリプトが提供されており、 これを利用することで稼働中のインスタンス一覧を動的に取得することができる。

docs.ansible.com

動的に取得したインスタンスから対象となるホストをフィルタリングするため、 さまざまな属性ごとにインスタンスを取得することができる。 今回注目するのはタグによる抽出方法で、 これはタグのキー/バリューを指定して一致するインスタンス一覧を取得する方法である。

例えば、ansible-playbookで タグ Env=production が付与されたインスタンスを対象としてansibleを適用したい場合は hostsの欄に指定すればよい。

- hosts: tag_Env_production
  remote_user: ec2-user
  roles:
    - { role: docker, tags: docker }
...

EC2 dynamic inventoryにおけるタグの扱い

ドキュメントにも、タグは tag_KEY_VALUE の形に変換されるとドキュメントに記載されている。 例えば Name=webserver というタグが付与されたインスタンスであれば tag_Name_webserver という形で整理される。

問題なのがKey/Valueに記号が含まれる場合で、 このとき対象の記号はアンダースコアに変換される。 すなわち Name=web-serverName=web_server も等しく tag_Name_web_server に変換される。 もっと言うと、 Name-web=serverというタグであっても tag_Name_web_server に変換される。

AWSは起動テンプレートやオートスケール、またはECSやEKSなどのサービスを通してEC2インスタンスを起動する場合に その情報をタグとして埋め込んでくれるが、 このときの情報はコロンで区切られている。 例えば aws:ec2launchtemplate:id = lt-xxxxxaws:autoscaling:groupName = xxxx といった感じである。 このコロンもアンダースコアに変換される。

AWSが付与するタグの中にansibleが混同するような情報は現在のところなさそう。 ただし、自分たちで独自にタグを付けるときに中途半端にAWSの付与するタグと似たようなものを利用すると、 どこかで不具合になる可能性がある。 あまり複雑なタグを付与しないように注意する必要がある。

GitHubのプルリクエストにRedmineチケットへのリンクがあったら対象チケットにプルリク情報をコメントする

RedmineとGitHubの連携を実現するためにいろいろ試行錯誤したのでそのまとめ。

課題トラッカーとしてRedmineを利用する

課題トラッカーとしてGitHub issueではなくRedmineを利用する場合、 ソフトウェア開発との連携が課題になる。 具体的にはプルリクエストの作成状況とRedmineのチケットを連動させたい。

GitHub issueであれば単位issue番号を記載すれば自動的にリンクになるし、 issue側にもプルリクエストへのリンクが生成される。 単にこれだけではあるが、これをRedmineでも実現したい。

RedmineとGitHubの連携方法

リポジトリ同期

一番単純なのはRedmine上のリポジトリと連動させる方式。 GitHub webhookでredmineにpushしてあげればリポジトリを同期させることができる。

ただし、この方法ではコミットにredmineチケット番号を埋め込めば連動できるが プルリクエストの内容は連動しない。 プルリクエストがマージされればマージコミットのコメントとして埋め込むことはできるが プルリクエストがオープンした段階でも連動して欲しいし、 マージコミットには標準ではプルリク番号しか埋め込まれないので Redmineチケット番号を別途埋め込む必要が出てくる。 redmine側でもリポジトリを持たなくてはいけないのも若干面倒。

webhook連携

GitHubのプルリクの操作を契機としてwebhookが飛ばせるので これを利用してRedmineのチケットにコメントを追記する方法がある。

webhookは単にイベントを飛ばすだけなので直接RedmineのAPIを叩くわけにはいかない。 このため、よくある例としては AWS API Gateway + Lambdaなどの構成により、 webhookを受け取ってコメント内容を整形してからRedmine APIを叩く方法がある。

この方法であれば(おそらく)上手くいくが、わざわざコメントを記載するためだけに API Gateway+Lambdaを構築するのは若干大掛かりな気がする。 単にコメント以外にもいろんな通知等を行うのであればよいが 今回の目的はシンプルなのでもっと簡単に実現したい。

GitHub Actions連携

ということで、よりシンプルな構成で実現する方法としてGitHub Actionsを利用する。 GitHub Actionsでもプルリクのイベントを契機として動作させることができ、 GItHub のAPIやRedmineのAPIを叩くようにCIを構築すればよいので より簡単に構成できる。

GitHub Actionsの構築

実際に構築したGitHub Actionsのyamlファイルが以下の通り。 シークレットとしてREDMINEのAPIキーを設定しておく。

name: Redmien

on:
 pull_request:
   types: [opened, closed, reopened]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v4
        id: pr-details
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            pr = await github.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.payload.pull_request.number
            })
            return pr.data
      - name: parse redmine issue number
        id: pr-number
        run: |
          number=$(echo '${{steps.pr-details.outputs.result}}' | jq -r .body | sed -r 's|.*https://redmine.example.com/issues/([0-9]+).*|\1|')
          echo "redmine_issue=$number" >> $GITHUB_ENV
          title=$(echo '${{steps.pr-details.outputs.result}}' | jq -r .title)
          echo "pr_title=$title" >> $GITHUB_ENV
      - name: add note to redimne issue
        if: env.redmine_issue != ''
        run: curl -X PUT -H "Content-Type:application/json" -H "X-Redmine-API-Key:${{secrets.REDMINE_API_KEY}}" -d '{"issue":{"notes":"pull request [${{env.pr_title}}](${{github.event.pull_request._links.html.href}}) ${{github.event.action}}"}}' https://redmine.example.com/issues/${{env.redmine_issue}}.json

このスクリプトは以下の3つのステップで構成されている。

プルリク詳細の取得

プルリクのオープン/クローズでは対象のプルリク番号等は取得できるが、その内容は取得できない。 今回はプルリクの本文にredmineチケットへのリンクがあったら対象のredmineチケットにコメントしたいので、 プルリク本文を取得する必要がある。 また、単にリンクを張るだけではチケット側の情報が不足するので、せめてプルリクのタイトルを記載したい。

これを実現するのがgithub-scriptで、GitHub Actions内でGitHub APIを叩くためのactionである。 これを用いてプルリクの詳細を取得して返してあげる。 プルリク本文の取得方法は、README記載の通り octokit/rest.jsの pulls-getを利用すればよい。

octokit.github.io

注意点として、github-scriptのREADMEの通り、 result-encoding: string で情報を返そうとすると タイトルと本文の両方が取得できない。 このため、取得したプルリク情報をjsonのまま返してあげて、次の工程でタイトルと本文を取得することにした。

プルリクタイトルとRedmineチケット番号の取得

プルリク詳細が取得できたので、ここからプルリクのタイトルとRedmineのチケット番号を抽出してあげる。 プルリクのタイトルは単にjsonをパースすればよく、チケット番号はプルリクの本文から正規表現で抽出すればよい。 抽出した結果はそれぞれ別の環境変数へと格納している。 ワークフローコマンドを使うのが一般的(?)だが、 今回は環境ファイルを使うことで対応した。

この程度であれば1つ目のステップで合わせて実行すればよいかとも考えたが、 Redmineチケットへのリンクが無い場合や複数ある場合の対処を考え始めると複雑になりそう。 現時点ではリンクがない場合のみ考慮し複数ある場合は対象外としたが、 そういった対応のためステップを分けた方がよさそうと判断した。

Redmineへのコメントの通知

RedmineのAPIを叩いてコメントを追記する。 この時点で対象のRedmineのチケット番号もコメントとして記載するプルリク番号もあるので 単にRedmineのAPIを叩けばよい。 対象プルリクへのリンクはコンテキスト情報から取得している。 最初コンテキストにどのような情報が含まれているかわからなかったので 公式ドキュメントに従ってログ出力するのがお手軽だった。 (最初ここからプルリク本文が取れることを期待していた)。

Redmine APIを叩く際には各種SDKやCLIを使うことも考えた。 例えばGo実装npm実装なんかがある。 ただ、今回のように1行コメントを書くには不要と判断し、curlで直接叩いてやることにした。 具体的なコメント例が以下の通り。

f:id:thaim:20210515141806p:plain

とりあえずこれでやりたいことは実現できたはず。

SimpleCovを並列実行した結果をマージする

CircleCI実践入門で紹介されていたRubyアプリケーションのCI事例について、 SimpleCovでカバレッジを計測するとき、CircleCIを並列実行すると結果が分かれてしまうので 並列実行した結果をマージする必要がある。 このマージの実装が古くなっており動作しなかったのでその修正。

マージエラー

サンプルとなるコードはこちらのリポジトリで公開されている。 ただし、この設定で単にCIを実行してもエラーで動作しない。

原因は mimemagicのライセンス問題により過去のバージョンのgemが非公開されたこと。 これにより bundle installが失敗するようになった。

...
Install missing gems with `bundle install`
Fetching gem metadata from https://rubygems.org/............
Your bundle is locked to mimemagic (0.3.5), but that version could not be found
in any of the sources listed in your Gemfile. If you haven't changed sources,
that means the author of mimemagic (0.3.5) has removed it. You'll need to update
your bundle to a version other than mimemagic (0.3.5) that hasn't been removed
in order to install.
...

ちなみに、mimemagicのライセンス問題は以下が詳しい。

hackmd.io

では単に bundle updateしてgemを更新すればよいかというと、それでも上手くいかない。 当然アプリは動作するようになり、rspecも正常に動作するが、 標記のSimpleCovの並列実行結果をマージする処理で失敗する。

#!/bin/bash -eo pipefail
bundle exec rake simplecov:merge_results

rake aborted!
TypeError: no implicit conversion of Array into String
/home/circleci/project/vendor/bundle/ruby/2.6.0/gems/simplecov-0.21.2/lib/simplecov/result_merger.rb:57:in `exist?'
/home/circleci/project/vendor/bundle/ruby/2.6.0/gems/simplecov-0.21.2/lib/simplecov/result_merger.rb:57:in `read_file'
/home/circleci/project/vendor/bundle/ruby/2.6.0/gems/simplecov-0.21.2/lib/simplecov/result_merger.rb:52:in `parse_file'
/home/circleci/project/vendor/bundle/ruby/2.6.0/gems/simplecov-0.21.2/lib/simplecov/result_merger.rb:47:in `valid_results'
/home/circleci/project/vendor/bundle/ruby/2.6.0/gems/simplecov-0.21.2/lib/simplecov/result_merger.rb:37:in `merge_results'
/home/circleci/project/lib/tasks/simplecov.rake:14:in `store_result'
/home/circleci/project/lib/tasks/simplecov.rake:9:in `block (2 levels) in <main>'
/home/circleci/project/vendor/bundle/ruby/2.6.0/gems/rake-13.0.3/exe/rake:27:in `<top (required)>'
/usr/local/bin/bundle:23:in `load'
/usr/local/bin/bundle:23:in `<main>'
Tasks: TOP => simplecov:merge_results
(See full trace by running task with --trace)
Coverage report generated for RSpec to /home/circleci/project/coverage. 0 / 33 LOC (0.0%) covered.
Stopped processing SimpleCov as a previous error not related to SimpleCov has been detected

Exited with code exit status 1
CircleCI received exit code 1

原因は SimpleCov 0.19.0より SimpleCov::Result.from_hashの挙動が変更になったことである。 このため、マージ処理の実装の修正が必要である。

github.com

解決策1: SimpleCovのバージョンを古いままにする

一番わかりやすい解決策としてSimpleCovのバージョンを0.18のままにすればよい。 以下の通り修正した上で bundle updateすればmimemagicの問題を解決しつつSimpleCovのカバレッジ結果のマージも問題なく動作する。

--- a/Gemfile
+++ b/Gemfile
@@ -52,7 +52,7 @@ group :test do
   gem 'selenium-webdriver'
   # Easy installation and use of web drivers to run system tests with browsers
   gem 'webdrivers'
-  gem 'simplecov', require: false
+  gem 'simplecov', '< 0.19.0', require: false
   gem 'rspec_junit_formatter'
 end

ただし、当然SimpleCovのアップデートができなくなるので、できれば別の方法で解決したい。

解決策2: Simplecov.collateでマージする

公式のREADMEにも並列環境での結果をマージする方法について記載がある。 公式には Simplecov.collateメソッドを利用する方法が紹介されている。 すなわち、自分でマージ処理を実装する必要はない。 (Simplecov.collate 自体は 0.18.0から実装されていた)

github.com

require "simplecov"

namespace :simplecov do
  desc "merge_results"
  task merge_results: :environment do
    SimpleCov.start 'rails' do
      merge_timeout(3600)
    end

    SimpleCov.collate Dir["coverage_results/.resultset-*.json"]
  end
end

これにより、最新のSimpleCovを利用しつつ、マージ処理も簡潔に実装できる。

railsアプリでdatadog logレベルをhttpのステータスコード依存にする

datadogに転送したアクセスログのログレベルを変更したいだけだったのに かなり時間を取られたのでそのまとめ。

やりたいこと

現在、railsアプリのログをdatadogに転送して可視化しているが、 このうち、webアクセスログの内容に応じてログレベルを変更したい。

現在はrailsログのうち例外等はエラーログとして扱ってくれるが、 webアクセスログは500系エラーであってもINFOレベルになってしまう。 200系300系はOKレベル、400系はWARNレベル、500系はERRORレベルに設定したい。

解決策

当然ながらdatadog logs pipelineで設定すればよい。 基本的には公式FAQのHow to remap custom severity values to the official log status? に従えばよい。

注意が必要なのは、 category processor で http.status_code ではなく payload.status を参照すること、 nested pipelien が必要なこと。 この解決に時間が掛かり、datadogサポートを通して何度もやりとりを行う必要が出てしまった。

はまったポイント

railsログは http.status_codeを持たない

自分たちのrailsアプリでは Semantic Logger を利用してログの構造化を行っている。 少なくともこのロガーの場合、アクセスログのHTTPステータスコードが payload.statusに記載される。 このため、 datadog logs configuration では http.status_code ではなく payload.status を参照する必要があったが これを認識できておらず、存在しない http.status_codeを参照しようとして失敗していた。

なぜ http.status_code を参照しようとしていたかというと、 datadogでは standard attribute という機能があり、 すべてのアクセスログは透過的に http.status_codeで参照できるような仕組みがあるからである。 この機能を利用していたため、無意識に http.status_codeにHTTPステータスコードがあるものだと思い込んでいた。 実際、以下のような設定が有効になっており、 http.status_code を参照すればよい状況だった。

f:id:thaim:20210403200742p:plain

standard attribute機能は pipeline を通過したログに対して payload.status の値を http.status_codeにコピーする。 ログ分析画面やconfigurationのpipeline preview画面では pipeline通過後のログを参照するため http.status_code が参照できるが pipelineの設定ではまだ属性がコピーされていないので参照できない、という仕組みだった。 結果として、以下のように payload.status を参照して処理するように設定すればよい。

f:id:thaim:20210403201035p:plain f:id:thaim:20210403201107p:plain

http status codeを持たないログが含まれている

Webのアクセスログ(rack log)は http status codeを持つが、 自分で埋め込んだログなどはhttp status codeを持たない。 このため、すべてのログが http status codeを持つという仮定は誤りである。

解決策としては、 nested pipeline によりhttp status codeを持つログを絞り込む必要がある。 これにより、status codeを持つログのみ処理対象とし、status codeを持たないログはログレベルを他の方法で決定することができる。 自分の場合は基本的に level という属性にログレベルを格納しており、datadogもデフォルトで level属性を参照してくれるので上手く動作する。 nested pipelineを用いたログのフィルタイングは以下の通り。

f:id:thaim:20210403195503p:plain

始めはstatus codeを持たないログが処理できずエラーになることを認識しておらず nested pipelineを作らなかった。 このため、pipelineが処理できる場合と処理できない場合が混在していた。 datadog ではpipelineが正常にログを処理できたか、エラーが発生したかを知る術はないため、これに気付くことができない。 このあたりdatadogにどうにかして欲しいところ。