#!/bin/bash # ============================================================================= # setup-overlayroot.sh — RK3588 Edge 断电保护(方案一:overlayroot + /data 分区) # ----------------------------------------------------------------------------- # 解决场景:SD 卡断电重启导致根文件系统损坏、无法启动。 # # 原理: # 1) 用 Ubuntu 官方 overlayroot 把根文件系统置于 tmpfs overlay 之上, # 系统对根分区的一切写入进入内存,重启即丢,根分区永久只读, # 断电不再损坏根文件系统。 # 2) 在 SD 卡剩余空间划分独立读写分区 /data(ext4),存放日志、 # 告警图片/视频、设备 ID 等需要持久化的数据,不受 overlay 影响。 # # 用法(在设备上执行): # sudo ./setup-overlayroot.sh install 一键启用(推荐) # sudo ./setup-overlayroot.sh install --yes 跳过确认(无人值守) # sudo ./setup-overlayroot.sh install --hls-ram HLS 分片放内存(tmpfs), # 减小 SD 写入(按需) # sudo ./setup-overlayroot.sh status 查看当前状态 # sudo ./setup-overlayroot.sh verify 重启后验证是否生效 # sudo ./setup-overlayroot.sh disable 临时禁用(维护模式,可写) # sudo ./setup-overlayroot.sh enable 重新启用(退出维护模式) # # 可选环境变量: # HLS_MODE=ram|data HLS 分片存放位置(默认 data,--hls-ram 等价 ram) # JOURNAL_MODE=ram|data 系统 journal 日志(默认 ram) # SKIP_PARTITION=1 跳过分区创建(镜像已含 /data 分区时使用) # ============================================================================= set -euo pipefail # --------------------------------------------------------------------------- # 颜色与输出 # --------------------------------------------------------------------------- RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' info() { echo -e "${CYAN}[INFO]${NC} $*"; } ok() { echo -e "${GREEN}✓${NC} $*"; } warn() { echo -e "${YELLOW}⚠${NC} $*"; } die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } # --------------------------------------------------------------------------- # 全局配置 # --------------------------------------------------------------------------- DATA_MOUNT="/data" DATA_LABEL="safesight-data" DATA_SUBDIRS="hls logs alarms clips agent" RUNTIME_ROOT="/var/lib/safesight-edge-server" AGENT_LIB="/var/lib/safesight-edge-agent" FSTAB="/etc/fstab" OVERLAY_CONF="/etc/overlayroot.conf" MARKER_BEGIN="# safesight-overlayroot (自动生成,勿手动修改)" MARKER_END="# end safesight-overlayroot" HLS_MODE="${HLS_MODE:-data}" # data | ram JOURNAL_MODE="${JOURNAL_MODE:-ram}" # ram | data SKIP_PARTITION="${SKIP_PARTITION:-0}" ASSUME_YES=0 # --------------------------------------------------------------------------- # 参数解析 # --------------------------------------------------------------------------- CMD="${1:-install}" shift || true for arg in "$@"; do case "$arg" in --yes) ASSUME_YES=1 ;; --hls-ram) HLS_MODE=ram ;; --journal-data) JOURNAL_MODE=data ;; *) warn "未知参数: $arg" ;; esac done # --------------------------------------------------------------------------- # 基础检查 # --------------------------------------------------------------------------- require_root() { [ "$(id -u)" -eq 0 ] || die "请使用 sudo 运行此脚本" } detect_distro() { if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}" in ubuntu|debian) return 0 ;; esac fi warn "非 Ubuntu/Debian 系统,overlayroot 可能不可用,继续尝试..." } overlay_active() { grep -qs 'overlay' /proc/mounts } # --------------------------------------------------------------------------- # 磁盘/分区检测 # --------------------------------------------------------------------------- detect_root_disk() { ROOT_DEV="$(findmnt -no SOURCE / 2>/dev/null || true)" [ -n "${ROOT_DEV:-}" ] || die "无法定位根分区" case "$ROOT_DEV" in /dev/mmcblk*) ROOT_DISK="${ROOT_DEV%p*}"; ROOT_PART="${ROOT_DEV##*p}" ;; /dev/nvme*) ROOT_DISK="${ROOT_DEV%p*}"; ROOT_PART="${ROOT_DEV##*p}" ;; /dev/sd*) ROOT_DISK="${ROOT_DEV%[0-9]*}"; ROOT_PART="${ROOT_DEV##*[a-z]}" ;; *) die "无法识别的根设备: $ROOT_DEV(本脚本仅支持 SD 卡/eMMC/NVMe 直连分区)" ;; esac info "根分区: $ROOT_DEV → 磁盘: $ROOT_DISK" } # /data 是否已存在(已挂载,或磁盘上有数据分区) data_partition_exists() { # 已挂载 if mountpoint -q "$DATA_MOUNT" 2>/dev/null; then DATA_PART="$(findmnt -no SOURCE "$DATA_MOUNT" 2>/dev/null || true)" return 0 fi # 磁盘上已有标签 safesight-data 的分区(未挂载) for p in $(lsblk -ln -o NAME,TYPE "$ROOT_DISK" 2>/dev/null | awk '$2=="part"{print $1}'); do d="/dev/$p" if [ "$(blkid -s LABEL -o value "$d" 2>/dev/null)" = "$DATA_LABEL" ]; then DATA_PART="$d" return 0 fi done return 1 } find_free_space() { # 返回 "起始MiB 大小MiB"(取最后一个 >=2048MiB 的 Free Space),否则返回空 LANG=C parted -s "$ROOT_DISK" unit MiB print free 2>/dev/null | \ awk '$4=="Free" && $3>=2048 {start=$1; size=$3} END {if (size>=2048) print start, size}' } create_data_partition() { info "在 $ROOT_DISK 空闲空间中创建 /data 分区($DATA_LABEL)..." FREE_INFO="$(find_free_space || true)" [ -n "$FREE_INFO" ] || die "未找到足够空闲空间(至少 2GiB)。请先扩容 SD 卡镜像或参考文档手动划分 /data 分区。" FREE_START="$(echo "$FREE_INFO" | awk '{print $1}' | tr -d 'MiB')" FREE_SIZE="$(echo "$FREE_INFO" | awk '{print $2}')" echo "" echo "即将执行:" echo " 在 $ROOT_DISK 偏移 ${FREE_START}MiB 处创建 ext4 分区(约 ${FREE_SIZE})" echo " 分区标签: $DATA_LABEL" echo " 该分区将挂载到 $DATA_MOUNT,保存日志/告警/设备ID等持久数据" echo "" if [ "$ASSUME_YES" -ne 1 ]; then read -r -p "继续? [y/N]: " yn [ "${yn:-n}" = "y" ] || [ "${yn:-n}" = "Y" ] || die "已取消" fi # 分区表类型:gpt 用名称参数,msdos 用 primary TABLE_TYPE="$(parted -s "$ROOT_DISK" print 2>/dev/null | awk '/Partition Table/{print $3}')" if [ "$TABLE_TYPE" = "gpt" ]; then parted -s "$ROOT_DISK" mkpart "$DATA_LABEL" ext4 "${FREE_START}MiB" 100% else parted -s "$ROOT_DISK" mkpart primary ext4 "${FREE_START}MiB" 100% fi # 刷新分区表 partprobe "$ROOT_DISK" 2>/dev/null || partx -u "$ROOT_DISK" 2>/dev/null || true udevadm settle 2>/dev/null || sleep 3 # 新分区 = 磁盘上最后一个 part DATA_PART="$(lsblk -ln -o NAME,TYPE "$ROOT_DISK" | awk '$2=="part"{p=$1} END{print "/dev/"p}')" [ -n "${DATA_PART:-}" ] || die "分区创建失败" mkfs.ext4 -F -L "$DATA_LABEL" "$DATA_PART" ok "数据分区已创建: $DATA_PART (标签 $DATA_LABEL)" } # --------------------------------------------------------------------------- # fstab 写入(先于挂载执行) # --------------------------------------------------------------------------- write_fstab() { [ -n "${DATA_UUID:-}" ] || die "未获取到数据分区 UUID" local bak="$FSTAB.bak-$(date +%Y%m%d%H%M%S)" cp -a "$FSTAB" "$bak" info "fstab 已备份: $bak" # 移除旧的管理块(幂等) sed -i "/^${MARKER_BEGIN}/,/^${MARKER_END}/d" "$FSTAB" { echo "$MARKER_BEGIN" echo "# 独立读写数据分区(断电保护核心,rootfs 只读后唯一持久化区域)" echo "UUID=$DATA_UUID $DATA_MOUNT ext4 defaults,noatime,nofail,x-systemd.device-timeout=10s,commit=60 0 2" # safesight 运行数据 bind 到 /data(路径保持 /var/lib/... 不变,兼容现有部署) echo "# safesight 运行数据 → /data(日志/告警/视频/设备ID 断电不丢)" if [ "$HLS_MODE" = "ram" ]; then echo "# HLS 分片 → 内存 tmpfs(重启即丢,减小 SD 写入;容量按需调整)" echo "tmpfs $RUNTIME_ROOT/hls tmpfs defaults,noatime,size=512m,mode=755 0 0" else echo "$DATA_MOUNT/safesight/hls $RUNTIME_ROOT/hls none bind,nofail 0 0" fi echo "$DATA_MOUNT/safesight/logs $RUNTIME_ROOT/logs none bind,nofail 0 0" echo "$DATA_MOUNT/safesight/alarms $RUNTIME_ROOT/alarms none bind,nofail 0 0" echo "$DATA_MOUNT/safesight/clips $RUNTIME_ROOT/clips none bind,nofail 0 0" echo "$DATA_MOUNT/safesight/agent $AGENT_LIB none bind,nofail 0 0" if [ "$JOURNAL_MODE" = "data" ]; then echo "# 系统 journal → /data(断电不丢,便于事后排查)" echo "$DATA_MOUNT/safesight/journal /var/log/journal none bind,nofail 0 0" fi echo "$MARKER_END" } >> "$FSTAB" ok "fstab 已更新" } # --------------------------------------------------------------------------- # /data 目录初始化 + 存量数据迁移 + 挂载 # --------------------------------------------------------------------------- setup_data_dirs() { local sub for sub in $DATA_SUBDIRS; do mkdir -p "$DATA_MOUNT/safesight/$sub" done [ "$JOURNAL_MODE" = "data" ] && mkdir -p "$DATA_MOUNT/safesight/journal" # 迁移已有数据(bind 挂载前执行,避免覆盖) migrate_dir() { local src="$1" dst="$2" name="$3" # bind 已挂载时 src 与 dst 是同一目录,跳过 [ "$(readlink -f "$src")" != "$(readlink -f "$dst")" ] || return 0 if [ -d "$src" ] && [ -n "$(ls -A "$src" 2>/dev/null)" ]; then cp -a "$src"/. "$dst"/ && ok "已迁移 $name 存量数据 → $dst" fi } for sub in hls logs alarms clips; do migrate_dir "$RUNTIME_ROOT/$sub" "$DATA_MOUNT/safesight/$sub" "$sub" done migrate_dir "$AGENT_LIB" "$DATA_MOUNT/safesight/agent" "agent" # 确保 bind 目标目录存在 for sub in hls logs alarms clips; do mkdir -p "$RUNTIME_ROOT/$sub" done mkdir -p "$AGENT_LIB" mkdir -p /var/log/journal } mount_all() { # 挂载 /data 与全部 bind(依赖上一步 fstab/目录已就绪) mount -a 2>/dev/null || true if ! mountpoint -q "$DATA_MOUNT" 2>/dev/null; then die "$DATA_MOUNT 挂载失败,请检查 /etc/fstab" fi ok "$DATA_MOUNT 已挂载 rw (UUID=$DATA_UUID)" } # --------------------------------------------------------------------------- # journald 配置(控制系统日志内存占用 / 持久化) # --------------------------------------------------------------------------- setup_journald() { mkdir -p /etc/systemd/journald.conf.d if [ "$JOURNAL_MODE" = "data" ]; then cat > /etc/systemd/journald.conf.d/safesight.conf << 'EOF' [Journal] Storage=persistent SystemMaxUse=256M SystemMaxFile=32M MaxRetentionSec=14d SyncIntervalSec=1h EOF ok "journald → /data(持久化,上限 256M / 保留14天)" else cat > /etc/systemd/journald.conf.d/safesight.conf << 'EOF' [Journal] Storage=volatile RuntimeMaxUse=64M EOF ok "journald → 内存(volatile,上限 64M,重启即丢)" fi systemctl restart systemd-journald 2>/dev/null || true } # --------------------------------------------------------------------------- # overlayroot 安装与配置 # --------------------------------------------------------------------------- install_overlayroot() { if ! dpkg -s overlayroot >/dev/null 2>&1; then info "安装 overlayroot ..." apt-get update -qq 2>/dev/null || warn "apt update 失败(设备可能离线),尝试直接安装..." apt-get install -y overlayroot || die "overlayroot 安装失败。若设备离线,请将 overlayroot 预装进母卡镜像(sudo apt install overlayroot)后重新打包。" else ok "overlayroot 已安装" fi # 备份旧配置 if [ -f "$OVERLAY_CONF" ]; then cp -a "$OVERLAY_CONF" "$OVERLAY_CONF.bak-$(date +%Y%m%d%H%M%S)" fi cat > "$OVERLAY_CONF" << 'EOF' # /etc/overlayroot.conf — 由 setup-overlayroot.sh 生成 # 启用后根文件系统只读,一切写入进入内存(tmpfs),重启即丢,断电不损坏根分区。 # 维护模式: 运行 sudo /opt/safesight-edge-server/scripts/setup-overlayroot.sh disable overlayroot="tmpfs" overlayroot_tmpfs_size="50%" overlayroot_quiet="n" # /data 是独立分区(fstab 挂载,不经过 overlay),保持可写,无需排除。 # 若个别镜像出现其它分区被意外只读,可在此追加排除,例如: # overlayroot_exclude_partitions="/var/lib/docker" EOF ok "/etc/overlayroot.conf 已配置 (overlayroot=\"tmpfs\")" info "重建 initramfs ..." update-initramfs -u || die "update-initramfs 失败" if [ -d /boot/extlinux ]; then warn "检测到 extlinux 引导(U-Boot/香橙派)。overlayroot 由 initramfs 加载,无需改 extlinux.conf。" fi ok "initramfs 已更新" } # --------------------------------------------------------------------------- # 命令实现 # --------------------------------------------------------------------------- cmd_install() { require_root detect_distro detect_root_disk if overlay_active; then warn "当前系统已处于 overlayroot 保护模式。如需调整配置,请先运行: $0 disable" fi # 1) 数据分区 if data_partition_exists; then ok "检测到数据分区: ${DATA_PART:-(/data 已挂载)}" else if [ "${SKIP_PARTITION:-0}" = "1" ]; then die "SKIP_PARTITION=1 但未检测到 $DATA_LABEL 分区,请确认镜像包含数据分区" fi create_data_partition fi DATA_UUID="$(blkid -s UUID -o value "$DATA_PART" 2>/dev/null || true)" [ -n "$DATA_UUID" ] || die "未获取到数据分区 UUID ($DATA_PART)" # 2) fstab + 目录 + 挂载(fstab 先写,再按新 fstab 挂载) write_fstab setup_data_dirs mount_all # 3) journald setup_journald # 4) overlayroot install_overlayroot echo "" echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN} 配置完成!重启后根文件系统将进入只读保护模式${NC}" echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}" echo "" echo "下一步:" echo " sudo reboot # 重启生效" echo "" echo "重启后验证:" echo " sudo /opt/safesight-edge-server/scripts/setup-overlayroot.sh verify" echo "" echo "数据分区(断电不丢):" echo " $DATA_MOUNT/safesight/{logs,alarms,clips,agent}" echo " $DATA_MOUNT/safesight/hls # HLS 分片 (${HLS_MODE})" echo "" echo "日常维护(装软件/改系统配置,需临时可写):" echo " sudo /opt/safesight-edge-server/scripts/setup-overlayroot.sh disable" echo " sudo reboot # 进入可写维护模式" echo " sudo /opt/safesight-edge-server/scripts/setup-overlayroot.sh enable" echo " sudo reboot # 恢复只读保护" echo "" echo "注意:overlayroot 启用后,apt 安装、系统配置修改都会在重启后丢失," echo " 必须进入维护模式操作!" } cmd_status() { require_root echo -e "${BLUE}══════════ overlayroot 状态 ══════════${NC}" if overlay_active; then echo -e " 根文件系统: ${GREEN}● 保护模式(overlay, 只读+tmpfs)${NC}" mount | grep ' / ' | sed 's/^/ /' echo -e " 上层(tmpfs)容量: $(df -h / | awk 'NR==2{print $2}') 已用: $(df -h / | awk 'NR==2{print $3}')" else echo -e " 根文件系统: ${YELLOW}○ 可写模式(overlay 未启用)${NC}" fi echo "" if mountpoint -q "$DATA_MOUNT" 2>/dev/null; then echo -e " $DATA_MOUNT: ${GREEN}已挂载 rw${NC} $(findmnt -no SOURCE,FSTYPE "$DATA_MOUNT")" echo " 磁盘占用: $(df -h "$DATA_MOUNT" | awk 'NR==2{print "总"$2" 已用"$3" 可用"$4}')" else echo -e " $DATA_MOUNT: ${RED}未挂载${NC}" fi echo "" echo " bind 挂载:" for b in logs alarms clips hls agent; do m="$RUNTIME_ROOT/$b" [ "$b" = "agent" ] && m="$AGENT_LIB" if mountpoint -q "$m" 2>/dev/null; then echo -e " ${GREEN}✓${NC} $m → $(findmnt -no SOURCE "$m")" else echo -e " ${RED}✗${NC} $m 未挂载" fi done echo "" if [ -f "$OVERLAY_CONF" ]; then echo " /etc/overlayroot.conf:" grep -E '^overlayroot' "$OVERLAY_CONF" | sed 's/^/ /' else echo -e " /etc/overlayroot.conf: ${RED}不存在${NC}" fi if [ -f /etc/systemd/journald.conf.d/safesight.conf ]; then echo "" echo " journald: $(grep -E '^\s*(Storage|RuntimeMaxUse|SystemMaxUse)=' /etc/systemd/journald.conf.d/safesight.conf | tr '\n' ' ')" fi echo "" if [ -f "$AGENT_LIB/device_id" ]; then echo " 设备 ID: $(cat "$AGENT_LIB/device_id")(持久化于 /data)" else echo -e " 设备 ID: ${YELLOW}尚未生成${NC}" fi } cmd_verify() { require_root echo -e "${BLUE}══════════ 断电保护验证 ══════════${NC}" FAIL=0 if overlay_active; then ok "overlay 已启用,根分区写入受保护" else warn "overlay 未启用(仍处于可写模式)" FAIL=1 fi # /data 可写测试 if mountpoint -q "$DATA_MOUNT"; then TEST_FILE="$DATA_MOUNT/.write_test_$(date +%s)" if echo ok > "$TEST_FILE" 2>/dev/null && rm -f "$TEST_FILE"; then ok "$DATA_MOUNT 可读写" else warn "$DATA_MOUNT 不可写!" FAIL=1 fi else warn "$DATA_MOUNT 未挂载" FAIL=1 fi # bind 挂载 for b in logs alarms clips agent; do m="$RUNTIME_ROOT/$b" [ "$b" = "agent" ] && m="$AGENT_LIB" if mountpoint -q "$m" 2>/dev/null; then ok "$m → $(findmnt -no SOURCE "$m")" else warn "$m 未挂载(若为首次启用,服务运行后会自动创建)" fi done # 根分区是否真的只读(写测试) ROTEST="/.write_test_root_$$" if touch "$ROTEST" 2>/dev/null; then rm -f "$ROTEST" warn "警告: 根分区仍可写!请检查 overlayroot 配置" FAIL=1 else ok "根分区只读" fi # 服务状态 for s in safesight-edge-server safesight-edge-agent; do if systemctl is-active --quiet "$s" 2>/dev/null; then ok "$s 运行中" else warn "$s 未运行" fi done echo "" if [ "$FAIL" -eq 0 ]; then echo -e "${GREEN}✓ 断电保护配置正常,可放心断电${NC}" else echo -e "${YELLOW}存在未通过项,请按上方提示处理${NC}" fi exit "$FAIL" } # 切换 overlayroot 开关(disable=可写维护 / enable=恢复保护) toggle_overlay() { local mode="$1" require_root [ -f "$OVERLAY_CONF" ] || cp /dev/null "$OVERLAY_CONF" cp -a "$OVERLAY_CONF" "$OVERLAY_CONF.bak-$(date +%Y%m%d%H%M%S)" if [ "$mode" = "disable" ]; then sed -i 's/^overlayroot=.*/overlayroot=""/' "$OVERLAY_CONF" # 若文件里没有可识别的行则追加 grep -q '^overlayroot=' "$OVERLAY_CONF" || echo 'overlayroot=""' >> "$OVERLAY_CONF" echo "" echo -e "${YELLOW}overlayroot 已设为空(可写模式)。重启后根分区恢复可写,用于安装软件/修改配置。${NC}" else sed -i 's/^overlayroot=.*/overlayroot="tmpfs"/' "$OVERLAY_CONF" grep -q '^overlayroot=' "$OVERLAY_CONF" || echo 'overlayroot="tmpfs"' >> "$OVERLAY_CONF" echo "" echo -e "${GREEN}overlayroot 已重新启用(保护模式)。重启后根分区只读。${NC}" fi info "重建 initramfs ..." update-initramfs -u echo "" echo "请执行: sudo reboot" } # --------------------------------------------------------------------------- # 主入口 # --------------------------------------------------------------------------- case "$CMD" in install) cmd_install ;; status) cmd_status ;; verify) cmd_verify ;; disable) toggle_overlay disable ;; enable) toggle_overlay enable ;; *) echo "RK3588 Edge 断电保护脚本(overlayroot + /data)" echo "" echo "用法: sudo $(basename "$0") [命令] [选项]" echo "" echo "命令:" echo " install 一键启用断电保护(创建/挂载 /data + 启用 overlayroot)" echo " status 查看当前保护状态" echo " verify 重启后验证保护是否生效" echo " disable 临时禁用(维护模式:装软件/改系统配置)" echo " enable 重新启用(恢复保护)" echo "" echo "选项:" echo " --yes 跳过确认" echo " --hls-ram HLS 分片放内存(减小 SD 写入,重启即丢)" echo " --journal-data 系统日志持久化到 /data(默认内存)" echo "" echo "示例:" echo " sudo $(basename "$0") install" echo " sudo $(basename "$0") install --hls-ram" echo " sudo $(basename "$0") status" ;; esac