dip Engineer Blog

Engineer Blog
ディップ株式会社のエンジニアによる技術ブログです。
弊社はバイトル・はたらこねっとなど様々なサービスを運営しています。

CloudWatchLogsからLambda経由でログメッセージを通知する

AWSを利用していると、アプリケーションのログをCloudWatch Logsに出力させることがあると思います。 本記事ではCloudWatch Logsに出力されたログの文字列を検知してAWS Lambda(以下、Lambda)を起動するシステムを構築していきます。

Lambdaの作成

CloudWatch Logsのサブスクリプションを作成する前に、起動先のLambdaを作成しておきます。 Lambda構築の説明は省略しますが、コードは以下のようにslack通知するようにしています。

require 'slack/incoming/webhooks'
require 'json'

def slack_notification(message)
  slack = Slack::Incoming::Webhooks.new ENV['SLACK_INCOMING_URL'], channel: '#general'
  slack.post message
end

def handler(event:, context:)
  slack_notification('エラーだよ')
  { event: JSON.generate(event), context: JSON.generate(context.inspect) }
end

CloudWatch Logs subscriptionsの設定

CloudWatch LogsからLambdaを起動させる方法の一つとして、サブスクリプションがあります。

サブスクリプションを使用して CloudWatch Logs からのログイベントのリアルタイムフィードにアクセスし、カスタム処理、分析、他のシステムへのロードを行うために、 Amazon Kinesis ストリーム、Amazon Kinesis Data Firehose ストリーム、AWS Lambda などの他のサービスに配信することができます。 ログイベントが宛先サービスに送信されると、Base64 でエンコードされ、gzip 形式で圧縮されます。

設定には、ロググループ設定のサブスクリプションフィルターのタブから作成できます。 また、サブスクリプションフィルターは1つのロググループにつき2つまでしか作成できません。 Lambdaサブスクリプションフィルターを選択する事でLambdaへの起動をするフィルターを作成できます。

実際に作成するにはまず起動先のLambda関数を選択します。 ログの形式は、今回はカスタムフィルターを利用したいのでその他にします。 サブスクリプションフィルターのパターンに、検知したい文字列を入力し、名前をつけます。

パターンをテストの項目で先ほど指定して文字列の検知が合っているかを確認できます。 すでにいくつかログが出力されている場合、そのログを利用してパターンのテストができます。

f:id:hayaosato:20201202153552p:plain

今回は ERROR という文字列を検知するようにしました。

ストリーミングを開始を設定する事でLambdaへのトリガが作成され、起動するようになります。

実際に飛ばしてみよう

作成したサブスクリプションが起動するか、実際にCloudWatch Logsで検知するであろう文字列(今回はERROR)を実際に出力させて Lambdaが起動するか確かめて見ます。

CloudWatch Logsのロググループにログを出力させるのは以下のコマンドで出来ます。

aws logs put-log-events --log-group-name ロググループ名 --log-stream-name ログストリーム名 --log-events timestamp=$(node -e 'console.log(Date.now())'),message='出力したいメッセージ'

今回は ロググループ: /sample-logs ログストリーム: stream01 出力するメッセージ: ERROR: hogehoge として実際に動かしてみます。

aws logs put-log-events --log-group-name /sample-logs --log-stream-name stream01  --log-events timestamp=$(node -e 'console.log(Date.now())'),message='ERROR: hogehoge'

ちなみに、成功すると

{
    "nextSequenceToken": "xxxxxxxxxxxxxxxx"
}

とトークンが発行されるので、2回目以降は

aws logs put-log-events --log-group-name /sample-logs --log-stream-name stream01  --log-events timestamp=$(node -e 'console.log(Date.now())'),message='ERROR: hogehoge'  --sequence-token='xxxxxxxxxxxxxx'

とトークンを指定する必要があります。

以上のように、ログを出力する事でLambdaが起動します。 今回はSlack通知を飛ばすLambdaなので、

f:id:hayaosato:20201202153513p:plain

出来ました。

CloudWatchからのログをデコード

しかし、実際の運用でこの仕組みを使おうとするとCloudWatchからのメッセージは

{
  "awslogs": {
    "data": "H4sIAAAAAAAAAHWPwQqCQBCGX0Xm7EFtK+smZBEUgXoLCdMhFtKV3akI8d0bLYmibvPPN3wz00CJxmQnTO41whwWQRIctmEcB6sQbFC3CjW3XW8kxpOpP+OC22d1Wml1qZkQGtoMsScxaczKN3plG8zlaHIta5KqWsozoTYw3/djzwhpLwivWFGHGpAFe7DL68JlBUk+l7KSN7tCOEJ4M3/qOI49vMHj+zCKdlFqLaU2ZHV2a4Ct/an0/ivdX8oYc1UVX860fQDQiMdxRQEAAA=="
  }
}

