なまえは まだ ない

思いついたことをアウトプットします

新入社員のトレーニングを担当する上で気をつけたこと、気付かされたこと

この記事はフラー Advent Calendar 2020 の13日目の記事です。12日目は @Gaku07jp さんで「自動レビュー依頼のactionを作成しました」でした。


今年に入り、フラーのサーバーサイドエンジニアも結構人数が増えてきました。 フラーではサーバーサイドの開発を主にGolangで行っています。 今年入ったサーバーサイドエンジニアの中には入社までGolangを触ってなかったという方もおり、 実際の案件に入ってもらうまでの間にGolangの演習問題をやってもらっていました。

私自身Golangを使い始めてまだ1年半ほどですが、今年に入って3人の社員にGolangを教えてきました。 今日はそのトレーニングの中で気をつけていたこと、逆に私自身の学びになったことなどを書いていきます。

フラーで実施しているGolangの課題

フラーのクライアントワークでは、スマートフォンアプリと通信するWeb APIGolangで開発しており、 データベースはAWSAmazon Aurora MySQLを利用しています。 ですので、課題においてもGolang+MySQLの組み合わせで開発するWeb APIの題材を与え、 Golangの基本構文はもちろんのこと、MySQLサーバーとの接続、テーブル設計や一般的なクエリ、 レースコンディション対策などを学んでもらっています。

また、発展課題としてGolangユニットテスト実施や、 クライアントワークで採用しているGoa (v1)フレームワークの導入なんかも盛り込んでいます。

レーニングを担当する上で気をつけたこと

なるべく指摘の根拠・出典を併記する

コードレビューで何か指摘を入れる際、極力その指摘の根拠を記載するようにしました。

例えば、Golangはコードスタイルを公式が提供しており、Golang独特の書き方・推奨されている書き方といったものは公式のドキュメントがあります。 これらのリンクを併記してコメントすることで、「なぜそのように直さないといけないか」という納得感が大分変わってきます。

f:id:furusax0621:20201207231838p:plain
根拠となる公式ドキュメントへのリンクを併記してあげる

情報収集に長けている人は自力でこういったドキュメントに辿り着くかもしれませんが、大抵の人は最初にどこを調べるべきか知りません。 正しい情報に導く手助けはとても重要です。

ちなみにGo Code Review CommentsDeclaring Empty SlicesIndent Error FlowInitialisms あたりは参照する頻度が多かった気がします。

伝わりやすい文章を書く

課題をやる人は新しい言語、新しい分野、新しい技術の習得のために頑張ってます。 私の書いた文章で混乱や誤解を招かないよう、少しでもわかりやすい文章を書くよう気を付けました。

いくつかポイントはありますが、代表的なところでいうと

  • 一文を長くしすぎない(大体50文字前後を目処に)
  • 文の主語を明確にする
  • 文章内で単語のチョイス・表現を一貫させる

あたりを気をつけながら書いてました。 一文を書くだけでも何度か読み直したり修正をかけたりしたので、全体のコードレビューを返すのに平均で60分程度かかった記憶があります。

聞かれてないことに答えない

相手の質問にピシッと答えるのは当然なのですが、それ以上に聞かれてもいないことをベラベラと喋らないよう気をつけました。

理由は単純明快で、相手にとって不快だからです。 自分がした質問からズレた回答が返ってくるとか、 「ちなみに」から始まる余談が長いとか、隙あらば自分語りとか……質問者からすればストレスでしかありません。

前職の新人教育でも言われましたが、相手が求めているものを汲み取って答えるのが真のコミュニケーションです。 自分がひけらかしたい知識をグッっっっっと奥に閉まって、相手が欲しがってそうな情報だけ返すよう心がけました。

良くできたところは褒める

課題のコードレビューをやってるとどうしても「できてないところ」の指摘が多くなってしまいます。 ダメ出しばかりだと相手もつらくなってきてしまうので、褒めるべきところはきちんと褒めるよう心がけました。

誤りがあれば訂正し、謝る

そのまんまです。人間歳を取ると中々自分の誤りを認めなくなるものです。自分の誤りは素直に認めて謝るよう気をつけました。

レーニングの中での気付き・学び

結構時間と体力を使う

先も書いたとおり、コードレビューを一回返すのに60分ぐらい使っていました。 それだけでなく、Slackでの質問や相談への回答、議論、トレーニング面以外でのコミュニケーション等々…… 全部ひっくるめると毎日業務時間の内2〜3時間はトレーニングに時間を充てていたのじゃないかと思います。

本業であるクライアントワークの開発スケジュールとの調整はもちろんのこと、 開発とトレーニングのコンテキストスイッチも地味に体力を持っていかれます。 幸い(?)トレーニングを担当している期間にそこまで激しい開発がなかったので滞りなく掛け持ちできましたが、 本業の開発が忙しい時期だったら……ちょっと大変だったかもしれません。

知識が足りない!

教える立場にある人間は、教える相手よりも何倍もの知識を必要とするなと言う気付きです。

