Azure Container Apps (ACA) 上使用 Node.js 搭配 SQLite

在 Azure Container Apps (ACA) 上使用 Node.js 搭配 SQLite 是可行的,但因為容器具備「重啟後資料會消失」的特性,你必須進行額外的設定才能確保資料庫不會遺失,並避免檔案鎖定的問題。

以下是實現方式與需要注意的重點:

必須使用持久化儲存 (Azure Files)

預設情況下,容器內部的檔案系統是暫時性的。如果你把 database.sqlite 放在容器內的資料夾,每次部署新版本或容器重啟,資料就會重置。

  • 解決方案:你需要建立一個 Azure Storage Account (File Share),並將它掛載 (Mount) 到 ACA 的容器中。
  • 掛載路徑:例如將 File Share 掛載到容器內的 /data 目錄,然後將 SQLite 的連線路徑指向 /data/database.sqlite

關鍵:解決資料庫鎖定問題 (SQLITE_BUSY)

這是最常遇到的坑。因為 Azure Files 預設使用 SMB 協定,其檔案鎖定機制與 SQLite 不相容,常會導致 Error: SQLITE_BUSY: database is locked

  • 修正方法:在掛載設定中,必須加入 nobrl (No Byte Range Locks) 選項。
  • 設定範例:在 ACA 的環境儲存設定中,確保掛載選項包含這個參數,這樣 SQLite 才能正常寫入檔案。

限制與建議

雖然技術上可行,但在雲端原生環境(如 ACA)使用 SQLite 有以下限制:

  • 無法水平擴展:如果你將 Replicas (執行個體數量) 設為超過 1 個,多個容器同時寫入同一個 SQLite 檔案極易造成損壞或衝突。SQLite 只適合單一執行個體 (Single Instance) 的情境。
  • 效能問題:因為資料庫檔案是透過網路掛載的 (Azure Files),讀寫速度會比本地磁碟慢,不適合高併發的應用。
  • 部署中斷:ACA 在更新版本時,舊的容器還沒關閉,新的就可能已經啟動並嘗試存取同一個檔案,這也會引發鎖定錯誤。

總結建議

如果你只是做小型專案、個人工具或原型開發,SQLite + Azure Files (加上 nobrl) 是最省錢的選擇。

但如果你的應用預計會有較多流量,或者需要多台容器同時運作,建議改用 Azure Database for PostgreSQLCosmos DB,這類服務才是針對雲端擴展性設計的。


既然你打算在 Azure Container Apps (ACA) 實作,以下是針對 Node.js 搭配 Azure Files 掛載 SQLite 的具體步驟與程式碼範例。

第一步:準備 Azure 儲存體

你必須先在 Azure 建立一個 Storage Account,並在裡面建立一個 File Share。這是因為容器重啟後內部資料會被重置,只有掛載外部空間才能持久保存 sqlite 檔案。

  1. 在 Azure 入口網站建立 Storage Account。
  2. 建立一個 File Share(檔案共用),名稱可以叫 sqlitedata
  3. 到 ACA 的「環境」設定中,點選「儲存體」,將剛才的 File Share 新增進去。重要提示:在掛載選項中,請務必確認支援 nobrl,這能防止 SQLite 的檔案鎖定錯誤。

第二步:在 ACA 容器設定掛載路徑

在部署 Container App 時,你需要將剛才定義的儲存體掛載到容器內的特定路徑。

  • 儲存體名稱:選擇你剛才在環境層級定義的名稱。
  • 掛載路徑:例如 /app/data

這樣一來,你的 Node.js 程式只要存取 /app/data/database.sqlite,實際上就是在讀寫 Azure Files 上的檔案。


第三步:Node.js 程式碼範例

在 Node.js 中使用 sqlite3better-sqlite3 套件。建議使用環境變數來管理資料庫路徑,方便開發與雲端環境切換。

JavaScript

const sqlite3 = require('sqlite3').verbose();
const path = require('path');

// 從環境變數讀取掛載路徑,預設為本地路徑方便測試
const dbDir = process.env.DB_PATH || './data';
const dbPath = path.join(dbDir, 'database.sqlite');

// 確保連線時路徑正確
const db = new sqlite3.Database(dbPath, (err) => {
    if (err) {
        console.error('無法連線至 SQLite:', err.message);
    } else {
        console.log('成功連線至掛載空間中的 SQLite');
    }
});

