hitode909の日記

趣味はマリンスポーツですの日記です

Perlの依存モジュールのアップデートを自動化するためのCLIツールを作った。GitHub Actions上で動かしてPull Requestも送れる

近年のソフトウェア開発では、RenovateDependabotといった依存関係更新のためのツールが普及していて、ツールの支援を借りながら依存ライブラリを更新していく開発フローが広まってきている。
これらのツールは、package.jsonで管理されているライブラリだったり、Dockerfileで指定しているイメージだったりを自動的に最新版に更新してPull Requestを出してくれるので、人間は内容を確認してマージボタンを押すか、変なところがあったら手直ししてからマージしていくだけでよい。
はてなでの開発フローでも使い倒していて、先月くらいにも、社内で共有して使ってる設定を公開したりしていた。今ではRenovateのない暮らしに戻ることは考えられないくらいに広まっている。
developer.hatenastaff.com


普段、仕事ではPerlやTypeScriptを書いていて、TypeScript側はRenovateの活躍でどんどんバージョンを上げていける。
しかし、PerlにはRenovate的なライブラリの依存関係を上げまくってくれるグッズがないので、人間が頑張って上げていくしかなく、依存ライブラリが古びていきがちだった。
Perlでも依存関係を上げてくれるグッズがほしい、と思い立って連休中に作り始めて、CIからPull Requestを送ってくれるところまでできるようになった。

App::UpdateCPANfileというのを作った

cpanfileを更新する、App::UpdateCPANfileというライブラリを作った。CPANに上がっていて、あわせてupdate-cpanfileというCLIコマンドもセットでインストールされる。

% cpanm App::UpdateCPANfile
% update-cpanfile pin


Renovateにならって、Pin Dependenciesして、Update Dependenciesしていく、という、2ステップを踏んで更新していく。それぞれの工程の役割は以下の通り。

  • Pin Dependencies
    • 依存ライブラリのバージョン指定をrangeを使った指定ではなく、ピンピントで固定する工程
  • Update Dependencies
    • 依存ライブラリのバージョン指定を実際に最新版に更新する工程

依存ライブラリを固定する

Node.JSについて考えると、package.jsonにはこのバージョンの範囲内のライブラリをください、とバージョンのレンジだけを書いて、npm installすると、package-lock.jsonに、実際にインストールされたバージョンが記録される。
そうではなくて、package.jsonの時点で、このライブラリはこのバージョン、と固定して書いてしまうのが、dependency pinningという操作。
pinするかどうかはRenovateのドキュメントには詳細な議論が載っているので参照してください。

Node.JSでは複数バージョンが同時にインストールされることがあるので各ライブラリが好きに固定してもいいけど、Perlでは同時に2バージョンをインストールして動かすことはできない点は要注意。
実行環境が制御範囲内にあり、自分たちでデプロイするようなアプリケーションを作っているなら固定してしまってもデメリットは無くて、CPANで配って使ってもらうようなプログラムやライブラリを作っているなら、共にインストールされる他のライブラリのことを考えてpinせずにrangeで指定するのがよさそう。


Perlで依存ライブラリを固定するには、cpanfile.snapshotに記録されているバージョンをcpanfileに書き戻せばよい。cpanfileはModule::CPANfile、cpanfile.snapshotはCarton::Snapshotを使ってパースした。
このときに注意すべきなのは、1.0 では >= 1.0の意味になってしまう。== 1.0 と設定する必要がある点。

Note that, per CPAN::Meta::Spec, when a plain version number is given, it means the version or newer is required. If you want a specific version for a module, use the specific range syntax, i.e. == 2.1 .

https://metacpan.org/pod/distribution/Module-CPANfile/lib/cpanfile.pod

今回作った、update-cpanfileコマンドにサブコマンドとしてpinを渡すと、cpanfileを上書きしてくれる。

% update-cpanfile pin
% git status
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   cpanfile

cpanfileを確認すると、こういう調子で各モジュールのバージョンが固定されていることがわかる。

% git diff | cat
diff --git a/cpanfile b/cpanfile
index 41f0722..2686e73 100644
--- a/cpanfile
+++ b/cpanfile
@@ -1,23 +1,23 @@
 requires 'perl', '5.008001';