例えば、私が担当している案件では、サーバーサイドのコードは古き良き(?)MVCパターンをベースに設計しています。 出した課題の想定解には当然MVCパターンが組み込まれてしまっているので、ここに別の設計パターンの解答がくると混乱してしまうわけです。

あとは私自身がDockerに疎いので、サービス全体をDocker Composeとかで括られたりするとレビューできなくて困ります。 「こんな設定です!」って言われたものに「あ、ふーんそうなんだ。まぁ動いてるからヨシッ」と返してしまったこともありました。反省してます。

私自身の知識不足をかなり痛感させられました。

聞かれたことに答えるのはムズカシイ

先に触れたとおり、「相手が本当に聞きたいこと(論点)を汲み取って、相手が返してほしいであろう回答を渡す」のが真のコミュニケーションです。 これが意外と難しく、意識的に意図を汲み取る努力をしないと中々ズバッと回答を渡せません。

レーニング期間に限らず常に気を配っているつもりですが、100%実践できているかというとそうではなく、質問の意図を正しく汲み取れないときもままありました。

f:id:furusax0621:20201209001622p:plain
間違った回答をすると一刀両断される

質問が曖昧な場合や意図を汲み取りづらい場合は、「この質問はAということですか?それともBということ?」と自分なりの解釈を提示した上で、 一緒に質問の意図を明らかにしていくという方法も効果的でした。

コミュニケーションは密にとるべき

フラーではメンバーのコミュニケーションにSlackを利用し、コードレビューなどはGitHubのPull Request上でやり取りをしています。 すると対面に相手がいない分、相手が今何をしているのか把握しづらいことがあります。

例えばGitHubに2時間コミットがなかったとして、そのとき相手がどんな状態なのか(ローカルでガリガリ書いてるのか、手が止まってるのか、何もしてないのか)こちらからは観測できません。 時たまこういう状況に陥って、私自身が結構不安に感じることがありました。

教育する側と教育を受ける側、コミュニケーションのキッカケを作る際にハードルが低いのはおそらく前者です。 私の方からガンガン様子を聞きに行っても良かったかなぁと思います。

まとめ

私がフラーで新入社員のトレーニングをする際に気をつけたこと、また逆に学びになったことをまとめました。

よくよく思い返してみたら、文章やコミュニケーション面で気をつけたことは前職(新卒で入った会社)の新入社員研修で学んだことばかりでした。 丁寧に研修を組んでくれた前職の教育担当に改めて感謝です。ちゃんと身になってますよ!!!


さて、フラーではまだまだサーバーサイドエンジニアを募集しています。 興味を持った方、今の仕事に退屈になってきた方、新潟に移住したいけど職に困ってる方等など、お気軽にご連絡ください。

www.wantedly.com

CloudFormation でドリフトを検出してしまった Aurora MySQL クラスターを再インポートした話

この記事はフラー Advent Calendar 2020 の4日目の記事です。3日目は @shogo82148 さんで「2020年に書いた GitHub Action + α」でした。


さて、フラーに入って1年半が過ぎ、GoによるWeb API開発だけでなくそれを運用するインフラ領域にもそこそこ真面目に手を出すようになりました。今回はそこでハマった話を書きます。

AWS CloudFormation

弊社、特に私が担当している案件では、インフラを主にAWSで構築・運用しています。 同じような構成のインフラをdevelopment/staging/productionと複数環境に構築することもままあるため、構築は基本的にAWSCloudFormationを利用しています。

Amazon Aurora MySQL

データベースサーバーは基本的にAmazon AuroraのMySQL互換エディションを使ってます。 AWS魔改造してるので、通常のMySQLよりすごいらしいです。何がどのくらいすごいのか、通常のMySQLを運用したことない私にはちょっとよくわかりません。 詳しい方がいらっしゃったら教えてください。

Aurora MySQLのエンジンバージョンとCloudFormation

さて、先日弊社で運用しているAWSアカウントに対し、AWS様から「Aurora MySQLのエンジンバージョンを上げなさい」とのお達しがきました。 しゃーねぇ変えてやるか、ということでCloudFormationにデプロイしてあるスタックテンプレートを確認します。簡略化のため諸々省略してますが、大体↓のようなテンプレートを使ってました。

Resources:
  DBCluster:
    Type: AWS::RDS::DBCluster
    Properties:
      DBClusterParameterGroupName: default.aurora-mysql5.7
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.08.1
      MasterUsername: root
      MasterUserPassword: "very-very-secret"
  DBInstance1:
    Type: AWS::RDS::DBInstance
    Properties:
      AutoMinorVersionUpgrade: false
      DBClusterIdentifier: !Ref DBCluster
      DBInstanceClass: db.t3.small
      DBParameterGroupName: default.aurora-mysql5.7
      Engine: aurora-mysql

アップデートしろと言ってるのはDBClusterの EngineVersion という箇所です。じゃあこいつを上げて更新すればええんやな!……と思うじゃないですか。そうはいかないのです。CloudFormationのドキュメントを見てみましょう。