のようにエンコードされ、gzipで圧縮されています。 Lambdaをログをデコードおよび圧縮解除し、Slack通知するファンクションにしようと思います。 コードは以下のようになります。

require 'slack/incoming/webhooks'
require 'json'
require 'zlib'
require 'stringio'
require 'base64'


def slack_notification(message)
  slack = Slack::Incoming::Webhooks.new ENV['SLACK_INCOMING_URL'], channel: '#general'
  slack.post message
end

def parse_message(data)
  gzip_data = Base64.strict_decode64(data)
  gz = Zlib::GzipReader.new(StringIO.new(gzip_data))
  JSON.parse(gz.read).to_s
end

def main(event:, context:)
  slack_message = parse_message(event['awslogs']['data'])
  slack_notification(slack_message)
  { event: JSON.generate(event), context: JSON.generate(context.inspect) }
end

Base64デコードをしてGzipの圧縮解除で読み込んだデータをjsonにパースしています。 slackにメッセージを送信するために無理やり文字列に変換してますが、実際に運用する際には各自整形してあげてください。

このようにログの中身も確認できるようになりました。

f:id:hayaosato:20201202153640p:plain

Terraform化

最後に、この仕組みをTerraform化してみようと思います。

まずはLambdaのTerraformです。IAM Roleは適宜変更をお願いします。

resource "aws_lambda_function" "default" {
  filename      = "your_script.zip"
  function_name = "your_function_name"
  role          = aws_iam_role.default.arn
  handler       = "your.handler"
  timeout       = 900

  runtime = "ruby2.7"
}

resource "aws_iam_role" "default" {
  name               = "your_role_name"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*",
      "Effect": "Allow"
    }
  ]
}
EOF
}

続いて、CloudWatch LogsとLambdaの連携をするTerraformです。

resource "aws_cloudwatch_log_group" "default" {
  name = "/your/log/group/name"
}

resource "aws_cloudwatch_log_subscription_filter" "default" {
  name            = "your subscription filter"
  log_group_name  = aws_cloudwatch_log_group.default.name
  filter_pattern  = "ERROR"
  destination_arn = aws_lambda_function.default.arn
}

resource "aws_lambda_permission" "from_cloudwatch_logs" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.default.function_name
  principal     = "logs.ap-northeast-1.amazonaws.com"
  source_arn    = aws_cloudwatch_log_group.default.arn
}

このように、ロググループのサブスクリプションフィルターはlambdaと連携させてあげる必要があります。

参考

著者

dippeople.dip-net.jp (写真左)

【小ネタ】XcodeのSchemeの並び順を変更する方法

はじめに

こんにちは。iOSエンジニアの@satoshi-babaです。 社内のあるアプリチームからXcodeのSchemeが見辛くなってしまったと相談を受けました。

f:id:satoshi-baba:20200929135440p:plain

うーん...確かにこれは見辛い...。 どうにかして変更ができないか調べてみました。

並び替えてみる

ここに表示されているものはManage Schemeで管理されているものです。 もしかしたらManage Schemeの画面をみてみたら何かヒントがあるかもしれません。

Product → Scheme → Edit Schemeで開いてみましょう。

f:id:satoshi-baba:20200929135504p:plain

先ほど見かけたSchemeの並び順と一致してますね。 この画面で並び替えができたので並び替えた結果がこちらです。

f:id:satoshi-baba:20200929135510p:plain

最初に相談されていた箇所も並び替えが完了しています。

f:id:satoshi-baba:20200929135515p:plain

感想

Schemeの並び替えなんて考えたこともありませんでした。

開発に直接関係はありませんでしたが、こういうところからプロジェクトを綺麗にしていけるといいですね!

著者

dippeople.dip-net.jp

バイトルアプリ開発にジョインしました!@林田守加

ご挨拶٩( ᐛ )و

みなさま、初めまして!
林田守加 @hayashidamoka と申します!
9月1日よりディップ株式会社に入社いたしました!
好きな仕事を”選んで”楽しく働ける人を増やし、
その人の人生も周りの人も全世界すらも楽しく変えちゃうようなそんなきっかけを作るため、
Androidエンジニアとしてバイトルアプリの開発をがんがんやって参ります!
どうぞ、よろしくお願い致します!

