Paperless. Проблема с утечкой RAM

Всем привет.
Несколько недель назад захостил сервис для хранения документов Paperless по видеоинстрeкции от Romnero (установка в docker). В процессе эксплуатации столкнулся с рядом проблем - утчечка RAM и высокая утилизация CPU.

На просторах сети нашёл описание похожих проблем, так же автор канала Romnero в переписке подтвердил это.

  1. High CPU (& RAM?) idle usage on Cloudron · paperless-ngx/paperless-ngx · Discussion #7440 · GitHub
  2. Reddit — Сердце сети
  3. High cpu utilization since update 1.25.1 | Cloudron Forum
  4. https://www.youtube.com/watch?v=ygtp8Z5Mslk
Спойлер

Так как сервис мне понравился, то решил попробовать разобраться в проблеме и найти решение. Всё описанное ниже есть творчество человека, который является новичком в администрировании linux и не претендует на истину.
Поехали.

Архитектура

  1. Гипервизор Proxmox 8.4.1
  2. Установка в Unprivileged LXC containers (2CPU, 2Gb RAM, 0,5Gb swap, 8GB HDD)
  3. Сервисы Paperless-ngx v2.18.4 развёрнуты в виде docker образов.
    3.1 Добавлен образ Tika (извлечения текста и поддержка доп. форматов - docx, xlsx, ppt и др.)
    3.2 Добавлен образ Gotenberg (конвертация различных документов в PDF для дальнейшей обработки)

Описание проблемы

  1. Сервис после перезагрузки LXC потребляет в простое: 0,6% CPU, 0,88GB (43%) RAM, 0% swap.

  2. Сервис после загрузки документов (по 4 шт. каждого типа: docx, xlsx, txt, pdf, jpg) потребляет: 0,9% CPU, 0,76GB (38%) RAM, 0,46Gb 90% swap.
    Изменние потребления RAM: (0,76+0,46)-0,88=0,34Gb

Считаю это утечкой по причине того, что после завершения обработки документов в Paperless RAM не возвращается к исходным значениям и по истечении времени может протечь ещё больше.

В случае с отключением swap в LXC ситуация с RAM немного лучше, после загрузки того же тестового набора файлов RAM фиксируется на отметке 1,16Gb (утечка RAM 1,16-0,88=0,28Gb)

Поиск решения
1. Альтернативная архитектура
Установка сборки для LXC через helper-scripts.
В этой сборке отсутствуют компоненты Tika и Gotenberg, а так же используется предыдущая версия БД postgres v.16, в отличие от официальной сборки для docker с postgres v.17 (это некритично, но сам факт).
У меня не получилось установить Tika и Gotenberg непосредственнов LXC и связать с Paperless, поэтому добавил их в виде docker образов.
Результаты тестирования с swap и без него оказались идентичными.


Из плюсов:

  • Восстановление сервиса после перезагрузки LXC происходит в 2-3 раза быстрее (около 10 секунд) в сравнении с официальной сборкой.
  • Субъективно сервис работает чуть стабильнее. Не сталкивался с 100% утилизацией CPU из-за циклической ошибки в web-server при загрузкке тяжёлых pdf.

Из минусов:

  • Обновления зависят от разработчиков helper-scripts
  • Использование docker образов - может быть причина утечки в них.
  • Перенос БД между разными экземплярами Paperless нельзя выполнить простым копированием файлов на уровне директорий.
  • Без скрипта процесс настройки усложняется.

Для удобства сделал скрипт, который устанавливает Paperless через helper-scripts, добавляет Tika и Gotenberg docker образы, обновляет сервисы и переменные окружения, отдаёт работоспособный сервис в LXC под ключ:

Спойлер
## 🛠 Script: `setup_paperless_to_lxc.sh`
## Description
## The script installs Paperless in an LXC container using
## PVE helper-scripts.
## It also installs Docker Engine and pulls two images:
## Tika and Gotenberg — to enable support for working with format
##such as DOCX, XLS, and PPT.

#!/bin/bash

# ===================== SETTINGS =====================
START_TIME=$(date +%s)
TOTAL_STEPS=14
COMPLETED_STEPS=0
ERROR_STEPS=()

# For calculating file size
TOTAL_SIZE=0

# Colors
GREEN="\033[0;32m"
RED="\033[0;31m"
YELLOW="\033[1;33m"
NC="\033[0m" # no color
CHECK_MARK="\xE2\x9C\x85" # ✅
CROSS_MARK="\xE2\x9D\x8C" # ❌

