[實作筆記] Ollama 容器化調研筆記

前情提要

最近在搞 AI 模型的本地部署,並試著將 Ollama 容器化。
經過一段時間的調研和實測,想記錄一下目前的進度和對幾個主流方案的看法。

目標

  • 找到穩定的 Ollama 容器化方案
  • 評估各種新興工具的可用性
  • 為未來的產品開發做技術預研

Ollama 簡介

Ollama 是一個開源工具,讓你在本地電腦上輕鬆運行大型語言模型(如 Llama、CodeLlama、Mistral 等)。
安裝簡單,一行指令就能下載和運行各種 AI 模型,無需複雜設定。支援 macOS、Linux 和 Windows。

1
2
#  安裝後直接用
ollama run llama3.2

類似 Docker 的概念,但專門為 AI 模型設計。
還提供 REST API:

1
2
3
# 啟動後自動開啟 API server (預設 port 11434)
curl http://localhost:11434/api/generate \
-d '{"model": "llama3.2", "prompt": "Hello"}'

沒有 Ollama 的話,你需要:

  • 手動下載模型檔案(通常好幾 GB)
  • 處理不同模型格式(GGUF、GGML 等)
  • 設定 GPU 加速環境
  • 寫代碼載入模型到記憶體
  • 處理 tokenization 和 inference
  • 自己包 API server

Ollama 把這些都幫你搞定了。

選擇

目前採用:Ollama Container + 指令初始化

經過評估,選擇 ollama container 方案最穩定且彈性也最高,

只要需要透過指令的方法來就可以來取模型。

基本用法:

1
2
3
4
5
# 啟動 ollama container
docker run -d --name ollama -p 11434:11434 ollama/ollama

# 下載模型
docker exec -it ollama ollama pull llama3.2

Docker Model Runner:值得關注但暫不採用

DMR 在 2025 年 3月隨 Docker Desktop 4.40 推出 Beta 版,

看起來很有潛力,但目前不打算用在產品上。

主因如下

  • 平台支援階段性:最初只支援 Apple Silicon (M1-M4),5月的 Docker Desktop 4.41 才加入 Windows NVIDIA GPU 支援
  • 實驗階段:工具變化快速,預期會持續改進,對產品開發風險較高
  • Linux 支援:目前在 Linux (包含 WSL2) 上支援 Docker CE,但整體生態還在發展中

會持續觀察,最新的 Docker Desktop 4.42 甚至支援了 Windows Qualcomm 晶片,發展很快。

Podman AI Lab:概念不錯但有使用限制

Podman AI Lab 提供一份精選的開源 AI 模型清單,概念上跟 DMR 類似,但實際使用上有些考量。

現況 支援 GGUF、PyTorch、TensorFlow 等常見格式

提供精選的 recipe 目錄,幫助導航 AI 使用案例和模型

最近與 RamaLama 整合,簡化本地 AI 模型執行

但採用精選模型清單的策略,可能不包含所有想要的模型

相對於 Ollama 的廣泛模型支援,選擇較為有限

不過隨著 RamaLama 能從任何來源簡化 AI 模型的本地服務,未來可能會更靈活。

一些要注意的小問題

Ollama Container 常見坑

  1. GPU 支援:記得加 --gpus all 或用 docker-compose 設定
  2. 模型路徑:預設在 /root/.ollama,要持久化記得 mount volume
  3. 網路設定:如果在 k8s 環境,注意 service 的 port 設定

Docker Model Runner 評估心得

  • 目前主要在 macOS Apple Silicon 上比較穩定
  • Linux 支援還在完善中
  • Windows 支援是最近才加的,需要更多實測

技術選型思考

這次調研讓我想到幾個點:

  1. 穩定性 > 新功能:對產品開發來說,穩定性永遠是第一考量
  2. 生態完整性:單一工具再好,生態不完善就是硬傷
  3. 維護成本:新技術通常需要更多維護工作

下一步

短期計畫:

  1. 把 Ollama container 的初始化流程自動化
  2. 持續追蹤 DMR 發展
  3. 建立技術方案評估標準

