その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

YAPC::Kyoto 2023で話します! そしてチケットを今すぐに購入しましょう!!

YAPC::Kyoto 2023の採択トークが決まったようですね。面白そうなトークが沢山あってすごいですね。

blog.yapcjapan.org

僕のトークが採択されていない......というのはトリックで、今回は畏れ多くもゲストスピーカーとして招待されましたので、そちらの枠でお話しします。

blog.yapcjapan.org

何を話すかは実際まだ100%は固まってはいないのですが、仮決めとしては「ソフトウェアエンジニアリングサバイバルガイド: 廃墟を直す、廃墟を出る、廃墟を壊す、あるいは廃墟に暮らす、廃墟に死す」というタイトルでお話できれば良いかなと思案しているところです。

つまりはこういうことです。どういうことだ?


それはそうとして、そんなYAPC::Kyoto 2023ですがチケット販売が今月1月の31日までとなっています。

passmarket.yahoo.co.jp

今月中にチケットを買わないと参加ができないのです! 今、まさにこの瞬間、すぐに買いましょう!!!!!


買いましたか? 買いましたね。それでは会場でお会いいたしましょう!
京都は僕が今住んでいるシアトルから片道10時間ってところなのでギリ日帰り圏内なのが助かりますね。

GoのHTTPクライアントがAWS NLB配下にあるコンポーネントと通信するときに5-tupleが分散しないので特定のインスタンスにしか負荷分散されないという話題

Microservicesのようなものを考えた際、Goで書かれたコンポーネントがHTTP(S)を使って他のコンポーネントと通信するという場合があると思います。
その「他のコンポーネント」がAWS NLBの配下にある時、GoのHTTPクライアントがTCPコネクションを使い回す場合があり、その状況においては特定のNLB配下のインスタンスにしかリクエストを割り振らない挙動をするという話題です。

NLB

プロトコル、ソースIP、ソースポート、宛先IP、宛先ポート、そしてTCPシーケンス番号に基いてフローハッシュアルゴリズムを用いて割り振り先のインスタンスを選択するようになっています。

ref:

For TCP traffic, the load balancer selects a target using a flow hash algorithm based on the protocol, source IP address, source port, destination IP address, destination port, and TCP sequence number. The TCP connections from a client have different source ports and sequence numbers, and can be routed to different targets. Each individual TCP connection is routed to a single target for the life of the connection.
https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html

つまり、同一のTCPコネクションが継続的に張られている状態 (すなわち5-tupleが同じ状況) では、その上を通るHTTPリクエストは常に同じインスタンス (ターゲット) に割り振られることになります。

GoのHTTPクライアント

GoのHTTPクライアントは基本的にHTTP Keep-Aliveするようになっています。デフォルトのHTTPクライアントのTransporthttp.Transportが用いられており、MaxIdleConnsあるいはMaxIdleConnsPerHostによってKeep-Aliveして使い回すコネクションの数をコントロールしています。

ref: https://engineers.fenrir-inc.com/entry/2018/11/12/153859

デフォルトではMaxIdleConnsは無制限 *1、かつMaxIdleConnsPerHost2 *2 *3 となっています。
つまりデフォルトの状況では同一ホストに対して2並列コネクションまではKeep-Aliveが有効となり、それ以上の並列リクエストについては都度コネクションを張り切りするという挙動となります。

GoのHTTPクライアントからNLB配下のコンポーネントへリクエストを送る時

以上から、並列リクエスト数が少ない時 (すなわち MaxIdleConns あるいは MaxIdleConnsPerHost 以下の時) にTCPのコネクションがHTTP Keep-Aliveによりpersistentに保たれるため、固定のインスタンスにしかリクエストが割り振られないこととなります。たとえば高々1リクエストしか同時に捌かないような時 (つまり総リクエスト数が少ない時)、そのリクエストは常に一意なNLB配下のインスタンスに振り分けられることとなります。