ディップの好きなところ

まだ入社から約1ヶ月しか経っていないのですが、好きポイントをたくさん見つけたのでお話しさせてください!

1. 先輩たちがとても優しい!

流行りの新型コロナウイルスの対策で、出社は週1日。
週4日はリモートでお仕事をしています。
入社早々のリモートワークだったので、1人でやっていけるかな...と不安もあったのですが、杞憂でした!
先輩方が常に気にかけてくださっていて、私が困る前にSlackで声をかけてくださいます。文章だけでなく、ビデオ通話で顔を見ながら優しく教えてくださることも!歓迎会もリモートで開催していただきました✨

2. 急速に成長できる環境

またまた先輩たちのお話になってしまうのですが、
私が将来どうしたいかをじっくりと聞いてくださって、そのためには何をしていくべきなのかを一緒になって本気で考えてくださいます。
どんな環境でも自分次第で成長することはできますが、周りの方々の支えもあると何倍にも早いスピードで進化していくことができます。
うまく言葉にするのは難しいのですが、面接中から大きな期待をひしひし感じていて、こんな環境で揉まれたい!と志望度がさらに跳ね上がりました。
プレッシャーはありますが、こんなに成長できる環境はありません!!期待を超えられるようにがんばります!!!
そして近い将来、後輩の人生に寄り添って育てていく側になりたいです💪

3.素敵な出会い

ディップは拠点が38箇所あり、2000名以上の社員が在籍しています!(契約社員、アルバイト、パートナーさんを含めるともっと多いです!)

TUNAGという社内SNSが活発に使われていて、社員全員のプロフィールを見て好きな物やどんな人かを知ったり、気軽に記事の投稿をして社員全員に共有することができます!
入社して半月経たないうちにびっくりするぐらい趣味が会うお友達と出会うことができました!部署もオフィスも全然違う方なので超奇跡的!!🥰
他にも、同好会があったり、共通の趣味を持った人たちが集まってざっくばらんなお話をする会(現在はリモートで開催)があったりするそうなので参加を企んでおります😎
会社全体で交流する文化があるのって素敵だなぁって思いました❣️

4. オフィスからの景色

私が通勤しているディップ本社は六本木グランドタワーの31Fにあります!
なのですっごく高いです。それに大きな窓がいっぱいあります。全面ガラス張りってやつだと思います。
なんと東京タワーが目の前!スカイツリーだって見えちゃうのです。
東京を一望できるこのビル...朝もお昼も夜も前も後ろも右も左も絶景ですが、今回はお昼のスカイツリー側の写真を載せます!

f:id:hayashidamoka:20201007110403j:plain
✨ザ・東京!って感じ✨

語り出すと長くなってしまうので割愛しますが
エンジニアになれるなんて夢にも思っていなかった上京したての頃に、同じビルのとある会社からこの景色を見させていただく機会がありました。
いつかこんなオフィスで働けるエンジニアになれたら...と憧れを抱いてこの景色を写真に収めたのを覚えています。
私にとってはただ綺麗な景色というだけでなく、初心を思い出させてくれる景色でもあるのです。
あの時憧れたかっこいいエンジニアを目指して精進していきます!

まとめ

エンジニアが欲しい制度が沢山あるところとか、社員に優しすぎるところとか 、社員総会が超豪華で楽しみとか、好きなところはまだまだありますが書ききれないのでそろそろ締めます!
気になったそこのあなたはぜひ入社してきてください!
一緒に働けるのを楽しみにしています!!🤗

余談

テックブログを書かせていただくにあたって、はてなブログのアカウントを作りました。メアドとかパスワードとか入力して利用規約とか読んで、順調にアカウント開設に向かっていたところ...
...!!!!!

こんな質問をされました。

f:id:hayashidamoka:20201007110637p:plain
「いぬどしであるか、」

....????
い ぬ ど し . . . ? ฅU•ﻌ•Uฅワン
なんで戌年???私丑年だし押せないなぁ...いやでもそもそもなんでこんなこと聞くんだろ???ってちょっと悩んで

コピペして調べてよく読んだら気がついたんですけど
「せいねんであるか、」
って問われてただけでした...

成年と戌年、似てますよね...????笑💧

著者

林田守加
f:id:hayashidamoka:20201007110900p:plain
(インタビュー記事はまだないです...お楽しみにっ!)

TerraformでFargateを構築する

はじめに