// 範例查詢
db.serialize(() => {
    db.run("CREATE TABLE IF NOT EXISTS counts (id INTEGER, val INTEGER)");
    db.run("INSERT INTO counts VALUES (1, 100)");
    db.each("SELECT id, val FROM counts", (err, row) => {
        console.log(`ID: ${row.id}, Value: ${row.val}`);
    });
});

常見問題與解決方案

如果你的 Node.js backend 在啟動時噴出 SQLITE_CANTOPENSQLITE_BUSY

  1. 權限問題:請確認 Dockerfile 中的 User 是否有權限讀寫 /app/data。如果使用非 root 使用者,記得在 Dockerfile 用 CHOWN 修改權限。
  2. 連線字串:在某些情況下,使用 file:/app/data/database.sqlite?cache=shared 這種 URI 格式可以增加穩定性。
  3. 擴展限制:請務必將 ACA 的 Minimum ReplicasMaximum Replicas 都設為 1。如果設為多個,不同容器同時寫入同一個檔案會導致資料毀損。

部署此設定的 Dockerfile 範本

針對 Azure Container Apps (ACA) 掛載 SQLite 的 Node.js 環境,Dockerfile 的重點在於確保目錄權限正確,並讓容器具備讀寫掛載磁碟的能力。

以下是一個實用的 Dockerfile 範本,建議配合 node:18-slimnode:20-slim 以減少映像檔體積。

Dockerfile 範本

Dockerfile

# 使用輕量級的 Node.js 映像檔
FROM node:20-slim

