Roy-Try-Catch
← Back to list

【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH

Roy • Updated 2026-10-15 10:02:25

前言

之前寫過一篇 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(["&", "<", ">"], ["&amp;", "&lt;", "&gt;"], (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 維運實戰

保護 → 部署 → 觀測 → 調校,建議依序閱讀:

Comments

No comments yet.

請先登入