Update requires: Replacement

Replacement というのは、ここに差分が生じた場合リソースの更新ではなく置換が行われてしまう、ということです。つまり、CloudFormationでエンジンバージョンを更新すると今動いてるデータベースが殺されて新しいデータベースが立ち上がるのです。困ったことに、置換の際にデータをレプリケーションしてくれるなんて気の利いたことはしてくれません。置換が走った場合、全データロスト(!)となってしまいます。困った困った。

……というわけで、置換を回避しつつエンジンバージョンを上げるにはAWSマネジメントコンソールから直接操作してあげる必要があります。

AWS マネジメントコンソールからエンジンバージョンをアップデートする

というわけでAWSマネジメントコンソールからエンジンバージョンの更新をやってみます。RDSのクラスターを変更し、エンジンバージョンを 5.7.mysql_aurora.2.08.1 から 5.7.mysql_aurora.2.08.3 に更新してみます。

f:id:furusax0621:20201201232941p:plain

この状態で更新をかけ、しばらく待てばアップデート完了です。やったね!!!

……残念ながら、そう単純な話ではありません。CloudFormationのコンソールに移動して、データベースを管理しているスタックのドリフトを検出してみましょう。

f:id:furusax0621:20201201233403p:plain

ドリフトが検出された! エンジンバージョンを手動で更新してしまったので、テンプレートとの差分が出てしまいました。 エンジンバージョンはReplacementなプロパティなので、これを放置したままスタックの他の箇所を更新しようとすると、このドリフトによってデータベースの置換が発生してしまいます(要検証)。というわけで、更新したくてもできない可哀相なリソースの誕生です。困った困った。

ドリフトを検出してしまったリソースを再インポートする

前置きがだいぶ長くなりました、ここからが本題です。 Replacementなプロパティでドリフトを検出してしまった可哀相なリソースを、CloudFormationのインポート機能で救済してみます。

STEP1. ドリフトの差分を埋める

まずはお手元のスタックテンプレートを更新して、発生してる差分を埋めてあげましょう。 今回はエンジンバージョンに差分が出てしまっているのでそこを修正します。

Resources:
  DBCluster:
    Type: AWS::RDS::DBCluster
    Properties:
      DBClusterParameterGroupName: default.aurora-mysql5.7
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.08.3 # エンジンバージョンを現在の値に揃える
      MasterUsername: root
      MasterUserPassword: "very-very-secret"
  DBInstance1:
    Type: AWS::RDS::DBInstance
    Properties:
      AutoMinorVersionUpgrade: false
      DBClusterIdentifier: !Ref DBCluster
      DBInstanceClass: db.t3.small
      DBParameterGroupName: default.aurora-mysql5.7
      Engine: aurora-mysql

修正したらこれをデプロイします。現在の値と比べて差分が無いので、何もアップデートが発生しないはずです。

STEP2. DeletionPolicy: Retainを設定する

AWSのドキュメントによると、 既存リソースをスタックにインポートするためには対象のテンプレートに DeletionPolicy 属性を付与しておく必要があるそうです。 後の手順の都合もあるため、ここでは DeletionPolicy: Retain を設定します。

Resources:
  DBCluster:
    Type: AWS::RDS::DBCluster
    DeletionPolicy: Retain # これを設定
    Properties:
      DBClusterParameterGroupName: default.aurora-mysql5.7
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.08.3
      MasterUsername: root
      MasterUserPassword: "very-very-secret"
  DBInstance1:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Retain # これを設定
    Properties:
      AutoMinorVersionUpgrade: false
      DBClusterIdentifier: !Ref DBCluster
      DBInstanceClass: db.t3.small
      DBParameterGroupName: default.aurora-mysql5.7
      Engine: aurora-mysql

忘れがちですが、 DBClusterDBInstance1 の両方に設定しましょう。 修正したテンプレートは忘れずに反映しておきましょう。

STEP3. CloudFormationからリソースを削除する

さて、ここまで来たら一度スタックテンプレートから DBClusterDBInstance1 のリソースを削除します。 後でインポートする際に必要なので、スタックテンプレートではコメントアウトするに留めておくと良いでしょう。

Resources:
#   DBCluster:
#     Type: AWS::RDS::DBCluster
#     DeletionPolicy: Retain
#     Properties:
#       DBClusterParameterGroupName: default.aurora-mysql5.7
#       Engine: aurora-mysql
#       EngineVersion: 5.7.mysql_aurora.2.08.3
#       MasterUsername: root
#       MasterUserPassword: "very-very-secret"
#   DBInstance1:
#     Type: AWS::RDS::DBInstance
#     DeletionPolicy: Retain
#     Properties:
#       AutoMinorVersionUpgrade: false
#       DBClusterIdentifier: !Ref DBCluster
#       DBInstanceClass: db.t3.small
#       DBParameterGroupName: default.aurora-mysql5.7
#       Engine: aurora-mysql

先の手順で DeletionPolicy: Retain を設定してるので、スタックから削除してもリソース自体は生き残ります。