インフラエンジニアとしてTerraform運用を行っているのですが、 TerraformやFargateもだいぶ浸透してきて、導入している企業も増えてきているように感じます。 そのようなケースのサンプルとして公開したいと思います。

ファイル構成

ファイル構成は以下としています。

├── logs
│   ├── backend.tf
│   ├── main.tf
│   ├── outputs.tf
│   ├── provider.tf
│   └── variables.tf
├── buckets
│   ├── backend.tf
│   ├── main.tf
│   ├── outputs.tf
│   ├── provider.tf
│   └── variables.tf
├── ecr
│   ├── backend.tf
│   ├── main.tf
│   ├── outputs.tf
│   ├── provider.tf
│   └── variables.tf
└── fargate
    ├── backend.tf
    ├── files
    │   └── container_definition.json
    ├── main.tf
    ├── outputs.tf
    ├── provider.tf
    └── variables.tf

ポイントとしては、logとFargateのtfファイルの階層を分けている事です。 分ける理由としては、同階層にしてしまうとTerraform destoryした時にログまで消えてしまって後悔。。。というケースが起こるかなと思い階層を分けています。 同様の理由としてS3bucket、ECRも階層を分けています。

また、files以下にコンテナ定義のjsonファイル等を入れています。

Fargate

まずはクラスターから

resource "aws_ecs_cluster" "default" {
  name = local.service_name
}

タスク定義は以下のようになっています。 定義の内容をjsonファイルに記載しておいて、ECRのARNなどはdataソースを使って記入しています。

data "template_file" "default" {
  template = file("files/container_definition.json")
  vars = {
    ECR_ARN      = data.terraform_remote_state.ecr.outputs.default["repository_url"]
    SERVICE_NAME = local.service_name
  }
}

resource "aws_ecs_task_definition" "default" {
  family                   = local.service_name
  container_definitions    = data.template_file.default.rendered
  task_role_arn            = aws_iam_role.default.arn
  network_mode             = "awsvpc"
  execution_role_arn       = aws_iam_role.default.arn
  cpu                      = 512
  memory                   = 1024
  requires_compatibilities = ["FARGATE"]
}

タスク定義のjsonファイルは以下のようになっています。

[
  {
    "name": "${SERVICE_NAME}",
    "image": "${ECR_ARN}",
    "essential": true,
    "portMappings": [
      {
        "containerPort": 80,
        "hostPort": 80
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-region": "ap-northeast-1",
        "awslogs-group": "/ecs/${SERVICE_NAME}",
        "awslogs-stream-prefix": "${SERVICE_NAME}"
      }
    }
  }
]

Ecsのサービスは以下のようになっています。

resource "aws_ecs_service" "default" {
  name            = local.service_name
  cluster         = aws_ecs_cluster.default.id
  task_definition = aws_ecs_task_definition.default.arn
  desired_count   = 2
  launch_type     = "FARGATE"

  load_balancer {
    target_group_arn = aws_lb_target_group.default.arn
    container_name   = local.service_name
    container_port   = 80
  }

  network_configuration {
    subnets = [
      data.terraform_remote_state.subnet.outputs.publib_a["id"],
      data.terraform_remote_state.subnet.outputs.publib_c["id"],
    ]
    security_groups = [
      aws_security_group.default.id
    ]
    assign_public_ip = true
  }
}

LB

resource "aws_lb" "default" {
  name = local.service_name
  internal           = false
  load_balancer_type = "application"
  subnets = [
    data.terraform_remote_state.subnet.outputs.publib_a["id"],
    data.terraform_remote_state.subnet.outputs.publib_c["id"],
  ]
  security_groups = [
    aws_security_group.default.id,
  ]

  enable_deletion_protection = true

  access_logs {
    bucket  = data.terraform_remote_state.buckets.outputs.default["id"]
    enabled = true
  }
}

resource "aws_lb_target_group" "default" {
  name        = local.service_name
  port        = 80
  protocol    = "HTTP"
  vpc_id      = data.terraform_remote_state.subnet.outputs.default["id"],
  target_type = "ip"
}

VPCとサブネットはバックエンドから参照しています。 また、security groupは別で作成しておいてください。 bucketについては、前述の通り階層を分けているためバックエンドから参照してください。 ターゲットグループのtarget_typeはFargateと連携するためにipに指定しておいてください。

LBへSSL証明書の適用 Certificate Managerに登録してあるSSL証明書を参照しています。

resource "aws_lb_listener" "default" {
  load_balancer_arn = aws_lb.default.arn
  protocol          = "HTTPS"
  port              = "443"
  ssl_policy        = "ELB_SecurityPolicy-TLS-1-2-2017-01"
  certificate_arn   = data.terraform_remote_state.certificate.outputs.default["arn"]
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.default.arn
  }
}

