GitHub Actions から VPN に届かない:自前 runner かトンネルか
ホスト型 runner はあなたのネットワークの外にある新しい VM です。そのネットワーク内に runner を置くか、job からトンネルを張って本当に通る経路を待ってください。
デプロイのステップが社内ホスト名で止まるのに、同じコマンドはノート PC からは即座に返り、両方のマシンでクライアントのビルドは同一です。ジョブは GitHub がホストする runner 上で動いています。そのジョブ専用に GitHub のクラウド内に作られた仮想マシンで、あなたのプライベートアドレス空間への経路も、社内ゾーンの resolver エントリも、プライベート endpoint が受け入れる証明書もありません。この制約に収まる設計は二つだけで、どちらも後から workflow を作り直さずに切り替えることはできません。YAML の最初の行を書く前に選んでください。後からの移行は最初の試行より高くつきます。
選択肢は、ネットワークがすでに終わる場所に runner を置くか、一つのジョブの間だけ存在するトンネルを張るかです。どちらも長年本番デプロイを支えており、失敗の仕方が異なります。
境界は実際どこにあるのか
ノート PC に入れたものはノート PC で止まり、ジョブ中に入れたものは runner とともに消えます。クライアントが実行間でつながり続けられる永続的なネットワーク名前空間はなく、どの実行も同じ到達不能な状態から始まります。Azure は Microsoft ホストのエージェントについてこの境界を文書化しており、プライベートネットワークへの VPN と ExpressRoute の接続はサポートされません。GitHub ホストの runner も別の仕組みではなく、同じ形の制約を引き受けています。これは製品の性質として読み、回避すべき欠陥とは考えないでください。
リポジトリの設定で緩むものはありません。ジョブのラベルがどのプールに仕事を取らせるかを決め、ホストのラベルは実行の間ずっと境界の外のマシンを意味します。
したがってプライベートな到達性は、自分が所有する何かから用意する必要があります。ネットワークがすでに終わる場所に置いた runner か、一つのジョブだけ生きるトンネルかです。以降の本文は、本番で請求書を見つけるのではなく、どちらかを意図的に選んで払う話です。
慣れではなく実行頻度で決める
デプロイが 一日に何度も入る場合、ジョブが複数のプライベートポートに触る場合、あるいはプラットフォームチームがすでにパッチと認証情報のローテーションを担っている場合は、VPC かオフィスネットワーク内の自前 runner を選びます。そのマシン上のジョブはファイアウォール背後の他のホストと同じように解決し経路を選びます。ネットワークの作業は導入時に一度だけで、実行ごとには発生しません。
アクセスが稀で、ネットワークへの認証情報を保持し続ける長命のエージェントを望まない場合は、ジョブの中でトンネルを張ります。ジョブは数分間だけ自身のネットワーククライアントになり、何も残しません。多くの場合それが狙いそのものです。
トンネルを選ぶ前にコストを見積もってください。実行のたびにプロファイルの実体化、クライアント起動、レディネス、撤収にコストがかかります。立ち上がったまま決して検証されないトンネルは、途中で失敗するデプロイを生み、誰も計画していない形のプライベート状態を残します。その後の再試行がきれいになることは稀で、二度目の失敗はたいていクライアントのバージョンのせいにされます。
ステップが中断されないようにプロファイルを書く
プロファイルと認証情報はリポジトリの外に置き、必要とするステップの中で secret から実体化します。順序がジョブの生死を決めます。sudo の下で作ったファイルは root のもので、権限のないステップのシェルはもう書き込めません。
- name: Bring up OpenVPN
run: |
umask 077
profile="$RUNNER_TEMP/client.ovpn"
printf '%s\n' "$VPN_PROFILE" > "$profile"
: > "$RUNNER_TEMP/openvpn.log"
printf '%s\n' "$VPN_CREDENTIALS" > "$RUNNER_TEMP/credentials.txt"
sudo openvpn --daemon openvpn-job \
--config "$profile" \
--auth-user-pass "$RUNNER_TEMP/credentials.txt" \
--writepid "$RUNNER_TEMP/openvpn.pid" \
--log "$RUNNER_TEMP/openvpn.log"このブロックの 二つの詳細は後続のステップのためのものです。umask 077 によりプロファイルは 0600 で runner ユーザーの所有になり、sudo は読めるままで、クライアントは runner がすでに持つファイルにログを書くので、レディネスのループは 二度目の sudo なしでログを読めます。--writepid は撤収が狙う PID を記録します。
クライアント証明書で認証するプロファイルは同じやり方で鍵を書く必要があり、static-challenge のプロファイルには対応する認証モードが要ります。DNS オプションを push するプロファイルは Ubuntu と Debian で openvpn-systemd-resolved が必要です。push だけではベースイメージ上で何も変わらないからです。
レディネスは探索であって停止ではない
固定の sleep はコンセントレータが遅くなるその瞬間までしか持ちません。するとデプロイのステップは経路ができる前に走ります。失敗はその後デプロイツールの中でプライベートホストへの timeout として現れ、トンネルについては何も語りません。
- name: Await private endpoint
run: |
for attempt in $(seq 1 30); do
if getent hosts private.example.internal > /dev/null && \
timeout 3 bash -c 'exec 3<>/dev/tcp/private.example.internal/8443' 2>/dev/null; then
echo "endpoint answered after $attempt attempts"
exit 0
fi
sleep 2
done
grep -Ei 'error|verify|auth' "$RUNNER_TEMP/openvpn.log" | tail -40
exit 1ループが 二つを見るのは、どちらも壊れた半分になり得るからです。getent hosts は push された resolver がその名前に対して答えることを示し、socket が開くことはデプロイが使うポートで何かが待ち受けていることを示します。経路だけのプロファイルではリテラルアドレスが必要で、getent の行は削除します。
ループが尽きたとき、三つの異なる障害を分けるのはログだけです。証明書の拒否、認証モードの誤り、そして決して届かなかった経路です。tail の前に絞り込むことで、ジョブがまだ失敗している間に読める長さに収まります。最後の行ではなく最初のエラーを読んでください。認証できないクライアントはループが諦めるまで同じことを繰り返すからです。ip -4 route show dev tun0 は経路がそもそも push されたかという単純な問いに答え、同じループの中で 一行のコストで済みます。
撤収と成果物の話
ジョブは if: always() で守られたステップで閉じ、そのステップは sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" だけを実行します。共有の自前 runner では プロセス名で殺してはいけません。別のジョブが同じバイナリと同じような引数のクライアントを持っているかもしれません。障害は後から走ったジョブに落ち、そのログは何も悪いことをしていない workflow を指します。
ガードが要るのは、失敗したデプロイも生きたトンネルを残すからです。always() がなければ、クリーンアップのステップはまさに必要なときに飛ばされ、次の実行はすでに接続済みのクライアントに当たります。
どちらの設計を採る前に問いが 一つ残ります。ビルド段階にプライベート経路は本当に必要でしょうか。すでにネットワーク内にいるデプロイエージェントは承認済みの成果物を取得でき、それによって通常のビルドはプライベート経路から離れます。トンネルを持つジョブが減れば、古い経路が潜む場所も減ります。