f:id:furusax0621:20201201233642p:plain

STEP4. スタックテンプレートから EngineVersion を削除する

一旦削除したリソースを復活させ、 ドリフトの原因となった EngineVersion をスタックテンプレートから消しておきます。 これでインポート後はエンジンバージョンが上がってもドリフトを検出しなくなります。 また、この時点ではまだスタックテンプレートを反映してはいけません。

Resources:
  DBCluster:
    Type: AWS::RDS::DBCluster
    DeletionPolicy: Retain
    Properties:
      DBClusterParameterGroupName: default.aurora-mysql5.7
      Engine: aurora-mysql
      MasterUsername: root
      MasterUserPassword: "very-very-secret"
  DBInstance1:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Retain
    Properties:
      AutoMinorVersionUpgrade: false
      DBClusterIdentifier: !Ref DBCluster
      DBInstanceClass: db.t3.small
      DBParameterGroupName: default.aurora-mysql5.7
      Engine: aurora-mysql

STEP5. CloudFormationにリソースを再インポートする

いよいよインポートです。 CloudFormationのコンソールでインポート先のスタックを開き、 スタックへのリソースのインポート を選択します。

f:id:furusax0621:20201201233902p:plain

STEP4 で修正したスタックテンプレートをアップロードします。 アップロード済みのS3オブジェクトを指定する方法とテンプレートファイルをその場でアップロードする方法がありますが、今回はその場でアップロードする方法を選択しました。

f:id:furusax0621:20201201233917p:plain

スタックテンプレートから、インポートが可能なリソースが検出されます。 DBClusterDBInstance1 が無事検出されたので、インポートするデータベースの識別子を記述します。

f:id:furusax0621:20201201233934p:plain

確認画面で想定どおりのリソース紐付けができてることを確認したら、 リソースをインポート を選択します。

f:id:furusax0621:20201201233959p:plain

インポート作業の成功を祈りながら見守ります。 IMPORT_COMPLETE となれば成功です。やったね!!!

f:id:furusax0621:20201201234018p:plain

念の為、インポートした後のスタックでドリフトを検出しないか確認しておきましょう。 今回は無事ドリフトを検出することなく作業を終えることができました。

f:id:furusax0621:20201201234140p:plain

まとめ

CloudFormationで不意にドリフトを検出してしまったリソースの再インポート手順について、今回はAurora MySQLのエンジンバージョンを事例にまとめました。

CloudFormationのインポート機能を使ったのは初めてでしたが、とても便利な機能だなと思いました。 最初からCloudFormationを利用してる環境ではあまり登場シーンが無いかもしれませんが、覚えておいて損はない機能でした。

参考

大きなかべさんはISUCON10予選の大きな壁を越えられなかった

概要

というわけで、去年に続き会社の同僚と共にISUCONに参戦しました。今年はメンバーを変えて、かべさん(@kabesan)とたけちゃん(@ftaked)と3人で大きなかべさんチームとして出場です。

当日のリポジトリはこちらです。

github.com github.com

やったこと

今年は主にインフラやミドルウェア周りを担当しました。

事前準備

各サーバーにインストールするツールの選定とレシピ作成をやりました。入れたのはISUCONで定番のalpにmyprofiler、特別ライセンスが入手できたNew Relicのエージェントあたり。New RelicのエージェントはUbuntuのバージョン毎にリポジトリが分かれているので、一応どのバージョンでもインストールできるようにレシピを書いたことが工夫した点でしょうか。会社で使い慣れているMItamaeを使いましたが、今思うと手元からSSHでレシピをプッシュできるAnsibleの方が良かったかもしれません。

当日

競技開始後はとりあえずインフラ周りの調査とツール類のインストール、GoコードのGitHub管理化、MySQLスロークエリログの設定をしました。サーバーが全部1コアしか無いのはさすがにちょっと面食らいました。

競技を始めてalpでアクセスログ解析をしてもこれといって特段悪い場所が見当たらず、どうしようかと頭を抱えました。New Relicやtopコマンドを眺めてるとMySQLによってCPUリソースを使い切ってるのが明らかだったので、とりあえずMySQLを別サーバーに逃しました。MySQLはサーバー設定もユーザーもデフォルトでlocalhostからしかアクセスできないように設定されてましたが、この辺はちょうどつい最近業務で同じような作業をしたのでスムーズに設定変更できました。別サーバーに移動してもまだ重い。データベースに2つしかテーブルが作成されておらず、更にテーブル間に一切リレーションがなかったので、もしかしてテーブル毎に別サーバーにしたら良いんじゃね?と思いつきます。Goのコードを見てくれていた @kabesan にDBを2つ使ってもらうよう実装をお願いし諸々頑張ったところ、スコアがちょっと伸びて一瞬だけ22位まで上がりました。

f:id:furusax0621:20200913202813p:plain

この時点でまだまだMySQLによるCPU負荷が高く、結局このまま最後までこの負荷を減らす方法を実践できず競技終了となってしまいました。最高スコアは1000ちょっとでした。

