前言
之前寫過一篇 GitLab CI/CD 的文章,用 GitLab Runner 把 Laravel 專案從測試一路跑到部署。那套流程好用,但我後來把自己的幾個小服務(app-a、app-b、app-c)搬到同一台 2 核 VM 上,程式碼都放在 GitHub,繼續用 GitLab CI 就得多養一套 Runner,而且部署用的 SSH key 放在 CI 變數裡,我心裡一直不太踏實:只要 pipeline 的設定檔被改壞(或被惡意 PR 改掉),那把 key 就等於能在正式機上執行任意指令。
【系列】自架 VM 維運實戰 第 2 篇/共 5 篇
上一篇:【資安】Cloudflare Access 實戰:不用 VPN 保護後台與 Grafana
下一篇:【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警
所以這次我換成自架 Jenkins,並且把「安全」當成第一優先來設計。目標只有幾個:
- 設定即程式碼:Jenkins 本身用 JCasC + Job DSL 管理,容器砍掉重建,設定一行都不會掉。
- 人工核准:測試過了不代表可以上線,部署前一定要有人按下去。
- 受限 SSH 部署:就算部署 key 外洩,攻擊者也只能執行「部署某個 commit」這一件事。
- 最小暴露面:Webhook 能進來,但其他路徑一律進不來。
本文把整套設計拆開來講,所有設定都已經改寫成範例(網域用 example.com、帳號與 credential id 都是示意值)。最後我也會分享上線後才發現的幾個問題,這些踩坑比設計本身更有參考價值。
為什麼是 Jenkins,而不是繼續用 GitLab CI
先說清楚,GitLab CI 本身沒有問題,我也不是要說 Jenkins 比較好。這次換工具的理由很個人化:程式碼已經在 GitHub 上,我想要一套「完全由我掌控信任邊界」的流程,而且我已經有一台常駐的 VM 與完整的 Prometheus、Grafana 監控,多跑一個 Jenkins 的邊際成本很低。下面是我評估時列的比較:
| 面向 | GitLab CI(舊做法) | 自架 Jenkins(現在) |
|---|---|---|
| 設定來源 | repo 內的 .gitlab-ci.yml |
JCasC + Job DSL 管 Jenkins,Jenkinsfile 管流程 |
| 部署 key 保管 | CI/CD 變數,任何能改 pipeline 的人都有機會讀到 | 放在 controller 的 credential,agent 只在部署 stage 取用 |
| 部署權限 | key 通常等同於 shell 權限 | forced command,只能執行 deploy-app |
| 核准機制 | manual job,權限依專案角色 | input step,可指定 submitter、逾時自動中止 |
| 維運成本 | 託管,幾乎不用管 | 要自己升級、備份、監控 |
最後那一列是老實話:自架一定比託管累。如果你的團隊沒有人願意顧 Jenkins,用託管的 CI 反而是比較安全的選擇,因為一個沒人更新的 Jenkins 本身就是最大的風險。我願意自己顧,才選這條路。
這篇文章的威脅模型
設計安全機制前,我先問自己「我到底在防什麼」,不然很容易做出一堆看起來很安全、其實沒擋到任何事的設定。我設定的假設是:
- Jenkins 可能被攻破:網頁服務一定有漏洞,只是時間問題,所以不能讓 Jenkins 擁有正式機的完整權限。
- 部署 key 可能外洩:備份、log、截圖都可能不小心帶出去,所以 key 本身的能力必須很小。
- commit 內容不可信:commit message、分支名稱都是外部輸入,不能直接信任或插進指令。
- 人會手滑:誤按、誤推,所以要有核准與回滾。
後面每一節的設計,都可以對應回這四點。如果某個設定回答不了「它擋住了哪一種情況」,我就不加,因為多一個設定就是多一個要維護、要出錯的地方。
整體架構
先用表格把元件與職責講清楚:
| 元件 | 角色 | 重點設計 |
|---|---|---|
| Jenkins controller | 排程、UI、核准、保管 credential | 跑在 Docker,只綁 127.0.0.1,numExecutors: 0,不跑任何 build |
| PHP agent | 真正執行 composer 與 PHPUnit | 獨立容器,透過 WebSocket 連回 controller,PHP 8.2(只與 app-a 正式環境一致) |
| Cloudflare Access | Jenkins 的第一道門 | 整站要登入,只有 webhook 路徑 bypass,且限定 GitHub 官方 IP 段 |
| GitHub | 原始碼與 webhook 來源 | push 觸發;webhook 帶 secret 簽章 |
| Slack | 通知「等待核准」與結果 | Incoming webhook,commit message 先跳脫 |
| 正式機 deploy 帳號 | 接收部署指令 | 每個 app 一把 key,forced command 綁死 deploy-app |
流向可以用一行字描述:
GitHub push
→ (Cloudflare Access bypass + 官方 IP 段 + webhook secret)
→ Jenkins controller 排入 job
→ PHP agent:checkout → composer install → php artisan test
→ Slack:「等待核准」+ 核准連結
→ 人工按下核准(30 分鐘逾時)
→ PHP agent 用受限 SSH key 連到正式機
→ forced command:deploy-app <app> deploy <sha>
→ 健康檢查,失敗自動 rollback
注意一個關鍵的信任邊界:Jenkins 只負責「請求部署」,真正決定「能不能部署、怎麼部署」的是正式機上的 deploy-app。即使 Jenkins 整台被打下來,攻擊者拿到的也只是一個「只能部署 main 分支上已存在 commit」的權限。
Jenkins 設定即程式碼:JCasC 與 Job DSL
Docker Compose
Jenkins 2.568.3 LTS 用 Docker Compose 跑起來,controller 與 agent 是兩個服務。幾個我刻意做的選擇:port 只綁 127.0.0.1(對外一律走 Cloudflare 的 tunnel 或反向代理)、secret 全部用唯讀 bind mount 掛進 /run/secrets、關掉 setup wizard 與使用統計:
services:
jenkins:
build: ./controller
image: example/jenkins-controller:2.568.3
container_name: jenkins
restart: unless-stopped
environment:
JAVA_OPTS: >-
-Xms256m -Xmx768m
-Djenkins.install.runSetupWizard=false
-Dhudson.model.UsageStatistics.disabled=true
CASC_JENKINS_CONFIG: /var/jenkins_home/casc
JENKINS_ADMIN_PASSWORD: ${JENKINS_ADMIN_PASSWORD}
TZ: Asia/Taipei
volumes:
- jenkins-home:/var/jenkins_home
- ./casc:/var/jenkins_home/casc:ro
- ./secrets/app-a-deploy-key:/run/secrets/app-a-deploy-key:ro
- ./secrets/app-b-deploy-key:/run/secrets/app-b-deploy-key:ro
- ./secrets/app-c-deploy-key:/run/secrets/app-c-deploy-key:ro
- ./secrets/slack-webhook:/run/secrets/slack-webhook:ro
- ./secrets/github-webhook-secret:/run/secrets/github-webhook-secret:ro
- ./secrets/known_hosts:/etc/ssh/ssh_known_hosts:ro
ports:
- "127.0.0.1:8080:8080"
mem_limit: 1536m
php-agent:
build: ./agent
image: example/jenkins-agent-php:8.2
container_name: jenkins-agent-php
restart: unless-stopped
depends_on: [jenkins]
environment:
JENKINS_URL: http://jenkins:8080/
JENKINS_AGENT_NAME: php-agent
JENKINS_SECRET: ${JENKINS_AGENT_SECRET}
volumes:
- agent-work:/home/jenkins/agent
- ./secrets/known_hosts:/etc/ssh/ssh_known_hosts:ro
mem_limit: 1g
兩個容器都設了 mem_limit,因為這台 VM 只有 2 核、記憶體也有限,Jenkins 吃光資源會連帶拖垮同機的其他服務。controller 的 JVM 堆積上限我抓 768 MB,跑三個 job 綽綽有餘。
plugin 清單我用 plugins.txt 把版本全部釘死,只裝需要的:configuration-as-code、job-dsl、workflow-aggregator、git、github、credentials-binding、ssh-agent、timestamper、ws-cleanup、prometheus。plugin 越少,攻擊面越小,升級時要看的 changelog 也越少。
JCasC:系統設定
JCasC 的精神是「UI 上的改動,重啟後會被檔案蓋掉」。我一開始覺得有點煩,後來發現這正是優點:沒有人能在 UI 上偷偷改設定而沒留下紀錄。系統層的設定長這樣:
jenkins:
systemMessage: "CI(設定由 JCasC 管理,UI 上的改動重啟後會被覆蓋)"
numExecutors: 0 # controller 不跑 build,全部交給 agent
mode: EXCLUSIVE
markupFormatter: plainText
securityRealm:
local:
allowsSignup: false
users:
- id: admin
password: "${JENKINS_ADMIN_PASSWORD}"
authorizationStrategy:
loggedInUsersCanDoAnything:
allowAnonymousRead: false
nodes:
- permanent:
name: php-agent
labelString: php
remoteFS: /home/jenkins/agent
numExecutors: 2
launcher:
inbound:
webSocket: true
retentionStrategy: always
unclassified:
gitHubPluginConfig:
hookUrl: "https://jenkins.example.com/github-webhook/"
hookSecretConfigs:
- credentialsId: github-webhook-secret
location:
url: https://jenkins.example.com/
timestamper:
allPipelines: true
prometheusConfiguration:
path: prometheus
useAuthenticatedEndpoint: false
collectingMetricsPeriodInSeconds: 120
幾個重點值得拆開講:
numExecutors: 0+mode: EXCLUSIVE:controller 不會接任何 build。Jenkins 官方一直建議這樣做,因為 build 裡的程式碼(composer scripts、測試)可以讀到 controller 的檔案系統。把執行拆到 agent,controller 上的 credential 就不會被 build 直接碰到。allowAnonymousRead: false:匿名連 job 列表都看不到。markupFormatter: plainText:不讓 job 描述等欄位渲染 HTML,避免 XSS。- 密碼不寫死:
${JENKINS_ADMIN_PASSWORD}從環境變數取,環境變數放在不進版控的.env。 - agent 用 WebSocket 連線:不用多開 50000 port,agent 只需要能連到 controller 的 HTTP port。
JCasC:Credential
Credential 一樣用 JCasC 宣告,內容用 ${readFile:...} 從掛載進來的 secret 檔案讀取,所以 YAML 本身可以安心放進版本控制,裡面沒有任何機密:
credentials:
system:
domainCredentials:
- credentials:
- basicSSHUserPrivateKey:
scope: GLOBAL
id: deploy-ssh-app-a
username: deploy
description: "Jenkins -> 正式機 deploy-app app-a(受限 forced command)"
privateKeySource:
directEntry:
privateKey: "${readFile:/run/secrets/app-a-deploy-key}"
- string:
scope: GLOBAL
id: github-webhook-secret
description: "GitHub webhook 簽章 secret"
secret: "${readFile:/run/secrets/github-webhook-secret}"
- string:
scope: GLOBAL
id: slack-webhook
description: "Slack incoming webhook"
secret: "${readFile:/run/secrets/slack-webhook}"
每個 app 都有自己的一把 deploy key(deploy-ssh-app-a、deploy-ssh-app-b……),原因在後面「受限 SSH 部署」那一節會說明。
Job DSL:job 也是程式碼
JCasC 裡的 jobs: 區塊可以直接內嵌 Job DSL 腳本。我的 job 全部由這裡產生,沒有任何一個是在 UI 上手動點出來的:
jobs:
- script: |
pipelineJob('app-a') {
description('composer install → 測試 → 人工核准 → 部署')
logRotator { numToKeep(20) }
properties {
disableConcurrentBuilds()
pipelineTriggers {
triggers {
githubPush() // GitHub webhook 即時觸發
pollSCM { scmpoll_spec('H/30 * * * *') } // 備援:webhook 漏送時仍會觸發
}
}
}
definition {
cpsScm {
scm {
git {
remote { url('https://github.com/example-org/app-a.git') }
branch('*/main')
}
}
scriptPath('Jenkinsfile')
lightweight(true)
}
}
}
這段有幾個安全上的刻意設計:
- 用
pipelineJob而不是 multibranch。multibranch pipeline 會自動為每個分支、甚至每個 fork PR 建 job,也就是外人的程式碼有機會跑在我的 agent 上。我的 job 把branch('*/main')寫死,只建 main(app-c 這個 repo 是 master),完全不處理 fork PR。這個服務是個人專案,不需要接受外部貢獻,那就乾脆不開這扇門。 - Jenkinsfile 放在 repo 裡(
cpsScm)。流程改動走正常的 commit 與 code review,而 job 的「來源、分支、觸發方式」則鎖在 JCasC,兩者分開管理。 disableConcurrentBuilds():同一個 job 不會同時跑兩個 build,不會有兩個部署互相踩到。- webhook 加輪詢備援:
githubPush()負責即時觸發,pollSCM每 30 分鐘(H讓不同 job 錯開時間)當保險,webhook 偶爾漏送也不會漏掉 commit。
私有 repo 的話,remote 改用 SSH URL 並指定一把唯讀的 GitHub deploy key credential;Jenkins 對 GitHub 只需要讀,沒有任何理由給它寫入權限。
Agent 映像檔
agent 的 Dockerfile 要同時有 Java(跑 agent.jar)與 PHP(跑測試),我讓 PHP 版本與 app-a 的正式環境一致(8.2),想避免「CI 綠燈、正式機炸掉」。但要老實說,這只對 app-a 成立:app-b 正式環境跑的是 FrankenPHP(PHP 8.4),它的測試目前跑在 8.2 的 agent 上,版本並不一致。這是已知缺口,我把「每個 PHP 版本各準備一個 agent,或改成在容器裡用 docker 跑測試」列為改進方向(後者需要 docker 權限,得另外評估風險):
FROM php:8.2.33-cli-alpine3.24
RUN apk add --no-cache openjdk21-jre-headless git openssh-client bash curl \
icu-dev libzip-dev oniguruma-dev \
&& docker-php-ext-install bcmath intl zip pdo_mysql \
&& adduser -D -u 1000 -h /home/jenkins jenkins \
&& mkdir -p /home/jenkins/agent && chown -R jenkins:jenkins /home/jenkins
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
USER jenkins
WORKDIR /home/jenkins
ENTRYPOINT ["/entrypoint.sh"]
agent 以非 root 的 jenkins 使用者執行,而且這個容器沒有掛 docker.sock。這點很重要:掛了 docker.sock 等於把 host 的 root 交給 build,那前面做的隔離就白費了。測試用的資料庫我用 SQLite 或 .env.example 的測試設定,不需要在 agent 裡再起其他容器。
Jenkinsfile 各階段
三個服務共用同一套 Jenkinsfile 骨架,只差 APP、PROD_HOST、DEPLOY_CRED 三個環境變數。先看整體結構:
pipeline {
agent none
environment {
APP = 'app-a'
PROD_HOST = 'app-a.example.com'
DEPLOY_CRED = 'deploy-ssh-app-a'
}
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20'))
}
stages {
stage('Test') { /* 見下 */ }
stage('Approve') { /* 見下 */ }
stage('Deploy') { /* 見下 */ }
}
post {
always {
notifySlack('result')
}
}
}
agent none 是關鍵:pipeline 層級不佔用任何 agent,由各 stage 自己決定要不要借 agent。等一下你會看到,最花時間的「等人核准」這個 stage 完全不佔 executor。
Test:安裝相依與跑測試
stage('Test') {
agent { label 'php' }
options { timeout(time: 15, unit: 'MINUTES') }
environment {
COMPOSER_CACHE_DIR = '/home/jenkins/agent/.composer-cache'
}
steps {
script {
env.DEPLOY_SHA = sh(returnStdout: true, script: 'git rev-parse HEAD').trim()
env.COMMIT_MSG = sh(returnStdout: true, script: 'git log -1 --format=%s').trim()
}
sh 'composer install --no-interaction --prefer-dist --no-progress'
sh '''
cp .env.example .env
php artisan key:generate --force
php artisan test
'''
}
post {
always { cleanWs() }
}
}
這個 stage 做了幾件事:
- 先記下 commit SHA。後面的核准與部署,用的都是這個
DEPLOY_SHA,而不是「部署時的 main 最新版」。這樣你核准的就是你看到測試通過的那一版,核准期間就算有人又 push 了新 commit,也不會被偷渡上線。 - timeout 15 分鐘:測試卡死不會永遠佔著 agent。
- composer cache 放在 agent 的持久 volume,每次 build 不必從頭下載套件。
cleanWs():不論成敗都清掉工作區,避免殘留.env與 vendor。
測試階段只用 .env.example 產生的設定,CI 上沒有任何正式環境的機密。這是一條簡單但重要的原則:會跑「別人寫的程式碼」(composer scripts、測試)的環境,不該拿得到能動正式環境的東西。
Approve:人工核准(先預告,下一節詳述)與 Deploy
stage('Approve') {
// 先發 Slack 附核准連結;等待期間不佔用 agent,30 分鐘沒按就中止
options { timeout(time: 30, unit: 'MINUTES') }
steps {
notifySlack('approval')
input message: "部署 ${env.DEPLOY_SHA.take(7)}「${env.COMMIT_MSG}」到 ${env.PROD_HOST}?", ok: '部署'
}
}
stage('Deploy') {
agent { label 'php' }
options {
skipDefaultCheckout()
timeout(time: 10, unit: 'MINUTES')
}
steps {
sshagent(credentials: [env.DEPLOY_CRED]) {
// 這把 key 在主機端被 forced command 綁定,只能執行 deploy-app $APP
sh 'ssh -o StrictHostKeyChecking=yes -o BatchMode=yes -o LogLevel=ERROR [email protected] deploy "$DEPLOY_SHA"'
}
}
}
Deploy stage 的 SSH 指令值得注意:
StrictHostKeyChecking=yes:host key 預先放進known_hosts並唯讀掛載,防止連到假的主機。BatchMode=yes:不會卡在任何互動式提示。- 指令只有
deploy <sha>。因為 forced command 會把它當成$SSH_ORIGINAL_COMMAND交給 deploy-app,Jenkins 想傳別的指令也沒用。 - 目標位址是 Docker 的 bridge gateway,所以 agent 容器是從私有網段連到 host,這個來源網段會在
authorized_keys的from=再卡一次。
Webhook 與 Cloudflare Access bypass
Jenkins 的網頁介面我放在 Cloudflare Access 後面,要通過身分驗證才看得到登入頁,等於在 Jenkins 自己的帳密之外多了一層。但這帶來一個問題:GitHub 的 webhook 不會登入 Cloudflare Access,會被擋在門外。
解法是替 webhook 路徑開 bypass,但不能「直接放行」了事,我疊了兩道驗證:
| 層級 | 機制 | 擋掉什麼 |
|---|---|---|
| 1. Access policy | 只對 /github-webhook/ 路徑 bypass,來源限定 GitHub 官方 webhook IP 段 |
不是 GitHub 的來源,連 webhook 路徑都碰不到 |
| 2. Jenkins | GitHub plugin 驗證 X-Hub-Signature-256 簽章(webhook secret) |
就算偽造來源 IP,沒有 secret 也無法觸發 |
GitHub 官方 IP 段可以從 https://api.github.com/meta 的 hooks 欄位取得,這個清單會變動,我設了提醒定期對一次。
Cloudflare Access 是在 Zero Trust 後台用介面設定的(沒有設定檔),我建了兩個 Application:
| Application | Destination | Policy |
|---|---|---|
| Jenkins UI | jenkins.example.com(整個子網域) |
Allow,Include:Emails = 自己的 email |
| GitHub Webhook | jenkins.example.com/github-webhook/(路徑較具體,會優先匹配) |
Bypass,Include:IP ranges = api.github.com/meta 回傳的 hooks 清單 |
在 GitHub 端,webhook 的 Payload URL 指向 https://jenkins.example.com/github-webhook/,Content type 選 application/json,Secret 填同一把 secret(也就是前面 github-webhook-secret 那份 credential)。
另外 Prometheus 的 metrics 端點,我只讓內網的 Prometheus 抓,不經過公開路徑。下面「上線後」會再提到監控。
最後再強調一次:即使 webhook 被偽造成功觸發了 build,也只會讓 Jenkins 去拉 main 分支的最新 commit、跑一次測試,然後停在 Approve 等人核准。最糟的結果是收到一則多餘的 Slack 通知,不會直接部署。這就是「人工核准」這一層的價值。
Webhook 觸發後發生了什麼事
實際排查過幾次之後,我整理出 webhook 從送出到 build 開始的完整路徑,萬一哪天沒觸發,就照這個順序查:
- GitHub 端:到 repo 的 Webhooks 頁面看 Recent Deliveries,回應碼是 200 代表 Jenkins 收到並接受;302(被導去登入頁)通常代表請求沒命中 bypass,被 Jenkins UI 那條 Access 規則攔下,原因多半是 IP 段過期或路徑不符;簽章錯誤則是 secret 兩邊不一致。
- Cloudflare 端:看 Access 的 log,確認請求有命中 bypass policy,而不是被預設的登入頁擋掉。
- Jenkins 端:webhook 觸發的 build,觸發原因會顯示「Started by GitHub push」,也可以看 GitHub Hook Log 確認有沒有收到事件;輪詢觸發則會顯示由 SCM 變更啟動。
因為有 30 分鐘的輪詢備援,即使上面任何一關暫時壞掉,最慢 30 分鐘內 Jenkins 還是會發現新 commit。這是我很喜歡的一個設計:即時性靠 webhook,可靠性靠輪詢,兩者互相補位,而且輪詢是 Jenkins 主動往外連 GitHub,不需要對外多開任何入口。
人工核准與 Slack 通知
為什麼不是全自動部署
測試綠燈就自動上線,聽起來很美,但我的服務會處理使用者資料,測試覆蓋再完整,我也想在上線前再看一眼 commit 內容。所以流程在測試通過後,會停下來等我按「部署」。
Jenkins 的 input step 正好做這件事。它有三個重點:
- 放在沒有 agent 的 stage:等待核准不佔用 executor,agent 可以去跑別的 build。
- 用
timeout包起來:30 分鐘沒人按,build 會被中止成 ABORTED,不會有一個永遠懸著的 build 在那邊等。 - 訊息帶 SHA 前 7 碼與 commit 標題:按下去之前,我就知道自己在核准什麼。
Slack 通知與格式注入
光有 input 還不夠,我不想一直盯著 Jenkins 網頁看,所以在進入核准前先發一則 Slack 通知,附上核准連結(BUILD_URL 加上 input/)。
這裡有一個很容易忽略的陷阱:通知內容裡有 commit message,而 commit message 是別人(或任何有 push 權限的人)可以控制的字串。Slack 的訊息格式裡,<!channel> 會通知整個頻道,<https://evil.example.com|點此核准> 能把任意連結偽裝成正常文字。如果直接把 commit message 串進去,就有「格式注入」的風險。
Slack 官方的做法是把 &、<、> 三個字元轉成 HTML entity。我的通知函式用 PHP 寫(agent 上本來就有 PHP,省得再裝別的),並用環境變數傳值,而不是把字串直接插進 shell 指令,這樣 commit message 裡就算有單引號、反引號或 $(...),也不會被 shell 解讀:
// 送 Slack 通知;commit message 會跳脫 & < >,避免注入 Slack 格式(例如 <!channel>)
def notifySlack(String kind) {
node('php') {
withCredentials([string(credentialsId: 'slack-webhook', variable: 'SLACK_URL')]) {
withEnv(["KIND=${kind}", "RESULT=${currentBuild.currentResult}"]) {
sh '''
php -r '
$esc = fn ($s) => str_replace(["&", "<", ">"], ["&", "<", ">"], (string) $s);
$url = (string) getenv("BUILD_URL");
$title = getenv("JOB_NAME")." #".getenv("BUILD_NUMBER")." ".substr((string) getenv("DEPLOY_SHA"), 0, 7)." ".$esc(getenv("COMMIT_MSG"));
if (getenv("KIND") === "approval") {
$text = ":raised_hand: *等待部署核准* → ".getenv("PROD_HOST")."\n".$title."\n<".$url."input/|點此核准或拒絕>(30 分鐘內)";
} else {
$map = ["SUCCESS" => [":white_check_mark:", "部署成功"], "ABORTED" => [":double_vertical_bar:", "未部署(取消或核准逾時)"]];
[$icon, $word] = $map[getenv("RESULT")] ?? [":x:", "失敗"];
$text = $icon." ".$word." — ".$title." (<".$url."|build log>)";
}
echo json_encode(["text" => $text], JSON_UNESCAPED_UNICODE);
' | curl -sf -X POST -H 'Content-Type: application/json' --data @- "$SLACK_URL" >/dev/null || true
'''
}
}
}
}
這段有幾個細節:
- 跳脫只做在 commit message:連結的
< >是我自己組的 Slack 語法,必須保留;而使用者可控的內容一律先過$esc。 - 用
json_encode組 payload,而不是字串拼 JSON,避免引號與換行弄壞格式。 - Webhook URL 放在 credential 裡,用
withCredentials注入,Jenkins 會在 log 中自動遮蔽。 - 通知失敗不影響部署:結尾的
|| true讓 Slack 掛掉時,pipeline 仍然正常跑(只是少一則通知)。 - 結果通知放在
post { always }:成功、失敗、逾時被中止,都會收到對應的訊息。
用 Groovy 三單引號 ''' 包住 shell 腳本,是為了讓 Groovy 不要去做字串插值,所有變數都交給 shell 與 PHP 的 getenv 處理。這是 Jenkinsfile 裡處理不可信字串最穩的寫法。
受限 SSH 部署
前面所有設計,最後都要落到「Jenkins 怎麼把新版本放上正式機」這一步。這也是整個流程最敏感的地方,我花了最多心思。
常見做法的問題
最直覺的做法,是在 CI 裡 ssh deploy@server "cd /var/www/app && git pull && composer install ..."。這樣做的問題是:部署的邏輯寫在 Jenkinsfile 裡,而 Jenkinsfile 在 repo 裡,所以只要有人能改 repo(或 CI 被入侵),他就能讓正式機執行任何指令,那把 SSH key 等同於 shell 權限。
我的做法是把角色反過來:部署邏輯放在正式機上、由我管的一支腳本;Jenkins 只能「請求執行這支腳本」。
authorized_keys 的 forced command
每個 app 一把專屬的 SSH key。正式機的 deploy 帳號,authorized_keys 長這樣(key 本體以 AAAA... 取代):
restrict,from="172.16.0.0/12",command="sudo /usr/local/sbin/deploy-app app-a \"$SSH_ORIGINAL_COMMAND\"" ssh-ed25519 AAAA... jenkins->deploy-app-a
restrict,from="172.16.0.0/12",command="sudo /usr/local/sbin/deploy-app app-b \"$SSH_ORIGINAL_COMMAND\"" ssh-ed25519 AAAA... jenkins->deploy-app-b
restrict,from="172.16.0.0/12",command="sudo /usr/local/sbin/deploy-app app-c \"$SSH_ORIGINAL_COMMAND\"" ssh-ed25519 AAAA... jenkins->deploy-app-c
逐個選項解釋:
| 選項 | 作用 |
|---|---|
restrict |
一次關掉 port forwarding、agent forwarding、X11 forwarding、PTY 分配與 ~/.ssh/rc。這是 OpenSSH 7.2 之後的建議用法,比一個個列 no-port-forwarding,no-pty,... 更不容易漏 |
from="172.16.0.0/12" |
只接受來自私有網段(Docker bridge 所在範圍)的連線,key 外洩後從外網也用不了 |
command="..." |
不論 client 要求執行什麼,sshd 一律改執行這個指令;client 原本要求的字串放進 $SSH_ORIGINAL_COMMAND |
注意 command 裡的 app 名稱(app-a、app-b、app-c)是寫死在 authorized_keys 的,不是 Jenkins 傳來的。所以 app-a 的 key 就算外洩,也無法拿去部署 app-b,這就是「每個 app 一把 key」的意義。
sudoers 也只開一條規則:
# /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /usr/local/sbin/deploy-app
deploy 帳號只能用 root 身分執行這一支腳本,除此之外什麼都不能做。腳本放在 /usr/local/sbin,擁有者是 root,deploy 帳號無法修改它,否則整套限制就形同虛設。
deploy-app:把規則寫死在伺服器端
接下來是核心。$SSH_ORIGINAL_COMMAND 會是 deploy <40 碼 sha>,deploy-app 對它做完整驗證。以下是精簡版(保留所有安全檢查,拿掉各 app 的細節差異):
#!/bin/bash
# 用法:deploy-app <app> deploy <40 碼 sha> | deploy-app <app> status
# app 名稱來自 authorized_keys 的 forced command,不是使用者輸入
set -euo pipefail
APP=${1:-}; shift || true
read -r -a ARGS <<< "${*:-}"
ACTION=${ARGS[0]:-}; SHA=${ARGS[1]:-}
case "$APP" in
app-a)
REPO=/var/www/app-a; BRANCH=main; HOST=app-a.example.com
WEB=app-a-php; IMAGE_PATHS="deployment/php"; COMPOSE_DIR=$REPO/deployment ;;
app-b)
REPO=/var/www/app-b; BRANCH=main; HOST=app-b.example.com
WEB=app-b-web; IMAGE_PATHS="deployment/Dockerfile deployment/php"; COMPOSE_DIR=$REPO/deployment ;;
app-c)
REPO=/var/www/app-c; BRANCH=master; HOST=app-c.example.com
WEB=app-c-php-fpm; IMAGE_PATHS="deployment/Dockerfile deployment/php"; COMPOSE_DIR=$REPO/deployment ;;
*) echo "unknown app"; exit 2 ;;
esac
log() { echo "[deploy-$APP $(date -u +%H:%M:%S)] $*"; }
git_() { git -C "$REPO" -c safe.directory="$REPO" "$@"; }
in_web() { docker exec -w /var/www/html "$WEB" "$@"; }
post_deploy() {
# 注意:這個函式是在 if ! post_deploy 裡被呼叫,set -e 在此無效,
# 所以每個指令都要自己 || return 1
in_web composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader || return 1
in_web php artisan migrate --force || return 1
in_web php artisan config:cache || return 1
}
case "$ACTION" in
status) git_ log -1 --format='%H %ad %s' --date=iso; exit 0 ;;
deploy) [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || { log "invalid sha"; exit 2; } ;;
*) log "usage: deploy <sha> | status"; exit 2 ;;
esac
# 同一個 app 同時只准一個部署
exec 9>"/run/deploy-$APP.lock"; flock -n 9 || { log "another deploy is running"; exit 3; }
PREV=$(git_ rev-parse HEAD)
log "current $PREV -> target $SHA"
# 1. 工作目錄不乾淨就拒絕(有人手動改過檔案)
[ -z "$(git_ status --porcelain --untracked-files=no)" ] || { log "working tree has local changes, refusing"; exit 4; }
# 2. 必須是遠端主分支上已存在的 commit
git_ fetch -q origin "$BRANCH"
git_ merge-base --is-ancestor "$SHA" "origin/$BRANCH" || { log "$SHA is not on origin/$BRANCH, refusing"; exit 5; }
# 3. 只允許 fast-forward
git_ merge -q --ff-only "$SHA"
這段的設計意圖我逐條講:
- SHA 必須是 40 碼十六進位(
^[0-9a-f]{40}$)。這同時擋掉了指令注入(; rm -rf之類)與「傳分支名、傳HEAD~5」這類模糊參照。部署的永遠是一個精確的 commit。 - 必須在
origin/main上(git merge-base --is-ancestor)。沒有 merge 進主分支的 commit,不管 Jenkins 傳什麼都不會被部署。所以就算有人把惡意分支推上 GitHub,也進不了正式機。 - 工作目錄有本地修改就拒絕。正式機上若有人手動改過檔案,通常代表有人在 hotfix 或有人動過手腳,這時自動部署應該停下來讓人看,而不是默默覆蓋或產生衝突。
- 只允許
--ff-only:歷史必須是線性往前,不會產生 merge commit,rollback 時也能精準回到前一版。 - flock 互斥鎖:同一 app 同時只有一個部署在跑,補上 Jenkins
disableConcurrentBuilds管不到的情況(例如有人手動 ssh 呼叫)。 - 伺服器本身沒有 GitHub 的推送權限。正式機只能 fetch(公開 repo 直接 fetch,私有 repo 用唯讀 deploy key),就算被入侵,也不能把惡意程式碼寫回 GitHub。
還有一個容易被忽略的設計:status 子指令是唯讀的,方便排查「現在正式機上跑的是哪個 commit」,而不用真的登入伺服器。
失敗回滾
部署最怕的是「改了一半壞掉,人又不在電腦前」。deploy-app 的後半段負責 build、後置步驟、健康檢查與自動回滾:
rollback() {
log "ROLLBACK to $PREV"
git_ reset -q --hard "$PREV"
post_deploy || true
}
# 只有 image 相關檔案有變動才重建 image
if [ "$PREV" != "$SHA" ] && ! git_ diff --quiet "$PREV" "$SHA" -- $IMAGE_PATHS; then
log "image-related files changed, rebuilding"
if ! (cd "$COMPOSE_DIR" && docker compose build && docker compose up -d); then
log "rebuild failed"; rollback; exit 6
fi
sleep 10
fi
if ! post_deploy; then
log "post-deploy step failed"; rollback; exit 6
fi
# 健康檢查:最多等 30 秒
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: $HOST" http://127.0.0.1/ || true)
[ "$code" = 200 ] && { log "health check OK (200), deployed $(git_ log -1 --format='%h %s')"; exit 0; }
sleep 3
done
log "health check FAILED (last $code)"; rollback; exit 7
整個失敗處理流程是這樣:
- 程式碼是 bind mount 進容器的,所以
git merge --ff-only完,新程式碼就已經在容器裡了。 - 如果 image 相關檔案(Dockerfile、PHP 設定)有變,才重建 image 並重啟容器;沒變就跳過,部署只需要幾秒。
- 跑
post_deploy:composer install、migrate、快取重建。 - 任何一步失敗,就立刻
git reset --hard回前一版,再跑一次post_deploy把快取與相依套件調回去。注意「任何一步失敗」是靠每個指令明確的|| return 1與if !判斷達成的,不能只靠set -e(原因見「上線後才發現的事」第 6 點)。 - 全部成功後,用
curl打本機並帶上 Host header 做健康檢查;10 次都不是 200,同樣 rollback。
exit code 也刻意分開(2 參數錯誤、3 已有部署在跑、4 工作目錄不乾淨、5 不在主分支、6 後置步驟失敗、7 健康檢查失敗),Jenkins 的 console log 一看就知道卡在哪一關。
必須誠實說明這個 rollback 的限制:它回滾的是程式碼,不是資料庫。如果新版的 migration 已經執行成功、之後才健康檢查失敗,回到舊程式碼時資料表已經是新結構。所以我的原則是 migration 一律向前相容(先加欄位、程式碼不再用後才刪),讓「舊程式碼跑在新 schema 上」是安全的。這件事靠流程紀律,而不是靠腳本。
第二個限制:有重建 image 時,rollback 只會還原程式碼,不會還原 image。腳本不會替舊版 image 保留 tag,所以回到舊 commit 時,容器跑的仍然是新 build 出來的 image。如果出問題的原因正好在 image(例如 PHP 擴充),rollback 救不了,得手動處理;較完整的做法是 build 前先替目前的 image 打上備份 tag,rollback 時一併切回去,這也是我的待辦之一。
上線後才發現的事
三個服務(app-a、app-b、app-c)都用同一套流程,pipeline 全部綠燈並實際部署成功。但上線一陣子後,有幾件事是設計時沒想到、實際跑了才發現的(最後一條則是寫這篇文章、請人審查時才發現)。
1. 只改 entrypoint 就重建整個 image
我當初把 entrypoint 腳本也列進「image 相關檔案」(IMAGE_PATHS),理由是 entrypoint 會被 COPY 進 image。結果有一次我只改了一行 entrypoint,部署就觸發了完整的 docker compose build,在 2 核的 VM 上重新編譯 pecl redis 等 PHP 擴充,花了大約 5 分 40 秒,期間服務還在跑舊 image,健康檢查的等待也變得很尷尬。
回頭看,我的程式碼是 bind mount 進容器的,很多「看起來屬於 image」的檔案,其實重啟容器就能生效,根本不需要 rebuild。改進方向是縮小 IMAGE_PATHS:只留下真的會改變 image 內容的檔案(Dockerfile、會影響編譯的 PHP 設定),entrypoint 這類改動改成只重啟容器。教訓是:「image 相關」的判斷規則,要以「這個變動是否真的需要 rebuild 才能生效」為準,而不是以「檔案在哪個目錄」為準。
2. 浮動 tag 讓 build cache 莫名失效
Dockerfile 的 FROM php:8.4-alpine 是浮動 tag,上游一更新,底層 layer 就變了,build cache 整個失效,下次 build 又得從頭編譯。這不只是慢,更是不可重現:同一份 Dockerfile,今天 build 和下週 build 出來的東西可能不一樣。
改進方向是固定 digest(FROM php:8.4-alpine@sha256:...),再搭配定期手動升級:每個月挑一天,更新 digest、跑測試、確認沒問題再部署。這樣升級是我主動決定的事件,而不是上游隨時可能對我造成的意外。
3. input 沒設 submitter,誰都能按核准
這是最該早點發現的。我的 input step 沒有設定 submitter,意思是任何登入 Jenkins 的使用者都能按核准。目前 Jenkins 只有我一個帳號,風險不大,但這個「安全閘門」的前提是「按的人有資格按」,沒設 submitter 就是閘門沒鎖。
改進方向是在 input 加上限定核准人:
input message: "部署 ${env.DEPLOY_SHA.take(7)}「${env.COMMIT_MSG}」到 ${env.PROD_HOST}?",
ok: '部署',
submitter: 'admin' // 只有這個使用者(或群組)能核准
之後如果團隊變大,會再搭配 Jenkins 的角色權限(例如 Role-based Strategy plugin)把「能看」與「能核准」分開。
4. 多個 job 共用 composer cache
三個 job 的 COMPOSER_CACHE_DIR 指向同一個 agent volume。好處是下載一次大家都快;風險是理論上某個 job 若被污染,可能把有問題的套件寫進共用 cache,影響其他 job。目前因為每個 job 只 build 自己 repo 的 main 分支、不處理 fork PR,風險很低,所以我接受這個取捨,但要知道它存在:如果日後開放外部 PR,這個 cache 就必須按 job 隔離。
5. 順手把壞掉的測試套件修好
app-b 的測試套件在接上 CI 之前其實是跑不起來的,沒有 CI 逼著,它壞了好一陣子都沒人發現。接 CI 時不得不把它修好(缺的設定、過時的 factory 與斷言),這可能是整件事最意外的收穫:CI 最大的價值,是逼你面對那些一直沒人管的壞掉的東西。順便說,「測試綠燈」的核准流程如果建立在一個根本沒在跑的測試上,那就只是儀式而已。
6. set -e 在 { ... } || 裡是失效的
這一條是寫這篇文章、整理 deploy-app 時,經過審查才發現的。我原本的寫法是把 build 與 post_deploy 包在 { ...; } || { rollback; } 裡,並自以為「有 set -e,任何一步失敗都會中斷並 rollback」。事實上,只要一個指令是出現在 ||、&&、if 的條件位置,bash 就會在整個範圍內(包含其中呼叫的函式)停用 set -e,整個 list 只看最後一個指令的結束碼。最小重現只要三行:
bash -c 'set -e; f(){ false; echo 還在跑; }; { f; } || echo rollback'
# 實際輸出:還在跑
# 不會印出 rollback,因為 f 的最後一個指令 echo 成功了
換句話說,composer install 或 migrate 失敗了,腳本會繼續往下跑,只要最後一個指令成功,就不會 rollback,只能靠後面的健康檢查兜底。修法是不要依賴 set -e:函式內每個指令自己加 || return 1,呼叫端用 if ! post_deploy; then rollback; exit 6; fi,而 rebuild 那段也改成 if ! (cd ... && docker compose build && docker compose up -d); then ...,用 && 串起來讓前一步失敗就短路。文中的 deploy-app 範例是修正版,正式機上的那份也已經換上。換上之前,我在暫存目錄建了一個假的 git repo,把 docker、composer、migrate 換成可以指定失敗的 stub,對新舊兩版跑同一組情境:舊版在 composer、migrate、docker build 失敗時都回傳 exit 0、HEAD 停在新版,等於壞掉的部署被當成成功;新版 6 種情境(全部成功、composer/migrate/最後一步/build 失敗、健康檢查非 200)全部符合預期。
監控:Jenkins 本身也要被盯著
最後補一個運維面的設計。JCasC 裡已經開了 Prometheus plugin 的 /prometheus 端點,我讓 Prometheus 定期抓取,再用 Grafana 做兩條告警:
- Jenkins 無回應:抓取失敗或健康檢查逾時就告警。CI 壞掉最討厭的是「你不知道它壞了」,等到要部署才發現。
- agent 離線:controller 的
numExecutors是 0,agent 一掛,所有 build 都排不進去,所以 agent 離線必須告警。
這個端點因為沒有帶驗證(useAuthenticatedEndpoint: false),所以我只讓內網能碰到它,不走對外的 Cloudflare 路徑。
日常操作長什麼樣子
講完設計,也分享一下實際用起來的感覺。我 push 一個 commit 到 main,大約一分鐘內,Slack 就跳出「等待部署核准」,上面有 commit 標題與 SHA 前 7 碼。我點連結進 Jenkins,看一眼測試結果與變更內容,按下「部署」,再過幾十秒(沒有 image 重建的情況下)就收到「部署成功」。整個流程我只需要做一件事:決定要不要上線。其他全部是機器的事。
如果當下不方便,不按也沒關係,30 分鐘後 build 會自動中止,並通知我「未部署(取消或核准逾時)」。要上線時只要重新 push 或手動重跑一次即可。這種「預設不部署、要部署才需要動作」的設計,讓我完全不用擔心有 commit 在我不知情的狀況下上了正式機。
小結
回頭看,從 GitLab CI 換到自架 Jenkins,最大的差別不是工具,而是信任邊界的重新劃分。這套設計的核心原則可以濃縮成幾句:
- 設定即程式碼:JCasC + Job DSL 讓 Jenkins 可以隨時砍掉重建,不怕設定遺失,也沒有人能在 UI 上偷偷改設定。
- controller 不跑 build:
numExecutors: 0,把會執行不可信程式碼的地方與保管 credential 的地方分開。 - 入口最小化:Cloudflare Access 只對 webhook 路徑 bypass,再疊上 GitHub 官方 IP 段與 webhook secret 雙重驗證;job 寫死只建主分支,不處理 fork PR。
- 人工核准是最後一道閘門:記住被測試的 SHA、限時、通知時跳脫 commit message,而且別忘了設定
submitter。 - 部署邏輯放在伺服器端:每個 app 一把 key,
restrict+from=+command=,Jenkins 只能請求部署,驗證 SHA、確認在主分支、拒絕髒工作目錄、ff-only、失敗回滾都由 deploy-app 把關。
如果你也想從 GitLab CI 或手動部署轉過來,我的建議是先從「受限 SSH 部署」做起,它不依賴 Jenkins,換成任何 CI 都能沿用,而且一次把「key 外洩的最壞情況」降到可以接受的程度。接下來我打算補上 image digest 固定、縮小 IMAGE_PATHS、限定核准人這三項改進,等實際跑過一陣子,再來分享後續。
系列文章:自架 VM 維運實戰
保護 → 部署 → 觀測 → 調校,建議依序閱讀:
- 第 1 篇:【資安】Cloudflare Access 實戰:不用 VPN 保護後台與 Grafana
- 第 2 篇:【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH(本篇)
- 第 3 篇:【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警
- 第 4 篇:【監控】Loki + Alloy 集中 Laravel 與 Nginx Log:遮罩與踩坑
- 第 5 篇:【效能】k6 壓測 Laravel Octane:找出 2 核 VM 的真實上限
No comments yet.