GitHub Actions가 VPN에 닿지 않는다: 자체 runner 아니면 터널
호스팅 runner는 네트워크 밖의 새 VM입니다. 그 네트워크 안에 runner를 두거나, job에서 터널을 열고 실제 경로를 기다리세요.
배포 단계가 내부 호스트 이름에서 멈추는데 같은 명령은 노트북에서 즉시 응답하고 두 머신의 클라이언트 빌드는 동일합니다. job은 GitHub가 호스팅하는 runner에서 실행됩니다. 그 job 하나를 위해 GitHub 클라우드 안에 만들어진 가상 머신으로, 사설 주소 공간으로 가는 경로도, 내부 영역용 resolver 항목도, 사설 endpoint가 받아 줄 인증서도 없습니다. 이 제약에 맞는 설계는 두 가지뿐이고 어느 쪽도 workflow를 다시 만들지 않고는 나중에 켤 수 없습니다. 첫 YAML 줄 전에 선택하세요. 나중에 옮기는 비용이 첫 시도보다 큽니다.
선택은 네트워크가 이미 끝나는 지점에 runner를 두는 것과 한 job 동안만 존재하는 터널 사이입니다. 둘 다 수년간 프로덕션 배포를 지탱해 왔고 실패하는 방식은 다릅니다.
경계는 실제로 어디에 있는가
노트북에 설치한 것은 노트북에서 멈추고, job 중에 설치한 것은 runner와 함께 사라집니다. 클라이언트가 실행 사이에 붙어 있을 영구 네트워크 네임스페이스가 없으므로 모든 실행은 같은 도달 불가 상태에서 시작합니다. Azure는 Microsoft 호스팅 에이전트에 대해 이 경계를 문서화합니다. 사설 네트워크로 향하는 VPN과 ExpressRoute 연결은 지원되지 않습니다. GitHub 호스팅 runner도 별도 메커니즘이 아니라 같은 형태의 제약을 집니다. 이것을 제품 속성으로 읽고 우회할 결함으로 보지 마세요.
어떤 저장소 설정도 이것을 풀지 않습니다. job 라벨이 어느 풀이 작업을 가져갈지 정하고, 호스팅 라벨은 실행 내내 경계 밖의 머신을 뜻합니다.
따라서 사설 도달성은 당신이 소유한 무언가에서 와야 합니다. 네트워크가 이미 끝나는 지점에 둔 runner, 또는 한 job만 사는 터널입니다. 나머지 글은 프로덕션에서 청구서를 발견하는 대신 둘 중 하나를 의도적으로 지불하는 이야기입니다.
익숙함이 아니라 실행 빈도로 결정하세요
배포가 하루에도 여러 번 들어오거나, job이 하나 이상의 사설 포트를 건드리거나, 플랫폼 팀이 이미 패치와 자격 증명 교체를 맡고 있다면 VPC나 사무실 네트워크 안의 자체 runner를 쓰세요. 그 머신의 job은 방화벽 뒤 다른 호스트처럼 이름을 풀고 경로를 잡습니다. 네트워크 작업은 설치 시점에 한 번만 하고 실행마다 하지 않습니다.
접근이 드물고 네트워크로 들어가는 자격 증명을 쥔 장수명 에이전트를 원하지 않는다면 job 안에서 터널을 만드세요. job은 몇 분 동안 자기 네트워크 클라이언트가 되고 아무것도 남기지 않습니다. 종종 그것이 전부입니다.
터널을 고르기 전에 비용을 추정하세요. 실행마다 프로필 실체화, 클라이언트 기동, 준비 상태 확인, 철거에 비용을 냅니다. 올라오지만 결코 검증되지 않는 터널은 중간에 실패하는 배포를 만들고 아무도 계획하지 않은 형태로 사설 상태를 남깁니다. 그 뒤의 재시도는 좀처럼 깨끗하지 않고 두 번째 실패는 대개 클라이언트 버전 탓이 됩니다.
단계가 중단되지 않도록 프로필 쓰기
프로필과 자격 증명은 저장소 밖에 두고 필요한 단계에서 secret으로 실체화하세요. 순서가 job의 생존을 결정합니다. 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 앞에서 걸러 두면 job이 아직 실패하는 동안 읽을 수 있을 만큼 짧게 유지됩니다. 마지막 줄이 아니라 첫 오류를 읽으세요. 인증하지 못하는 클라이언트는 루프가 포기할 때까지 같은 일을 반복합니다. ip -4 route show dev tun0 은 경로가 애초에 push되었는지라는 더 단순한 질문에 답하고, 같은 루프 안에서 한 줄의 비용으로 끝납니다.
철거, 그리고 산출물 문제
job은 if: always() 로 보호된 단계로 닫고 그 단계는 sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" 만 실행합니다. 공유 자체 runner에서는 절대 프로세스 이름으로 죽이지 마세요. 다른 job이 같은 바이너리와 비슷한 인자를 가진 클라이언트를 갖고 있을 수 있습니다. 실패는 나중에 돈 job에 떨어지고 그 로그는 아무 잘못도 하지 않은 workflow를 가리킵니다.
보호가 중요한 이유는 실패한 배포도 살아 있는 터널을 남기기 때문입니다. always() 이 없으면 정리 단계가 꼭 필요한 순간에 건너뛰어지고, 다음 실행은 이미 연결된 클라이언트를 마주합니다.
어느 설계를 택하든 전에 질문이 하나 남습니다. 빌드 단계에 사설 경로가 정말 필요한가요? 이미 네트워크 안에 있는 배포 에이전트는 승인된 산출물을 가져올 수 있고, 그러면 일반 빌드는 사설 경로에서 멀어집니다. 터널을 쥔 job이 적을수록 낡은 경로가 숨을 곳도 줄어듭니다.