80ポートへのアクセスはリダイレクトするようにします。

resource "aws_lb_listener" "redirect_https" {
  load_balancer_arn = aws_lb.default.arn
  port              = "80"
  protocol          = "HTTP"

  default_action  {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

variables

最後に、variablesは以下のようになっています。

variable "environment" {
  default = "development"
}

locals {
  service_name = "${var.environment}-service-name"
}

ここでlocalsを使っているのは、環境名などを変数に埋め込むためです。 特に必要ないかもしれないです。

最後に

これからTerraformを導入していきたい、Fargateを使ってみたいという方に少しでも参考になれば。。。

参考

筆者

dippeople.dip-net.jp (写真左)

BitriseのiOS Auto Provisionが超絶使いやすくなった件

はじめに

こんにちは。iOSエンジニアの@satoshi-babaです。 BitriseではiOS Auto Provisionの新しいステップが6月にリリースされました。 これは神対応だと思ったので早速使ってみました。

何が神対応なのか

BitriseはProvisioning ProfileをBitriseに登録する必要があり、Provisioning Profileが更新された場合に毎回アップロードし直してました。 ですが2年ほど前にリリースされたiOS Auto Provisionのステップを使用すると、BitriseにProvisioning Profileの登録が不要になり、自動で管理してくれるようになりました。

しかしこれは2FAを使用していたため、1ヶ月に1度の更新が必要だったのです。 それが2020年の6月にリリースされた、iOS Auto Provision with App Store Connect APIを使用すると解決するのです!

僕は弊社のBitriseの管理をしているのですが、毎月1度はBuildがうまくいかない問題の対応をしていました。(忘れる方が悪いのですが...) これは僕にとって救世主であって、即座に入れた方がいいと判断して導入しました!

導入の仕方

長い前置きでしたがここから導入していきます。 導入は簡単で認証情報の登録→ステップの差し替え→認証情報の設定をするだけです。

1. 認証情報の登録

まずはApp Store Connectにアクセスし、ユーザとアクセスのキーを開きます。 f:id:satoshi-baba:20200803101643p:plain

+ボタンを押すと登録するためのモーダルが表示されるので、適当な名称を入力します。 権限はDeveloper権限以上があれば動作します。 f:id:satoshi-baba:20200803101649p:plain

登録が完了すると一覧に表示されますので、APIキーのダウンロードをします。 また、キーIDとIssuer IDがこの後に必要になるのでコピーしておきましょう。 f:id:satoshi-baba:20200803101653p:plain

2. ステップの差し替え

次はWorkflowの修正をします。 ios-auto-provisionをios-auto-provision-appstoreconnectに変更するだけです。

- ios-auto-provision:
    inputs:
    - team_id: xxx
    - distribution_type: ad-hoc

- ios-auto-provision-appstoreconnect@0:
    inputs:
    - distribution_type: ad-hoc

3. 認証情報の設定

最後にBitriseに1で登録した認証情報を設定します。 Account Settings → Apple Developer Account → Add an Accountから登録します。 f:id:satoshi-baba:20200803101804p:plain

1で登録した際にメモした各種情報を入力します。 APIキーも忘れずにアップロードしましょう。 f:id:satoshi-baba:20200803101809p:plain

Workflowを変更したプロジェクトのTeamタブのApple Developer Portal APIに使用する認証情報を設定します。 f:id:satoshi-baba:20200803101813p:plain

以上で設定が完了しました。 Buildも通るようになっているはずです。

感想

これで月1の2FAの作業から解放されました...! このような神対応がされるのがBitriseのいいところですね! 皆さんもぜひ試してみてください!

著者

dippeople.dip-net.jp

GitHub Actionsでリリースノート作成を自動化してみた

はじめに

こんにちは、PHPで求人系サービスの開発や社内向けツールの開発を行なっている @taku-0728 です。
今回はGitHub Actionsについて記事を書きました。
この記事を書くまで全然知らなかったので実際に触ってみて、成果物としてリリースノート作成を自動化するワークフローを作ります。
同じように「GitHub Actions便利そうだし触ってみたいけどよくわからない...」という方が実際に触ってみるきっかけになればいいと思います。
よろしくお願いいたします。

GitHub Actionsとは

GitHub Actionsは、コードを保存するのと同じ場所でソフトウェア開発のワークフローを自動化し、プルリクエストやIssueで協力することを支援します。 個々のタスクを書き、アクションを呼び出し、それらを組み合わせてカスタムのワークフローを作成できます。ワークフローとは、GitHubで任意のコードプロジェクトをビルド、テスト、パッケージ、リリース、またはデプロイするためにリポジトリで設定できる、カスタムの自動プロセスです。

GitHub Actionsについてより

詳しくは上記サイトに載っていますが、要はPull RequestやIssueやPushなどのイベントをトリガーとして、ビルド、テスト、パッケージ、リリースを自動で行ってくれるものです。

背景

どのチームでもそうかもしれませんが、私のチームでは本番リリース作業終了時にGithubにリリースノートを作成しています。この作業ですがリリースの度に発生するにもかかわらず、毎回手動で作成しているので何かルールや仕組みで自動化できないかと思っていました。
リリースノートに書かれた説明とリリース用のプルリクエストの説明の文言が似ていたので、これは1つに統合したいと考えGitHub Actionsを使って自動化に挑戦しました。

今回作るもの

今回はすでに公開されているcreate-releaseを書き変えて、「masterブランチをベースにしたプルリクエストが作成されたとき、そのプルリクエストのタイトルをタグ名とし、そのプルリクエストの本文をリリースの説明としてドラフトリリースを作成する」ワークフローを作ります。

実際に作ってみる

ワークフローを設定する

  1. リポジトリ/.github/配下にworkflowsディレクトリを作ります。
    $ mkdir {リポジトリ名}/.github/workflows

  2. 1で作成したworkflowsディレクトリの配下にtest.ymlファイルを作成し、create-releaseを参考に下記のように記載します。

on:
  push:
    tags:
      - 'v*'

name: Create Release

jobs:
  build:
    name: Create Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@master
      - name: Create Release
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.ref }}
          release_name: Release ${{ github.ref }}
          body: |
            Changes in this Release
            - First Change
            - Second Change
          draft: false
          prerelease: false