なので並列リクエスト数が少ない時にNLBの配下にたくさんターゲットをぶら下げていても、大半のターゲットにはリクエストが振られず遊んでいる状況となるためまったくの無駄となります。なのでうまいこと並列リクエスト数に応じてスケールアウト・スケールインできるようになっていると良さそうですね。
(ただこれはリクエスト元のGoで書かれたコンポーネントのプロセス数にも依存するとは思っており、もし大量のプロセスがいる場合はプロセスあたりの並列リクエスト数が少なくてもうまいこと5-tupleが分散する (あるいはプロセス内のコネクションが暇すぎてKeep-Aliveが切れて都度ハンドシェイクする) のでNLB配下のターゲットについては分散するとは思いますが、そもそもリクエスト数が少ない時の大量のプロセスを上げているのはリソース過剰であるのでそこが無駄では、という見方もできる気がします。)
一方で突然リクエスト数が増えた時にスケールアウトをトリガーしたとして、果たして間に合うかどうかという話題はありますが......そうなってくると遊ぶのを織り込んで余剰なターゲットをあらかじめ上げておくしかないような気もしているところです。


それはそうとして、低並列の場合でもTCPコネクションを良い感じでラウンドロビンして5-Tupleを散らす (ついでに良い感じでTCPコネクションをプールしておく)、みたいな方法は無いものですかね。自分でTransportレイヤーを書くしか無いのでしょうか? とはいえ並列リクエスト数が少ないということは総リクエスト数も少ないというわけで、その少ないリクエストをNLB配下のターゲットにまんべんなく割り振っても特に意味はないような気はしますね......ウーン。


[追記]
ありがたい助言:

なるほど、TCPレベルでのpoolをTransportレイヤで持つことを当初考えていましたが、HTTP Clientレベルでpoolすれば良いという気付きをいただきました。
[追記ここまで]

*1:MaxIdleConns controls the maximum number of idle (keep-alive) connections across all hosts. Zero means no limit. https://cs.opensource.google/go/go/+/refs/tags/go1.19.5:src/net/http/transport.go;l=192

*2:MaxIdleConnsPerHost, if non-zero, controls the maximum idle (keep-alive) connections to keep per-host. If zero, DefaultMaxIdleConnsPerHost is used.

*3:https://cs.opensource.google/go/go/+/refs/tags/go1.19.5:src/net/http/transport.go;l=58;bpv=1;bpt=1

IP Routing TableのGo実装 "go-iprtb" 書いた

GoのIP Routing Tableの実装について調べてみたところ、だいたいOSのRouting Tableを操作する系のライブラリがヒットし *1、そうではなくユーザー側コードでRouting Table相当の実装・処理をしたい時に使えそうなライブラリがパッと見当たらなかったのでそれを書いたという次第です。


さて、GoでシンプルなRouting Table実装を書くのは実に簡単で、以下のように書くだけでほぼ期待通りに動くと思います。

type RouteEntry struct {
	Destination net.IPNet
	Gateway     net.IP
	NwInterface string
	Metric      int
}

routes := map[string]RouteEntry{}
// ここでroutesにrouteを登録する

// `target net.IP` がrouting tableに含まれているかどうかをチェックする
var matched RouteEntry
var everMatched bool
for _, r := range routes {
	if r.Destination.Contains(target) {
		if !everMatched {
			matched = r
			everMatched = true
			continue
		}

		matchedMaskLen, _ := matched.Destination.Mask.Size()
		newRouteMaskLen, _ := r.Destination.Mask.Size()
		if newRouteMaskLen > matchedMaskLen { // for longest match
			matched = r
		}
	}
}

fmt.Println(matched)

