[實作筆記] 用 GitLab CI/CD 實現自動打 Tag、Build 與 Push Docker Image 到 Registry

前情提要

在專案自動化部署流程中,讓 CI/CD pipeline 自動產生遞增 tag、build Docker image 並推送到 GitLab Container Registry,是現代 DevOps 的常見需求。

這篇文章記錄我在 GitLab CI/CD 上實作這一流程的經驗。

目標

  • 自動產生遞增 tag(如 ver.1.0.1)
  • 自動 build 並 push Docker image,image tag 與 git tag 同步
  • 支援多專案共用同一份 CI 設定
  • 僅 main 分支觸發

實作

  1. 變數抽象化,支援多專案共用

    首先,將專案名稱、群組路徑等資訊抽成變數,方便不同專案複用:

    1
    2
    3
    4
    variables:
    PROJECT_NAME: my-proj
    GROUP_PATH: my-group
    REGISTRY_PATH: registry.gitlab.com/$GROUP_PATH/$PROJECT_NAME
  2. 多階段 Dockerfile 精簡 image

    使用 multi-stage build,確保 production image 只包含必要檔案與 production dependencies:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    FROM node:23-alpine AS builder
    WORKDIR /app
    COPY package*.json ./
    RUN npm install
    COPY . .
    RUN npm run build

    FROM node:23-alpine AS production
    WORKDIR /app
    COPY package*.json ./
    RUN npm install --production
    COPY --from=builder /app/dist ./dist
    ENV NODE_ENV=production
    EXPOSE 3000
    CMD ["npm", "start"]

    建議搭配 .dockerignore 避免多餘檔案進入 image。

  3. GitLab CI/CD Pipeline 設定只在 main 分支執行

    1
    2
    3
    4
    rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    when: always
    - when: never
  4. 自動產生遞增 tag

    1. 採用 major.minor.patch 的版本規則,啟始版號為 ver.1.0.0
    2. 先同步 remote tag,避免本地殘留影響
    3. 取得最大 patch 號,自動 +1
    4. 檢查 tag 是否已存在,確保唯一
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    script:
    - git fetch --prune --tags
    - git tag -l | xargs -n 1 git tag -d
    - git fetch --tags
    - |
    prefix="ver."
    max_patch=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | awk -F. '{print $3}')
    if [ -z "$max_patch" ]; then
    new_tag="${prefix}1.0.0"
    else
    major=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | cut -d. -f1)
    minor=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | cut -d. -f2)
    patch=$((max_patch + 1))
    new_tag="${prefix}${major}.${minor}.${patch}"
    fi
    # 確保 tag 唯一
    while git rev-parse "$new_tag" >/dev/null 2>&1; do
    patch=$((patch + 1))
    new_tag="${prefix}${major}.${minor}.${patch}"
    done
    echo "new tag is $new_tag"
    echo "NEW_TAG=$new_tag" >> build.env
    - source build.env
  5. 自動打 tag 並 push

    1
    2
    3
    4
    5
    - git config --global user.email "[email protected]"
    - git config --global user.name "gitlab-runner"
    - git remote set-url origin https://gitlab-runner:[email protected]/$GITLAB_REPO.git
    - git tag "$NEW_TAG"
    - git push origin "$NEW_TAG"
  6. Loging、Build & Push Docker image

    1
    2
    3
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
    - docker build -t $REGISTRY_PATH:$NEW_TAG .
    - docker push $REGISTRY_PATH:$NEW_TAG
  7. Docker-in-Docker 設定

    DOCKER_TLS_CERTDIR='' 是為了讓 dind 關閉 TLS,讓 CI job 可以直接用明文 TCP 連線 docker daemon,避免 TLS 憑證錯誤。

    1
    2
    3
    4
    5
    services:
    - docker:dind
    variables:
    DOCKER_HOST: tcp://docker:2375/
    DOCKER_TLS_CERTDIR: ''

    可以參考本文

  8. 一些要注意的小問題

  • DOCKER_TLS_CERTDIR 不能不設定。

  • tag 跳號或重複?
    請務必先刪除本地 tag 再 fetch remote tag。

  • 無法 push tag?
    請確認 Deploy Token/PAT 權限,且 remote url 正確。

  • docker build 失敗,daemon 連不到?
    請檢查 gitlab runner 是否有 privileged mode,且 dind 有啟動。
    /etc/gitlab-runner/config.toml

    1
    2
    3
    4
    5
    6
    [[runners]]
    name = "docker-runner"
    executor = "docker"
    [runners.docker]
    privileged = true
    ...

小結

完整 .gitlab-ci.yml 範例如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
stages:
- build
variables:
PROJECT_NAME: my-proj
GROUP_PATH: my-group
REGISTRY_PATH: registry.gitlab.com/$GROUP_PATH/$PROJECT_NAME

