GitHub Webhooks, API Gateway(공개), Lambda(프라이빗)를 사용하여 프라이빗 Jenkins를 안전하게 트리거하기
I’m happy to help translate the article, but I’ll need the full text you’d like translated. Could you please paste the content (excluding the source link you already provided) here? Once I have the article text, I’ll translate it into Korean while preserving all formatting, code blocks, and URLs.
아키텍처의 아이디어
핵심 아이디어는 간단합니다:
- API Gateway는 유일한 외부 공개 컴포넌트입니다.
- Lambda와 Jenkins는 VPC 내부에 비공개로 유지됩니다.
- Jenkins가 트리거되기 전에 모든 것이 검증됩니다.
GitHub (Public)
|
| HTTPS Webhook
v
API Gateway (Public REST API)
|
v
AWS Lambda (Private – inside VPC)
|
v
Jenkins (Private – EC2 / VPC)
결과
- Jenkins는 인터넷 트래픽을 전혀 보지 않습니다.
- Lambda가 보안 게이트 역할을 합니다.
- 검증된 GitHub 이벤트만 빌드를 트리거합니다.
우리가 사용하는 것
- API Gateway (REST API) – 공개 웹훅 엔드포인트.
- AWS Lambda (VPC 내부) – 페이로드를 검증하고 요청을 전달합니다.
- Private Jenkins – 동일한 VPC 내부에서 실행됩니다.
- GitHub HMAC 서명 검증 – 페이로드 무결성을 보장합니다.
- 별도의 고급 서비스는 없습니다 – 적절한 구성 요소를 적절한 위치에 배치한 것입니다.
단계별 가이드
1️⃣ Jenkins 설정 (프라이빗)
- VPC 내부의 EC2 인스턴스(또는 VM)에서 Jenkins를 실행합니다.
- 공용 IP가 없는 프라이빗 서브넷에 배치합니다.
- 보안 그룹
- 인바운드: Lambda 보안 그룹에서 오는 트래픽만 허용합니다.
- 아웃바운드: Jenkins가 외부 접근이 필요하면 HTTPS를 허용합니다.
이 시점에서 Jenkins는 인터넷과 완전히 격리됩니다.
2️⃣ Lambda 함수 생성 (프라이빗)
| Setting | Value |
|---|---|
| Runtime | Python 3.10 |
| Timeout | 10–15 seconds |
| Memory | 128 MB |
- 함수를 프라이빗 서브넷에 연결합니다.
- Jenkins에 도달하기 위해 아웃바운드 HTTPS를 허용하는 보안 그룹을 사용합니다.
3️⃣ API Gateway 생성 (공용 진입점)
⚠️ REST API를 사용하고, HTTP API는 사용하지 마세요.
REST API는 요청 검증 및 헤더에 대한 완전한 제어를 제공합니다.
- API Gateway → Create API → Choose REST API.
- 리소스를 생성합니다(예:
/). - POST 메서드를 추가합니다.
- 이 API만 인터넷에 노출됩니다.
4️⃣ API Gateway를 Lambda에 연결
- POST 메서드의 Integration Request에서:
- Integration type: Lambda Function
- 생성한 Lambda를 선택합니다.
- API를 배포합니다(예:
dev스테이지).
Lambda 프록시 통합을 활성화합니다 – 헤더, 본문 및 원시 요청 페이로드를 그대로 전달합니다.
5️⃣ 메서드 요청 구성 (주의사항)
- API Gateway → Resources → POST → Method Request를 엽니다.
- 다음과 같이 설정합니다:
| Parameter | Value |
|---|---|
| Authorization | None |
| Request Validator | Validate body, query string parameters, and headers |
| API Key Required | No |
왜 중요한가
요청 검증기를 사용하지 않으면 API Gateway가X‑Hub‑Signature‑256와 같은 헤더를 삭제할 수 있습니다. 이 경우 Lambda에서 서명 검증이 항상 실패합니다.
- 설정을 변경한 후 API를 재배포합니다.
6️⃣ GitHub 웹훅 설정
GitHub 저장소에서:
Settings → Webhooks → Add webhook
| Field | Value |
|---|---|
| Payload URL | https://<api-id>.execute-api.<region>.amazonaws.com/dev/ |
| Content type | application/json |
| Secret | demo-github-secret |
| Events | Push events (or any you need) |
GitHub은 이제 다음을 전송합니다:
- 원시 JSON 페이로드.
X‑Hub‑Signature‑256헤더.
Lambda가 실제로 하는 일
Lambda 함수는 GitHub와 Jenkins 사이의 보안 게이트 역할을 합니다. 이 함수는 다음을 수행합니다.
- 읽기: 원시 webhook 페이로드를 읽습니다.
- 검증: GitHub HMAC 서명을 검증합니다.
- 확인: 브랜치(또는 기타 기준)를 확인합니다.
- 트리거: Jenkins 작업을 비공개로 실행합니다.
Lambda 코드 (프로덕션‑Ready)
import json
import hmac
import hashlib
import base64
import urllib.parse
import requests
from requests.auth import HTTPBasicAuth
# ----------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------
GITHUB_SECRET = b"demo-github-secret"
JENKINS_URL = "https://jenkins.example.com"
JENKINS_USER = "demo-user"
JENKINS_TOKEN = "demo-api-token"
JOB_URL = f"{JENKINS_URL}/job/demo-pipeline/buildWithParameters"
# ----------------------------------------------------------------------
def extract_payload(event):
"""
Preserve the exact raw body GitHub signed.
"""
# Normalise header keys to lower‑case
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
body = event.get("body", "")
# GitHub may send the payload base64‑encoded (API Gateway default)
if event.get("isBase64Encoded"):
raw_body = base64.b64decode(body)
else:
raw_body = body.encode("utf-8")
# Some GitHub integrations wrap the JSON in a `payload=` form‑urlencoded string
if raw_body.startswith(b"payload="):
raw_body = urllib.parse.unquote_to_bytes(
raw_body.decode().replace("payload=", "", 1)
)
return raw_body, headers
def verify_signature(raw_body, headers):
"""
Verify the HMAC SHA‑256 signature sent by GitHub.
"""
signature_header = headers.get("x-hub-signature-256")
if not signature_header:
raise ValueError("Missing X-Hub-Signature-256 header")
# Header format: sha256=abcdef...
try:
_, received_sig = signature_header.split("=")
except ValueError:
raise ValueError("Malformed signature header")
mac = hmac.new(GITHUB_SECRET, raw_body, hashlib.sha256)
expected_sig = mac.hexdigest()
if not hmac.compare_digest(expected_sig, received_sig):
raise ValueError("Signature verification failed")
return True
def trigger_jenkins(payload):
"""
Call Jenkins with the appropriate parameters.
"""
data = {
"token": JENKINS_TOKEN,
# Example: forward the branch name
"BRANCH": payload.get("ref", "").split("/")[-1],
}
response = requests.post(
JOB_URL,
data=data,
auth=HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN),
verify=False, # set to True with proper certs
timeout=10,
)
response.raise_for_status()
return response.text
def lambda_handler(event, context):
try:
raw_body, headers = extract_payload(event)
verify_signature(raw_body, headers)
# Parse JSON *after* verification
payload = json.loads(raw_body)
# Optional: filter branches, actions, etc.
if payload.get("ref") != "refs/heads/main":
return {"statusCode": 200, "body": "Ignored non‑main branch"}
trigger_jenkins(payload)
return {"statusCode": 200, "body": "Jenkins job triggered"}
except Exception as e:
# Log the error (CloudWatch) and return a 400/500 as appropriate
print(f"Error: {e}")
return {"statusCode": 400, "body": str(e)}
코드의 핵심 포인트
- **
extract_payload**는 GitHub가 서명한 정확한 바이트 시퀀스를 보존합니다(payload=형태‑urlencoded 본문 처리 포함). - **
verify_signature**는 타이밍 공격을 방지하기 위해hmac.compare_digest를 사용합니다. - **
trigger_jenkins**는 기본 인증(또는 API 토큰)을 이용해 프라이빗 VPC 네트워크를 통해 Jenkins 작업을 호출합니다.
Recap
| 구성 요소 | 공개 / 비공개 | 역할 |
|---|---|---|
| GitHub | 공개 | 웹훅 이벤트를 전송합니다. |
| API Gateway (REST) | 공개 | 웹훅을 수신하고 원시 요청을 Lambda로 전달합니다. |
| Lambda | 비공개 (VPC) | 서명을 검증하고, 이벤트를 필터링하며, Jenkins를 트리거합니다. |
| Jenkins | 비공개 (VPC) | 빌드 파이프라인을 실행합니다. |
Jenkins를 완전히 인터넷에서 차단하고 API Gateway와 Lambda를 게이트키퍼로 두면, 보안이 강화된 프로덕션‑레디 솔루션으로 GitHub‑트리거 빌드를 구현할 수 있습니다. 유일한 “주의점”은 API Gateway에서 요청 검증기를 활성화하여 서명 헤더가 제거되지 않도록 하는 것입니다. 이를 설정하면 흐름이 완벽하게 작동합니다.
Overview
This document explains a production‑ready AWS Lambda function that acts as a secure bridge between GitHub webhooks and a private Jenkins server.
It:
- 원시 웹훅 페이로드를 추출합니다.
- GitHub 서명을 검증합니다.
- 이벤트를 필터링합니다 (
main브랜치만). - 내부 네트워크를 통해 Jenkins 파이프라인을 트리거합니다.
GitHub 서명 검증
def verify_signature(raw_body, headers):
signature = headers.get("x-hub-signature-256")
if not signature:
return False
expected = "sha256=" + hmac.new(
GITHUB_SECRET,
raw_body,
hashlib.sha256
).hexdigest()
# Securely compare the two values to prevent timing attacks
return hmac.compare_digest(signature, expected)
x-hub-signature-256헤더가 없으면 요청이 즉시 거부됩니다.- 예상 서명은 페이로드의 원시 바이트를 사용해 다시 계산됩니다.
hmac.compare_digest는 상수 시간 비교를 제공합니다.
메인 Lambda 핸들러
def lambda_handler(event, context):
# 1️⃣ Extract and verify first – never parse JSON before validation
raw_body, headers = extract_payload(event)
if not verify_signature(raw_body, headers):
return {"statusCode": 401, "body": "Invalid GitHub signature"}
# 2️⃣ Safe to parse JSON now
payload = json.loads(raw_body.decode("utf-8"))
# 3️⃣ Process only pushes to the main branch
ref = payload.get("ref", "")
if not ref.endswith("/main"):
return {"statusCode": 200, "body": "Not main branch"}
# 4️⃣ Trigger Jenkins (privately)
session = requests.Session()
session.auth = HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN)
# Handle Jenkins CSRF protection
crumb = session.get(f"{JENKINS_URL}/crumbIssuer/api/json").json()
crumb_header = {crumb["crumbRequestField"]: crumb["crumb"]}
response = session.post(
JOB_URL,
headers=crumb_header,
params={"BRANCH": "main"},
timeout=10
)
return {
"statusCode": response.status_code,
"body": "Jenkins pipeline triggered"
}
- 잘못된 요청은 추가 처리 전에 차단되므로 Jenkins가 이를 보지 못합니다.
- JSON 페이로드는 서명 검증이 통과된 후에만 파싱됩니다.
- 함수는
main이 아닌 브랜치에 대해200응답을 반환하여 웹훅을 만족시키면서 아무 작업도 하지 않습니다.
Lambda 코드 이해
| 단계 | 수행 내용 |
|---|---|
| Extract payload | 일반 텍스트와 application/x-www-form-urlencoded 본문을 모두 처리하고, 필요 시 Base64를 디코딩합니다. |
| Verify signature | 원시 페이로드 바이트를 사용해 HMAC‑SHA256 서명을 재생성하고 안전하게 비교합니다. |
| Filter events | */main에 대한 푸시인 경우에만 계속 진행합니다. |
| Trigger Jenkins | Jenkins API 토큰으로 인증하고, CSRF 크럼을 받아 buildWithParameters를 통해 파이프라인을 실행합니다. |
전체 프로덕션‑준비 Lambda 코드
import json
import hmac
import hashlib
import base64
import urllib.parse
import requests
from requests.auth import HTTPBasicAuth
# ── Configuration ────────────────────────────────────────────────────────
GITHUB_SECRET = b"demo-github-secret"
JENKINS_URL = "https://jenkins.example.com"
JENKINS_USER = "demo-user"
JENKINS_TOKEN = "demo-api-token"
JOB_URL = f"{JENKINS_URL}/job/demo-pipeline/buildWithParameters"
# ────────────────────────────────────────────────────────────────────────
def extract_payload(event):
"""
Returns:
raw_body (bytes): the exact bytes that GitHub sent.
headers (dict): lower‑cased header dict for easy lookup.
"""
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
body = event.get("body", "")
# API Gateway may Base64‑encode the body
if event.get("isBase64Encoded"):
raw_body = base64.b64decode(body)
else:
raw_body = body.encode("utf-8")
# GitHub can send payload as `payload=...` (url‑encoded)
if raw_body.startswith(b"payload="):
raw_body = urllib.parse.unquote_to_bytes(
raw_body.decode().replace("payload=", "", 1)
)
return raw_body, headers
def verify_signature(raw_body, headers):
"""
Re‑creates the HMAC‑SHA256 signature and compares it with the header.
Returns True if the signature matches, False otherwise.
"""
signature = headers.get("x-hub-signature-256")
if not signature:
return False
expected = "sha256=" + hmac.new(
GITHUB_SECRET,
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
def lambda_handler(event, context):
raw_body, headers = extract_payload(event)
if not verify_signature(raw_body, headers):
return {"statusCode": 401, "body": "Invalid GitHub signature"}
payload = json.loads(raw_body.decode("utf-8"))
# Only act on pushes to the main branch
ref = payload.get("ref", "")
if not ref.endswith("/main"):
return {"statusCode": 200, "body": "Not main branch"}
# Authenticate to Jenkins
session = requests.Session()
session.auth = HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN)
# Obtain CSRF crumb
crumb = session.get(f"{JENKINS_URL}/crumbIssuer/api/json").json()
crumb_header = {crumb["crumbRequestField"]: crumb["crumb"]}
# Trigger the pipeline
response = session.post(
JOB_URL,
headers=crumb_header,
params={"BRANCH": "main"},
timeout=10
)
return {
"statusCode": response.status_code,
"body": "Jenkins pipeline triggered"
}