この実装の問題としてはlongest matchを考慮するためにはRouting Tableのエントリをすべて線形に走査する必要があるため、Routing Table中のエントリ数が増えれば増えるほど計算量が増える、ということが挙げられます。
したがってTable中のroutes全てを1つのPrefix Treeに落としこんでやることで、対象のアドレスについてその木のパスを1回走査するだけで対象アドレスがRouting Tableにマッチしているかどうかを判断することができ、これにより計算量を減らすことができるという狙いがあります。


例えば 10.0.0.0/8, 192.0.0.0/8, そして 192.128.0.0/9 という3つのサブネットを考えたとき、それぞれの2進数表記は以下のようになります。表にはサブネットに属するゲートウェイについても記載しています。

Subnet Subnet Binary Gateway
10.0.0.0/8 00001010 00000000 00000000 00000000 GW1
192.0.0.0/8 11000000 00000000 00000000 00000000 GW2
192.128.0.0/9 11000000 10000000 00000000 00000000 GW3

太字はサブネットマスクを適用した後のアドレスを表わしています。このアドレスビット列についてTrie木を構築していくと以下のようになります。

                 R
                / \
              /     \
            0         1
           /           \
          0             1
         /             /
        0             0
       /             /
      0             0
       \           /
        1         0
       /         /
      0         0
       \       /
        1     0
       /     /
 GW1 [0]   [0] GW2
             \
             [1] GW3

† R: Root Node
†† [n]: Terminal Node

そして対象アドレスを2進数にしてこの木に対して最長マッチをさせていくと以下のような結果が得られます ([ ]で囲われた箇所は終端ノードを表わしています):

Target IP Target IP Binary Found Gateway
10.10.10.10 0000101[0] 00001010 00001010 00001010 GW1
192.10.10.10 1100000[0] 00001010 00001010 00001010 GW2
192.192.10.10 11000000 [1]1000000 00001010 00001010 GW3
127.0.0.1 01111111 00000000 00000000 00000001 N/A


今回書いたライブラリでは上記で挙げたような木構造による実装をしたところ、以下のような性能改善を実現できました。

$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: github.com/moznion/go-iprtb
cpu: 12th Gen Intel(R) Core(TM) i7-1280P
Benchmark_PrefixTreeAlgorithm-20        18535410                81.68 ns/op           64 B/op          1 allocs/op
Benchmark_LinearSearch-20                6148155               192.1 ns/op            96 B/op          1 allocs/op
PASS
ok      github.com/moznion/go-iprtb     2.968s

速い! 良かったですね。
なお他にもroute消去時の枝刈りなど細々としたパフォーマンスのチューニングが入っていたりもします。

ぜひご利用ください。

CのShared LibraryにしたGoのコードで簡単にダングリングポインタを発生させる

例えば以下のようなGoのコードを書き、

package main

import "C"
import (
	"fmt"
)

//export DoSomething
func DoSomething(cstr *C.char) {
	fmt.Printf("Go: %s\n", C.GoString(cstr))
}

func main() {
}

go build -buildmode c-shared -o libdosomething.so ./ としてCのShared Libraryを生成して、以下のようにCのプログラムに組み込みます。

#include <stdio.h>
#include <stdlib.h>
#include "libdosomething.h"

int main() {
    char *str = (char *)malloc(sizeof(char) * 4);
    str[0]='f';
    str[1]='o';
    str[2]='o';
    str[3]='\0';

    printf("C: %s\n", str);
    DoSomething(str);

    free(str);

    return 0;
}

そして gcc -L . main.c -ldosomething とこのCのコードをコンパイルし、LD_LIBRARY_PATH=. ./a.out のように実行すると

C: foo
Go: foo

という風に、もちろんこのコードは期待通りに動作します。

さて、Go側の DoSomething() の処理をgoroutineを使うように変更してみましょう。

package main

import "C"
import (
	"fmt"
	"time"
)

//export DoSomething
func DoSomething(cstr *C.char) {
	fmt.Println("Go: DoSomething is called")
	go func() {
		time.Sleep(3 * time.Second)
		fmt.Printf("Go: %s\n", C.GoString(cstr))
	}()
}