中期目標:

  • 等 DMR 穩定後再評估導入
  • 研究其他容器化方案
  • 整理最佳實踐文件

小結

AI 基礎設施變化很快,容器化技術也在演進。目前沒有完美方案,但這個調研過程很有價值。

技術選擇是動態平衡的過程,要在穩定性、功能性、維護成本間找平衡點。

會持續關注這領域的發展,有新發現再分享。

(fin)

[生活筆記] 2025 常用工具整理 - AI 篇

前情提要

最近 AI 工具如雨後春筍般冒出,整理一些目前在用或值得關注的工具,主要分為對話、開發、專業應用三大類。

對話與通用 AI

Claude

  • Claude Chat
  • Anthropic 開發的 AI 助手,擅長寫作、分析、程式開發
  • 支援多模態輸入(文字、圖片、文件)
  • 免費版有使用限制,付費版更穩定

Grok

  • Grok
  • xAI 推出的對話式 AI
  • 風格比較直接犀利,會吐槽(特色)
  • 需要 X Premium 會員才能使用

Gemini (Google)

  • Gemini
  • Google 最新多模態 AI 助手,支援文字、圖片、程式碼等多種輸入
  • 已整合進 Google Workspace、Android、Chrome 等生態系

Microsoft Copilot

  • Copilot
  • 微軟全家桶 AI 助手,整合於 Windows 11、Edge、Office 365
  • 支援 Word、Excel、PowerPoint 直接 AI 協作

Perplexity AI

  • Perplexity
  • 強調即時網路搜尋與問答,支援多語言、引用來源
  • 適合知識查詢、研究輔助

Notion AI

  • Notion AI
  • 整合於 Notion 筆記/知識管理平台的 AI 助手
  • 支援自動摘要、重寫、腦力激盪等功能

ChatGPT (OpenAI)

  • ChatGPT
  • OpenAI 的對話式 AI,支援 GPT-4/4o,應用於對話、程式、資料分析等
  • 免費/付費版皆有

Kimi Chat(Moonshot AI)

  • Kimi Chat
  • 中文社群討論度高,支援長文閱讀、PDF 分析

Phind

  • Phind
  • 專為工程師設計的 AI 搜尋與問答平台,程式碼解釋、技術文件查找很強

程式開發工具

程式碼生成與協助

  • OpenAI Codex

    • 雲端軟體工程代理,可同時處理多項開發任務
    • 支援功能開發、bug 修復、重構等
  • GitHub Copilot

    • 直接在編輯器中提供程式碼建議
    • 可生成整行或整個函數
    • 需付費訂閱
  • Cursor

    • AI 程式碼編輯器
    • 專注於智能程式開發體驗
    • 支援自然語言與程式碼互動
  • Claude Code

  • AugmentCode

    • AI 驅動的程式碼搜尋與理解平台
    • 支援跨專案、跨語言的程式碼查詢、摘要與自動解釋
    • 適合大型專案維護、協作與知識傳承

應用開發

  • Firebase Studio

    • Google 的應用開發平台
    • 整合多種後端服務
  • DeepWiki

    • AI 驅動的知識搜尋與摘要工具
    • 支援多語言、快速整理主題重點,適合研究、學習、資料彙整
    • 可用於技術、學術、產業等多種領域

雜七雜八

法律專業 — Harvey AI

  • 專門為法律行業設計的 AI 助手
  • 強調安全性和合規性(重要)

文件研究 — NotebookLM

  • Google 的筆記和研究工具
  • 可以分析和總結文件
  • 支援多種文件格式上傳

NVIDIA Container Toolkit

  • NVIDIA 的容器化工具包
  • 主要用於 GPU 加速的容器應用

參考

小結

目前 AI 工具生態系統發展迅速,各家都在搶佔不同的應用場景。
對話類工具已經相當成熟,程式開發輔助工具也越來越實用,專業領域的 AI 應用則剛起步但潛力巨大。
對我個人而言重建自已的工作流程整合 AI 協助已經刻不容緩

(fin)

[實作筆記] 用 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)