iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Software Development

從零打造邊緣運算閘道器:Raspberry Pi 與 Linux 底層軟硬整合實戰系列 第 28

Day 28:系統服務化:systemd 守護神與 journalctl 中文亂碼 (Locale) 的坑

  • 分享至 

  • xImage
  •  

🎯 問題情境 (The Problem)

在開發邊緣運算閘道器 (EdgeNode) 時,工程師通常會在 SSH 終端機手動執行 python3 main.py 進行除錯。此時一切看似完美:輸出日誌文字清晰、繁體中文顯示正常、連線斷開時也能手動重新啟動。

然而,當專案轉入生產環境 (Production),並透過 systemd 註冊為開機自動啟動的背景服務 (Daemon) 後,噩夢隨之而來:

  1. journalctl 中的中文變十六進位亂碼:觀察日誌時,原本中文的訊息全部變成 \xe6\xaa\x94\xe6\xa1\x88\xe4\xb8\x8a\xe5\x82\xb3... 或是直接拋出 UnicodeEncodeError: 'ascii' codec can't encode character 導致進程崩潰。
  2. 日誌遲遲不刷新 (Output Buffering):程式明明正在運作,journalctl -fu datagateway 卻半天印不出一行字,直到緩衝區 (Buffer) 塞滿或程式崩潰時才一口氣噴出大量舊日誌。
  3. 崩潰後陷入死循環:當硬體未就緒(例如 USB 驅動尚未初始化完成)時,systemd 瘋狂重試,造成 CPU 使用率高達 100%,最終被 Linux 核心關閉。

❌ 錯誤嘗試 (The Pitfalls)

許多開發者在面對背景服務化時,經常使用無效或危險的臨時避坑手段:

1. 使用 nohuprc.local 啟動服務

# ❌ 極度不推薦的舊式做法 (/etc/rc.local)
nohup /opt/gateway/venv/bin/python3 /opt/gateway/src/main.py > /tmp/app.log 2>&1 &

這種做法無法監控進程生命週期,一旦 Python 程式發生 Unhandled Exception 退出,系統完全無法自動重啟。此外,/tmp/app.log 會無限膨脹直至填滿樹莓派 SD 卡。

2. 在 Python 中用 encode('ascii', errors='ignore') 強行抹除中文

有些工程師為了不讓 UnicodeEncodeError 壓垮系統,乾脆把中文註解與輸出文字全強制轉成 ASCII 或是把例外吞掉 (except: pass)。這導致除錯時完全無法得知設備當下的真實狀態。

3. 忽略環境變數 PYTHONUNBUFFERED

沒有設定無緩衝輸出,導致日誌停留在 stdout 緩衝區中,當發生斷電時,最後關鍵的 4KB Log 完全沒寫入磁碟,無法排查斷電前的異狀。


🧠 底層原理:systemd 執行環境與 C Standard I/O 緩衝

1. POSIX Locale 與 Python Encoding

當在 SSH 互動式 Shell 中執行命令時,Bash 會自動繼承使用者環境變數(如 LANG=zh_TW.UTF-8)。
但當由 systemd 啟動服務時,systemd 預設工作環境是極簡的 C/POSIX Locale。此時 Python 的 sys.stdout.encoding 會降級為 ANSI_X3.4-1968 (ASCII)。只要 print() 印出任何非 ASCII 字元(如中文或 Emoji),Python 3 即會拋出致命的 UnicodeEncodeError

https://ithelp.ithome.com.tw/upload/images/20260913/20183645E67toXWDfD.png

2. Standard I/O 流水線緩衝

Python 為了提升 I/O 效能,當偵測到 stdout 不是接在互動式 TTY 終端機上(而是接在管道 Pipe 或檔案,即 systemd journal 接收端)時,會自動切換為 Block Buffering (通常為 4096 Bytes)。這就是為何 journalctl 無法即時看到 Log 的根本原因。


💡 最終解決方案:商業級 systemd 服務組態配置

為了確保 EdgeNode 在生產環境中具備無人值守的穩定度,我們定義了標準的 Service 檔案與環境變數隔離。

1. 完善的 datagateway.service 服務設定

/etc/systemd/system/datagateway.service 中寫入以下設定:

[Unit]
Description=EdgeNode Industrial Gateway Controller
After=network-online.target local-fs.target systemd-sysctl.service
Wants=network-online.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/gateway

# 🔑 關鍵 1:鎖定 UTF-8 語系與禁用 Python I/O 緩衝
Environment="LANG=zh_TW.UTF-8"
Environment="LC_ALL=zh_TW.UTF-8"
Environment="PYTHONIOENCODING=utf-8"
Environment="PYTHONUNBUFFERED=1"

# 🔑 關鍵 2:前置修復腳本與主程式
ExecStartPre=/usr/bin/bash /opt/gateway/scripts/fix_storage.sh
ExecStart=/opt/gateway/venv/bin/python3 /opt/gateway/src/main.py

# 🔑 關鍵 3:穩健的重啟策略與崩潰間隔 (避免死循環)
Restart=always
RestartSec=5s
StartLimitIntervalSec=60s
StartLimitBurst=5

# 🔑 關鍵 4:資源上限限制 (防止 Memory Leak 壓垮樹莓派)
MemoryMax=400M
MemoryHigh=350M
CPUQuota=80%

# 標準輸出轉向至 journald
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

2. Python 端相容性安全輸出封裝 (logger.py)

除了 systemd 設定外,我們在程式碼內部實作防護,確保在各種極端環境下 print 皆不會崩潰:

import sys
import os
import logging

def setup_safe_logging():
    """設定安全日誌輸出,防止 Console 編碼崩潰"""
    # 確保標準輸出編碼為 UTF-8
    if sys.stdout.encoding.lower() != 'utf-8':
        try:
            sys.stdout.reconfigure(encoding='utf-8', errors='replace')
            sys.stderr.reconfigure(encoding='utf-8', errors='replace')
        except AttributeError:
            # Python < 3.7 備用相容邏輯
            import io
            sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
            sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')

    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s [%(levelname)s] %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )

def safe_print(msg: str):
    """安全列印函式,過濾非安全字元"""
    try:
        print(msg, flush=True)
    except UnicodeEncodeError:
        clean_msg = msg.encode('ascii', errors='replace').decode('ascii')
        print(clean_msg, flush=True)

🎯 小結

在 Day 28 中,我們克服了背景服務化最常見的「隱形死穴」:

  1. Locale 降級機制:理解 systemd 預設的 POSIX 語系為何會撕裂中文日誌。
  2. PYTHONUNBUFFERED=1 的重要性:確保斷電前最後一刻的 Log 依然能即時寫入系統日誌。
  3. 記憶體與重啟邊界 (MemoryMax / StartLimitBurst):防範異常進程將樹莓派硬體資源耗盡。

在下一篇 Day 29 中,我們將進行出貨前的終極挑戰:「實機壓力測試:如何寫一支 Fault Injection (錯誤注入) 腳本摧毀自己的系統?」


上一篇
Day 27:斷電與拔插救援:如何實作開機自動化 fsck 檢查與壞軌修復?
下一篇
Day 29:實機壓力測試:如何寫一支 Fault Injection (錯誤注入) 腳本摧毀自己的系統?
系列文
從零打造邊緣運算閘道器:Raspberry Pi 與 Linux 底層軟硬整合實戰30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言