func main() {
}

そして説明のためにCのコードも以下のようにします。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "libdosomething.h"

int main() {
    char *str = (char *)malloc(sizeof(char) * 4);
    str[0]='f';
    str[1]='o';
    str[2]='o';
    str[3]='\0';

    printf("C: %s\n", str);
    DoSomething(str);

    free(str);

    printf("C: C string has been freed and it's sleeping...\n");

    sleep(5);

    return 0;
}

そして先程と同じようにCのプログラムをコンパイルして実行すると、

C: foo
Go: DoSomething is called
C: C string has been freed and it's sleeping...
Go: 3/_

という風に、めでたくGoの C.GoString(cstr) がダングリングポインタを参照したおかげでおかしな文字列が出力されました。

理由としてはおわかりのように単純で、goroutineの中でsleep (これは何らかの処理を模しています) している最中にGoの関数の引数として渡されたCの char *str がCの側で解放されてしまったため、いざsleepから目覚めてgoroutineの中で C.GoString(cstr) を実行するとダングリングポインタを参照してしまう、ということになります。なまじ変数をGoの世界に引き込んだので油断してしまい、Goの関数に渡された時点でGo世界にallocateされてcopyされているだろうとタカをくくっていたところそんなはずは無く、そりゃ *C.char なんだから明らかにそうなんですが、まあ普通にpointerでしたね......という感じです。

これを避けるためには以下のように同期的に (すなわちgoroutineの外で) Goの文字列にしてしまえば良いでしょう。goroutineの引数としてキャプチャしてしまっても良いと思います。

func DoSomething(cstr *C.char) {
	gostr := C.GoString(cstr)
	go func() {
		time.Sleep(3 * time.Second)
		fmt.Printf("Go %s\n", gostr)
	}()
}


こうして見ると原因は単純ですし、この例のようにsleepを使った露骨なコードだとわかりやすいのですが、実際のコードでこういう事が起こるとなかなかわかりにくいですね。ハマりました。疲れますね。

ts-dynamodb-attributes-transformer - TypeScriptオブジェクト向けDynamoDB Attributes変換器

TypeScriptのオブジェクトをAmazon DynamoDB Attributes (正確には aws-sdk-js-v3 がサポートする Record<string, AttributeValue>) に自動変換するTypeScript Transformer Pluginであるts-dynamodb-attributes-transformerをリリースしました。

npmからもご利用いただけます。

これはTypeScript Compiler APIを使ったCode Transformer (コード変換器) です。ざっくり言うと、コンパイル時にそのASTを見つつそこでコード変換を挿し込めるという面白いやつです。特定の言語におけるマクロに似ている感じがしますね。

ts-dynamodb-attributes-transformerはどういうTransformerなのかというと、例えば以下のようなTypeScriptコードがあった時、

import { AttributeValue } from '@aws-sdk/client-dynamodb';
import { dynamodbRecord } from '@moznion/ts-dynamodb-attributes-transformer';

interface User {
  readonly id: number;
  readonly name: string;
  readonly tags: Map<string, string>;
}

const record: Record<keyof User, AttributeValue> = dynamodbRecord<User>({
  id: 12345,
  name: 'John Doe',
  tags: new Map<string, string>([
    ['foo', 'bar'],
    ['buz', 'qux'],
  ]),
});

このTransformerは dynamodbRecord<T>(obj: T) (つまりこの場合は dynamodbRecord<User>(...)) を検出して、型パラメーター T (今回は User) の定義を読み取り、そして引数に与えられたオブジェクトの内容に従って以下のようなDynamoDB Attributesのコードに変換します。

const record = function (arg) {
    return {
        id: {
            N: arg.id.toString()
        },
        name: {
            S: arg.name
        },
        tags: {
            M: function () {
                var m;
                m = {}
                for (const kv of arg.tags) {
                    m[kv[0]] = { S: kv[1] }
                }
                return m;
            }()
        }
    };
}({
    id: 12345,
    name: 'John Doe',
    tags: new Map([
        ['foo', 'bar'],
        ['buz', 'qux'],
    ]),
});