# 安裝 SQLite3 執行環境所需的工具(部分 npm 套件編譯時需要)
RUN apt-get update && apt-get install -y \
    python3 \
    make \
    g++ \
    && rm -rf /var/lib/apt/lists/*

# 設定工作目錄
WORKDIR /app

# 先複製 package.json 以利用 Docker 快取層
COPY package*.json ./

# 安裝依賴套件
RUN npm install --production

# 複製其餘程式碼
COPY . .

# 建立預計掛載的資料夾,並確保權限
# 假設你在 ACA 設定將 Azure Files 掛載到 /app/data
RUN mkdir -p /app/data && chmod 777 /app/data

# 定義環境變數,讓 Node.js 知道資料庫在哪裡
ENV DB_PATH=/app/data

# 暴露應用程式埠號
EXPOSE 8080

# 啟動應用程式
CMD ["node", "index.js"]

設定重點說明

  • 權限處理:在 Dockerfile 中預先 mkdir -p /app/data 是為了確保路徑存在。雖然 Azure Files 掛載時會覆蓋該目錄,但預設建立目錄能避免部分 Node.js 套件因找不到路徑而報錯。
  • 環境變數 DB_PATH:這樣你在程式碼中可以用 process.env.DB_PATH || './' 來動態切換本地開發與雲端環境。
  • 避免使用 root:為了安全,ACA 建議使用非 root 使用者執行。如果你在 Dockerfile 最後加上 USER node,請確保 /app/data 的權限已經透過 chown -R node:node /app/data 轉移給該使用者。

ACA 部署建議

當你使用這個 Dockerfile 部署到 ACA 時,請在 Azure 入口網站或透過 CLI 檢查以下設定:

  1. 儲存體掛載:將你的 Azure File Share 掛載點設為 /app/data
  2. 連線字串:在 Node.js 中連線 SQLite 時,請使用絕對路徑 /app/data/database.sqlite
  3. 單一執行個體:在 ACA 的「調整調整 (Scaling)」設定中,將 Min replicasMax replicas 都設為 1。這是因為 SQLite 不支援多個容器同時寫入同一個檔案。

Azure CLI 部署指令

這是一份完整的 Azure CLI 部署腳本範本。這套指令會幫你建立所需的儲存體、檔案共用,並將其掛載到 Azure Container Apps (ACA) 中。

請將 < > 中的變數替換成你的實際名稱。

Azure CLI 部署指令

Bash

# 1. 設定變數
RESOURCE_GROUP="MyResourceGroup"
LOCATION="eastasia"
STORAGE_ACCOUNT="mystorage$(date +%s)" # 儲存體名稱需唯一
FILE_SHARE_NAME="sqlitedata"
ENVIRONMENT_NAME="my-aca-env"
APP_NAME="nodejs-sqlite-app"
IMAGE_NAME="<你的容器映像檔路徑,例如 myacr.azurecr.io/node-sqlite:v1>"

# 2. 建立儲存體帳戶與 File Share
az storage account create --name $STORAGE_ACCOUNT --resource-group $RESOURCE_GROUP --location $LOCATION --sku Standard_LRS
STORAGE_KEY=$(az storage account keys list --resource-group $RESOURCE_GROUP --account-name $STORAGE_ACCOUNT --query "[0].value" -o tsv)
az storage share create --name $FILE_SHARE_NAME --account-name $STORAGE_ACCOUNT --account-key $STORAGE_KEY

# 3. 在 ACA 環境中定義儲存體掛載(關鍵:設定 nobrl 防止 SQLite 鎖定)
az containerapp env storage set \
  --name $ENVIRONMENT_NAME \
  --resource-group $RESOURCE_GROUP \
  --storage-name "my-sqlite-storage" \
  --account-name $STORAGE_ACCOUNT \
  --account-key $STORAGE_KEY \
  --share-name $FILE_SHARE_NAME \
  --access-mode ReadWrite

# 4. 建立並部署 Container App,同時掛載路徑
az containerapp create \
  --name $APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --environment $ENVIRONMENT_NAME \
  --image $IMAGE_NAME \
  --target-port 8080 \
  --ingress external \
  --min-replicas 1 \
  --max-replicas 1 \
  --set-env-vars DB_PATH=/app/data \
  --storage-mounts target-path=/app/data storage-name=my-sqlite-storage

指令關鍵說明

  • –min-replicas 1 與 –max-replicas 1:這非常重要。SQLite 不支援多個容器同時寫入同一個檔案。如果設為多個 Replicas,當兩個容器同時嘗試存取 Azure Files 上的資料庫時,會發生嚴重的資料錯誤或鎖定。
  • –storage-mounts:這行指令將你在環境中定義的 my-sqlite-storage 對應到容器內部的 /app/data。這就是你在 Dockerfile 中預留的路徑。
  • 環境變數 DB_PATH:透過指令傳入 /app/data,讓 Node.js 程式知道該去哪裡找 .sqlite 檔案。

後續維護建議

當你需要更新程式碼時,只需要執行更新映像檔的指令:

Bash

az containerapp update \
  --name $APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --image <新映像檔名稱>

更新過程中,ACA 會嘗試啟動新容器並關閉舊容器。因為 SQLite 檔案被鎖定的特性,更新期間可能會有短暫的幾秒鐘連線失敗(因為新舊容器可能同時在爭奪同一個檔案的控制權),這是使用 SQLite 搭配雲端容器環境的小小代價。

如果你發現 az containerapp env storage set 指令在你的環境中無法運作,通常是因為 Azure CLI 版本過舊,建議先執行 az extension add --name containerapp --upgrade 更新擴充功能。


在本地端驗證 Docker 映像檔(Image)與 SQLite 掛載是否正常,最重要的是模擬 Azure Container Apps 的掛載行為。你可以透過以下步驟進行測試:

使用 Docker Run 模擬掛載環境

在本地端,你可以使用 -v 參數將電腦上的資料夾掛載到容器內,這能確保你的 Node.js 程式可以正確讀寫外部目錄。

Bash

# 1. 在本地建立一個測試用的資料目錄
mkdir -p $(pwd)/test-data

# 2. 執行容器並掛載目錄
# 將本地的 test-data 對應到容器內的 /app/data
docker run -d \
  --name node-sqlite-test \
  -p 3000:8080 \
  -v $(pwd)/test-data:/app/data \
  -e DB_PATH=/app/data \
  <你的映像檔名稱>

驗證步驟與檢查點

1. 檢查檔案是否產生

執行容器後,檢查你本地的 test-data 目錄。如果程式邏輯正確,你應該會看到目錄下多出了一個 database.sqlite(或你在程式中設定的檔名)。

Bash

ls -l ./test-data

2. 進入容器內部檢查

你可以直接進入執行中的容器,查看路徑與權限是否如預期:

Bash

docker exec -it node-sqlite-test sh

# 在容器內檢查
ls -la /app/data
# 測試手動寫入檔案看是否有權限
touch /app/data/test.txt

3. 驗證資料持久性(最重要的一步)

這是模擬雲端重啟的核心測試:

  1. 透過 API 或程式寫入一筆資料到 SQLite。
  2. 刪除並移除目前的容器:docker rm -f node-sqlite-test
  3. 重新執行相同的 docker run 指令(保持掛載同一個 test-data 目錄)。
  4. 檢查剛才寫入的資料是否還在。如果資料還在,代表持久化儲存設定成功。

常見問題排除

  • Permission Denied (權限拒絕):如果容器內出現無法寫入 SQLite 的錯誤,通常是因為本地資料夾的權限與容器內的使用者不符。你可以暫時將本地目錄設為 chmod 777 ./test-data 來排除是否為權限問題。
  • 路徑不一致:確保你的環境變數 DB_PATH 確實傳遞到了 Node.js 程式中。你可以在程式啟動時加上 console.log('Database path:', dbPath) 來確認。

進階驗證:使用 Docker Compose

如果你覺得指令太長,可以使用 docker-compose.yml 來簡化驗證過程:

YAML

services:
  app:
    build: .
    ports:
      - "3000:8080"
    environment:
      - DB_PATH=/app/data
    volumes:
      - ./test-data:/app/data

直接執行 docker-compose up 就能快速啟動測試環境。


自動檢查 SQLite 資料表是否建立成功

這是一段簡單的 Node.js 測試腳本,你可以將它命名為 test-db.js。這段腳本會自動檢查資料庫檔案是否存在、能否寫入資料,以及讀取出的內容是否正確。

你可以將這段程式碼放入你的專案,或直接整合進 index.js 的啟動邏輯中。

自動化驗證腳本

JavaScript

const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');

const dbDir = process.env.DB_PATH || './data';
const dbPath = path.join(dbDir, 'database.sqlite');

console.log('--- 開始資料庫驗證 ---');
console.log(`目標路徑: ${dbPath}`);

// 1. 檢查目錄權限
try {
    if (!fs.existsSync(dbDir)) {
        fs.mkdirSync(dbDir, { recursive: true });
    }
    fs.accessSync(dbDir, fs.constants.W_OK);
    console.log('目錄檢查: 成功 (可寫入)');
} catch (err) {
    console.error('目錄檢查: 失敗', err.message);
    process.exit(1);
}

// 2. 測試資料庫操作
const db = new sqlite3.Database(dbPath);

db.serialize(() => {
    // 建立測試資料表
    db.run("CREATE TABLE IF NOT EXISTS test_log (id INTEGER PRIMARY KEY, ts TEXT)");

    // 寫入當前時間
    const now = new Date().toISOString();
    db.run("INSERT INTO test_log (ts) VALUES (?)", [now], function(err) {
        if (err) {
            console.error('寫入測試: 失敗', err.message);
            process.exit(1);
        }
        console.log(`寫入測試: 成功 (ID: ${this.lastID})`);
    });

    // 讀取並驗證
    db.all("SELECT * FROM test_log ORDER BY id DESC LIMIT 1", (err, rows) => {
        if (err) {
            console.error('讀取測試: 失敗', err.message);
            process.exit(1);
        }
        if (rows.length > 0) {
            console.log('讀取測試: 成功', rows[0]);
            console.log('--- 驗證完成,SQLite 運作正常 ---');
        }
        db.close();
    });
});

如何在 Docker 中執行此測試

你可以修改 Dockerfile 的 CMD,或是在執行 docker run 時覆蓋啟動指令,直接執行這段測試腳本。

方法 A:透過 docker run 覆蓋指令

假設你的 Image 叫 my-node-app

Bash

docker run --rm \
  -v $(pwd)/test-data:/app/data \
  -e DB_PATH=/app/data \
  my-node-app node test-db.js

方法 B:觀察執行結果

如果腳本輸出「驗證完成,SQLite 運作正常」,請到你電腦的 test-data 資料夾查看:

  • 你應該會看到 database.sqlite 檔案。
  • 如果你重複執行該指令,你會看到 test_log 的 ID 持續增加,這代表掛載與持久化機制完全正常。

最終檢查清單

檢查項目預期結果
本地目錄執行後出現 database.sqlite 檔案
容器日誌出現「寫入測試: 成功」字樣
容器重啟再次執行後,舊的 ID 資料依然存在
檔案權限本地檔案的擁有者應與容器執行使用者相容

完成本地驗證後,你就可以放心地使用先前提供的 Azure CLI 指令 將映像檔推送到雲端執行。需要我幫你把這段測試邏輯寫進 GitHub Actions 的 CI 流程中嗎?

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *