[踩雷筆記] GitHub Action ssh-keyscan not found 問題修復

前情提要

GitHub Action marsen/[email protected] 之前運行正常,但最近開始失敗,錯誤訊息:

1
/usr/app/entrypoint.sh: 9: /usr/app/entrypoint.sh: ssh-keyscan: not found

問題分析

初步檢查

檢查 entrypoint.sh 第9行:

1
ssh-keyscan -t rsa github.com >> /root/.ssh/known_hosts

檢查 Dockerfile 套件安裝:

1
2
FROM node:20-buster-slim
RUN apt-get install -y git openssh-client

明明有安裝 openssh-client,為什麼找不到 ssh-keyscan

根本原因

Debian Buster 生命週期結束

  • node:20-buster-slim 基於 Debian 10 (Buster)
  • Debian 10 於 2024年8月達到 End of Life
  • 套件庫不再維護,套件結構可能變化

OpenSSH 套件重組

  • 2024年 OpenSSH 多個版本發布 (9.7, 9.9)
  • ssh-keyscan 可能從 openssh-client 移到其他套件

Docker 映像自動更新

  • Docker Hub 會自動重建映像
  • 新版本移除不必要工具以減少攻擊面

解決方案

快速修復(不推薦)

1
RUN apt-get install -y git openssh-client openssh-server

雖然可行,但安裝 SSH 伺服器會增加攻擊面。

正確解法

升級基底映像到支援版本:

1
2
3
4
5
# 從過期版本
FROM node:20-buster-slim

# 升級到安全版本
FROM node:20-bookworm-slim

完整的 Dockerfile 修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM node:20-bookworm-slim

LABEL version="1.0.12"
LABEL repository="https://github.com/marsen/hexo-action"
LABEL homepage="https://blog.marsen.me"
LABEL maintainer="marsen.lin <[email protected]>"

WORKDIR /usr/app

COPY entrypoint.sh /usr/app/entrypoint.sh
COPY sync_deploy_history.js /usr/app/sync_deploy_history.js

# 安全且最佳化的套件安裝
RUN apt-get update > /dev/null && \
apt-get install -y git openssh-client > /dev/null && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
chmod +x /usr/app/entrypoint.sh

ENTRYPOINT ["/usr/app/entrypoint.sh"]

關鍵改進

  1. 基底映像升級:buster-slim → bookworm-slim

    • Debian 12 取代已 EOL 的 Debian 10
    • 更好的安全性和長期支援
  2. 最小權限原則:只安裝 openssh-client

    • 包含所需的 ssh-keyscan 工具
    • 不安裝 SSH 伺服器,減少攻擊面
  3. Docker 最佳實踐

    • 清理 apt 快取減少映像大小
    • 使用 && 合併 RUN 指令減少層數

預防措施

  • 使用具體版本標籤而非 latest
  • 定期檢查基底映像的生命週期
  • 建立自動化測試驗證依賴可用性

小結

這次問題的核心是基底映像過期導致的連鎖反應。在容器化開發中,外部依賴的變化往往會影響既有系統。解決方案不是快速修復,而是從根本上升級到安全的長期支援版本。

除錯關鍵思路:分析「為什麼之前可以,現在不行」,往往能找到外部環境變化的線索。

(fin)

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

Please enable JavaScript to view the LikeCoin. :P