実挙動としては dynamodbRecord<T>(obj: T) の関数呼び出しをごっそりDynamoDB Attributes (の生成関数) に置き換えるという動きをします。

これはTypeScriptのTransformer Pluginのため、実際の環境で利用するためにはそのTransformerをTypeScriptコンパイル時にうまくこのTransformerをフックしてあげる必要があります。現時点では ttypescript *1なんかを利用するのが手っ取り早いと思います。詳しくは使い方をご参照ください。

TypeScript Compiler APIを使ったTransformerにした利点としては、対象オブジェクトをその定義に従って自動的にDynamoDB Attributesにできるというのはもちろんのこと、コンパイル時に静的にコード変換されて解決されるため「ランタイムで動的にリフレクションなりなんなりをして頑張ってDynamoDB Attributes Objectを組み立てる」ということをしなくても良くなりパフォーマンスの向上が狙えるというのがあります。この高パフォーマンスが狙いでした。良かったですね。*2

[追記]
ベンチマークを類似のライブラリであるkayomarz/dynamodb-data-typesに対して取ってみたところ以下のような結果となりました。

node version: v16.17.0
dynamodb-data-types marshalling x 3,475,450 ops/sec ±0.45% (96 runs sampled)
ts-dynamodb-attributes-transformer marshalling x 13,405,409 ops/sec ±0.43% (91 runs sampled)
Fastest is ts-dynamodb-attributes-transformer marshalling

おおよそ3.85倍の性能向上が認められます。
[追記ここまで]


TypeScript Compiler APIを使ってTransformerを書いたのははじめてだったので、何かおかしな点などありましたらご指摘いただけるとうれしいです。
TypeScript Compiler API Transformerについては、index.d.ts を手で書いてユーザーに提供し、ユーザーはそのd.tsの中で定義されている関数を使ってコードを書き、Transformerはその関数呼び出しに対してコード変換を実行することでユーザー目線では単なる関数呼び出しのように使える一方で裏側では関数のSignatureに則ったコードに変換されている (つまり、無論実際には違うのですがイメージとしては「関数呼び出しが予めコンパイル時に行なわれている」感じ)、というのは個人的に面白いなと思いました。

実際に現場で使いはじめておりますが便利な感じがしています。ぜひご利用ください。

*1:TypeScriptコンパイル時に良い感じにTransformer Pluginをコンパイルフェイズに挿し込んでくれる便利なTypeScriptカスタムコンパイラ

*2:なお似たようなアプローチでJSON.stringify()を5倍程度高速化した samchon/typescript-json というプロジェクトがありました

docker composeでワンショットタスクを実行する

TL;DR

docker compose up --abort-on-container-exit

[追記]

docker compose run --rm ${service_name} で良かった......
[追記ここまで]




例えばなんらかのテストを実行する時、テスト用にDB等のストレージコンポーネントを用意してそいつに対し読み書きすることでend to endのテストを模擬しているというようなことがあると思います。
かつてはテストケースごとにデータベースプロセスを上げそれに対してread/writeを実行、ということもよくしていましたが *1、最近ではテスト起動時にストレージコンテナを立ち上げてそれを読み書きしている事も多くなってきました。


さて、そのような時に「テスト起動するにあたってはコンポーネントAとコンポーネントBを事前にマシン上で立ち上げておいてください」みたいなのはいかにも面倒くさいので、こういう時に便利なツールとしてパッと思い浮ぶのはdocker composeでしょう。
docker compose自体については本記事では説明しませんが、本記事ではそういった「ストレージのような永続的なライフサイクルを持つコンテナ」と「テストランナーのような一時的なライフサイクルのコンテナ」を同時に起動させ、テストランナーの実行が終了したらすべて店仕舞いさせるというワンショットタスクをdocker composeで実行する方法について記します。