詳細はGitHub Actionsのワークフロー構文にリファレンスがありますがざっくり説明すると

on:
  push:
    tags:
      - 'v*'

まず1行目のonでどのイベントをトリガーとして動作するかを指定します。2行目にpushを記載されているので、ここではpushイベントがトリガーとして動作します。3行目と4行目にそれぞれtagsv*とあるので今回は「先頭がvのタグがpushされると動作する」という意味になります。

name: Create Release

jobs:
  build:
    name: Create Release
    runs-on: ubuntu-latest

ここの先頭のnameでこのワークフローに名前をつけ、jobsから実際のワークフロー処理に入っていきます。buildはこのジョブのIDですので、任意の文字列で大丈夫です。次のnameはこのジョブの名前です。その次のruns-onではこのジョブを実行するマシンの種類を設定します。macOSやWindowsも指定できますが、ここではUbuntuの最新版を指定します。

steps:
  - name: Checkout code
    uses: actions/checkout@master
  - name: Create Release
    id: create_release
    uses: actions/create-release@v1
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    with:
      tag_name: ${{ github.ref }}
      release_name: Release ${{ github.ref }}
      body: |
        Changes in this Release
        - First Change
        - Second Change
      draft: false
      prerelease: false

実際に処理を行っている部分です。 ここで行っていることは大きく分けて2つあります。

- name: Checkout code
  uses: actions/checkout@v2

上のnameでこの処理に名前をつけ、uses:で既に公開されているアクションを使用できます。今回はブランチをチェックアウトしてくれる既存アクションのactions/checkoutを使用します。

- name: Create Release
  id: create_release
  uses: actions/create-release@master
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    tag_name: ${{ github.ref }}
    release_name: Release ${{ github.ref }}
    body: |
      Changes in this Release
      - First Change
      - Second Change
    draft: false
    prerelease: false

今回の本題となる実際にリリースを作成している部分です。
envでは環境変数を指定できます。今回はGitHub Actionsを使うときに機密情報を保持するためのsecrets配下のGITHUB_TOKENを指定していますが、Action側で生成されるため自身で用意する必要はありません。
withではこのアクションに対する入力値を指定します。${{ github.ref }}はワークフローの実行をトリガーしたブランチまたはタグ名が入ります。詳しくはこちら
bodyにはこのリリースの説明が入ります。
draftはドラフトリリースにするかどうかを示し、prereleaseはプレリリースにするかどうかを示します。

以上がcreate-releaseのテンプレートです。
今回はトリガーにタグを指定しているため、実際にタグをpushしてみます。

