GitHub Actions เข้าถึง VPN ของคุณไม่ได้: runner ของตัวเองหรืออุโมงค์
runner แบบโฮสต์คือ VM ใหม่ที่อยู่นอกเครือข่ายของคุณ จะวาง runner ไว้ในเครือข่ายนั้น หรือสร้างอุโมงค์จาก job แล้วรอเส้นทางจริง
ขั้นตอน deploy ของคุณค้างอยู่ที่ชื่อโฮสต์ภายใน ขณะที่คำสั่งเดียวกันตอบกลับทันทีจากแล็ปท็อป และไคลเอนต์บิลด์บนทั้งสองเครื่องเหมือนกันทุกประการ job กำลังรันบน runner ที่ GitHub โฮสต์ ซึ่งเป็นเครื่องเสมือนที่สร้างขึ้นสำหรับ job เดียวนั้นในคลาวด์ของ GitHub ไม่มีเส้นทางเข้าสู่พื้นที่แอดเดรสส่วนตัวของคุณ ไม่มีรายการ resolver สำหรับโซนภายใน และไม่มีใบรับรองที่ endpoint ส่วนตัวจะยอมรับ มีเพียงสองดีไซน์ที่เข้ากับข้อจำกัดนี้ และทั้งสองแบบเปลี่ยนภายหลังไม่ได้ถ้าไม่สร้าง workflow ใหม่ เลือกให้ได้ก่อนบรรทัดแรกของ YAML เพราะการย้ายภายหลังมีต้นทุนสูงกว่าครั้งแรก
ทางเลือกอยู่ระหว่าง runner ที่วางไว้ตรงจุดที่เครือข่ายสิ้นสุดอยู่แล้ว กับอุโมงค์ที่มีอยู่เท่าอายุของ job หนึ่งงาน ทั้งสองแบบรับงาน deploy บนโปรดักชันมากันหลายปี และล้มเหลวคนละแบบ
ขอบเขตอยู่ตรงไหนจริง ๆ
ทุกอย่างที่คุณติดตั้งบนแล็ปท็อปจะหยุดอยู่ที่แล็ปท็อป และทุกอย่างที่ติดตั้งระหว่าง job จะหายไปพร้อม runner ไม่มีเนมสเปซเครือข่ายแบบถาวรให้ไคลเอนต์เกาะอยู่ระหว่างการรันแต่ละครั้ง ทุกครั้งจึงเริ่มจากสภาพที่เข้าถึงไม่ได้เหมือนเดิม Azure เอกสารขอบเขตนี้ไว้สำหรับ agent ที่ Microsoft โฮสต์ ซึ่งการเชื่อมต่อ VPN และ ExpressRoute เข้าสู่เครือข่ายส่วนตัวไม่ได้รับการรองรับ และ runner ที่ GitHub โฮสต์ก็มีข้อจำกัดรูปแบบเดียวกัน ไม่ใช่กลไกแยก อ่านสิ่งนี้เป็นคุณสมบัติของผลิตภัณฑ์ ไม่ใช่ข้อบกพร่องที่ต้องอ้อม
ไม่มีตั้งค่าใดในรีโพซิทอรีที่ผ่อนปรนเรื่องนี้ ป้ายของ job เป็นตัวกำหนดว่าพูลใดจะรับงาน และป้ายแบบโฮสต์หมายถึงเครื่องที่อยู่นอกขอบเขตของคุณตลอดการรัน
การเข้าถึงแบบส่วนตัวจึงต้องมาจากสิ่งที่คุณเป็นเจ้าของ จะเป็น runner ที่วางไว้ตรงจุดที่เครือข่ายสิ้นสุดอยู่แล้ว หรืออุโมงค์ที่มีชีวิตอยู่เท่า job เดียว ส่วนที่เหลือของบทความพูดถึงการจ่ายเงินให้หนึ่งในสองอย่างนั้นอย่างตั้งใจ แทนที่จะไปพบใบเรียกเก็บเงินในโปรดักชัน
ตัดสินจากความถี่ในการรัน ไม่ใช่จากความคุ้นเคย
ใช้ runner ของตัวเองใน VPC หรือเครือข่ายออฟฟิศเมื่อ deploy ลงหลายครั้งต่อวัน เมื่อ job แตะพอร์ตส่วนตัวมากกว่าหนึ่งพอร์ต หรือเมื่อทีมแพลตฟอร์มดูแล patching และการหมุนเวียนข้อมูลรับรองอยู่แล้ว job บนเครื่องนั้น resolve และ route เหมือนโฮสต์อื่นหลังไฟร์วอลล์ งานเครือข่ายเกิดครั้งเดียวตอนติดตั้ง ไม่ใช่ทุกครั้งที่รัน
สร้างอุโมงค์ใน job เมื่อการเข้าถึงเกิดขึ้นนาน ๆ ครั้ง และคุณไม่อยากได้ agent อายุยาวที่ถือข้อมูลรับรองเข้าสู่เครือข่าย job จะกลายเป็นไคลเอนต์เครือข่ายของตัวเองไม่กี่นาทีแล้วไม่เหลืออะไรไว้ ซึ่งมักเป็นจุดประสงค์ทั้งหมด
ประเมินต้นทุนของอุโมงค์ก่อนเลือก ทุกครั้งที่รันต้องจ่ายค่าสร้างโปรไฟล์ ค่าเริ่มไคลเอนต์ ค่ารอความพร้อม และค่ารื้อถอน อุโมงค์ที่ขึ้นมาแต่ไม่เคยถูกตรวจสอบจะทำให้ deploy ล้มกลางทางและทิ้งสถานะส่วนตัวไว้ในรูปที่ไม่มีใครวางแผนไว้ การลองใหม่หลังจากนั้นแทบไม่เคยสะอาด และความล้มเหลวครั้งที่สองมักถูกโยนให้เวอร์ชันของไคลเอนต์
เขียนโปรไฟล์ให้ขั้นตอนหยุดกลางทางไม่ได้
เก็บโปรไฟล์และข้อมูลรับรองไว้นอกรีโพซิทอรี แล้วสร้างจาก 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 ต้องใช้โหมด auth ที่ตรงกัน โปรไฟล์ที่พุชออปชัน DNS ต้องมี openvpn-systemd-resolved บน Ubuntu และ Debian เพราะการพุชอย่างเดียวไม่เปลี่ยนอะไรบนอิมเมจพื้นฐาน
ความพร้อมคือการตรวจสอบ ไม่ใช่การหยุดรอ
sleep แบบคงที่อยู่ได้จนถึงวินาทีที่คอนเซนเทรเตอร์ช้า แล้วขั้นตอน deploy ก็วิ่งก่อนที่เส้นทางจะมี ความล้มเหลวจึงไปโผล่ในเครื่องมือ deploy เป็น 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 พิสูจน์ว่า resolver ที่ถูกพุชตอบชื่อนั้นได้ และการเปิดซ็อกเก็ตพิสูจน์ว่ามีอะไรบางอย่างฟังอยู่บนพอร์ตที่ deploy จะใช้ โปรไฟล์ที่มีแค่เส้นทางต้องใช้แอดเดรสตามจริงและลบบรรทัด getent ออก
เมื่อลูปหมดเวลา ล็อกคือสิ่งเดียวที่แยกความผิดพลาดสามแบบออกจากกัน ใบรับรองถูกปฏิเสธ โหมด auth ผิด และเส้นทางที่ไม่เคยมาถึง การกรองก่อน tail ทำให้เอาต์พุตนั้นสั้นพอจะอ่านได้ในขณะที่ job ยังล้มเหลวอยู่ อ่านข้อผิดพลาดแรกไม่ใช่บรรทัดสุดท้าย เพราะไคลเอนต์ที่ยืนยันตัวตนไม่ได้จะพูดซ้ำจนกว่าลูปจะยอมแพ้ ip -4 route show dev tun0 ตอบคำถามที่ง่ายกว่าว่ามีการพุชเส้นทางหรือไม่ และเสียต้นทุนหนึ่งบรรทัดในลูปเดียวกัน
การรื้อถอน และคำถามเรื่องอาร์ติแฟกต์
ปิด job ด้วยขั้นตอนที่ถูกป้องกันด้วย if: always() ซึ่งรัน sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" และไม่ทำอะไรอื่น บน runner ของตัวเองที่ใช้ร่วมกัน อย่าฆ่าด้วยชื่อโปรเซสเด็ดขาด เพราะ job อื่นอาจมีไคลเอนต์ไบนารีเดียวกันและอาร์กิวเมนต์ใกล้เคียง ความล้มเหลวจึงตกไปที่ job ที่รันทีหลัง และล็อกของมันชี้ไปที่ workflow ที่ไม่ได้ทำอะไรผิด
การป้องกันสำคัญเพราะ deploy ที่ล้มเหลวก็ยังทิ้งอุโมงค์ที่ยังมีชีวิต ถ้าไม่มี always() ขั้นตอนทำความสะอาดจะถูกข้ามในจังหวะที่ต้องการมันพอดี และการรันครั้งถัดไปจะเริ่มกับไคลเอนต์ที่เชื่อมต่ออยู่แล้ว
ยังเหลือคำถามหนึ่งข้อก่อนรับดีไซน์ใด ๆ คือขั้นตอน build ต้องการเส้นทางส่วนตัวจริงหรือไม่ agent สำหรับ deploy ที่อยู่ในเครือข่ายอยู่แล้วสามารถดึงอาร์ติแฟกต์ที่อนุมัติแล้วได้ ซึ่งทำให้บิลด์ทั่วไปอยู่ห่างจากเส้นทางส่วนตัว job ที่ถืออุโมงค์น้อยลงหมายถึงที่ซ่อนของเส้นทางเก่าก็น้อยลง