GitHub Actions לא מגיע ל-VPN שלכם: runner משלכם או מנהרה
runner מתארח הוא VM חדש מחוץ לרשת שלכם. או שימו runner בתוך הרשת הזו, או פתחו מנהרה מה-job וחכו למסלול אמיתי.
שלב ה-deploy שלכם נתקע על שם מארח פנימי, בעוד אותה פקודה עונה מיד מהמחשב הנייד, וה-build של הלקוח בשתי המכונות זהה. ה-job רץ על runner מתארח של GitHub: מכונה וירטואלית שנוצרה עבור אותו job אחד בתוך הענן של GitHub, בלי מסלול אל מרחב הכתובות הפרטי שלכם, בלי רשומת resolver לאזור הפנימי ובלי תעודה שה-endpoint הפרטי יקבל. רק שני עיצובים נכנסים למגבלה הזו, ואף אחד מהם לא יכול להידלק מאוחר יותר בלי לבנות מחדש את ה-workflow. בחרו ביניהם לפני השורה הראשונה של YAML, כי מעבר בדיעבד עולה יותר מהניסיון הראשון.
הבחירה היא בין runner שמוצב במקום שבו הרשת כבר נגמרת לבין מנהרה שקיימת למשך job אחד. שניהם נושאים deploys בפרודקשן כבר שנים, והם נכשלים בדרכים שונות.
איפה הגבול באמת עובר
כל מה שאתם מתקינים על המחשב הנייד נעצר במחשב הנייד, וכל מה שאתם מתקינים במהלך job נעלם יחד עם ה-runner. אין namespace רשת קבוע שאליו לקוח יישאר מחובר בין ריצות, ולכן כל ריצה מתחילה באותו מצב בלתי נגיש. Azure מתעדת את הגבול הזה עבור agents מתארחים של Microsoft, שבהם קישורי VPN ו-ExpressRoute אל רשתות פרטיות אינם נתמכים, ו-runners מתארחים של GitHub נושאים אותה צורה של הגבלה ולא מנגנון נפרד. קראו את זה כתכונה של המוצר, לא כפגם שיש לעקוף.
שום הגדרה של ה-repository לא מרפה מזה. תווית ה-job מחליטה איזה pool ייקח את העבודה, ותווית מתארחת משמעה מכונה מחוץ להיקף שלכם לכל הריצה.
נגישות פרטית חייבת אפוא לבוא ממשהו שבבעלותכם. runner שמוצב במקום שבו הרשת כבר נגמרת, או מנהרה שחיה job אחד. שאר המאמר עוסק בלשלם על אחד משני אלה בכוונה, במקום לגלות את החשבון בפרודקשן.
החליטו לפי תדירות ריצות, לא לפי היכרות
פנו ל-runner משלכם בתוך ה-VPC או ברשת המשרד כשהדיפלויים נוחתים כמה פעמים ביום, כשה-job נוגע ביותר מפורט פרטי אחד, או כשצוות פלטפורמה כבר אחראי על patching וסיבוב אישורים. job על המכונה הזו עושה resolve ומנתב כמו כל מארח אחר מאחורי ה-firewall. עבודת הרשת קורית פעם אחת, בהתקנה, במקום בכל ריצה.
בנו את המנהרה בתוך ה-job כשהגישה מזדמנת ואתם לא רוצים agent ארוך-חיים שמחזיק אישור אל תוך הרשת שלכם. ה-job הופך ללקוח הרשת של עצמו לכמה דקות ולא משאיר דבר מאחור, וזה לעתים קרובות כל העניין.
העריכו את עלות המנהרה לפני שאתם בוחרים בה. כל ריצה משלמת על מימוש ה-profile, הפעלת הלקוח, המוכנות והפירוק. מנהרה שעולה אבל אף פעם לא נבדקת מייצרת deploy שנכשל באמצע ומשאיר מצב פרטי בצורה שאיש לא תכנן. הניסיון החוזר אחרי זה כמעט אף פעם לא נקי, והכישלון השני מיוחס בדרך כלל לגרסת הלקוח.
לכתוב את ה-profile כך שהשלב לא יוכל להיקטע
החזיקו profiles ואישורים מחוץ ל-repository וממשו אותם מ-secrets בשלב שצריך אותם. הסדר מחליט אם ה-job שורד: קובץ שנוצר תחת sudo שייך ל-root, וה-shell חסר ההרשאות של השלב לא יכול יותר לכתוב בו.
- 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 ה-profile נוחת במצב 0600 בבעלות משתמש ה-runner, sudo עדיין יכול לקרוא אותו, והלקוח כותב את הלוג שלו לקובץ שה-runner כבר מחזיק, כך שלולאת המוכנות יכולה לקרוא את הלוג בלי sudo נוסף. --writepid רושם את ה-PID שאליו יכוון הפירוק.
profile שמזדהה בתעודת לקוח צריך את המפתח שלו כתוב באותה דרך, ו-profile עם אתגר סטטי צריך את מצב האימות המתאים. profiles שדוחפים אפשרויות DNS צריכים openvpn-systemd-resolved באובונטו ובדביאן, כי הדחיפה לבדה לא משנה דבר בתמונת בסיס.
מוכנות היא בדיקה, לא הפסקה
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 שנדחף עונה עבור השם הזה, ופתיחת ה-socket מוכיחה שמשהו מאזין בפורט שה-deploy ישתמש בו; profile עם מסלול בלבד צריך את הכתובת המילולית ואת השורה getent שנמחקה.
כשהלולאה פגה, הלוג הוא הדבר היחיד שמפריד בין שלוש תקלות שונות: תעודה שנדחתה, מצב אימות שגוי ומסלול שמעולם לא הגיע. סינון לפני ה-tail משאיר את הפלט קצר מספיק לקריאה בזמן שה-job עדיין נכשל. קראו את השגיאה הראשונה ולא את השורה האחרונה, כי לקוח שלא מצליח להזדהות חוזר על עצמו עד שהלולאה מוותרת. ip -4 route show dev tun0 עונה לשאלה הפשוטה יותר אם בכלל נדחף מסלול, וזה עולה שורה אחת בתוך אותה לולאה.
פירוק, ושאלת ה-artifact
סגרו את ה-job בשלב שמוגן על ידי if: always() שמריץ sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" ושום דבר אחר. על runner משותף משלכם לעולם אל תהרגו לפי שם תהליך: job אחר יכול להחזיק לקוח עם אותו בינארי וארגומנטים דומים. הכשל נוחת אז על ה-job שרץ שני, והלוג שלו מצביע על workflow שלא עשה דבר רע.
ההגנה חשובה כי deploy שנכשל עדיין משאיר מנהרה חיה. בלי always() שלב הניקוי מדולג בדיוק כשצריך אותו, והריצה הבאה מתחילה מול לקוח שכבר מחובר.
נשארת שאלה אחת לפני שאתם מאמצים אחד מהעיצובים: האם שלב ה-build בכלל צריך מסלול פרטי? agent פריסה שכבר יושב בתוך הרשת יכול למשוך artifact מאושר, וזה מרחיק builds רגילים מהנתיב הפרטי. פחות jobs שמחזיקים מנהרה פירושו פחות מקומות שבהם מסלול מיושן יכול להסתתר.