build-image:
image: docker:latest
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375/
DOCKER_TLS_CERTDIR: ''
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: always
- when: never
script:
- git fetch --prune --tags
- git tag -l | xargs -n 1 git tag -d
- git fetch --tags
- |
prefix="ver."
max_patch=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | awk -F. '{print $3}')
if [ -z "$max_patch" ]; then
new_tag="${prefix}1.0.0"
else
major=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | cut -d. -f1)
minor=$(git tag -l "${prefix}[0-9]*.[0-9]*.[0-9]*" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 | cut -d. -f2)
patch=$((max_patch + 1))
new_tag="${prefix}${major}.${minor}.${patch}"
fi
while git rev-parse "$new_tag" >/dev/null 2>&1; do
patch=$((patch + 1))
new_tag="${prefix}${major}.${minor}.${patch}"
done
echo "new tag is $new_tag"
echo "NEW_TAG=$new_tag" >> build.env
- source build.env
- git config --global user.email "[email protected]"
- git config --global user.name "rag-cicd"
- git remote set-url origin https://rag-cicd:[email protected]/$GITLAB_REPO.git
- git tag "$NEW_TAG"
- git push origin "$NEW_TAG"
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
- docker build -t $REGISTRY_PATH:$NEW_TAG .
- docker push $REGISTRY_PATH:$NEW_TAG

這樣設定後,每次 main 分支有 commit,CI/CD 就會自動產生新 tag、build 並推送對應版本的 Docker image 讓大家共用。

(fin)

[實作筆記] GCP Cloud Run 私有化部署:透過 Load Balancer 實現內網存取與 IP 限制

前情提要

我用 cloud run 建立一個 api 並且有 webhook 的功能
並且希望提供一個對外網址給客戶,而不是 *.run.app 結尾的網址。

在某些場景下,我們不希望 GCP Cloud Run 對外暴露,

例如內部 API Gateway 呼叫、CI/CD 系統內部流程、或僅提供給 VPC 內特定服務存取的微服務。

這次的例子是希望加上 IP 的防護,只允許特定的 IP 呼叫,這時候就會希望:

  • Cloud Run 不對外公開(Private)

  • 僅允許 內網 IP 或 Internal Load Balancer 存取

看似簡單,但 GCP Cloud Run 原生是無伺服器架構,預設就是「公開網址」,要讓它只對內部可見,需要搭配一些 GCP 網路元件操作。這篇筆記會帶你逐步設定,並避開一些常見坑洞。