$ git tag -a v1.0 -m 'auto release test'
$ git push origin v1.0

Github上で確認してみます。
f:id:taku-0728:20200710135226p:plain

期待通りのリリースが登録されているはずです。
自動化はできましたが、このままではリリースの内容が固定値しか入れられないので今回は

・トリガー:masterブランチをベースにしたプルリクエストが作成されたとき
・タグ名:そのプルリクエストのタイトル
・リリース内容:そのプルリクエストの本文

となるようにこのワークフローを書き換えていきます。

ワークフローを編集する

まずトリガーをタグのpushからプルリクエストの作成に書き変えます。 先ほどのコードの

on:
  push:
    tags:
      - 'v*'

ここを

on:
  pull_request:
    types: [opened]
    branches:
      - master

このように書き換えます。これでmasterブランチをベースとしたプルリクエストが作成された時にワークフローが動作するようになります。 そして

with:
  tag_name: ${{ github.ref }}
  release_name: Release ${{ github.ref }}
  body: |
    Changes in this Release
    - First Change
    - Second Change
  draft: false
  prerelease: false

ここの部分を

with:
  tag_name: ${{ github.event.pull_request.title }}
  release_name: ${{ github.event.pull_request.title }}
  body: ${{ github.event.pull_request.body }}
  draft: true
  prerelease: false

このように書き換えます。 これでタグ名とリリースの名前がプルリクエストのタイトルに、 リリース内容がプルリクエストの説明になり、ドラフトリリースが適用されます。
最終的なymlファイルの内容は下記になります。

name: リリースの自動作成
on:
  pull_request:
    types: [opened]
    branches:
      - master
jobs:
  auto_release:
    name: auto_release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@master
      - name: Create Release
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.event.pull_request.title }}
          release_name: ${{ github.event.pull_request.title }}
          body: ${{ github.event.pull_request.body }}
          draft: true
          prerelease: false

実際にプルリクエストを作ってみると、タイトルと本文を基にしたドラフトリリースが作成されているはずです。 f:id:taku-0728:20200710135220p:plain

まとめ

今回はリリース作業の中で自動化できそうな作業をみつけたので、GitHub Actionsを使って自動化に挑戦しました。 GitHub Actionsについてこの記事を書くまで全然知らなかったのですが、いざ触ってみると色々便利なことに気づきました。同じように触ってみたいけどよくわからない方の参考になれば幸いです。
(本当はプルリクエストがマージされたらドラフトリリースをリリースするところまでやりたかったのですが、思ったより詰まったのと長くなりそうなので別の記事でやります。)

参考

筆者

dippeople.dip-net.jp (写真右)

Railsはデフォルトでライブラリライセンスを消してしまうという話

はじめに

こんにちは。ウェブアプリエンジニアの @r-kirishima です。
業務では主にRuby on Rails(以下RoR)で開発をしています。
RoRは細かい内部処理を意識せずとも簡単にアプリが作れてしまう素敵フレームワークです。
しかし、時には内部でどのような処理が走っているのかを理解しなければならないこともあります。

今回はコード内にライセンスを明示しておく必要のあるJavascriptのライブラリをRoRアプリで利用しようとした際に、

  • ライセンス表示がRoRに消されてしまう可能性があること
  • またその問題解決のためにgemの実装について調べたこと

についてお話しします。

ライセンス表示守れていますか?

jQueryを例に

ユーザの多いjQueryのライセンスを例にお話しします。
jQuery foundation - Licenseに以下のような文言があります。

You are free to use the Project in any other project (even commercial projects) as long as the copyright header is left intact.

ざっくり訳すと、以下のようになります。
「jQueryのプロジェクトは商用であれ、他のどんなプロジェクトで使ってくれて構わないよ、ただしコピーライト表示はそのまま残しておいてくれよ!

ここでいうコピーライト表示とはjQuery 3.5.1の冒頭にある下記のようなコメントのことです。

/*!
 * jQuery JavaScript Library v3.5.1
 * https://jquery.com/
 *
 * Includes Sizzle.js
 * https://sizzlejs.com/
 *
 * Copyright JS Foundation and other contributors
 * Released under the MIT license
 * https://jquery.org/license
 *
 * Date: 2020-05-04T22:49Z
 */

ライセンスをあまり気にせずにRoR x jQueryの組み合わせを使っている方は多いと思います。
そういう方は本番環境( RAILS_ENV=production )のアプリのアセットをブラウザから確認してみてください。
もしかしたらライセンス表示が消えてしまっている可能性が高いですよ。
そしてそれは無自覚でもライセンス違反です......