反省点

インフラ担当になったものの、圧倒的にインフラ側の知識が足りなかったと思います。 NginxのリバースプロキシやMySQLの設定変更みたいな簡単な作業はできましたが、パフォーマンス調整という意味では結局何もできませんでした。全サーバー通してメモリにはまだまだ余裕があった(各DBサーバーは30%前後、アプリサーバーは15%程度しか使ってなかった)ことから、もっとメモリ側に負荷を寄せる方法があったのでは?と思ってます。

まとめ

というわけで、大きなかべさんチームは予選突破という大きな壁を越えることができませんでした。残念。非常に残念。予選突破した人や運営の解説・講評を読んでオベンキョしようと思います。運営の皆様お疲れ様でした

Go 1.15 リリースを機にHomebrewと決別した話

2023/04/05 追記

Go 1.16 で go install が追加されたことで、Go 1.16以降では紹介しているスクリプトの内容に修正が必要でした。修正版を追加。

Homebrew のアップデート遅くない??

MacでGoを書いている方の大半はHomebrewでGoをインストールしているかと思います。 最近に限った話ではないと思いますが、GoのアップデートがリリースされてからHomebrewに降ってくるまでにめちゃくちゃ時間がかかるのです。 まずテストにめちゃくちゃ時間がかかる。12時間とか平気でかかる。で、当然のようにコケる。 先日Go 1.15がリリースされた時なんて、直前に1.14.7のアップデートが重なっていたこともあり散々だったようです。

Homebrewに頼らないアップデート方法を考える

僕はせっかちなのでリリースされたらすぐアップデートしたいわけです。 ただ、公式サイトからダウンロードしてチェックサムを確認して……というのはどうしても手作業になってしまってダルい。なんか良い方法は無いものかと。

Goの公式ツイッターを眺めていると、リリース時に↓のようなメッセージが流れてきます。

お、これを良い感じに使えば自動アップデートスクリプト書けるのでは?と思ったので書いてみました。

#!/bin/bash

if [ $# -ne 1 ]; then
    echo "upgrade-go <new_go_version>"
    exit 1
fi

NEW_GO="go${1}"
go install "golang.org/dl/${NEW_GO}@latest"
$NEW_GO download

NEW_GO_PATH=$(which "$NEW_GO")
ln -sF "$NEW_GO_PATH" /usr/local/bin/go

なお、 Go 1.15以前をお使いの方は(さすがにもういないと信じていますが、、) go install の代わりに go get を使用してください。

#!/bin/bash

if [ $# -ne 1 ]; then
    echo "upgrade-go <new_go_version>"
    exit 1
fi

NEW_GO="go${1}"
go get "golang.org/dl/${NEW_GO}"
$NEW_GO download

NEW_GO_PATH=$(which "$NEW_GO")
ln -sF "$NEW_GO_PATH" /usr/local/bin/go

このスクリプトupgrade-go という名前で適当なパスに格納しておきます。アップデートしたいときは↓のように新バージョンを渡して実行するだけです。

upgrade-go 1.15.1

スクリプトの中身を見ればすぐわかりますが、 go get をする都合上、最初にGoがインストールされていないといけません。

インストールしたGoのバイナリを /usr/local/bin/go としてシンボリックリンクを貼ります。これを一度流したらHomebrewでインストールしたGoはいらない子になるのでアンインストールしちゃいましょう。

注意点として、この方法でインストールするとGoのSDK${HOME}/sdk 配下に展開されます。Macを使うユーザーが自分だけなら良いですが、複数ユーザーが存在する場合影響あるかもしれません。

あと、過去にインストールしたバージョンのSDKとバイナリは変わらずローカルディスクに残り続けてしまいます。長く使ってると徐々にディスク容量を圧迫してしまうかも……?

まとめ

Homebrew に頼ることなく、ある程度簡単にGoをアップデートする方法を考えてみました。 前述の通りいくつか課題はあるので、気が向いたら改善していきます。

Pull Request がマージされると自動的にタグを打ってリリースを作るGitHub Actionsワークフローを書いた話

前置き

僕が所属してる吹奏楽団体には(当然ながら)会員規約というものがあります。こういった規約って時々改訂されるけど、何が変わったのかその差分がわからないのって気持ち悪くないですか?知らぬ間に僕らに不利な条項が増えたらどうしよう!と思ってしまうわけです。団体で事務系の管理をしている友人にその話をしたところ、GitHubリポジトリを作ってくれました。

github.com

規約が更新されたらこのリポジトリにPull Requestを送ります。バージョン管理すればどこがいつ変わったか丸わかり!ヤッタネ!!

どうせならリリースも作ったほうが良くない?

さて、ニンゲンは欲張りな生き物なのでドンドン要求が増えるわけです。 どうせGitHubで管理するならタグを打ってしまいましょう。タグを元にリリースを作れば「最新の規約はこれです!」とかそんな共有が簡単になります。 ただし規約の改訂なんてそんな頻繁にやる作業じゃないので、タグ打ってリリース作って……なんて作業をいざ更新するというときに手作業でやってたら絶対ミスります。こういう定形作業こそGitHub Actionsを使いましょう。

自動でタグを打ってリリースまで作るワークフロー

実際に動いているものは上に貼ったリポジトリを見てもらえば早いですが、せっかくなのでここにも書いておきます。

name: release

on:
  push:
    branches:
      - master
    paths:
      - terms.txt

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2

      - name: Setup Environment
        run: |
          version=$(TZ='Asia/Tokyo' date +v%Y-%m-%d)
          title=$(git log -1 --merges --pretty=format:"%b")
          echo "::set-env name=TAG::${version}"
          echo "::set-env name=TITLE::${title}"
      - name: Create New Tag
        run: |
          git config user.name 'GitHub Actions'
          git config user.email 'actions@github.com'
          git tag $TAG
          git push --tags
      - name: Create New Release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ env.TAG }}
          release_name: ${{ env.TAG }}
          body: ${{ env.TITLE }}
          draft: false
          prerelease: false