實作記錄

  1. 將 Cloud Run 設為「不公開」
    預設 Cloud Run 是公開的,要改為「只有授權的主體可以存取」:

    1
    2
    3
    4
    5
    6
    gcloud run services update YOUR_SERVICE \
    --ingress internal-and-cloud-load-balancing \
    --allow-unauthenticated \
    --region YOUR_REGION \
    --project YOUR_PROJECT

    這樣只有內部的網路才能呼叫 Cloud Run。

    也可以透過 GUI 設定,選擇你的 Cloud Run >

    Networking > Ingress 選 internal 勾選 Allow Traffic from external Application Load Balancers

    可以參考

  2. 建立 Serverless NEG(Network Endpoint Group)
    可以用 GUI 建立 Loading Blancer >

    Edit > Backend configuration >

    下拉選單選 Create backend service >

    Backend Type 選 Serverless network endpoint group

    因為是 Cloud Run

  3. 設定 Loading Blancer 的 Backend Service”
    LB > Edit backend service

    Regions 選擇 Cloud Run 所在的 Region

    預設會有一組 Security Policy

    Cloud Armor policies > 找到這組 Policy 可以作更細緻的設定,Ex:指定 Ip 呼叫

  4. Loading Blancer URL Map
    這是一個額外的設定,情境是原本我已有一組 Domain 與 Backend Service

    並且已經設定在 LB 上面,在想要共用 Domain 的情況下,我需要設定 URL Map

    保留一組 Path 讓流量打向 Cloud Run,但仍保留其他 API 可以用

    LB > Edit > Routing rules > Mode 選擇 Advanced host and path rule

    找到指定的 Domain,設定參考如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    defaultService: projects/my-gcp-proj/global/backendServices/my-api
    name: path-matcher-7
    pathRules:
    - paths:
    - /api/v1/*
    service: projects/my-gcp-proj/global/backendServices/my-api
    - paths:
    - /cloud-run-app/*
    service: projects/my-gcp-proj/global/backendServices/my-cloud-run-app
    routeAction:
    urlRewrite:
    pathPrefixRewrite: /

    以上設定簡單說明如下,

    • /api/v1/*:導向 my-api,保留原始路徑。
    • /cloud-run-app/*:導向 my-cloud-run-app,並將路徑重寫為去除 /cloud-run-app 前綴。
    • 其他路徑:預設導向 my-api。

    這樣設定可讓多個服務共用同一個負載平衡器並支援乾淨的 URL 管理。

參考

(fin)

[實作筆記] 我的 Vim 設定

我的 Vim 快捷鍵設定總表,IdeaVim 與 JetBrain


快捷鍵 模式 功能說明 IdeaVim 設定寫法
zcc n 清除所有自訂映射 nmap zcc :mapclear!<CR>
zso n 重新載入主 vimrc nmap zso :source ~/_ideavimrc<CR>
zf n/i 跳到定義(Goto Declaration) nmap zf :action GotoDeclaration<CR>
imap zf <Esc>:action GotoDeclaration<CR>
zrc, zo n/i 格式化程式碼(Reformat Code) nmap zrc :action ReformatCode<CR>
imap zrc <Esc>:action ReformatCode<CR>
nmap zo :action ReformatCode<CR>
zk n/i 返回(Back) nmap zk :action Back<CR>
imap zk <Esc>:action Back<CR>
zj n/i AceAction(自訂動作) nmap zj :action AceAction<CR>
imap zj <Esc>:action AceAction<CR>
ztt n/i 複製區塊並格式化(自訂巨集) nmap ztt y?[F<CR>/{<CR>%o<Esc>p:action ReformatCode<CR>
imap ztt <Esc>y?[F<CR>/{<CR>%o<Esc>p:action ReformatCode<CR>
zn n/i 跳到上一個錯誤(Goto Previous Error) nmap zn :action GotoPreviousError<CR>
imap zn <Esc>:action GotoPreviousError<CR>
zrr n/i 重新命名(Rename Element) nmap zrr :action RenameElement<CR>
imap zrr <Esc>:action RenameElement<CR>
zra n/i 執行所有單元測試 nmap zra :action RiderUnitTestRunSolutionAction<CR>
imap zra <Esc>:action RiderUnitTestRunSolutionAction<CR>
zrm n/v 萃取方法(Extract Method) nmap zrm :action ExtractMethod<CR>
vmap zrm :action ExtractMethod<CR>
zri n Inline(內聯變數/方法) map zri :action Inline<CR><Esc>
zrp n Introduce Parameter(引入參數) map zrp :action IntroduceParameter<CR>
zrv n Introduce Variable(引入變數) map zrv :action IntroduceVariable<CR>
zrf n Introduce Field(引入欄位) map zrf :action IntroduceField<CR>
,m n 顯示檔案結構 nmap ,m :action FileStructurePopup<CR>
zrt n 最近檔案 nmap zrt :action RecentFiles<CR>
zgc n 開啟 Commit 工具視窗 nmap zgc :action ActivateCommitToolWindow<CR>
zx n 快速重構選單 nmap zx :action Refactorings.QuickListPopupAction <CR>
zae n/i 插入 Assert.Equal(,); nmap zae aAssert.Equal(,);<Esc>T(i
imap zae Assert.Equal(,);<Esc>T(i
zrs n 新增 class 並格式化(自訂巨集) nmap zrs "add? class<CR>2w"bywjopublic <Esc>"bpa()<CR>{<Esc>"apo}<Esc>:action ReformatCode<CR>
z; n/i 行尾加分號 nmap z; $a;<Esc>
imap z; <Esc>$a;
,p, ,P n 黏貼暫存區 0 的內容 nmap ,p "0p
nmap ,P "0P
z,p, z,P i 插入暫存區 0 的內容 imap z,p <Esc>"0pa
imap z,P <Esc>"0Pa
z, n 選取括號內內容(vi)) :nmap z, vi)
z. n 選取大括號內內容(vi}) :nmap z. vi}
v 連續多次離開選取模式 :vmap <Esc> <Esc><Esc><Esc>
jj i 退出插入模式 :imap jj <Esc>
n 在普通模式下插入 Backspace :nmap <BS> a<BS>
zh n/i 跳到行首 :nmap zh ^
:imap zh <Esc>^i
zl n/i 跳到行尾 :nmap zl $
:imap zl <End>
hc n Ctrl+C :nmap hc ^C
zb n/v 單字取代 :nmap zb bcw
:vmap zb <Esc>bcw
zd i 刪除當前行 :imap zd <Esc>dd
j, k n 軟換行移動 nmap j gj
nmap k gk
qq n 強制關閉檔案 nmap qq ZQ
zq n 儲存並關閉檔案 nmap zq :wq<CR>
i 刪除當前行 :imap <C-x> <Esc>dd
i 全選 :imap <C-a> <Esc>ma<CR>ggVG
,i, ,a n 替換字元 :map ,i <Esc>r
:map ,a <Esc>r

說明:

  • n:普通模式(normal mode)
  • i:插入模式(insert mode)
  • v:視覺模式(visual mode)
  • <CR> 代表 Enter 鍵,<Esc> 代表 Esc 鍵

以下是我將 JetBrains(IdeaVim)Vim 快捷鍵移植到 VSCode(VSCodeVim)的對應表,
並說明哪些已成功移植、哪些無法移植及原因:


JetBrains 快捷鍵 VSCode 設定 (before) VSCode 指令 (commands) 功能說明 可否移植 備註/原因
zrr [“z”,”r”,”r”] editor.action.rename 重新命名 完全支援
zf [“z”,”f”] editor.action.revealDefinition 跳到定義 完全支援
zrc/zo [“z”,”r”,”c”]/[“z”,”o”] editor.action.formatDocument 格式化程式碼 完全支援(你已設 zrc,zo 也可加)
zn [“z”,”n”] editor.action.marker.prev 跳到上一個錯誤 完全支援
zrt [“z”,”r”,”t”] workbench.action.openRecent 最近檔案 完全支援
,m [“,”,”m”] workbench.action.gotoSymbol 檔案結構/大綱 完全支援
zx [“z”,”x”] editor.action.refactor 顯示重構選單 完全支援
zgc [“z”,”g”,”c”] workbench.view.scm 開啟 Source Control 面板 完全支援
zh [“z”,”h”] cursorHome 跳到行首 完全支援
zl [“z”,”l”] cursorEnd 跳到行尾 完全支援
zrv [“z”,”r”,”v”] editor.action.codeAction.extract.variable 萃取變數 ⚠️ VSCodeVim 有選取限制,部分情境下無法觸發
zrm [“z”,”r”,”m”] editor.action.codeAction.extract.method 萃取方法 ⚠️ VSCodeVim 有選取限制,部分情境下無法觸發
zri - - Inline(內聯變數/方法) VSCode 沒有公開指令 ID,無法 keybinding
zrp - - Introduce Parameter VSCode 沒有公開指令 ID,無法 keybinding
zrf - - Introduce Field VSCode 沒有公開指令 ID,無法 keybinding
ztt - - 巨集/多步驟自動化 VSCode 不支援 Vim 巨集或多步驟自動化
zso - - 重新載入 vimrc VSCodeVim 不支援 :source 或重新載入 vimrc
zra [“z”,”r”,”a”] testing.runAll 執行所有單元測試 需安裝 VSCode 官方 Testing 功能,已可 keybinding

補充說明

  • ✅:完全支援,已可用於 VSCodeVim。
  • ⚠️:VSCodeVim 有技術限制(如需選取內容,觸發不一定成功)。
  • ❌:VSCode/VSCodeVim 無對應指令或功能,無法移植。
  • 你可以持續用 VSCode 內建的 Cmd+. 或右鍵選單補足無法 keybinding 的重構功能。
  • zra:VSCode 1.59 以上內建 Testing 功能,指令為 testing.runAll,可直接 keybinding,無需額外外掛。

總結:
我已成功將大部分常用 JetBrains Vim 快捷鍵移植到 VSCode,
僅少數 JetBrains 專屬重構、巨集、vimrc 相關功能因 VSCode 限制無法移植。

(fin)

[學習筆記] TypeScript 不同寫法的除錯難易比較

前情提要

最近在整理一個專案的 API 路由權限設定,
對同樣的資料結構、不同的寫法,對於型別檢查和除錯有一點小小心得,稍微作個記錄。

情境說明

假設我有一個 API 權限設定如下,每個路由對應一組角色才能訪問:

1
2
3
4
{
endpoint: "/admin",
roles: ["admin", "superuser"]
}

然後我有一個函式 registerPermissions 要吃這個設定。

建議寫法

先給最近建議的寫法,先定義型別,然後變數也套型別
好處是能具體的抓到錯誤,壞處是要多考慮一個型別(命名、擺放位置都會是一個要考量的點)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type PermissionConfig = {
routes: {
endpoint: string;
roles: string[];
}[];
};

const permissionConfig: PermissionConfig = {
routes: [
{
endpoint: "/admin",
roles: ["admin", "superuser"],
},
{
endpoint: "/profile",
roles: "user", // ❌ 馬上紅字報錯
},
],
};

const registerPermissions = (config: PermissionConfig) => {
// 註冊權限邏輯
};

registerPermissions(permissionConfig);

先定義變數,但沒套型別

這是最不推薦的寫法。雖然寫起來最快、最順手,不用考慮命名和檔案擺放的位置,但錯誤訊息會一大坨,難以閱讀。

在現在這個 AI 時代,也許 AI 能幫你更快理解錯誤,但我還是不建議這麼做。因為一旦習慣這種寫法,很容易錯上加錯。單一錯誤 AI 還能理解,但錯中錯時,AI 也可能會搞不清楚狀況。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const permissionConfig = {
routes: [
{
endpoint: "/admin",
roles: ["admin", "superuser"],
},
{
endpoint: "/profile",
roles: "user", // 🧨 這裡故意寫錯,應該是 array
},
],
};

const registerPermissions = (config: {
routes: {
endpoint: string;
roles: string[];
}[];
}) => {
// 註冊權限邏輯
};

registerPermissions(permissionConfig);

這種寫法會噴出一堆錯誤。

寫法三:直接 inline 傳入函式

如果沒有共用必要的參數,我通常會推薦 inline 寫法。當然前提是這個方法的複雜度要夠低,讓人容易理解。這樣也可以省去命名和型別檔案擺放位置的煩惱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const registerPermissions = (config: {
routes: {
endpoint: string;
roles: string[];
}[];
}) => {
// 註冊權限邏輯
};

registerPermissions({
routes: [
{
endpoint: "/admin",
roles: ["admin", "superuser"],
},
{
endpoint: "/profile",
roles: "user", // ❌ 馬上紅字報錯
},
],
});

小結:推薦寫法

最推薦第一種,但實務上我可能會更多的選擇第三種寫法。
要有上下文,請團隊作好自已的判斷。

(fin)

[實作筆記] Linux NO Password 執行指令

前情提要

最近在部署某個 webapi server,
這個 webapi 會用一個特別的帳號去執行,但是不會給他設定密碼,
我需要使用 pm2 startup 設定開機自動啟動 webapi 服務。

問題:執行指令會要求輸入密碼

但是我沒有也不打算提供密碼給個 webapi 服務帳號

1
2
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u marsen --hp /home/marsen
[sudo] password for marsen:

嘗試解法:用 visudo 寫 NOPASSWD

為了讓 aiplux 帳號執行特定指令時不需要輸入密碼,可以透過 visudo 編輯 sudoers 檔案。以下是具體步驟:

  1. 開啟 sudoers 編輯器:

    1
    sudo visudo
  2. 在檔案中新增以下內容:

1
aiplux ALL=(ALL) NOPASSWD: /usr/lib/node_modules/pm2/bin/pm2

這樣的設定可以確保 aiplux 帳號在執行 /usr/lib/node_modules/pm2/bin/pm2 時不需要輸入密碼,同時避免開放過多權限。

小心資安風險

上面的作法是改自另一位同事的作法,

1
2
echo "marsen ALL=(ALL) NOPASSWD: /usr/bin/apt, /usr/bin/apt-get, /usr/bin/env" | sudo tee /etc/sudoers.d/marsen >/dev/null
chmod 440 /etc/sudoers.d/marsen

這段設定的目的是:

  1. 允許 marsen 自由使用 aptapt-get 安裝套件。
  2. 使用 env 包裝 PATH 或其他環境變數來執行 pm2
  3. 避免密碼卡住,讓 CI/CD 流程順利執行。

雖然給足夠的權限,可以無密碼執行,但是也帶來很多潛在風險。

資安問題:/usr/bin/env 是個危險洞口

看起來只是想加環境變數用 env,但其實這一條非常危險。為什麼?來看看 env 的本質:

1
2
3
sudo /usr/bin/env bash
sudo /usr/bin/env node
sudo /usr/bin/env python

這些指令會用 root 權限執行對應的 shell 或程式,等於你給了 marsen 完整 root 執行任意程式的能力,這十分危險。

再看 /usr/bin/aptapt-get,同樣是高權限指令。如果沒有限制,也可以被濫用來刪除套件或改動系統。

小結

審慎選擇開放的指令
在自動化流程中,為了避免 sudo 密碼擋路,設定 NOPASSWD 是常見的解法。
但在設定時,務必審慎選擇開放的指令範圍,避免一時圖方便,打開整台主機的後門。
建議只開放必要的指令,並明確指定路徑,確保安全性。

(fin)

[踩雷筆記] CORS 錯誤的元兇竟然是瀏覽器外掛?

前情提要

最近在本機開發一支簡單的上傳 API,使用 Express.js 搭配 TypeScript。
預期只是在 local 上做個整合測試,前端跑在 localhost:5173,後端是 localhost:3000,照理來說只要 CORS 設好就萬事 OK。

結果——炸了。

問題現象

瀏覽器跳出熟悉的錯誤訊息:

1
Access to fetch at 'http://localhost:3000/upload' from origin 'http://localhost:5173' has been blocked by CORS policy...

一看就是 CORS 錯誤,老問題了,馬上開始排查:

  • 確認 cors() middleware 有設好
  • 檢查 headers 有無正確設定
  • 懷疑是 OPTIONS preflight 沒處理好,也做了補強

怎麼改都一樣。

接著我試了無痕模式,欸?沒事了。
懷疑是瀏覽器快取作怪,清除後還是一樣。
換用 Edge(沒裝任何外掛)測試,正常。

真相大白

這時我就卡關了,因為一切設定看起來都沒問題。

隨口問了新人同事,提到有可能是瀏覽器外掛的問題,我再測試一下

確定是我在 Brave 安裝的外掛「Page Assist - A Web UI for Local AI Models」在搞鬼!

這個外掛可能會偷偷注入 JS 或改寫 request header,導致 CORS 預檢請求出現異常,讓瀏覽器誤以為是 server 設定問題。

我一停用外掛,再次測試,一切正常。

心得與提醒

這次的經驗讓我再次體會:

最難 debug 的錯誤,往往來自你「以為不會出錯」的地方。

給未來的自己幾個建議:

  • 開無痕模式測試:能快速排除快取與外掛的干擾
  • 換乾淨的瀏覽器:有時候 Edge 或 Safari 能救你一命
  • 早點求救:卡住就問,別一個人浪費太多時間在錯的地方打轉,別人可以給你不同觀點

這類奇怪的問題其實很常見,但也正因為難以複製與預期,才更值得記錄下來。

下次遇到奇怪的 CORS 錯誤時,可以考慮是裝的外掛在搞事。

(fin)

[實作筆記] Node.js Express 錯誤處理全攻略:同步、非同步與 Stream Error

前情提要

Node.js + Express 是開發 REST API 時最常見的組合。
這篇文章會試著寫出三大常見錯誤類型的處理方式:

  • 同步錯誤(throw)
  • 非同步錯誤(async/await)
  • Stream error(例如檔案下載)

如果你用的是 Express 5,其實已經支援原生 async function 錯誤攔截,連 async wrapper 都可以省下!

Express 的錯誤處理 middleware 是怎麼運作的?

只要你呼叫 next(err),Express 就會跳到錯誤處理 middleware:

1
2
3
4
app.use((err, req, res, next) => {
console.error(err)
res.status(500).json({ message: err.message })
})

這個 middleware 需要有四個參數,Express 會自動辨識它是錯誤處理 middleware。

同步錯誤怎麼處理?

這種最簡單,直接 throw 就可以:

1
2
3
app.get('/sync-error', (req, res) => {
throw new Error('同步錯誤')
})

Express 會自動幫你捕捉,送進錯誤 middleware,不需要特別處理。

Express 4 async/await 的錯誤要小心

Express 4 的情況:
你可能會寫這樣的 code:

1
2
3
app.get('/async-error', async (req, res) => {
throw new Error('async/await 錯誤') // ❌ 不會被 Express 捕捉
})

這樣寫會直接讓整個程式 crash,因為 Express 4 不會自動處理 async function 裡的錯誤。

解法:自己包一層 asyncWrapper

1
2
3
export const asyncWrapper = (fn) => (req, res, next) => {
fn(req, res, next).catch(next)
}

使用方式:

1
2
3
app.get('/async-error', asyncWrapper(async (req, res) => {
throw new Error('async/await 錯誤')
}))

這樣就能安全把錯誤丟給錯誤 middleware 處理。

Express 5:原生支援 async function

如果你用的是 Express 5,那更簡單了,直接寫 async function,Express 就會自動捕捉錯誤,完全不需要 async wrapper!

1
2
3
4
5
app.get('/async-error', async (req, res) => {
const data = await getSomeData()
if (!data) throw new Error('找不到資料')
res.json(data)
})

是不是清爽多了?

!!注意:要確認你的專案使用的是 [email protected] 版本。
可以用以下指令確認

1
npm list express

Stream error:Express 捕不到

問題在哪?
stream 錯誤是透過 EventEmitter 的 ‘error’ 事件傳遞,Express 根本不知道有這回事,例如:

1
2
3
4
app.get('/file', asyncWrapper(async (req, res) => {
const file = getSomeGCSFile()
file.createReadStream().pipe(res) // ❌ 權限錯誤會 crash,Express 不會接到
}))

正確作法:自己監聽 ‘error’,再丟給 next()**

1
2
3
4
5
6
app.get('/file', asyncWrapper(async (req, res, next) => {
const file = getSomeGCSFile()
const stream = file.createReadStream()
stream.on('error', next)
stream.pipe(res)
}))

Bonus:包成一個工具函式

1
2
3
4
5
6
7
8
// utils/streamErrorHandler.ts
import { Response, NextFunction } from 'express'
import { Readable } from 'stream'

export function pipeWithErrorHandler(stream: Readable, res: Response, next: NextFunction) {
stream.on('error', next)
stream.pipe(res)
}

使用:

1
2
3
4
app.get('/file', asyncWrapper(async (req, res, next) => {
const file = getSomeGCSFile()
pipeWithErrorHandler(file.createReadStream(), res, next)
}))

整體範例程式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import express from 'express'
import { asyncWrapper } from './utils/asyncWrapper'
import { pipeWithErrorHandler } from './utils/streamErrorHandler'

// 模擬 GCS 檔案物件
function getSomeGCSFile(shouldError: boolean) {
const { Readable } = require('stream')
if (shouldError) {
// 會 emit error 的 stream
const stream = new Readable({
read() {
this.emit('error', new Error('stream error 測試'))
}
})
return {
createReadStream: () => stream
}
} else {
// 正常回傳資料
return {
createReadStream: () => Readable.from(['Hello World'])
}
}
}

const app = express()

// 同步錯誤
app.get('/sync-error', (req, res) => {
throw new Error('同步錯誤')
})

// 非同步錯誤(Express 4 寫法)
app.get('/async-error-4', asyncWrapper(async (req, res) => {
throw new Error('async/await 錯誤, Express 4.x 以下的寫法')
}))

// 非同步錯誤(Express 5 寫法)
app.get('/async-error', async (req, res) => {
throw new Error('async/await 錯誤')
})

// stream error(正確處理)
app.get('/file', asyncWrapper(async (req, res, next) => {
const file = getSomeGCSFile(true)
pipeWithErrorHandler(file.createReadStream(), res, next)
}))

// stream error(沒處理會 crash)
app.get('/file-no-handle', asyncWrapper(async (req, res, next) => {
const file = getSomeGCSFile(true)
file.createReadStream().pipe(res)
}))

// 錯誤 middleware
app.use((err, req, res, next) => {
console.error(err)
res.status(500).json({ message: err.message })
})

app.listen(3000, () => {
console.log('Server started')
})

如果你用的是 Express 5,可以把 asyncWrapper 都拿掉,程式碼會更簡潔!

小結

類型 會自動處理? 解法
同步錯誤 ✅ Express 自動處理 直接 throw 就好
async/await 錯誤 ❌ Express 4 不會處理 用 asyncWrapper(Express 4)
✅ Express 5 自動處理 或直接寫 async function(Express 5)
Stream error ❌ 完全不會處理 監聽 stream.on(‘error’, next)

參考

(fin)

[生活筆記] 我們不再需要碼農??

前情提要

2023 當主管到今天的一些體悟, 也感受到一些風向的轉換,
先說結論,在3~5年內我們不需要低階工程師了。
軟技能與 AI 應用能力高的業務、年輕人可以輕易的取代掉這一層人。

人物與事件

以下內容為個人觀察與經驗分享
如有雷同,純屬巧合,無意影射任何特定個人或團體。

不願意嚐試新技術,守成者

某些特定技術持有者,主要的技術用得很熟,但對於新技術完全不感興趣,拒絕學習。
理由是「這些技術不穩定,還是用熟悉的比較好」。
或是技術框架很小,只能處理前端或是資料庫…諸如此類
時代在變,特定技能的需求降低之,或許只能請他離開(業務性質變更,有減少勞工之必要,又無適當工作可供安置時。)

當團隊需要他協助帶新人時,他的方式是「放著讓他看 Code」,完全不願意提供指導,導致新人進步緩慢。
當團隊需要他改善流程時,他的方式以拖待變,不推動事務改變。

沒事就睡覺,睡魔

經常在工作時間睡覺,對專案進度毫不關心,最終因為態度問題被老闆開除。
明明就有一定的技術力,但可能人生已經無慾無求,沒有動力。
上班只是來打發時間,像在等待什麼結束的信號。
每天坐在電腦前,螢幕亮著,眼神卻空洞,彷彿身體還在職場,靈魂早已辭職。
他對看板的任務視而不見,對系統通知的提醒習以為常,
彷彿這些專案任務與他毫無關聯。
工作到一半,頭一低好像走了…

紙上談兵學院派,研究生

這位工程師基本衛生不佳,雖然後口條很好但是有嚴重口臭。
經常在會議中侃侃而談,除了氣味不佳外,實際執行與說得也有很大的差距。
說一套作一套,程式雖然能運行,但結構混亂,難以維護,不要說交接,過個幾周他的程式他自已就看不懂。  

此外連基本的誠實都作不到,有一例是曾經把奶茶打翻到筆電上造成損毀。  
本來不是多大的錯誤,但問他有沒有什麼可能性時,完全沒有提及。
電腦後送修有人為毀損証據才承認;  
不論是遺忘或刻意不提都是反應自私與不負責的表現,令人無法信賴。

你沒說站起來要用腳,被動的小寶寶

這類型的工程師在面對複雜任務時,總是無法將大目標拆解成可執行的小步驟,導致工作不透明,副作用就是進度緩慢且混亂。

只會按照指令執行,缺乏主動思考與解決問題的能力。
你下的指令要十分精確,依他所見,所有的錯都是別人的錯,
只要有人給他夠精確的指令,他才不會失敗。
當遇到需要延伸思考的情境時,無法舉一反三或觸類旁通,反而責怪別人沒有說清楚。

舉例來說,當主管要求他修復一個功能時,他只會修復表面問題,卻忽略了相關的邏輯錯誤或潛在的風險,
最終導致問題反覆出現,越處理越模糊。

這樣的人往往需要主管或同事不斷提醒與協助,無法獨立完成任務。
更嚴重的是,他們常常拖累高效率的同事,形成能者過勞的團隊氛圍。

工作拆解的 Cynefin

別人都爛扣,就我神乎奇技

他愛挑別人的錯,卻聽不得一句建議。
自己寫的程式像拼圖,混亂又難維護,還自誇是「靈活不受限的寫法」。

碰到維運就閃,遇到 bug 就推,專案落後了,只會說:「一開始規劃就有問題吧。」
從不提解法,只有抱怨。

團隊在前進,他卻像錨,把整艘船拖住。
當指出他的問題,還會氣急敗壞發火。讓整個氣氛更加尷尬。

看不懂英文文件還想當工程師

花了三周搞不定一個 API, 每次都說這個那個多複雜,  
別人(還是兩位)1小時就搞定。
最後才承認英文看不懂。一是不懂承諾,二是卡住不求救

這樣的情況怎麼能不讓人失望?明明一開始就知道自己有困難,卻硬是選擇隱瞞,
這樣的態度才是最大的問題。不求助,讓問題像滾雪球一樣越滾越大。

問題不在於他無法解決,而在於他選擇逃避,選擇不與人合作,甚至不願意承認自己的困境。
這種自我封閉的態度,讓他錯失更多學習的機會也讓團隊承擔額外的風險。

(fin)

[生活筆記] First Trade 開卡記錄

前情提要

First Trade Debit 卡是一張簽帳金融卡,與信用卡不同,它直接從投資帳戶扣款,不能超額消費。
這張卡可在全球任何支援 VISA 的商店與 ATM 使用,方便在美國內外進行交易與提款。
First Trade 可以在線上申請,網站上說約一個月的工作日,我大概10天左右收到。

開卡

卡片包裹內會附上開卡說明,但如果看不懂,直接跟著以下步驟操作就好。

  1. 撥打開卡電話
    使用國際電話撥打 First Securities 客服專線:002 1-855-253-1057
    電話錄音為英文,但全程無需說話,只需依指示輸入數字即可。

  2. 開卡流程
    輸入16位卡號:直接輸入卡片正面16位數,不需要按 #。
    輸入社會安全碼(SSN):輸入 5436。
    輸入生日資訊:
    格式為「MMYY」,例如 1985年2月生 → 輸入 0285。(特別注意不要輸入成月日)
    輸入卡片背面安全碼(CVV):3 位數的安全碼,位於卡片背面。
    設定提款/交易密碼:輸入 4 位數密碼。
    確認密碼:再次輸入相同密碼。

以上完成開卡,約1分鐘內可以完成

注意事項

Firstrade Debit 卡適合那些在 Firstrade 賬戶中有至少 $25,000 存款的投資者。
它可全球使用,可以用來提款與消費,並且每月首次提款免費。
依據文章說明,每個月第一次的使用(提領或消費)可享跨境手續費返還。
申請人需開設現金管理賬戶並保持最低存款。
需要注意的是,每日提款限額為 $1,000,並且需要保持最低餘額。
超過就要收 3% 手續費。
感覺實用性不高,或許在國外緊需現金時可以用來應急。

參考

(fin)

[實作筆記] 設定 VSCode 外掛 CLine(MCP Client) Azure Open AI 的踩雷筆記

前情提要

MCP(Model Context Protocol)是一種協議,最近社群很夯的東西,
所以稍微研究它一下,不論它是否只是個 Buzz word。

Cline 是開源 AI 程式設計助理,可以外掛至 VS Code,具計劃/執行模式,支援 MCP 協議,
所以我選擇它當作入門的工具之一。

問題筆記

使用 VSCode 與 Cline 作為起點時,一個雷點就是使用 Azure Open AI 用戶,究竟應該怎麼設定?
此處可以看到很多人的討論。
看來有很多人也常常撞牆,雖然無法全面的測試一輪,
但以下是我對 Azure OpenAI 成功的設置,記錄下來作為以後參考用

API Provider 選擇 OpenAI Compatible
Base URL 我直接從 Azure AI Foundry 上取出:
https://portal.azure.com/ 找到 Azure AI services | Azure OpenAI,再前往 Azure AI Foundry Portal
此時你應該可以在 Deployments 找到你的 Targe URI,類似如下
https://{resourcename}.openai.azure.com/openai/deployments/{deployment_name}/chat/completions?api-version={apiversion}
API Key 就不用多說了,Azure 會提供你兩把金鑰,如果有異常時可以替換它們,
Model ID 可以在 deployments > Model name 點擊連結後找到,會類似下面這樣

azureml://registries/azure-openai/models/gpt-4o/versions/2024-11-20

參考

(fin)

Please enable JavaScript to view the LikeCoin. :P