ライセンスはなぜ消えたのか

どうしてライセンス表示が消えてしまったのか?

コメント、長い変数名、インデント、改行などは人間の可読性を上げるためのものです。
一方で実行する機械にとっては基本的に不要なものです。
RoRにおいてはAsset Pipelineが機械にとっての「ゴミ」を取り除いてくれるのですが、
その際に人間にとっては必要なライセンスもまた「ゴミ」として削除されてしまうのです。

Asset PipelineとUglifier

上で少しでてきたAsset Pipelineについて軽く説明します。
Asset PipelineとはJavascript、CSS、画像などのアセットを入力すると、設定に従った加工してくれる仕組みのことです。
詳細はRailsガイドを参照してください。
本記事ではJavascriptから機械にとって不要なものを取り除いてくれるもの、ぐらいに考えてもらえば結構です。

Rails5においてはAsset Pipelineにsprockets-railsというgemを用いています。
また、特にSprocketsはJavascriptの圧縮作業(Compress)にUglifierという別のgemを用いています。

Uglifierのデフォルト設定とSprocketsのデフォルト設定

上述のようにSprocketsはUglifierを利用してJavascriptの圧縮を行っています。
このUglifierのコメントに関するデフォルトの設定はlautis / uglifierを参照すると、
下記のように Copyright というワードの入っているコメントブロックを保持する設定になっています。

  DEFAULTS = {
  # 中略
    :output => {
  # 中略
      :comments => :copyright, # Preserve comments (:all, :jsdoc, :copyright, :none)
  # 中略

  def comment_options
    case comment_setting
    when :all, true
      true
    when :jsdoc
      "jsdoc"
    when :copyright
      encode_regexp(/(^!)|Copyright/i)
    when Regexp
      encode_regexp(comment_setting)
    else
      false
    end
  end
  # 中略

一方でSprocketsでは下記のようにコメントを全て削除する設定でinitializeしてしまい、こちらの設定が実際に用いられます

  # 中略
    def initialize(options = {})
      # Feature detect Uglifier 2.0 option support
      if Autoload::Uglifier::DEFAULTS[:copyright]
        # Uglifier < 2.x
        options[:copyright] ||= false
      else
        # Uglifier >= 2.x
        options[:comments] ||= :none
      end
  # 中略

参考:ぼっち勉強会 - rails + sprockets + uglifierでCopyrightを残したままcompressする

ライセンスを消させない

参考として挙げたブログ内ではRailsの設定ファイルを下記のように書き換えることで解消できる、とされています。

- config.assets.js_compressor = :uglifier
+ config.assets.js_compressor = Uglifier.new(:comments => :copyright)
# より今時の書き方をすると comments: :copyright
# さらに厳密な書き方をすると output: {comments: :copyright}

しかし、commentsオプションをcopyrightに設定して保護されるのは Copyright と入っているコメントのみです。
上でふれているjQuery-3.5.1のライセンスなどには Copyright と入っていないため保護されません。

Copyrightと入っていなくてもライセンスを消させない

Uglifierのコードを見ると、実はSymbolを用いた指定以外にも正規表現を用いた指定が可能であることがわかります。
以下に抜粋して再掲します。

  def comment_options
    case comment_setting
    # 中略
    when Regexp
      encode_regexp(comment_setting)
    # 中略

そこで、今回は以下のように jquery.org/license Available for use under the MIT License Licensed MIT
のワードが入っているコメントを残せるようにしてみました!

- config.assets.js_compressor = :uglifier
+ config.assets.js_compressor = Uglifier.new(output: {comments: /jquery.org\/license|Available for use under the MIT License|Licensed MIT/})
# Rubyにおいてスラッシュ囲みは正規表現リテラルを表します。

増えるたびに正規表現を書くのは大変なので、反応するワードを配列で切り出してみました。

- config.assets.js_compressor = :uglifier
+ trigger_comments = ["jquery.org/license", "Available for use under the MIT License", "Licensed MIT"]
+ config.assets.js_compressor = Uglifier.new(output: {comments: /#{trigger_comments.join('|')}/})

いい感じですね!

おわりに

RoR x jQueryという組み合わせで利用している方も非常に多いと思いますが、
デフォルトでライセンス表示義務違反を犯してしまう罠があるというのはとても驚きです。

ただ闇雲にレールに乗るだけではなく、正しくRoRを理解して使っていきたいですね!

筆者

dippeople.dip-net.jp
dippeople.dip-net.jp