-requires 'Module::CPANfile';
-requires 'Module::CPANfile::Writer';
-requires 'CPAN::PackageDetails';
-requires 'CPAN::DistnameInfo';
-requires 'LWP::UserAgent';
-requires 'IO::String';
+requires 'Module::CPANfile', '== 1.1004';
+requires 'Module::CPANfile::Writer', '== 0.01';
+requires 'CPAN::PackageDetails', '== 0.261';
+requires 'CPAN::DistnameInfo', '== 0.12';
+requires 'LWP::UserAgent', '== 6.46';
+requires 'IO::String', '== 1.08';
 requires 'Getopt::Long';
-requires 'Carton';
+requires 'Carton', '== 1.000034';
 requires 'CPAN::Meta::Prereqs', '>= 2.150010';

pinする条件としては、cpanfileに載っているものを対象にしていて、cpanfileに載っているライブラリのさらに依存、までは見ていない。
除外する条件もあって、インストール済のライブラリがコアモジュールであって、かつperlに同梱されているバージョンがそのまま使われているとき、は除くようにしてみている。たとえば、File::Basenameについて考えると、File::Basenameはperlに同梱されているので、バージョンを固定してしまうと他のバージョンのperlで動かなくなる恐れがあるため。
一方で、perlに同梱されているバージョンがそのまま使われているときはpinしたほうがよくて、Encodeはコアモジュールだけど、最新版をCPANからインストールしているときには、対象バージョンをpinするようにしている。
こういう複雑なことをやっているけど、このあたりはどんな振る舞いになるのが一番使いやすいかはよくわかっていないので、様子を見て調整していきたい。

依存ライブラリを更新する

cpanfileに記載するモジュールのバージョン指定を最新版のバージョンに書き換えることがきれば、依存するライブラリを最新バージョンに更新することができる。
update-cpanfile updateとして更新コマンドを呼ぶと更新を試みて、cpanfileを上書きしてくれる。
内部的にはCPANから02packages.details.txt.gzを取得して、CPAN::PackageDetailsを使って最新のバージョン一覧を取得し、cpanfileでのバージョン指定と見比べて、上書きしていく処理を実装した。このあたりのデータ構造はパーサーが揃っていて助かった。
以下はModule::CPANfileをわざと古くしてからupdateしてみたときの様子。古くより放置しているcpanfileに対して実行すると、より迫力のあるdiffが出る。

% update-cpanfile udpate
% git diff | cat
diff --git a/cpanfile b/cpanfile
index 253ab2f..1577d67 100644
--- a/cpanfile
+++ b/cpanfile
@@ -1,6 +1,6 @@
 requires 'perl', '5.008001';

-requires 'Module::CPANfile', '== 1.1003';
+requires 'Module::CPANfile', '== 1.1004';
 requires 'Module::CPANfile::Writer', '== 0.01';
 requires 'CPAN::PackageDetails', '== 0.261';
 requires 'CPAN::DistnameInfo', '== 0.12';

pinのときにも触れたけど、いろんなライブラリたちが好き勝手にpin・updateしていくと、このライブラリとこのライブラリは要求するライブラリが異なっているので、同時にインストールできないという事態が発生すると思われる。ライブラリで細かく縛ってしまうと取り回しが悪いだけのライブラリになってしまう恐れがあるので、自前でデプロイして動かすアプリケーションのみupdateの対象にするのがよいのかなと考えている。

CIから更新したい

RenovateはPull Requestの作る数を制御をしてくれたり、issueに更新すべきライブラリが並んでいて、チェックをつけると更新が走ったりと、開発体験がたいへん良い。
そこまで作り込む気はなかった、というのと、普段の開発ではGitHub Actionsを使ってPull Requestを出したりしているので、同様に、ファイルの書き換えよりあとの工程はPull Requestを作ってくれるActionに任せることにした。peter-evans/create-pull-requestを使うと簡単。

GitHub Actionsからcpanfileを更新するサンプルのYAMLも用意していて、以下は毎朝9時(JST)にcpanfileを更新してPull RequestをつくるWorkflow。これをコピーしてお使いのプロジェクトに追加するとPull Requestを製造してもらえるはず。コアモジュールのバージョンがそのまま使われているかの判定などは実行中のperlのバージョンによって成果物の結果が変わるので、実行に使うperlのバージョンとか説明文とかは適宜調整してから利用する必要がある。

