Go 1.20で入ったexec.CommandのCancelとWaitDelayで外部コマンドを正しく終了させる
背景
Goでは外部コマンドの実行時に os/exec
パッケージの Command
や CommandContext
を利用します。特に CommandContext
を利用することで、 Goのcontextの流儀に従ってコマンドのタイムアウトや中断が可能であり大変便利です。
一方で、LinuxやmacOSなどにおいてContextによるタイムアウト・中断時には外部コマンドに即座に SIGKILL
が送られてキルされてしまうため、一部のケースでは孫プロセスが孤児プロセスとして残ってしまったり、後始末を正しく出来ないままコマンドが終了してしまうという問題が知られていました。
より安全にコマンドを終了させる方法として、まずは SIGINT
や SIGTERM
を送り、一定時間内に終了しなければ SIGKILL
で終了させるなどの方法が知られています。しかし、 CommandContext
を使わずにContextによる中断のハンドリングを自分で行う必要があるなど初学者には難しい状況になっていました。
Go 1.20で導入されたCancelとWaitDelay
ひっそりとリリースノートに記載されている内容なので、あまり気にしておられない方も多いかと思います。 exec.Cmd
構造体に新たに二つのフィールドが追加されました。
Cancel
の型は func() error
、 WaitDelay
の型は time.Duration
です。元々は下記のプロポーザルから実装されたもののようです。当初は KILL されるまでの時間と、ContextがDoneになったときに送信するシグナルを指定できるようにするはずだったようですが、途中で任意の実装を挟み込めるようになったようです。
Cancel
まず Cancel
は、 CommandContext
で渡したContextがDoneになったときに呼び出される関数で、デフォルトでは cmd.Process.Kill()
を呼び出す関数が設定されるようです。
代わりに SIGTERM
を送ったりする関数を与えることで、Contextをキャンセルしたりタイムアウトさせた際に送信するシグナルを指定できる他、任意の処理が出来るので標準入力を閉じたりネットワーク越しにリクエストを送ったりすることもできるようです。要するにこれまで手動でコンテキストをハンドリングして行っていたようなものをここに書いておくだけでよくなるということです。
WaitDelay
ContextがDoneになってから、 cmd.Process.Kill()
されるまでの猶予時間です。この間に Cancel
の処理を終わらせないと SIGKILL
で終了させられてしまいます。
デフォルトは0になっているため、 Cancel
を使う際は WaitDelay
を手動設定しておかないとうまく処理できません。
使い方
SIGINTを送って死ななければSIGKILLで終了させる
まずは以前までの書き方をおさらいします。まず、 exec.CommandContext
を使うとContextがDoneになったときにSIGKILLが送られてしまうのでこれは使えません。 exec.Command
を使い、Contextのハンドリングは自分で行わなければいけません。
package main import ( "context" "fmt" "os" "os/exec" "os/signal" "time" ) func run() error { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() cmd := exec.Command("bash", "-c", "trap 'echo \"signal received\"; sleep 10; echo \"done\"' SIGINT; sleep 120") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return err } errCh := make(chan error, 1) go func() { defer close(errCh) errCh <- cmd.Wait() }() for { select { case exitErr := <-errCh: return exitErr case <-ctx.Done(): fmt.Println("Send SIGINT") cmd.Process.Signal(os.Interrupt) select { case exitErr := <-errCh: return exitErr case <-time.After(5 * time.Second): fmt.Println("Send SIGKILL") cmd.Process.Kill() return <-errCh } } } } func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } }
(適当に書いたので穴があるかも知れない)
ここではシグナルを受け取るとContextをキャンセルし、子プロセスに SIGINT
を送って5秒待機、まだ終了しなければ SIGKILL
を送って待ちます。
Cancel
と WaitDelay
を使うと以下の様に書けます。
package main import ( "context" "fmt" "os" "os/exec" "os/signal" "time" ) func run() error { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() cmd := exec.CommandContext(ctx, "bash", "-c", "trap 'echo \"signal received\"; sleep 1; echo \"done\"' SIGINT; sleep 120") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } cmd.WaitDelay = 5 * time.Second return cmd.Run() } func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } }
実行してみると下記の様にシグナルを受け取っていることがわかります。
❯ go run main.go ^Csignal received done Error: exit status 130 exit status 1
シグナルを受け取ってからのスリープの時間を10秒などに延ばすと、 SIGKILL
が送られていることがわかります。
❯ go run main.go ^Csignal received Error: signal: killed exit status 1
注意点
Windowsでは cmd.Process.Signal
が実装されておらず、今回の方法で正しくプロセスが終了できるとは限らないことに注意が必要です。実際Windowsでのプロセスの正しい終了の仕方を全然知らない……知っている方がおられれば是非教えて頂きたいですね。
GitHub Actionsのキャッシュをより細かく制御するactions/cache/restoreとactions/cache/save
はじめに
これはGitHub Actions Advent Calendar 2022 22日目の記事です。諸事情によりフライング投稿です。
GitHub Actionsのキャッシュにおいて、そのリストアと保存を別々に制御する機能が actions/cache@v3.2.0-beta.1 で実装されたので使ってみました。トピックブランチではキャッシュを保存しない、ビルドが失敗した際にもキャッシュを保存する、などこれまでは出来なかった細かい制御が可能になっています。
背景
GitHub Actionsにおいて、ダウンロード済みの依存関係などをキャッシュすることでワークフローの実行を高速化することは、一般的によく知られたテクニックです。
一方でGitHub Actionsのキャッシュはいくつかの制限があることが知られています。
- 1リポジトリあたり合計10GBまで*1
- デフォルトブランチおよびカレントブランチのキャッシュしかリストアできない*2
- Pull Requestではベースブランチのキャッシュも利用可能
- 同じキーに対するキャッシュを上書きできない*3
- 暗黙的に定義された事後処理ステップにおいてキャッシュの保存が行われるため、無関係な箇所でジョブが失敗した場合にでもキャッシュの保存がスキップされてしまう
これにより、特にキャッシュサイズが大きい場合トピックブランチで複数回キャッシュの保存が行われるとデフォルトブランチのキャッシュが消えてしまったり、依存関係のフェッチとテストを別ジョブに分けて確実に依存関係がキャッシュされるようなワークアラウンドが必要なケースがありました。
actions/cache/restoreとactions/cache/save
以前からより詳細なキャッシュの制御がしたいという要望はあり、2019年頃から以下のようなissueがありましたが、あまり進展は見られていませんでした。
しかし、2022年12月になって急にDiscussionにてrestoreとsaveに対応するactionが実装されることが発表されました。
実際に v3.2.0-beta.1 からrestoreとsaveが実装されています。
それぞれについて書くことはそんなにありません。単にキャッシュのリストア・保存が別アクションに分かれただけです。
■ 追記(2022/12/26)
actions/cache/saveとactions/cache/restoreはv3.2.0でGAになりました。
以降のサンプルコードの@v3.2.0-beta.1
は @v3
で読み替えても動作します。
追記終わり
ユースケースの紹介
実際に背景で説明したいくつかの課題をこれで解決することが出来るので一例を紹介します。
トピックブランチではキャッシュを保存しない
2GBのランダムなダミーデータをキャッシュに保存してみることにします。ただし、トピックブランチではキャッシュの保存をスキップします。
name: Save cache only on main on: [push] jobs: run: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 - uses: actions/cache/restore@v3.2.0-beta.1 with: path: | ./large-object key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}- - name: Generate random file if needed run: | if [ ! -f ./large-object ]; then base64 /dev/urandom | head -c 2048M > large-object fi - uses: actions/cache/save@v3.2.0-beta.1 if: github.ref == 'refs/heads/main' with: path: | ./large-object key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}
キャッシュがevictされる問題に対応出来るだけでなく、そのトピックブランチでしか有効でないキャッシュの保存にかかる時間をスキップできることも大きいです。サイズの大きなキャッシュのアップロードを無効化するだけで場合によっては数十秒〜数分の短縮に繋がることもあります。
ただし、これはトピックブランチで長い期間開発する場合にはキャッシュがないことによりむしろ実行時間が延びる可能性があります。
常にキャッシュを保存する
例えばテストが失敗するケースでも、キャッシュを保存したいというようなユースケースです。特にflakyなテストが存在する場合には有用かもしれません。
name: Save cache always on: [push] jobs: run: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 - name: Step to fail run: | echo hello > ./file-to-cache false - uses: actions/cache/save@v3.2.0-beta.1 if: always() with: path: | ./file-to-cache key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}
途中のステップで失敗しているので、ジョブ全体のステータスとしては失敗になります。
restoreとsaveで異なるkeyを使う
これがどれくらい需要のあるユースケースなのかはわかりませんが、これまでは地味にできなかったことです。
hashFiles
などを使ってハッシュを計算する際、これまでは最初のリストア時に計算されたkeyが保存時にもそのまま利用されていました。つまりそのビルド中に hashFiles
による計算結果が変わる場合に対応出来ていませんでした。例として以前の挙動を確認してみます。
途中のステップで hashFiles
の対象としているファイルを作成しています。最初の評価時点ではファイルが存在しないため、 hashFiles('**/hello.txt')
は空文字列になります。
name: Old behavior on: [push] jobs: run: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 - uses: actions/cache@v3 with: path: | ./hello.txt key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}- - name: Generate hashFiles targets run: | echo "hello" > hello.txt
actions/cache/saveを使って同じ事をすると、保存時に hashFiles
の結果が再度評価されていることがわかります。
name: hashFiles get different result on: [push] jobs: run: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 - uses: actions/cache/restore@v3.2.0-beta.1 with: path: | ./hello.txt key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}- - name: Generate hashFiles targets run: | echo "hello" > hello.txt - uses: actions/cache/save@v3.2.0-beta.1 with: path: | ./hello.txt key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }}
hashFiles
を使う場合に限らず、restoreとsaveで異なるkeyを指定することが可能になっているので刺さる人には刺さるかも知れません。
まとめ
ライトなユースケースでは従来通り actions/cache
をそのまま利用するのがわかりやすく、記述も容易であるため完全に置き換わることはない印象です。
一方、より詳細なキャッシュの制御を求める人にとっては待望の新機能になりそうです。キャッシュサイズの大きさが気になっている人は、とりあえずトピックブランチでのキャッシュ保存を辞めてみると良いかも知れません。
go.workはmonorepoの夢を見るか
- TL; DR
- はじめに
- Workspace modeが解決したい課題
- Workspace modeのユースケース
- monorepoとWorkspace mode
- goplsのためのワークアラウンド
- まとめ
TL; DR
- multi moduleなmonorepoだから
go.work
を置こう、は間違い- 依存関係の管理を一緒にしてしまって問題ない物だけが一つのWorkspaceに共存できる
- monorepoでも普通のリポジトリでも、
go.work
は一時的に外部依存をローカルにあるモジュールで上書きするときに使う- 一時的な上書きは開発者のローカル環境の事情が混入するため
go.work
はコミットすべきでない
- 一時的な上書きは開発者のローカル環境の事情が混入するため
- goplsを動かしたいだけならVSCodeのMulti-root Workspaceを使え
はじめに
これはあくあたん工房アドベントカレンダー 2022 およびGoアドベントカレンダーその3 5日目の記事です。他の日の記事も面白いので、是非読んでみてください。
Go 1.18では Workspace モードが導入され、 go.work
というファイルで制御するということは比較的広く知られていると思います。最近monorepoで go.work
が使えないか( go.work
をリポジトリにコミットして良いか)という相談を受けることが何度かあったためこの記事を書くことにしました。現時点での自分の理解と、調べた限りの情報を元にしています。間違っていたら指摘を下さい。
Workspace modeが解決したい課題
まず、Workspace modeの元となるプロポーザルが以下にあります。
このプロポーザルには解決したい課題が書かれています。Backgroudのところです。
Users often want to make changes across multiple modules: for instance, to introduce a new interface in a package in one module along with a usage of that interface in another module. Normally, the go command recognizes a single “main” module the user can edit. Other modules are read-only and are loaded from the module cache. The replace directive is the exception: it allows users to replace the resolved version of a module with a working version on disk. But working with the replace directive can often be awkward: each module developer might have working versions at different locations on disk, so having the directive in a file that needs to be distributed with the module isn't a good fit for all use cases.
DeepL翻訳にかけた物も置いておきます。
例えば、あるモジュールのパッケージに新しいインターフェイスを導入し、別のモジュールでそのインターフェイスの使い方を変更したいなど、複数のモジュールにまたがって変更を加えたい場合がよくあります。通常、go コマンドは、ユーザが編集可能な単一の「メイン」モジュールを認識します。他のモジュールは読み込み専用で、モジュールキャッシュから読み込まれます。replace指示は例外で、解決されたモジュールのバージョンをディスク上の作業バージョンと置き換えることができます。しかし、replace ディレクティブの扱いはしばしば厄介です。各モジュールの開発者はディスク上の異なる場所に作業バージョンを持っているかもしれません。
workspace modeが導入されるまで、あるモジュールに変更を加えリリースするまでの間、それに依存する他のモジュールでは変更内容を試せないという問題の回避方法がreplaceするかリリースしてしまうかの二択しかありませんでした。
実験段階の物などはできればリリースしたくありませんし、そもそもローカルで気軽に試したいと考えるのは当然です。replaceはそのための有効な回避方法でしたが、プロポーザルに書かれているとおり各開発者のローカル環境の事情がgo.modに混入してしまうことが大きな課題でした。replaceで置き換えたのを忘れてcommitしてしまった経験のある方も多いのではないでしょうか。
Backgroudにはまだ続きがあり、こちらはgoplsに関する話題です。
gopls offers users a convenient way to make changes across modules without needing to manipulate replacements. When multiple modules are opened in a gopls workspace, it synthesizes a single go.mod file, called a supermodule that pulls in each of the modules being worked on. The supermodule results in a single build list allowing the tooling to surface changes made in a dependency module to a dependent module. But this means that gopls is building with a different set of versions than an invocation of the go command from the command line, potentially producing different results. Users would have a better experience if they could create a configuration that could be used by gopls as well as their direct invocations of cmd/go and other tools. See the Multi-project gopls workspaces document and proposal issues #37720 and #32394.
DeepL翻訳にかけたものが以下です。
gopls は、モジュール間の置換を操作することなく変更を行う便利な方法をユーザーに提供します。複数のモジュールが gopls のワークスペースで開かれると、作業中の各モジュールを取り込んだ supermodule と呼ばれる単一の go.mod ファイルが合成されます。スーパーモジュールの結果、単一のビルドリストができ、依存モジュールで行われた変更を依存モジュールに反映させるツールができます。しかし、これはgoplsがコマンドラインからのgoコマンドの呼び出しとは異なるバージョンのセットでビルドしていることを意味し、潜在的に異なる結果を生成します。cmd/go や他のツールを直接呼び出すのと同様に、gopls でも使用できるような設定を作成できれば、ユーザーはより良い経験を得ることができるでしょう。Multi-project gopls workspaces ドキュメントおよび提案課題 #37720 と #32394 を参照してください。
goplsではGo 1.18のWorkspace modeよりも前の時点から、multi moduleなprojectにおいても動作するように experimentalWorkpaceModule
というフラグがあり、実験的にサポートしていました*1。
しかしプロポーザルに書かれているとおり、これは本来のgoコマンドの挙動と異なる可能性がありました。具体的にはモジュールAがあるモジュールBのv1.0に依存し、別のモジュールCがモジュールBのv1.1に依存しているとします。このモジュールA、Cが存在するworkspaceにおいてgoplsはMinimal Version Selection(以下MVS)に従いモジュールBについてはv1.1を選択するという挙動になるはずです。しかしモジュールAはgo buildする際にはv1.0を使うので、v1.0では存在しなかった機能をgoplsが補完し、ユーザが取り込んでいるとビルドが壊れてしまいます。Go本体がworkspaceの機能を入れることで、これらの挙動を統一したいというわけです。
まとめると
- replaceを使った依存の上書きは開発者のローカル環境の事情が混入してしまうおそれがある
- 現状複数go.modがあるケースにおいてgoplsとgoコマンドの挙動に差異があり、ユーザを混乱させる恐れがある
これらの課題を解決するためにWorkspace modeが考案されています。
Workspace modeのユースケース
複数リポジトリにまたがる開発をする
具体的なストーリーをあげると↓のようなケースが考えられます。
- 自分が開発しているモジュールAに入れた変更をリリースする前に、それを使う予定の他のモジュールBから試したい
- フォークして修正したモジュールAを、PRを出す前に自身のモジュールBと組み合わせて試験したい
- ……etc
以下の様なディレクトリ構成で開発をすることが考えられます。go.workはリポジトリの外側にあり、当然コミットすることはできません。最終的にmodule-a、module-bの完成版がリリースされ、それらのgo.modで正しく解決される必要があります。
. ├── go.work ├── module-a │ ├── .git │ └── go.mod └── module-b ├── .git └── go.mod
go.workをリポジトリ内に置くこともできます。go.work
から other-module
を参照することができますが、このother-module
の存在はローカル環境依存のものであるため go.work
はコミットすべきではありません。
. ├── your-module │ ├── .git │ ├── go.mod │ └── go.work └── other-module ├── .git └── go.mod
experimentalWorkspaceModuleを置き換える
複数のgo.modを伴う開発において、goplsを experimentalWorkspaceModule: true
で使っていたユーザはこの機能で置き換えができます。ただし、注意点が二つありgoplsが動くからという理由でサブディレクトリのgo.modを全て列挙した go.work
を置くべきはなく、go.work
をリポジトリにコミットするべきでもありません。
- go buildの挙動が変わる
- これまで各go.modのみで評価していた依存関係がworkspace全体を考慮するようになるため、MVSの仕様により選択される依存関係のバージョンが変わる可能性がある
go.work
をコミットしてしまうと外部依存を開発者のローカル環境のモジュールで置き換えるという本来の使い方がやりにくくなる- go.modにreplaceを書きたくないからgo.workというコミットしないファイルにその情報を逃がしたのに、go.workをコミットしてしまうと同じ問題が再び起き得る
1はこれまでgoplsの中では既にそうなっていましたが、Workspace modeにより実際に生成されるバイナリにまで影響が及ぶためです。そしてこの仕様が許容できるなら、全体で一つのgo.modを共有するやり方で問題ないことになります。
2は先述した「go.work
をリポジトリ内に置き、リポジトリ外のother-module
を参照する」という場合と実質的に同じ事です。そのため go.work
をコミットするということは開発者のローカル環境の事情を混入させるおそれがあります。
monorepoとWorkspace mode
single moduleの場合
これはそのリポジトリにただ一つのgo.modをおいて全体でそれを共有する方式です。この場合、プロジェクトのルート直下にgo.modがないとgoplsが動作しない問題があります。ただしこれはmonorepoでなくとも起き得るため、特にmonorepoに特異な問題ではありません。
そして自明ですが、これはWorkspace modeで解消したかった課題ではありません。副次的に go.work
をリポジトリルートに置くことでgoplsが動作する可能性がありますが、本来の意図とは異なることに注意が必要です。各開発者が外部依存の上書きを可能にするため、go.work
はコミットせずignoreしておくのが無難です。
multi moduleな場合
一つのリポジトリ内に複数のgo.modが存在する方式です。この場合、各go.modごとに依存関係が管理されています。
まずreplaceの問題について考えてみます。元々のプロポーザルでは、replaceを使うことで開発者のローカル環境に依存した変更が入ってしまうことが問題としてあげられていました。しかし、multi moduleなmonorepoの中だけで言えば各モジュールの位置は開発者間で差異はなく、replaceを使っても問題ないと考えられます*2。monorepoの中から外部の依存関係を参照しており、それを一時的にreplaceするという使い方においてWorkspaceは有用です。これはmonorepoでないリポジトリでの開発と考え方は同じで、go.work
には開発者のローカル環境の事情が紛れ込んでしまうためコミットするべきではありません。
次にgoplsの問題について考えてみます。multi moduleなmonorepoを選択したことにはおそらく様々な事情があるはずです。
- 元々複数のリポジトリだったものをmonorepoに集約する過程で生じた
- モジュールごとに個別に依存関係のバージョンを管理する必要があった
- 複数の開発が並行することでgo.modやgo.sumが頻繁にコンフリクトするのを避けたかった
- ……etc
特に個別で依存関係の管理ができなければならない、という要件は見過ごされがちです。この仕様がある場合、何も考えず全てのモジュールを列挙した go.work
ファイルを置くと意図せず依存関係を変更してしまう可能性があります。よって、そもそも現在既に依存関係にあるもの同士しか go.work
に書かれるべきではありません。
goplsのためのワークアラウンド
「個別に依存関係を管理しなければならない」という複数go.modを持つプロジェクトでは、これまで書いた通り go.work
を置いて解決する方法がとれません。それでもgoplsを動かしたい場合に採用できる方法を2つ紹介します*3。
go.modのあるディレクトリにcdしてそこでエディタを開く
最も単純なワークアラウンドです。ただし開きたいモジュールの数だけエディタを開く必要がある他、それより上位のディレクトリにユーティリティ(Makefileなど)がある場合、それを使うのが面倒になるというデメリットが存在します。
エディタのMulti-root Workspaceを利用する
これは以前からサブディレクトリに go.mod
がある場合のワークアラウンドとして知られています。
VSCodeにはWorkspace機能があり、一つのウィンドウ内で複数のプロジェクトを開けるようになっています((この設定は *.code-workspace
というファイルに保存することができます。開発者のローカル環境の事情を汲んだファイルであることから共有する類いのファイルではありません。))。これを使うとあるプロジェクトの複数のサブディレクトリを、あたかも別プロジェクトのようにworkspaceに追加してそこをルートとして扱うことができます。これによりgoplsが動作するようになります。この方法はgoplsが公式に紹介しています。
https://github.com/golang/tools/blob/master/gopls/doc/workspace.md#multiple-workspace-folders
その他のエディタではプラグインがサポートしている場合があるので、以下のドキュメントを参照してください。
まとめ
go.work
に書かれたモジュールは依存関係を共有してしまうため、既に依存関係にあるモジュール以外を書くべきではない- monorepoであるかないかに関わらず、
go.work
は一時的な外部依存の上書きに使用する際に有用である- 一時的な上書きは開発者のローカル環境の事情が混入するため
go.work
はコミットすべきでない
- 一時的な上書きは開発者のローカル環境の事情が混入するため
- プロジェクトのルートにgo.modがなく、goplsを動かしたいケースではエディタの機能が現状では有用である
go.work
の導入に悩んでいる方の参考になれば幸いです。