CI/CD 十關

LV.9/10

Environments

用 environments 管理 review、staging、production,學會停止環境、鎖住部署與手動上線

進階約 30 分鐘environment、review app、on_stop、resource_group、手動上線

學習目標

  • 用 environment 記錄每次部署,在「Operate → Environments」看得到歷史與網址。
  • 為每個分支開一個 review 環境,並用 on_stop 收掉。
  • 用 resource_group 避免兩條 pipeline 同時部署同一個環境。
  • 正式環境用手動按鈕上線。

觀念

build ─▶ review/<分支>   (每個分支一個,1 天後自動停止,Stop 按鈕執行 stop-review)
      └▶ staging         (只有 main,自動)
            └▶ production(只有 main,手動按下)
設定說明
environment:name環境名稱,可以用變數;review/xxx 的斜線會在介面上分成資料夾
environment:url環境的網址,介面上會出現「Open」按鈕
environment:on_stop停止環境時要執行的 job,那個 job 要寫 action: stop
environment:auto_stop_in多久後自動停止
environment:deployment_tierproduction、staging、testing、development、other
resource_group同名的 job 一次只跑一個,其他的排隊

回復

環境頁面每一筆部署都有「Rollback」按鈕,它會重新執行那一次的部署 job。所以部署 job 必須只依賴自己的產物或映像標籤,重跑舊的 job 就等於部署舊版。Lv.10 用 commit 編號當映像標籤,就是為了這件事。

受保護環境

正式專案建議在「Settings → CI/CD → Protected environments」把 production 設成受保護,只有指定的人能按部署,也可以要求核准。本關為了讓任何人都能練習,沒有設定。

為什麼 production 設成 allow_failure: true

寫在 rules 裡的 when: manual 預設會讓 pipeline 停在「blocked」,直到有人按下。真實專案通常就要這樣。教學專案改成不擋,Lv.1~Lv.9 才能一起顯示通過;按下去一樣會部署。

動手做

  1. push 到 main 後打開「Operate → Environments」,看到 review/main、staging、production。
  2. 按 staging 的「Open」,網頁顯示 staging。
  3. 在 pipeline 按下 production 的播放鍵,環境頁面多一筆部署。
  4. 在 review/main 按「Stop」,會執行 stop-review。

常見錯誤

  • on_stop 的 job 和部署 job 的 rules 不一致:停止按鈕會不見。
  • 停止 job 沒寫 GIT_STRATEGY: none:分支刪掉後停止會失敗。
  • 環境名稱 review/main 帶斜線,拿去 sed "s/…/$CI_ENVIRONMENT_NAME/" 會出現 bad option in substitution expression:分隔符號改用 |(本關第一次實跑就踩到)。
  • 部署 job 用 latest 標籤:Rollback 也只會拿到最新版,等於不能回復。

下一關

Lv.10 把真正的網站建成映像、簽章,經 GitOps 部署到 k8s。

範例檔案

資料夾 lv09-environments/ 的全部檔案。.gitlab-ci.yml 可以直接複製到自己的專案根目錄執行。

lv09-environments/.gitlab-ci.yml
lv09-environments/.gitlab-ci.yml
# Lv.9 Environments:讓每次部署都有紀錄、看得到網址、可以停止與回復
# 這一關的「部署」是把網站放成 job 產物,在 GitLab 上直接打開;Lv.10 才真的部署到 k8s。

stages:
  - build
  - review
  - staging
  - production

default:
  image: python:3.13-alpine

build:
  stage: build
  script:
    - python3 lv09-environments/build.py
  artifacts:
    paths: [lv09-environments/public/]
    expire_in: 1 week

.deploy:
  needs: [build]
  script:
    - echo "部署 $CI_COMMIT_SHORT_SHA 到 $CI_ENVIRONMENT_NAME(等級:$CI_ENVIRONMENT_TIER)"
    - sed -i "s|__ENV__|$CI_ENVIRONMENT_NAME|" lv09-environments/public/index.html   # 環境名稱有斜線(review/main),分隔符號改用 |
  artifacts:
    paths: [lv09-environments/public/]
    expire_in: 1 week

# 每個分支一個 review 環境,網址指到這個 job 的產物
review:
  stage: review
  extends: .deploy
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: $CI_PROJECT_URL/-/jobs/$CI_JOB_ID/artifacts/file/lv09-environments/public/index.html
    on_stop: stop-review       # 環境頁面的「Stop」按鈕會執行這個 job
    auto_stop_in: 1 day        # 一天後自動停止
  resource_group: review-$CI_COMMIT_REF_SLUG

stop-review:
  stage: review
  needs: [review]
  image: alpine:3.22
  when: manual                 # job 層的 manual 預設不擋 pipeline
  variables:
    GIT_STRATEGY: none         # 分支可能已刪除,停止時不要 checkout
  script:
    - echo "移除 review/$CI_COMMIT_REF_SLUG"
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop

staging:
  stage: staging
  extends: .deploy
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  environment:
    name: staging
    deployment_tier: staging
    url: $CI_PROJECT_URL/-/jobs/$CI_JOB_ID/artifacts/file/lv09-environments/public/index.html
  resource_group: staging      # 同一時間只有一個部署,避免兩條 pipeline 互相覆蓋

production:
  stage: production
  extends: .deploy
  needs: [build, staging]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
      # 正式環境通常要擋住 pipeline 等人按(allow_failure: false)。
      # 教學 pipeline 設成不擋,這樣 Lv.1~Lv.9 能一起顯示通過;按下去一樣會部署。
      allow_failure: true
  environment:
    name: production
    deployment_tier: production
    url: $CI_PROJECT_URL/-/jobs/$CI_JOB_ID/artifacts/file/lv09-environments/public/index.html
  resource_group: production
lv09-environments/build.py
lv09-environments/build.py
"""Lv.9 建置:產生一頁顯示「部署到哪個環境」的網站,__ENV__ 由部署 job 換掉。"""
import os
from pathlib import Path

out = Path(__file__).parent / 'public'
out.mkdir(exist_ok=True)
sha = os.environ.get('CI_COMMIT_SHORT_SHA', 'local')
(out / 'index.html').write_text(f'''<!doctype html>
<html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lv.9 環境示範</title>
<style>body{{font-family:system-ui,sans-serif;margin:2rem;background:#F2EEE4;color:#1D2137}}
@media (prefers-color-scheme:dark){{body{{background:#070918;color:#CBD6E5}}}}</style></head>
<body><h1>Lv.9 Environments</h1><p>這一版({sha})部署在:<strong>__ENV__</strong></p></body></html>
''', encoding='utf-8')
print('已產生', out / 'index.html')