App::UpdateCPANfile自体の更新のPull Requestはこのような形で作れている。これをマージしてしまうと更新のテストができなくなるのでマージせずに置いといたら今度はコンフリクトしてしまった。毎日動くので、ほっといても明日朝9時に作り直してもらえるはず。
Update cpanfile by hitode909 · Pull Request #7 · hitode909/App-UpdateCPANfile · GitHub


また、すでに古びているプロダクトで一気にライブラリを更新してもレビューや動作確認が難しくなる、という問題がある。大量のモジュールを一気に変更して問題が出たとき、どこがまずかったか切り分けるのに苦労することになるであろう。
ビッグバンリリースを避けてちょっとずつアップデートしていくために、--limit 1で最初の1件だけ更新するオプションを用意している。これを使うとモジュールをアルファベット順に見ていって最初の1件だけを保存できる。仕事で触っているプロダクトではlimit 1でPull Requestを作るようにしたら、今日はAWS::XRayの更新のPull Requestがきていた。AからZまで順に進んでいくので先は長い。

% update-cpanfile update --limit 1

特定のライブラリだけ更新したい

このライブラリをとにかく最新に上げたい、というときには--filterオプションで対象のライブラリを絞り込める。手で最新バージョンを探してきてcpanfileを書き換えるよりは、日々のオペレーションがちょっとは楽になるはず。

% update-cpanfile update --filter JSON::XS
--- a/cpanfile
+++ b/cpanfile
-requires 'JSON::XS', '== 3.02';
+requires 'JSON::XS', '== 4.02';

ところでcpanfile.snapshotの更新はどうしますか

社のプロダクトでは、cpanfile.snapshotもCIから更新していて、cpanfileをpushすると、botがCI上でcarton installしてcpanfile.snapshotを続けてコミットしてくれる仕組みを用意している。
新しいライブラリとあなたのアプリケーションを結合した環境でテストが通っていますよ、というところまで自動でおこなわれると、レビューやマージする手間が下がる。

採らなかったアプローチ

Renovateが直接PerlをサポートできればRenovateが提供するスケジューリングの仕組みに乗っかれて楽なのだろうけど、cpanfileはPerlのDSLであり、package.jsonのようなJSONファイルなどと比べてパースが難しい。
cpanfileというパースしたことのない未知のデータ形式を勘でパースした構造に対して、バージョン更新という未知の処理を実装してしまうと、対象のデータ構造の扱いが変なのか、その後のロジックの切り分けが難しくなってしまうので、まずは着実に動く方法を取ろうと、既存のパースライブラリを利用して、バージョン更新処理だけをPerlで実装することにした。その結果なんとなく1日で動くものはできたので良い選択だったとは思う。

どうぞご利用ください

手元の数プロジェクトに対して動かしてみて、なんか動いてそうだね、というくらいなので、まだまだバグがあるはず。また、Perlの依存管理の世界は奥深いので、想定外のケースがあるにちがいない。cpanfileにconflictsって書けることは今朝知った。
作って終わりではなくて、社のプロダクトに向けては毎日Pull Requestを作るようにしてみたので、日々使ってみながら様子を見て、変なところは都度直していこうと思っている。
github.com

参考

追記、脆弱性検査と合わせて使えるとよさそう

こういう良いツールを作ったんですって自慢していたら、security issueのチェックに使えるのではって友達から意見をもらった。Dependabotとかは脆弱性のあるライブラリへの依存だけPull Requestを送ってくれたりする。
作ってる最中は、アプリケーションのライブラリはどんどん上げたらいいけど、ライブラリのライブラリを上げていく意味はあるのか?と気になっていたのだけど、脆弱性のあるライブラリへの依存を取り除いてくれるなら意義がありそう。
CPAN::Auditを使って、脆弱性のある依存だけを取り除いてくれるオプションを用意しておいて、自作のライブラリに脆弱性があるときだけPull Requestを出す、のような使い方はありだな〜と思ってきたので、タイミングを見てちょっとやってみようと思う。