ポイントはGitHub Actionsでタグのプッシュからリリース作成まで一気にやってるところです。 タグがプッシュされたらリリースが作成されるようなワークフローはよく見かけますが、タグそのものを作成するような例は中々見つけられませんでした。 GitHub Actionsは自身のリポジトリにプッシュできるトークンを持ってます。が、プッシュするためには最低限のGit設定(user.name, user.email)が必要なので、テキトーな情報を設定しています。

あとはタグ名ですか。 規約がいつ改訂されたのか一発でわかるように、改訂日(正確には、Pull Requestのマージ日)でタグを打つようにしています。 さすがに一日2回改訂が行われることはないでしょうから、 yyyy-mm-dd の形式にしておけば十分でしょう。 我々は日本で活動しているので、改訂日も日本時間に合わせておいた方が都合が良いです。 TZ 環境変数に日本のタイムゾーンを設定することで、 date コマンドの出力が日本時間になるようにしました。

TZ='Asia/Tokyo' date +v%Y-%m-%d

パフォーマンス

タグを打つ場所はコマンドだけで実現してるので高速です。実際に動かしてみたところ7秒で終わりました。

Merge pull request #8 from winds-nagaoka/release-2020-05-17 · winds-nagaoka/terms@c0e77c6 · GitHub

リリースを作るところは actions/create-release を利用していますが、これも1秒で終わってます。早い!

github.com

まとめ

というわけで、Pull Requestがマージされた日付を元にタグを打ってGitHubのリリースを作成するGitHub Actionsワークフローを書きました。僕なりに良い感じに書けたと満足してます。もっと良い感じの実装があればぜひ教えてください。

Pull Request Title Injection とその対策

2020/04/05 12:30 その後の顛末を追記しました。

この記事の続きです。

furusax0621.hatenablog.com

GitHub上でマージした変更内容をSlackに通知するGitHub Actionsを実装して意気揚々としていたところ、次のようなツッコミをもらったところまで書きました。

そういえばこれって Pull Request Title Injection できないですかね? まあ、タイトル書くの社員なのでいいんですが。

Pull Request Title Injectionについて

Pull Request Title Injection とは

そもそもPull Request Title Injectionとはどういうことでしょう?何を指すのか? 簡単に言うと、Pull Requestのタイトルに不正な文字列を設定することで、このGitHub Actionsを通じて任意のメッセージをSlackに通知できる(更に、任意のコマンドを実行できてしまうかもしれない)攻撃方法です。 例えば、前回の記事で紹介したActionsが設定されたリポジトリに対して、次のようなタイトルのPull Requestを作成します。

Test Pull Request Title Injection", footer: ":male-police-officer: Injection!! :male-police-officer:

このPull Requestを閉じた時、Slackには以下のようなメッセージが投稿されます。

f:id:furusax0621:20200331212806p:plain

おわかりでしょうか?Actionsでは pretexttitletitle_linkしか設定していないにも関わらず、メッセージのfooterに文字列が挿入されています。一体どういうことでしょうか?

Injection はなぜ発生したのか?

ここでもう一度Actionsの設定を見てみましょう。スコープをわかりやすくするため、jqコマンドに渡しているJSONの部分のみ切り出します。

{
    attachments: [{
        pretext: "Swagger が更新されたよ!",
        color: "good",
        title: "${{ github.event.pull_request.title }}",
        title_link: "${{ github.event.pull_request.html_url }}"
    }]
}

title に設定している ${{ github.event.pull_request.title }} はPull Requestのタイトルに一致するため、ここに上述のような文字列を設定した場合、任意のコマンドを実行できてしまう(かもしれない)わけですね。SQL Injectionと全く同じ手口です。怖い怖い。

Injectionの対策

Pull Request Title Injectionの概要がわかったところで、次はその対策です。

toJSON関数を使う

GitHub ActionsにはActions内部で使える特別な関数がいくつかあり、今回はその中のtoJSON関数を使います。