version: '3.9'

services:
  redis:
    image: redis:7.0.4
    container_name: redis
    ports:
      - "6379:6379"
    network_mode: host
  app-test:
    image: app-test-env:latest
    container_name: app-test
    command: npm test
    volumes:
      - ./:/app
    working_dir: /app
    network_mode: host
    depends_on:
      - redis

上記の docker-compose.yml では redis というRedisコンテナと、app-testというテストランナーコンテナを同時に実行するという定義をしています。


まずapp-test はテストの実行にあたってRedisを利用するので depends_onredis を指定することでredisコンテナの起動を先に行うようにします。
なお、これはあくまでredisコンテナの起動を先に行うというだけで「Redisが6379番ポートで実際にlistenしてくれるまで待つ」ということをしてくれるわけではありません。なのでドキュメントにも「実際に使えるようになるまで待つ必要がある場合は適切な方法でやるように」と書かれていますので注意しましょう *2: https://docs.docker.com/compose/startup-order/

ネットワークの設定は適宜書き換えてください。この例では簡単のためにredisコンテナの6379ポートをホストの6379ポートに公開し、redisとapp-testの両方のnetwork_modeをhostモードにすることでホストネットワークを介して6379ポートを通じて通信できるようにしてあります。


と、ひとまずこのようにしておくと、docker compose up を実行することでテストの実行は可能となります。
しかし、テストランナーの実行が終了してもredisコンテナの実行は継続するため明示的にSIGINT等を送ってあげないとこのdocker compose upは終了しなくなります。healthcheckなどを適切に設定するとこのへん上手くやれるはずですが、ワンショットのタスクにそこまでやるのも凝りすぎでは?


ということで、docker compose up --abort-on-container-exit というふうに --abort-on-container-exit を付与して実行すると、いずれかのコンテナがexitした時にそのexit codeを引き継いでdocker composeを終了してくれるようになります。

--abort-on-container-exit   Stops all containers if any container was stopped. Incompatible with -d

[追記]
冒頭にも書いたように、こうしておいて docker compose run --rm app-test と実行すると良いです。
[追記ここまで]

良かった良かった、これで簡単にワンショットのタスクをdocker composeで実行できましたね。
ちなみに、exitしたら全体をabortさせたいコンテナを PID=1 にすればexitした際にすべてを終わらせてくれるのではないか、と思って app-test に対し init: true を指定してみましたがこれは効きませんでした。

小ネタ

docker compose runの時はそのserviceのログしか出てこないので問題無いので大丈夫なんですが (つまり何も問題が無い)、docker compose upの場合はすべてのサービスのログが流れてきます。その際、今回のようなケースだとRedisのログが見れてもあまりうれしいことは無いので抑制したい。というわけで

...
  redis:
    image: redis:7.0.4
    logging:
      driver: none
...

このようにlogging driverを none に設定するのですがこれは期待通りに動きません。

詳しくはこのissueに書かれているのですが、docker compose upのログは実行中のコンテナに実際にattachしたものが表示されるのであって、logging driverの設定とは別とのことです。
つまり、ここでredisコンテナに対してnoneを設定すると、docker compose upのログには表示される一方、logging driverを使っているロガー、例えば docker logs ${container_id} 等には表示されなくなるという挙動をするようです。

なので、起動オプション --attach でログを見たいコンテナを指定すると、docker compose upのログにはそれだけが表示されるようになります。今回の例だと --attach app-test などとすると良いでしょう。

特定のユーザーのイベントによるGitHub ActionsのActionを保留状態にしておき、後で手動実行できるようにする

dependabotだとかrenovateだとかを使ってライブラリのバージョンアップのpull requestを自動的に送ってもらう、というような機構を利用されている方が多いと思います。
常にこれらのpull requestに目を光らせておいて常に取り込み続けるというのが理想的な形・そうあるべきだとは思うのですが、ふと気を抜くとバージョンアップのpull requestが溜まっていき、pull request自身も改訂に改訂を重ねている......みたいなことが起きがちではないでしょうか。

そういった折、誰も結果を見もしないCI (i.e. GitHub Actions) だけが回り続けているのを見て「このチェックは『ライブラリアップグレード業』をやる時に手動で回せばコンピューティングリソースの削減になるのでは?」と思い、それを試したという次第です。

この記事では例として、renovateからのpull requestに対して自動でActionを回す (つまり一般的な "CI" ) のではなくレビュアーの承認が降りた際に回すようにしてみます。

Environmentを作る

RepositoryにGitHubのEnvironmentを作ります。例えば、"renovate" という名前のEnvironmentを作ってみることとします。

"Required reviewers" にチェックを入れて、renovateからのpull requestに対するActionの実行を承認するユーザーあるいはチームを指定します。
この用途の場合は "Deployment branches" は "All Branches" で適当かと思いますが、都度環境に合わせると良いでしょう。

Action定義を書く

ここは普通にGitHub Actionsの定義を書きましょう。
重要な点は、普段 (つまりrenovate以外が) 実行するActionとrenovateが実行するActionをそれぞれ用意するということです。

普段実行するActionの定義については

...
jobs:
  your-action:
    if: ${{ github.actor != 'renovate[bot]' }}
...

と書いておき、renovateが実行するActionの定義には

jobs:
  your-action:
    if: ${{ github.actor == 'renovate[bot]' }}
    environment: renovate

と書いておくことで、renovate以外の時は普通に自動実行され、renovateの時は environment: renovate が有効になり承認が降りないとActionが実行されないこととなります。

このyaml中のifにどういった条件が書けるかについては以下のドキュメントを参照すると良いでしょう。

今回はactor (イベントの主体) の名前によって判断するという素朴な方法を採りましたが、その他のcontextを使うと色々ハイテクなことができそうです。例えばrenovateに限らず「botの時」はこういうふうにするとか。

あと、手元のyamlではrenovateが実行するActionのイベントに on push で指定しているのですが、これは on pull_request で十分なのかもしれない。

こんな感じになる

この場合 "e2e-test.yml" というのが普段走るActionのyaml、 "e2e-test-for-renovate.yml" がrenovateの時に走らせるActionのyamlとなっています。
これはrenovateによって作られたpull requestなのですが、「普段走るAction」がスキップされており「renovateの時に走らせるAction」がpending状態になっていることがわかると思います。

この時、 "This branch is waiting to be deployed" からメニューをポチポチ選んでゆくと

このような状況になるので、Review Deploymentsから承認することでActionがマニュアル実行されるという寸法です。あとは実行結果を見て、マージするかどうかを精査しましょう!

課題

task定義を複数用意するのダサくない・メンテ面倒じゃない?

それは本当にそう! 何か良い方法があると良いのですが......

[追記]

あと、composite actionを使えば良いのではないか、というアドバイスもありました: Creating a composite action - GitHub Docs

知見ありがとうございます。

[追記ここまで]


承認できるReviewersを6人までしか指定できないのは少ないし、何より権威主義的だ!!!

個人だけでなくチームを指定できるのでそこである程度カバーできるのではないでしょうか。

依存アップデートのpull requestのActionが自動で完了してないと、更にマージするモチベーション下がらない?

そうかも......なのでモチベーション高く常に依存アップデートのpull requestを見てるよ・マージしてるよ、という環境では不要だと思います。

一方、ある程度まとめて依存のアップデートをしているような環境だと都度都度Actionが回っている必要が無い、ということでリソースを節約できるのではないか、というのがこの仕組みの狙いです。

とはいえ、この方法を運用しはじめてから日が浅いので今後何か良い点・悪い点が見えてくる可能性があります。どうなることやら。