# ===================== UTILITIES =====================
function show_progress() {
    local step=$1
    local message=$2
    echo -e "[${GREEN} Step $step/${TOTAL_STEPS}${NC}] $message"
}

function check_success() {
    if [ $? -eq 0 ]; then
        ((COMPLETED_STEPS++))
        echo -e "${GREEN}${CHECK_MARK} Success: $1${NC}"
    else
        ERROR_STEPS+=("$1")
        echo -e "${RED}${CROSS_MARK} Error during: $1${NC}"
    fi
}

# ===================== Step 1: Get container list before installation =====================
echo "📦 Getting list of containers before installation......"
mapfile -t existing_cts < <(pct list | awk 'NR>1 {print $1}' | sort -n)

# ===================== Step 1.2: Start Paperless installation =====================
echo
echo "🚀 Launching paperless-ngx installation (interactive mode)..."
echo "Please complete all installation steps interactively."
echo

bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/paperless-ngx.sh)"

sleep 5

# ===================== Step 1.3: Get containers after installation =====================
show_progress 1 "📦 Getting list of containers after installation..."
mapfile -t new_cts < <(pct list | awk 'NR>1 {print $1}' | sort -n)
check_success "✅ Container list after installation received"

# ===================== Step 2: Determine CTID =====================
show_progress 2 "🆔 ОDetermining ID of new container..."
CTID=$(comm -13 <(printf "%s\n" "${existing_cts[@]}") <(printf "%s\n" "${new_cts[@]}") | head -n1 | tr -d '[:space:]')

if [ -z "$CTID" ]; then
  echo -e "\n❌ No new container found."
  echo "ℹ️ Make sure you've created a new LXC container before proceeding."
  echo "📌 Current containers:"
  printf "%s\n" "${new_cts[@]}"
  ERROR_STEPS+=("New container not found")
  exit 1
fi
check_success "✅ New container created with ID: $CTID"

# ===================== Step 3: Install bc in LXC =====================
# Installing 'bc' utility (Basic Calculator) in LXC...
show_progress 3 "🖩 Installing 'bc' utility (Basic Calculator)..."
pct exec $CTID -- bash -c "apt-get update -qq && apt-get install -y bc > /dev/null 2>&1"

# Saving the size of directories BEFORE installation
INITIAL_SIZE=$(pct exec "$CTID" -- bash -c \
  "du -sk /opt/paperless /var/lib/docker /var/lib/systemd /var/log 2>/dev/null | awk '{sum += \$1} END {print int(sum)}'")

# ===================== Step 4: Install Docker Engine =====================
show_progress 4 "🐳 Installing Docker Engine in container $CTID..."
pct exec $CTID -- bash -c "curl -fsSL https://get.docker.com -o get-docker.sh"
pct exec $CTID -- bash -c "sh get-docker.sh"
echo "🔍 Verifying Docker installation..."
if ! pct exec $CTID -- bash -c "docker --version"; then
  echo "❌ Error: Docker not installed or not working."
  ERROR_STEPS+=("Docker Engine not installed or not working.")
  exit 1
fi
check_success "✅ Docker Engine successfully installed in container $CTID"

# ===================== Step 5: Add root to docker group =====================
show_progress 5 "➕ Adding root user to docker group..."
if ! pct exec $CTID -- bash -c "usermod -aG docker root"; then
  echo "⚠️ Warning: Failed to add root to docker group (can be ignored)."
  ERROR_STEPS+=("Failed to add root to docker group")
fi

echo "🔍 Checking root user groups..."
if ! pct exec $CTID -- bash -c "id root"; then
  echo "⚠️ ОWarning: Failed to verify root's groups (can be ignored)."
  ERROR_STEPS+=("Failed to verify root group")
fi
check_success "✅ Root user added to docker group"

# ===================== Step 6: Start Tika =====================
show_progress 6 "🐳 Starting Tika container..."
pct exec $CTID -- bash -c "docker run -it -d --restart=always --name tika -p 9998:9998 apache/tika:latest"
check_success "✅ Tika container started"

# ===================== Step 7: Start Gotenberg =====================
show_progress 7 "📄 Starting Gotenberg container..."
pct exec $CTID -- bash -c "docker run -it -d --restart=always --name gotenberg -p 3000:3000 thecodingmachine/gotenberg:latest"
check_success "✅ Gotenberg container started"

