[實作筆記] EAI 機器瀏覽器安裝問題與解決方案

前情提要

最近拿到一個地端資源 EAI-I233 開發地端 AI 應用。
過程中遇到了安裝瀏覽器(firefox)無法正常運作的問題。
經過查找資料,發現這是 Jetson Orin Nano 上的已知問題,不是個案。
記錄一下如何透過 Flatpak 安裝 Chromium 在 EAI 機器上,以解決瀏覽器問題。

問題描述

在 Jetson Orin Nano 等 EAI 設備上,透過 snap 安裝的 Chromium 瀏覽器會出現無法正常啟動或運行不穩定的狀況。
這個問題並非個案,在 NVIDIA 開發者論壇上有許多用戶回報類似問題。

解決方案

移除現有的 snap 版本 Chromium

首先移除透過 snap 安裝的 Chromium:

1
sudo snap remove chromium

安裝與設定 Flatpak

更新套件管理器並安裝 Flatpak:

1
2
sudo apt update
sudo apt install flatpak

添加 Flathub 儲存庫:

1
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo

重開機並安裝 Chromium

重新啟動機器後,透過 Flatpak 安裝 Chromium:

1
2
# 重開機後執行
flatpak install flathub org.chromium.Chromium

為什麼要用 Flatpak?

相比於 snap,Flatpak 在 ARM 架構的設備上有更好的相容性。
特別是在 Jetson 系列開發板上,Flatpak 能夠提供更穩定的套件執行環境,避免因為 snap 沙盒機制與硬體驅動互動時產生的問題。

一些要注意的小問題

重開機是必要的
在安裝完 Flatpak 後,務必重開機再安裝 Chromium,確保所有相依服務正確啟動。

權限問題
如果遇到權限問題,確認當前使用者已加入相關群組:

1
sudo usermod -a -G sudo $USER

參考資料

小結

透過 Flatpak 安裝 Chromium 是目前在 EAI 設備上最穩定的解決方案。
雖然需要額外的設定步驟,但能夠有效解決 snap 版本在 ARM 架構上的相容性問題。
這個解決方案不僅適用於 Jetson Orin Nano,也可以應用到其他類似的 ARM 架構開發板上。
有機會拿到別的機器再來試試。

(fin)

[學習筆記] Express.js middleware auth 的業界標準(res.locals)

前言

在 Express.js 認證系統中,常見的做法是在每個需要驗證的路由中直接解析 JWT token。
但有一個更好的實作方式:使用中介軟體將認證結果存放在 res.locals 中。
這不僅是官方建議的做法,也避免了重複解析 token 的效能問題。

TL;DR

使用 res.locals 處理認證是 Express.js 的標準實作:

  • 避免在每個路由重複解析 JWT token
  • Auth.js、Passport.js 等主流函式庫都採用這種模式
  • Express.js 官方文檔明確支持這種用法

直接解析 JWT 的缺點

重複工作的效能問題

如果每個路由都直接解析 JWT,會造成不必要的重複運算:

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 在每個路由重複解析
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
const user = jwt.verify(token, JWT_SECRET) // 重複解析
res.json({ user })
})

app.get('/orders', (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
const user = jwt.verify(token, JWT_SECRET) // 又解析一次
res.json({ orders: getUserOrders(user.id) })
})

程式碼重複與維護困難

每個需要認證的路由都要寫類似的 token 驗證邏輯,違反了 DRY 原則,也增加了維護成本。

錯誤處理不一致

不同路由可能有不同的 token 驗證錯誤處理方式,造成 API 回應不一致。

什麼是 res.locals?

根據 Express.js 官方文檔,res.locals 是一個物件,用來存放請求範圍內的區域變數,這些變數只在當前請求-回應週期中可用。

1
2
3
4
5
6
// Express.js 官方範例
app.use((req, res, next) => {
res.locals.user = req.user
res.locals.authenticated = !req.user
next()
})

主流函式庫的標準實作

Auth.js 官方範例

Auth.js 官方文檔明確展示了使用 res.locals 的標準模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { getSession } from "@auth/express"

export function authSession(req, res, next) {
res.locals.session = await getSession(req)
next()
}

app.use(authSession)

// 在路由中使用
app.get("/", (req, res) => {
const { session } = res.locals
res.render("index", { user: session?.user })
})