Context and expression syntax for GitHub Actions - GitHub Help

toJSON関数は引数に渡したコンテキストをJSON形式で出力してくれる関数です。コンテキストのデータに特殊文字が含まれている場合、toJSON関数がいい感じにエスケープしてくれる(っぽい)です。問題となっているPull Requestのタイトルの部分にこの関数を噛ませてあげることで、今回のようなInjectionを回避できます。

name: notify
on:
  pull_request:
    types: ['closed']
    paths:
      - 'webapi/swagger/**'
    branches:
      - master

jobs:
  notify:
    name: Slack Notification
    runs-on: ubuntu-latest
    steps:
      - name: 'Send Notification'
        run: |
          jq -n '{
            attachments: [{
              pretext: "Swagger が更新されたよ!",
              color: "good",
              title: ${{ toJSON(github.event.pull_request.title) }},
              title_link: "${{ github.event.pull_request.html_url }}"
            }]
          }' | curl -H 'Content-Type: application/json' -d @- ${{ secrets.SLACK_WEBHOOK }}

実際に動かしてみると、以下のようなメッセージが投稿されます。titleの部分にPull Requestのタイトルが全て挿入されているのがわかります。

f:id:furusax0621:20200331215404p:plain

実行ログはこちらから確認できますJSON特殊文字がちゃんとエスケープされて渡されていることがわかると思います。

まとめ

GitHub Actionsで発生し得るPull Request Title Injectionの概要とその対策としてtoJSON関数を使う方法を紹介しました。 詳しく調べてませんが、以前の記事で紹介したいくつかのSlack通知Actionsでも同様のInjectionが実行できるようです。 Slack通知に限らず、Pull RequestのコンテキストをActionsのパラメータとして利用したい場合は十分気をつけましょう。

また、他にもInjection対策の方法があれば是非教えてください。

2020/04/05追記: 出題者よりお便り

こちらの記事を公開したところ、最初に議題を提案してくれた @shogo82148 よりまだまだ甘いぞというお手紙をいただきました。

RE: Pull Request Title Injection とその対策

。。精進しよう。。

ぼくがかんがえた(わけでもない)さいきょうのGoかいはつかんきょう

フラーに入社してもうすぐ1年になります。ということは、Goを触り始めてもうすぐ1年ということです。 良い節目(?)なので、ここ最近の私の開発環境を紹介します。

VSCodeがド安定

Goのコードは基本的にVisual Studio Codeで書いてます。Microsoftが言語拡張機能を提供しているので、とりあえずそれを入れておけば事足ります。

Go - Visual Studio Marketplace

VSCode以外にも定番エディタはありますが、VSCodeが安定すぎて調べてません。他にもっと良いエディタあったら教えてください。

VSCodeの追加設定

とりあえずGoの拡張機能を入れれば事足りる、とは書きましたが、それだけではつまらないので追加で設定している項目を紹介します。

Lint Tool

弊社ではGoのコード静的解析ツールとしてgolangci-lintを使ってます。GitHub Actionsに言わずとしれたreviewdogを登録することで、プッシュされた変更に対して自動でLintがかかるようにしています。

github.com

で、ですよ。いくらGitHub上でreviewdogさんが静的解析してくれるとはいえ、プッシュして初めて警告に気付くってなんか癪じゃないですか。 VSCodeではGoのLintツールにgolangci-lintを指定できるので、これを設定しておくことでワークスペースのコードにもLintが効くようになります。

"go.lintTool": "golangci-lint"

Use Language Server

私がGoを触り始めたのがほぼ1年前。Goは1.12系と1.11系がメインで走っていた時期かと思います。 Go 1.11/1.12といえばGo Modulesが実装され始め、Go関連のツールやライブラリがGo Modulesへの移行を絶賛進めていた時期かと思います。

その当時、GoのLanguage Serverといえばgo-langserverだったかと思いますが、残念ながらこちらはGo Modulesに非対応。 弊社では私が入社した時点ですでにGo Modulesへの移行をほぼ完了していたため、Language Serverのパワーを知ることなく選択肢から外れてました。。

github.com

時は経ち、いつの間にかGoogleによる公式のLanguage Server、goplsが登場しました。goplsがあればコードフォーマッタやインポート、ナビゲーション等全部やってくれます。多分。今までgoimportsとか入れてた設定が全部不要になります。多分。

VSCodeからはLanguage Serverを有効にすると、自動的にgoplsが選択されます。

"go.useLanguageServer": true

VSCode の機能で生産性を上げる

今までVSCodeをあまりちゃんと使えてなかったのですが、Visual Studio Code実践ガイドという書籍を読んで少し勉強しまして、いくつか便利そうな機能を使ってみました。 この書籍、基本的な機能から便利な拡張機能までたくさん紹介されています。 興味がある方は是非お手にとってみてください。

Visual Studio Code実践ガイド —— 最新コードエディタを使い倒すテクニック

Visual Studio Code実践ガイド —— 最新コードエディタを使い倒すテクニック

  • 作者:森下 篤
  • 発売日: 2020/02/21
  • メディア: 単行本(ソフトカバー)