# ===================== Step 8: Check containers =====================
show_progress 8 "🔍 Checking Docker containers..."
docker_output=$(pct exec $CTID -- bash -c "docker ps --format '{{.Names}}'" || echo "error")

if [[ "$docker_output" == "error" ]]; then
  echo "❌ Failed to get list of Docker containers"
  ERROR_STEPS+=("Docker container list check failed")
  exit 1
fi

if echo "$docker_output" | grep -q tika && echo "$docker_output" | grep -q gotenberg; then
  echo "✅ Tika and Gotenberg containers are running."
else
  echo "❌ One or both containers are not running"
  echo "Active containers: $docker_output"
  ERROR_STEPS+=("Tika or Gotenberg containers not running")
  exit 1
fi
check_success "✅ Tika and Gotenberg containers are running"

# ===================== Step 9: Configure environment variables =====================
show_progress 9 "⚙️ Configuring environment variables in paperless.conf..."
CONFIG_FILE="/opt/paperless/paperless.conf"
# Key=value pairs
declare -A settings=(
  ["PAPERLESS_OCR_LANGUAGE"]="rus"
  ["PAPERLESS_OCR_SKIP_ARCHIVE_FILE"]="always"
  ["PAPERLESS_TIME_ZONE"]="Europe/Moscow"
  ["PAPERLESS_ENABLE_UPDATE_CHECK"]="true"
  ["PAPERLESS_TIKA_ENABLED"]="true"
  ["PAPERLESS_GOTENBERG_ENABLED"]="true"
  ["PAPERLESS_TIKA_ENDPOINT"]="http://localhost:9998"
  ["PAPERLESS_TIKA_GOTENBERG_ENDPOINT"]="http://localhost:3000"
)

# Processing each setting
for key in "${!settings[@]}"; do
  value="${settings[$key]}"

  # Check: whether the line exists (commented or not)
  pct exec "$CTID" -- bash -c "
    if grep -qE '^\s*#?\s*${key}=' /opt/paperless/paperless.conf 2>/dev/null; then
    # Update the value and remove the comment
      sed -i -E 's|^\s*#?\s*${key}=.*|${key}=${value}|' /opt/paperless/paperless.conf
  else
    # Add the line to the end if it doesn't exist
      echo '${key}=${value}' >> /opt/paperless/paperless.conf
  fi
  "
done
check_success "✅ Environment variables configured"

# ===================== Step 10: Install Russian OCR =====================
show_progress 10 "🈷️ Installing tesseract-ocr-rus..."
pct exec $CTID -- apt install -y tesseract-ocr-rus
check_success "✅ Tesseract OCR RUS installed"

# ===================== Step 11: Restart systemd =====================
show_progress 11 "🔄 Restarting systemd..."
pct exec $CTID -- systemctl daemon-reexec
pct exec $CTID -- systemctl daemon-reload
check_success "✅ systemd restarted"

# ===================== Step 12: Restart services =====================
show_progress 12 "♻️ Restarting Paperless services..."
pct exec $CTID -- systemctl restart paperless-consumer.service paperless-scheduler.service paperless-task-queue.service paperless-webserver.service
check_success "✅ Paperless services restarted"

# ===================== Step 13: Check service status =====================
show_progress 13 "🔍 Checking status of Paperless services..."
service_status=$(pct exec $CTID -- bash -c "systemctl list-units --type=service | grep paperless")

echo "$service_status"

errors=0
while IFS= read -r line; do
  service_name=$(echo "$line" | awk '{print $1}')
  status=$(echo "$line" | awk '{print $4}')
  if [[ "$status" != "running" ]]; then
    echo "⚠️ Warning: service $service_name has status $status"
    errors=$((errors+1))
  fi
done <<< "$service_status"

if [[ $errors -eq 0 ]]; then
  echo "✅ All Paperless services are running correctly."
else
  echo "⚠️ Some services are not in 'running' state."
fi
check_success "✅ Paperless service statuses checked"

# ===================== ШStep 14: Retrieve credentials =====================
show_progress 14 "📄 Retrieving credentials from paperless-ngx.creds..."
# Get only IPv4 address (filter by eth)
IP=$(pct exec "$CTID" -- bash -c \
  "ip -4 -o addr show | grep 'eth' | awk '{print \$4}' | cut -d/ -f1 | head -n1")
# Check if an IP was obtained
if [ -z "$IP" ]; then
    IP="⚠️Container_$CTID_has_no_IPv4_address"
else
    :
fi