Passport.js 的實作模式

Passport.js 在認證成功時會設置 req.user 屬性,許多開發者會將此資訊複製到 res.locals 以便在視圖中使用:

1
2
3
4
5
6
// 認證中介軟體
app.use((req, res, next) => {
res.locals.user = req.isAuthenticated() ? req.user : null
res.locals.isAuthenticated = req.isAuthenticated()
next()
})

為什麼這是好實作?

1. 官方支持的標準

Express.js 官方文檔說明 res.locals 就是用來存放請求級別的資訊,如認證用戶、用戶設定等。這不是 hack 或 workaround,而是設計用途。

2. 生態系統共識

主流的認證函式庫都採用類似模式:

  • Auth.js 直接在文檔中展示 res.locals.session
  • Passport.js 社群普遍使用 res.locals.user
  • 許多教學都展示將認證狀態存放在 res.locals 的模式

3. 分離關注點

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 認證中介軟體 - 只負責認證
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]

if (token) {
const decoded = jwt.verify(token, JWT_SECRET)
res.locals.user = decoded
}

next()
}

// 路由處理器 - 只負責業務邏輯
app.get('/profile', authMiddleware, (req, res) => {
const { user } = res.locals
if (!user) {
return res.status(401).json({ error: 'Unauthorized' })
}
res.json({ profile: user })
})

4. 視圖整合優勢

在使用模板引擎時,res.locals 的資料會自動傳遞給視圖:

1
2
3
4
5
6
7
8
// 中介軟體設定
app.use((req, res, next) => {
res.locals.currentUser = req.user
next()
})

// 在 EJS/Pug 模板中直接使用
// <%= currentUser.name %>

常見的反模式

避免的做法

有些開發者認為過度使用 res.locals 會讓除錯變困難,但這通常是因為:

  1. 濫用中介軟體模式 - 把業務邏輯也放在中介軟體裡
  2. 過度耦合 - 多個中介軟體互相依賴 res.locals 的順序

正確的使用原則

中介軟體應該用於所有 HTTP 請求共通的事項,且不包含業務邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ✅ 好的做法 - 純粹的認證檢查
function authenticate(req, res, next) {
const token = getTokenFromRequest(req)
const user = validateToken(token)
res.locals.user = user
next()
}

// ✅ 好的做法 - 授權檢查
function requireAuth(req, res, next) {
if (!res.locals.user) {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
}

// ❌ 避免的做法 - 在中介軟體中處理業務邏輯
function badMiddleware(req, res, next) {
const user = res.locals.user
const orders = getUserOrders(user.id) // 業務邏輯不應該在這裡
res.locals.orders = orders
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
const jwt = require('jsonwebtoken')

function authMiddleware(req, res, next) {
try {
const token = req.headers.authorization?.replace('Bearer ', '')

if (token) {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
res.locals.user = decoded
res.locals.isAuthenticated = true
} else {
res.locals.user = null
res.locals.isAuthenticated = false
}

next()
} catch (error) {
res.locals.user = null
res.locals.isAuthenticated = false
next()
}
}

// 授權檢查中介軟體
function requireAuth(req, res, next) {
if (!res.locals.isAuthenticated) {
return res.status(401).json({ error: 'Authentication required' })
}
next()
}

// 使用方式
app.use(authMiddleware)
app.get('/profile', requireAuth, (req, res) => {
const { user } = res.locals
res.json({ user })
})

與 req 自定義屬性的比較

雖然也可以使用 req.userreq.data 等自定義屬性,但 res.locals 有幾個優勢:

  1. 語意清晰 - 明確表示這是給視圖用的資料
  2. 自動傳遞 - 模板引擎會自動取用 res.locals 的資料
  3. 標準化 - 社群共識,維護性更好

結論

res.locals 在認證系統中的使用確實是標準實作:

  1. 官方支持 - Express.js 和 Auth.js 官方文檔都展示這種用法
  2. 生態系統標準 - Passport.js 等主流函式庫採用相同模式
  3. 效能考量 - 避免重複解析 JWT 或查詢資料庫
  4. 開發體驗 - 控制器可以直接存取用戶資訊

所以下次有人說使用 res.locals 不是好實作時,可以拿出這些官方文檔來證明這確實是被廣泛接受的標準做法。

參考資料

(fin)

[踩雷筆記] 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)