GoaのDSLテンプレートを挿入するスニペット

弊社サーバーサイドではフレームワークとしてGoa v1、を更にforkしたものを使用しています。

github.com

基本的な使い方はGoaとほぼ同じなので割愛しますが、独自のDSLによってAPIのエンドポイントを設計します。 で、このDSLを書く時に必要なパッケージをドットインポートするわけですが、このドットインポートをぱっと書く方法が無くて困ってました。

DSLを記述するパッケージ(大体はdesignパッケージ)で最初に書くコードは大体固定なので、これを挿入するスニペットを作ってみました。 VSCodeのメニューから新規スニペットを構成して、 go.json という名前で保存します。

{
    "Goa Design":{
        "description": "Goa で書く design パッケージの共通部分",
        "prefix": "dsl",
        "body": [
            "package design",
            "",
            "import (",
            "\t. \"github.com/shogo82148/goa-v1/design\"",
            "\t. \"github.com/shogo82148/goa-v1/design/apidsl\"",
            ")",
            "",
            "var _ = Resource(\"${TM_FILENAME_BASE}\", func() {$0})",
            ""
        ]
    }
}

${TM_FILENAME_BASE} という部分はスニペットを挿入するファイル名(拡張子抜き)が挿入されます。例えば sample.go というファイルにこのスニペットを挿入すると、ここには sample という文字列が入ります。Resourceの名前とファイル名を一致させる場合が多いのでこのような設定にしています。

{$0} はこのスニペットを挿入した後のカーソル位置です。このスニペットを挿入した後はResourceの中にDSLを記述していくことになるので、この位置にカーソルがあると便利です。実際に実行してみると以下のような感じになります。

f:id:furusax0621:20200330224902g:plain

go generate を実行するタスク

Goaで書いたDSLを基にコードを自動生成してるわけですが、これをgoagenを使って自動生成してしまうと実際は不要なコードまで生成されます。 それだと色々使い勝手が悪いので、弊社では次のような自動生成用のコードを書いて、 go generate でこれが実行されるようにしています。

package main

import (
    _ "github.com/furusax0621/goasample/webapi/design"

    "github.com/shogo82148/goa-v1/design"
    "github.com/shogo82148/goa-v1/goagen/codegen"
    genapp "github.com/shogo82148/goa-v1/goagen/gen_app"
    genswagger "github.com/shogo82148/goa-v1/goagen/gen_swagger"
)

func main() {
    codegen.ParseDSL()
    codegen.Run(
        genswagger.NewGenerator(
            genswagger.API(design.Design),
        ),
        genapp.NewGenerator(
            genapp.API(design.Design),
            genapp.OutDir("app"),
            genapp.Target("app"),
        ),
    )
}

というわけで新しいAPIを開発するときは、まずdesignパッケージでDSLを記述し、一旦ターミナルに移動してgo generateを叩き、その後またVSCodeに戻ってコーディングし。。という感じでVSCodeとターミナルを行ったり来たりしてました。これが微妙に煩わしいので、go generateをタスク機能で叩けるようにしてみました。

リポジトリのルートに .vscode/tasks.json というファイルを作り、例えば次のように記述します。

    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0",
    "tasks": [
        {
            "label": "generate",
            "type": "shell",
            "presentation": {
                "echo": true,
                "reveal": "silent",
                "focus": false,
                "panel": "shared",
                "showReuseMessage": true,
                "clear": false
            },
            "command": "go",
            "args": ["generate", "./..."]
        }
    ]
}

ポイントは presentation パラメータの "reveal": "silent" の部分です。タスクを実行した時にコンソールを開くかどうかの設定で、デフォルトは always に設定されています。silentにするとタスクが失敗した時のみコンソールを表示するようになります。go generateの結果は失敗した時に見れば良いので、こういう設定をしておくと毎度コンソールがピョコっと現れて煩わしさを感じることがなくなります。

依存関係の更新タスク

さて、開発を進めてると依存関係がどんどん更新されていきますよね。リファクタリングとかしててパッケージの依存関係が変わったりすると、 go.modgo.sum に差分が出てきたりします。それらをガッと整理するのに時々 go mod tidy を叩くのですが、これもタスク定義することができます。先程の tasks.json"tasks": []の中に、次のような記述を追加します。

{
    "label": "gomod",
    "type": "shell",
    "presentation": {
        "echo": true,
        "reveal": "silent",
        "focus": false,
        "panel": "shared",
        "showReuseMessage": true,
        "clear": false
    },
    "command": "go",
    "args": ["mod", "tidy"]
}

ポイントは先程のタスクと同じです。最も、 go mod tidy の実行結果を眺める必要性は多分全く無いので、 reveal の設定は never でも良いかもしれません。

まとめ

というわけで、最近の私のGo開発環境の紹介でした。なんだかんだVSCodeがめっちゃ便利なので、よっぽどのことが無い限りこれからも使い続けるんじゃないかと思います。