# Retrieve and display credentials
pct exec $CTID -- bash -c "
if [ -f ~/paperless-ngx.creds ]; then
  LOGIN=\$(grep -i 'Paperless-ngx WebUI User:' ~/paperless-ngx.creds | cut -d ':' -f2- | xargs)
  PASSWORD=\$(grep -i 'Paperless-ngx WebUI Password:' ~/paperless-ngx.creds | cut -d ':' -f2- | xargs)

  echo -e '########################################################'
  echo -e 'Paperless-ngx service is available:\n'
  echo '    🌐   URL: ${IP}:8000'
  echo '    👱   Login: '\$LOGIN
  echo '    🔑   Password: '\$PASSWORD
  echo '    ℹ️   For information:'
  echo '    ⚙️   ~/paperless-ngx.creds'
  echo '    ⚙️   /opt/paperless/data'
  echo '    ⚙️   /opt/paperless/media'
  echo '    ⚙️   /opt/paperless/static'
  echo '    ⚙️   /opt/paperless/consume'
  echo -e '########################################################\n'

else
  echo -e '########################################################'
  echo -e 'Paperless-ngx service is available:\n'
  echo '    🌐   URL: ${IP}:8000'
  echo '    👱   Login: '⚠️ ~/paperless-ngx.creds file not found'
  echo '    🔑   Password: '⚠️  ~/paperless-ngx.creds file not found'
  echo '    ℹ️   For information:'
  echo '    ⚙️   ~/paperless-ngx.creds'
  echo '    ⚙️   /opt/paperless/data'
  echo '    ⚙️   /opt/paperless/media'
  echo '    ⚙️   /opt/paperless/static'
  echo '    ⚙️   /opt/paperless/consume'
  echo -e '########################################################\n'
  exit 1
fi
"
check_success "✅ Credentials retrieved from paperless-ngx.creds"

# ===================== FINAL =====================
END_TIME=$(date +%s)
EXECUTION_TIME=$((END_TIME - START_TIME))

# Total size of related files
FINAL_SIZE=$(pct exec "$CTID" -- bash -c "du -sk /opt/paperless /var/lib/docker /var/lib/systemd /var/log 2>/dev/null | awk '{sum += \$1} END {print int(sum)}'")
DIFF_SIZE=$((FINAL_SIZE - INITIAL_SIZE))

echo -e "\n${YELLOW}===== SCRIPT EXECUTION SUMMARY =====${NC}"
echo -e "${GREEN}Steps completed successfully: $COMPLETED_STEPS out of $TOTAL_STEPS${NC}"

if [ ${#ERROR_STEPS[@]} -gt 0 ]; then
    echo -e "${RED}Errors in steps:${NC}"
    for step in "${ERROR_STEPS[@]}"; do
        echo -e " - $step"
    done
else
    echo -e "${GREEN}No errors.${NC}"
fi

echo -e "⏱️ Script execution time: ${EXECUTION_TIME} seconds"
echo -e "📦 Approximate size of created files and services: ${DIFF_SIZE} KB"

Как запустить скрипт:

  1. Создать в Proxmox файл и скопировать в него код, например script.sh
  2. Добавить право на выполнение файла: chmod +x script.sh
  3. Выполнить скрипт с хоста Proxmox: ./script.sh

2. Приделываем костыли
В ходе всех изысканий пришёл к тому, чтобы реализовать скрипт для официальной сборки на основне docker образов, который будет перезагружать docker контейнер с web-server (как самого тяжёловесного) следущим образом:

  • При достижении CPU 85% в течение 10 минут с интервалом проверки 1 минута
  • При достижении RAM 70% в течение 10 минут с интервалом проверки 1 минута
  • Опционально настройка ежедневной перезагрузки через планировщик crontab.
Спойлер
## 🛠 Script: `setup_monitor.sh`
## Description
## This script is a workaround for addressing the issue of
## RAM and CPU leaks in the Paperless service deployed in docker.
## The script monitors the CPU and RAM utilization of the webserver Docker container.
## If threshold values are exceeded for a specified period of time,
## it automatically restarts the webserver docker container.
## The host operating system and other docker containers of the Paperless service
## continue running, which minimizes downtime.
## All variable values are configurable through an interactive dialog.
## Optionally, you can use a script to set up a crontab schedule for daily
## restarting of the Paperless web server Docker container.
## This script was written with the help of AI.

#!/bin/bash

# ===================== SETTINGS =====================
START_TIME=$(date +%s)
TOTAL_STEPS=16
COMPLETED_STEPS=0
ERROR_STEPS=()

# For calculating file size
TOTAL_SIZE=0

# Colors
GREEN="\033[0;32m"
RED="\033[0;31m"
YELLOW="\033[1;33m"
NC="\033[0m" # no color
CHECK_MARK="\xE2\x9C\x85" # ✅
CROSS_MARK="\xE2\x9D\x8C" # ❌

# ===================== UTILITIES =====================
function show_progress() {
    local step=$1
    local message=$2
    echo -e "[${GREEN} Step $step/${TOTAL_STEPS}${NC}] $message"
}

function check_success() {
    if [ $? -eq 0 ]; then
        ((COMPLETED_STEPS++))
        echo -e "${GREEN}${CHECK_MARK} Success: $1${NC}"
    else
        ERROR_STEPS+=("$1")
        echo -e "${RED}${CROSS_MARK} Error during: $1${NC}"
    fi
}

# ===================== Step 1: Install bc =====================
show_progress 1 "Installing 'bc' utility (Basic Calculator)..."
apt-get update -qq && apt-get install -y bc > /dev/null 2>&1
check_success "bc utility installed"

# ===================== Step 2: Dialog - Choose script path =====================
show_progress 2 "Choosing path for the script monitor_CPU_RAM_docker.sh..."

read -p "Use default path (/home/scripts)? [Y/n]: " default_path
if [[ "$default_path" =~ ^[Nn]$ ]]; then
    read -p "Enter the directory path: " custom_path
    read -p "Create 'scripts' directory inside it? [Y/n]: " create_dir
    if [[ "$create_dir" =~ ^[Nn]$ ]]; then
        TARGET_SCRIPT_DIR="$custom_path"
    else
        TARGET_SCRIPT_DIR="$custom_path/scripts"
    fi
else
    TARGET_SCRIPT_DIR="/home/scripts"
fi

mkdir -p "$TARGET_SCRIPT_DIR"
chmod 755 "$TARGET_SCRIPT_DIR"
check_success "Script directory created: $TARGET_SCRIPT_DIR"

# ===================== Step 3: Create monitor_CPU_RAM_docker.sh =====================
show_progress 3 "Creating monitor_CPU_RAM_docker.sh file..."
SCRIPT_PATH="$TARGET_SCRIPT_DIR/monitor_CPU_RAM_docker.sh"
touch "$SCRIPT_PATH"
chmod 755 "$SCRIPT_PATH"

# ===================== Step 3.1: Write monitoring script code =====================
cat << 'EOF' > "$SCRIPT_PATH"
#!/bin/bash
# Find the Docker container name
CONTAINER_NAME=$(docker ps --format '{{.Names}}' | grep -F 'webserver' | head -n 1)

# CPU and RAM threshold values (in percent)
CPU_THRESHOLD=170
RAM_THRESHOLD=70

# Time for resource usage evaluation (in seconds)
## CPU/RAM usage check interval: 1 minute
CHECK_INTERVAL=60
## Allowed time for exceeding CPU/RAM usage thresholds: 5 minutes
MONITOR_TIME=600

# Log file path
LOG_FILE="/home/log/docker_resource_monitor.txt"

# Get current CPU usage
get_cpu_usage() {
    docker stats --no-stream --format "{{.CPUPerc}}" "$CONTAINER_NAME" | tr -d '%'
}

# Get current RAM usage
get_ram_usage() {
    docker stats --no-stream --format "{{.MemPerc}}" "$CONTAINER_NAME" | tr -d '%'
}

# Log message function
log_message() {
    echo "$(date '+%d-%m-%Y %H:%M:%S') - $1" >> "$LOG_FILE"
    tail -n 10 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
}

# Main monitoring loop
start_time=$(date +%s)
while true; do
    # Get current CPU and RAM values
    cpu_usage=$(get_cpu_usage)
    ram_usage=$(get_ram_usage)
   
    # Convert strings to numbers
    cpu_usage=${cpu_usage//[^0-9.]/}
    ram_usage=${ram_usage//[^0-9.]/}

    # Check if CPU or RAM usage exceeds the threshold
    if (( $(echo "$cpu_usage > $CPU_THRESHOLD" | bc -l) )); then
        # If the CPU threshold is exceeded
        current_time=$(date +%s)
        elapsed_time=$((current_time - start_time))
        if ((elapsed_time >= MONITOR_TIME)); then
            # If MONITOR_TIME seconds have passed, log the event and restart the container
            log_message "RAM threshold ($CPU_THRESHOLD%) exceeded. Current: $cpu_usage%"
            docker restart "$CONTAINER_NAME"
            start_time=$current_time  # Update the start time
        fi
    elif (( $(echo "$ram_usage > $RAM_THRESHOLD" | bc -l) )); then
        # If the RAM threshold is exceeded
        current_time=$(date +%s)
        elapsed_time=$((current_time - start_time))
        if ((elapsed_time >= MONITOR_TIME)); then
            # If 10 seconds have passed, log the event and restart the container
            log_message "RAM threshold ($RAM_THRESHOLD%) exceeded. Current: $ram_usage%"
            docker restart "$CONTAINER_NAME"
            start_time=$current_time  # Update the start time
        fi
    else
        # If resources do not exceed the threshold, reset the timer
        start_time=$(date +%s)
    fi
    # Wait before the next check cycle
    sleep $CHECK_INTERVAL
done
EOF
check_success "Resource monitoring script created"

# ===================== Step 4: Dialog - set variable values =====================
show_progress 4 "Configuring variables..."

read -p "Enter CPU threshold (e.g., 85 for 1 CPU = 85, 2 CPUs = 170, etc.): " CPU_THRESHOLD
read -p "Enter RAM threshold (e.g., 70 for 70%): " RAM_THRESHOLD
read -p "Enter check interval in seconds (e.g., 60): " CHECK_INTERVAL
read -p "Enter max allowed threshold time in seconds (e.g., 300): " MONITOR_TIME

sed -i "s/^CPU_THRESHOLD=.*/CPU_THRESHOLD=$CPU_THRESHOLD/" "$SCRIPT_PATH"
sed -i "s/^RAM_THRESHOLD=.*/RAM_THRESHOLD=$RAM_THRESHOLD/" "$SCRIPT_PATH"
sed -i "s/^CHECK_INTERVAL=.*/CHECK_INTERVAL=$CHECK_INTERVAL/" "$SCRIPT_PATH"
sed -i "s/^MONITOR_TIME=.*/MONITOR_TIME=$MONITOR_TIME/" "$SCRIPT_PATH"
check_success "Script variables updated"

# ===================== Step 5: Create log directory =====================
show_progress 5 "Creating log directory..."

read -p "Use /home/log for logs? [Y/n]: " use_default_log
if [[ "$use_default_log" =~ ^[Nn]$ ]]; then
    read -p "Enter custom log directory: " custom_log_dir
    read -p "Create 'log' subdirectory inside? [Y/n]: " create_log_subdir
    if [[ "$create_log_subdir" =~ ^[Nn]$ ]]; then
        LOG_DIR="$custom_log_dir"
    else
        LOG_DIR="$custom_log_dir/log"
    fi
else
    LOG_DIR="/home/log"
fi

mkdir -p "$LOG_DIR"
chmod 755 "$LOG_DIR"
check_success "Log directory created: $LOG_DIR"

# ===================== Step 6: Create log file =====================
show_progress 6 "Creating log file..."

LOG_FILE="$LOG_DIR/log_docker_resources.txt"
touch "$LOG_FILE"
chmod 766 "$LOG_FILE"
check_success "Log file created: $LOG_FILE"

# Update log file path in the script
sed -i "s|^LOG_FILE=.*|LOG_FILE=\"$LOG_FILE\"|" "$SCRIPT_PATH"

# ===================== Step 7: Create systemd unit =====================
show_progress 7 "Creating systemd service..."

SERVICE_PATH="/etc/systemd/system/monitor_CPU_RAM_docker.service"
cat << EOF > "$SERVICE_PATH"
[Unit]
Description=Script 'monitor_CPU_RAM_docker.sh' starts when container starts

[Service]
Type=simple 
User=root
ExecStart=$SCRIPT_PATH

[Install]
WantedBy=multi-user.target
EOF

chmod 644 "$SERVICE_PATH"
check_success "systemd unit file created"

# ===================== Steps 8-10: Enable and start systemd =====================
show_progress 8 "Reloading systemd..."

systemctl daemon-reload
check_success "systemd reloaded"

show_progress 9 "Enabling service to start at boot..."
systemctl enable monitor_CPU_RAM_docker.service --now
check_success "Service autostart enabled"

show_progress 10 "Starting the systemd service..."
systemctl start monitor_CPU_RAM_docker.service
check_success "systemd service started"

# ===================== Step 11: Create cron job =====================
show_progress 11 "Creating cron job to restart paperless webserver..."

read -p "Create cron job to restart paperless webserver? [Y/n]: " setup_cron
if [[ "$setup_cron" =~ ^[Nn]$ ]]; then
    echo -e "${YELLOW}Skipping cron job creation.${NC}"
else

    # === Step 11.1: Timezone setup ===
    echo -e "${YELLOW}Set timezone?${NC}"
    echo "1. Default: Europe/Moscow"
    echo "2. Enter custom timezone"
    read -p "Choose option (1/2): " tz_choice

    if [[ "$tz_choice" == "2" ]]; then
        read -p "Enter your timezone (e.g., Europe/Berlin): " user_timezone
        if timedatectl list-timezones | grep -q "^$user_timezone$"; then
            timedatectl set-timezone "$user_timezone"
            check_success "Timezone set: $user_timezone"
        else
            echo -e "${RED}Invalid timezone. Keeping current.${NC}"
        fi
    else
        timedatectl set-timezone "Europe/Moscow"
        check_success "Timezone set: Europe/Moscow"
    fi


    # === Step 11.2: Choose path for log_reboot_docker.txt ===
    echo -e "${YELLOW}Select path for the log file log_reboot_docker.txt:${NC}"
    echo "1. Default: /home/log"
    echo "2. Specify path manually and create 'log' subdirectory"
    echo "3. Specify path manually without subdirectory"
    read -p "Enter choice (1/2/3): " log_choice

    case $log_choice in
        2)
            read -p "Enter directory path: " custom_log_path
            CRON_LOG_DIR="$custom_log_path/log"
            ;;
        3)
            read -p "Enter directory path: " custom_log_path
            CRON_LOG_DIR="$custom_log_path"
            ;;
        *)
            CRON_LOG_DIR="/home/log"
            ;;
    esac

    mkdir -p "$CRON_LOG_DIR"
    chmod 755 "$CRON_LOG_DIR"
    check_success "Log directory created: $CRON_LOG_DIR"

    CRON_LOG_FILE="$CRON_LOG_DIR/log_reboot_docker.txt"
    touch "$CRON_LOG_FILE"
    chmod 766 "$CRON_LOG_FILE"
    check_success "Log file created: $CRON_LOG_FILE"

    # === Step 11.3: Choose path for crontab_reboot_docker.sh ===
    echo -e "${YELLOW}Select path for the script crontab_reboot_docker.sh:${NC}"
    echo "1. Default: /home/scripts"
    echo "2. Specify path manually and create 'scripts' subdirectory"
    echo "3. Specify path manually without subdirectory"
    read -p "Enter choice (1/2/3): " script_choice

    case $script_choice in
        2)
            read -p "Enter directory path: " custom_script_path
            CRON_SCRIPT_DIR="$custom_script_path/scripts"
            ;;
        3)
            read -p "Enter directory path: " custom_script_path
            CRON_SCRIPT_DIR="$custom_script_path"
            ;;
        *)
            CRON_SCRIPT_DIR="/home/scripts"
            ;;
    esac

    mkdir -p "$CRON_SCRIPT_DIR"
    chmod 755 "$CRON_SCRIPT_DIR"
    check_success "Script directory created: $CRON_SCRIPT_DIR"

    # === Step 11.4: Create the script crontab_reboot_docker.sh ===
    CRON_SCRIPT_PATH="$CRON_SCRIPT_DIR/crontab_reboot_docker.sh"
    cat << EOF > "$CRON_SCRIPT_PATH"
#!/bin/bash
# Description of actions
# The Docker web server container for the Paperless service is restarted
# A message is written to the log file: log_reboot_docker.txt
# Containing the following information:
## Docker container name: paperless-webserver-1
## Restart date in the format: DD.MM.YYYY
## Restart time in the format: hh.mm.ss
# The expression "2>&1" is used to redirect the error stream (stderr)
# to the output stream (stdout)

# Searching for the docker container name 'webserver'
CONTAINER_NAME=$(docker ps --format '{{.Names}}' | grep -F 'webserver' | head -n 1)
# Restarting the docker container and writing to the log file
docker restart \$CONTAINER_NAME && echo "last reboot paperless-webserver-1: \$(date +%d.%m.%Y' '%H:%M:%S)" > "$CRON_LOG_FILE" 2>&1
EOF

    chmod 755 "$CRON_SCRIPT_PATH"
    check_success "The script created: $CRON_SCRIPT_PATH"

    # === Step 11.5: Add to crontab ===
    echo -e "${YELLOW}Set schedule for crontab:${NC}"
    echo "1. Daily at 00:00 (default)"
    echo "2. Specify custom schedule"
    read -p "Enter choice (1/2): " cron_schedule_choice

    if [[ "$cron_schedule_choice" == "2" ]]; then
        read -p "Enter schedule in crontab format (e.g., '30 3 * * *'): " custom_cron_schedule
        CRON_SCHEDULE="$custom_cron_schedule"
    else
        CRON_SCHEDULE="0 0 * * *"
    fi

    (crontab -l 2>/dev/null; echo "$CRON_SCHEDULE $CRON_SCRIPT_PATH") | crontab -
    check_success "Task added to crontab: $CRON_SCHEDULE $CRON_SCRIPT_PATH"
fi

# ===================== FINAL =====================
END_TIME=$(date +%s)
EXECUTION_TIME=$((END_TIME - START_TIME))

# Total size of related files
FINAL_SIZE=$(pct exec $CTID -- bash -c "du -sk /opt/paperless /var/lib/docker /var/lib/systemd /var/log 2>/dev/null | awk '{sum += \$1} END {print sum}'")
DIFF_SIZE=$((FINAL_SIZE - INITIAL_SIZE))

echo -e "\n${YELLOW}===== SCRIPT EXECUTION SUMMARY =====${NC}"
echo -e "${GREEN}Steps completed successfully: $COMPLETED_STEPS из $TOTAL_STEPS${NC}"

if [ ${#ERROR_STEPS[@]} -gt 0 ]; then
    echo -e "${RED}Errors in steps:${NC}"
    for step in "${ERROR_STEPS[@]}"; do
        echo -e " - $step"
    done
else
    echo -e "${GREEN}No errors.${NC}"
fi

echo -e "Script execution time: ${EXECUTION_TIME} seconds"
echo -e "📦 Approximate size of created files and services: ${DIFF_SIZE} KB"

Как запустить скрипт:

  1. Создать в LXC файл и скопировать в него код, например script.sh
  2. Добавить право на выполнение файла: chmod +x script.sh
  3. Выполнить скрипт в LXC: ./script.sh

В скрипте не реализован мониторинг активных процессов обработки дкументов в Paperless, поэтому если актуален сценарий с ситематической загрузкой больших пакетов документов, то можно настроить предельное пороговое время больше 10 минут, например 60 минут.
Назначение порогового значения для CPU имеет особенность - это значение необходимо умножать на количество CPU выделенных для LXC. Например, чтобы выставить пороговое значение для CPU в 85% для LXC:

  • с 1 CPU, то указываем: 85
  • с 2 CPU, то указываем: 170
  • с 3 CPU, то указываем: 255
    и т.д.

Из плюсов:

  • Наличие официальных обновлений
  • Всё работает из “коробки” при минимальных затратах на настройки
  • Простое конфигурирование сервиса через docker-compose
  • Простой перенос БД между разными экземплярами Paperless через копирование директорий data и pgdata

Из минусов:

  • Субъективно менее стабильный (несколько раз сталкивался с 100% утилизацией CPU из-за циклической ошибки в web-server при загрузкке тяжёлых pdf).
  • Сложнее диагностировать и анализировать утилизацию HDD (не всегда удаляются временные файлы из директории /tmp/paperless, а в случае с docker для этого нужно получать доступ в контейнер с web-server).

Ещё выявил одну особенность сервиса связанную с очисткой временных файлов в директории /tmp/paperless (для официальной версии на базе docker образов эта директория находится внутри docker контейнера web-server). Если посмотреть на эту директорию через утилиту ncdu, то можно обраружить в ней файлы, в т.ч. после загрузок, которые завершились с ошибкой. Таким образом эта директория потенциально может подъедать хранилище в LXC.

Например вы прокинули все каталоги с media и data в NAS и рассчитываете на то, что LXC в части хранилища расти не будет, но в один прекрасный день сервис может упасть, поэтому нужно настраивать мониторинг и создавать расписание в crontab на очистку этой директории.

Итоги
Для себя остановлися на официальной сборке на базе образов docker, отключил swap для LXC и внедрил скрипт для пониторинга и перезагрузки сервисов по расписанию.
Надеюсь, разработчики стабилизируют сервис или светлые головы из комьюнити найдут более изящное решение.
P.s.: Cкрипты писались с помощью ChatGPT и отлаживались на двух версиях Proxmox: 8.4.1 и 9.0.10.

3 лайка