OpenWISP 1.3.2 Ubuntu 22.04 安装指南

在 Ubuntu 22.04.5 LTS 上用 Python 虚拟环境或离线 wheel 安装 openwisp-controller 1.3.2,配套 PostgreSQL 14 + PostGIS、Redis、uWSGI 与 Nginx,systemd 托管并完成升级回滚。

OpenWISPOpenWISP网络管理Ubuntu 22.042026/10/28

OpenWISP 1.3.2 Ubuntu 22.04 安装指南

目标:在 Ubuntu 22.04.5 LTS 上从零部署一套可登录、可下发配置的 OpenWISP Controller(1.3.2),包含 PostgreSQL 14 + PostGIS 3.2、Redis 6、uWSGI、Celery worker/beat 与 Nginx 反向代理,全部由 systemd 托管。文中命令均在本机真实执行,回显照抄;离线安装方案同样实测通过。配置项逐条解释见 《OpenWISP 1.3.2 配置参数详解》,日常命令与备份排障见 《OpenWISP 1.3.2 常用 CLI 与运维命令详解》。

一、环境信息

以下为本次实测主机与组件版本(2026-10-28 采集):

  • 操作系统:Ubuntu 22.04.5 LTS(PRETTY_NAME="Ubuntu 22.04.5 LTS")
  • 主机资源:6 vCPU,18 GiB 内存,/opt 可用 151 GiB
  • Python:3.10.12(系统自带)
  • openwisp-controller:1.3.2(PyPI,2026-10-23 发布)
  • Django:5.2.18
  • PostgreSQL:14.24(14.24-0ubuntu0.22.04.1)
  • PostGIS:3.2.0(3.2.0+dfsg-1ubuntu1,postgis_version() 返回 3.2 USE_GEOS=1 USE_PROJ=1 USE_STATS=1)
  • Redis:6.0.16(5:6.0.16-1ubuntu1.2)
  • Nginx:1.18.0(1.18.0-6ubuntu14.22)
  • uWSGI:2.0.20(2.0.20-4,含 uwsgi-plugin-python3)
  • 应用目录:/opt/openwisp/venv(虚拟环境)、/opt/openwisp/app(Django 项目)
  • 配置目录:/etc/openwisp/(uWSGI 配置)、/etc/systemd/system/(服务单元)
  • 日志目录:/var/log/openwisp/
  • 监听端口:Nginx 80(对外)、PostgreSQL 5432、Redis 6379、uWSGI 仅 Unix Socket /run/uwsgi/openwisp.sock
  • 站点域名:openwisp.example.com(解析到本机)

💡 说明:OpenWISP 官方文档站当前显示 “OpenWISP 26.09 documentation”,而 PyPI 包版本是 1.3.2,两者编号体系不同。本文以 PyPI 包版本 1.3.2 为安装目标,文档站仅用于查配置项与操作说明。

二、前置条件

部署前需要确认三件事:系统版本、网络可达性、端口与目录规划。OpenWISP 是标准 Django 应用,对系统没有特殊内核要求,但 PostGIS 与 Python 编译依赖必须装齐。

系统与资源

  • Ubuntu 22.04.x LTS(22.04.5 实测),Python ≥ 3.10;官方文档标注 Python ≥ 3.10。
  • 建议至少 2 vCPU、4 GiB 内存、40 GiB 磁盘;本次实测 6 vCPU / 18 GiB 属于充裕配置。
  • 需要 root 或 sudo 权限执行软件包安装与 systemd 托管。

网络

  • 服务器需能访问 pypi.org 与 files.pythonhosted.org(在线方案)。
  • 若服务器在隔离网络,先在可联网机器执行本文第五章的离线打包流程。
  • 若下载缓慢,可临时设置 HTTP 代理:export https_proxy=http://192.168.1.62:7897(代理地址按实际环境替换)。

端口

端口用途是否对外
80Nginx 反向代理是
5432PostgreSQL否(仅本机)
6379Redis否(仅本机)
Unix SocketuWSGI 与 Nginx 通信否(文件系统内)

目录规划

路径用途属主
/opt/openwisp/venvPython 虚拟环境root
/opt/openwisp/appDjango 项目、静态文件、媒体文件www-data
/etc/openwispuWSGI 配置root
/var/log/openwisp应用与 uWSGI 日志www-data
/var/backups/openwisp数据库备份postgres

⚠️ 注意:应用目录必须让 www-data 可进入(/opt/openwisp 至少 755、/opt/openwisp/app 归 www-data),否则 uWSGI 启动时会报 chdir(): Permission denied。本文 4.7 节记录了这一问题的完整复现与修复。

三、版本选型说明

OpenWISP 套件由多个 Python 包组成,安装时必须锁定主包版本,让依赖解析器选择与之匹配的配套包。本次实测的版本矩阵如下(pip list 实测):

包版本说明
openwisp-controller1.3.2主包,设备与配置管理
openwisp-utils1.3.1后台主题、API 基础设施
openwisp-users1.3.2多组织与权限
openwisp-notifications1.3站内/邮件通知
openwisp-ipam1.3.1子网与 IP 管理
Django5.2.18Web 框架
celery5.6.3异步任务
channels / channels_redis4.3.2 / 4.3.0WebSocket 通知
redis(Python 客户端)8.1.0缓存与会话
psycopg3.3.6PostgreSQL 驱动(含 binary 变体)

选择 1.3.2 的理由:它是 2026-10-28 时 PyPI 上的最新稳定版(pip index versions 实测输出 LATEST: 1.3.2),且 1.3.1 → 1.3.2 只隔 8 天,属于 1.3.0 之后的连续修复,风险低于直接追开发分支。若需要长期稳定,可锁定 1.3.2 并把依赖锁文件一并归档(见第五章)。

💡 技术拓展:Python 应用的版本锁定有四种常见做法——完全不锁(pip install 最新)、锁主包(本文做法)、锁全量依赖(pip freeze 生成锁文件)、锁哈希(pip-compile --generate-hashes)。不锁最省事但不可复现;锁主包能保证主版本一致;锁全量依赖可复现但升级需整表重算;锁哈希安全性最高但维护成本大。本文在线方案锁主包,离线方案额外提供全量锁文件,兼顾可复现与可维护。

四、方案 A:venv 在线安装(主方案)

4.1 方案说明与适用范围

本方案直接在目标服务器上安装系统依赖,用 Python 虚拟环境安装 OpenWISP,全部组件由 systemd 托管。优点是路径透明、便于定制 settings.py、升级只需替换虚拟环境;缺点是需要服务器能访问 PyPI,且首次安装要编译少量 Python 依赖。

适用于:能访问公网或内网有 PyPI 软件源、需要深度定制配置、希望用系统包管理器统一维护数据库与 Redis 的团队。

4.2 系统依赖安装

先更新索引并安装数据库、缓存、应用服务器与编译工具。postgresql-14-postgis-3 是 Ubuntu 22.04 仓库中与 PostgreSQL 14 匹配的 PostGIS 包,必须与 postgresql 同版本安装。

sudo apt-get update
sudo apt-get install -y postgresql postgresql-contrib postgis postgresql-14-postgis-3 \
  redis-server nginx uwsgi uwsgi-plugin-python3 \
  python3-venv python3-dev build-essential \
  libpq-dev libjpeg-dev zlib1g-dev libssl-dev libffi-dev \
  gettext curl git

实测输出节选(安装过程共 263 个包被 Setting up,末尾为):

0 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
done.

验证安装结果与版本:

dpkg -l postgresql-14 postgresql-14-postgis-3 postgis redis-server nginx uwsgi uwsgi-plugin-python3 python3.10-venv | grep ^ii | awk '{print $2, $3}'

实测回显:

nginx 1.18.0-6ubuntu14.22
postgis 3.2.0+dfsg-1ubuntu1
postgresql-14 14.24-0ubuntu0.22.04.1
postgresql-14-postgis-3 3.2.0+dfsg-1ubuntu1
python3.10-venv 3.10.12-1~22.04.18
redis-server 5:6.0.16-1ubuntu1.2
uwsgi 2.0.20-4
uwsgi-plugin-python3 2.0.20-4

⚠️ 注意:Ubuntu 22.04 官方仓库里没有 openwisp-controller 的 apt 包,必须走 Python 包安装。若在教程里看到 apt install openwisp-controller,那不是官方渠道。

4.3 创建虚拟环境并安装 Python 包

虚拟环境放在 /opt/openwisp/venv,与项目目录分离,升级时可整体替换。安装时显式指定 openwisp-controller==1.3.2,避免解析到更新的次版本。

sudo mkdir -p /opt/openwisp
sudo python3 -m venv /opt/openwisp/venv
sudo /opt/openwisp/venv/bin/pip install --upgrade pip
sudo /opt/openwisp/venv/bin/pip install "openwisp-controller==1.3.2" "psycopg[binary]"

实测输出节选(末尾):

Successfully built dj-rest-auth
Installing collected packages: swapper, rjsmin, rcssmin, pytz, django-sortedm2m, brotli, ... , openwisp-controller
Successfully installed ... django-5.2.18 ... openwisp-controller-1.3.2 openwisp-ipam-1.3.1 openwisp-notifications-1.3 openwisp-users-1.3.2 openwisp-utils-1.3.1 ...

验证关键包版本:

/opt/openwisp/venv/bin/pip show openwisp-controller | head -3
/opt/openwisp/venv/bin/pip list | grep -Ei "^(Django|celery|openwisp-|psycopg|redis) "

实测回显节选:

Name: openwisp-controller
Version: 1.3.2
Summary: OpenWISP Controller

celery                             5.6.3
Django                             5.2.18
openwisp-controller                1.3.2
openwisp-ipam                      1.3.1
openwisp-notifications             1.3
openwisp-users                     1.3.2
openwisp-utils                     1.3.1
psycopg                            3.3.6
redis                              8.1.0

⚠️ 注意:psycopg[binary] 必须在首次 manage.py check 之前装好,否则会出现 django.core.exceptions.ImproperlyConfigured: Error loading psycopg2 or psycopg module。这是本次实测真实踩到的第一个坑,定位过程记录在第九章。

4.4 PostgreSQL 14 与 PostGIS 3.2 初始化

OpenWISP 依赖 GeoDjango 的空间字段,必须使用支持 PostGIS 的数据库后端。先创建独立数据库与账号,再在建好的库里启用 postgis 扩展。

sudo systemctl enable --now postgresql
sudo -u postgres psql -c "CREATE USER openwisp WITH PASSWORD 'q1w2e3r4';"
sudo -u postgres psql -c "CREATE DATABASE openwisp OWNER openwisp;"
sudo -u postgres psql -d openwisp -c "CREATE EXTENSION postgis;"

实测回显:

CREATE ROLE
CREATE DATABASE
CREATE EXTENSION

验证扩展版本:

sudo -u postgres psql -d openwisp -tAc "select postgis_version()"

实测回显:

3.2 USE_GEOS=1 USE_PROJ=1 USE_STATS=1

继续启动并验证缓存与代理服务:

sudo systemctl enable --now redis-server
redis-cli ping

实测回显:

PONG

💡 说明:CREATE EXTENSION postgis 必须在目标库内执行,且数据库属主需要有建扩展权限。若报 could not open extension control file,说明只装了 postgis 元包而没装 postgresql-14-postgis-3,补装对应版本即可。

4.5 项目骨架与 settings.py

在 /opt/openwisp/app 下建立一个最小 Django 项目,包名 openwisp2。目录结构如下:

/opt/openwisp/app
├── manage.py
├── openwisp2
│   ├── __init__.py
│   ├── asgi.py
│   ├── celery.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── static/        # collectstatic 输出
└── media/         # 上传文件

manage.py 只做一件事:把 DJANGO_SETTINGS_MODULE 指向 openwisp2.settings,然后交给 Django 的命令行入口。

#!/usr/bin/env python
import os
import sys

if __name__ == "__main__":
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openwisp2.settings")

    from django.core.management import execute_from_command_line

    execute_from_command_line(sys.argv)

openwisp2/__init__.py 暴露 Celery 应用,保证 celery -A openwisp2 能找到它:

from .celery import app as celery_app

__all__ = ["celery_app"]

openwisp2/celery.py 从 Django settings 读取 CELERY_* 配置并自动发现任务:

import os

from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openwisp2.settings")

app = Celery("openwisp2")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

openwisp2/urls.py 把根路径重定向到后台,并挂载 Controller、账号、REST API 三组路由:

from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path, reverse_lazy
from django.views.generic import RedirectView

redirect_view = RedirectView.as_view(url=reverse_lazy("admin:index"))

urlpatterns = [
    path("", redirect_view, name="index"),
    path("admin/", admin.site.urls),
    path("", include("openwisp_controller.urls")),
    path("accounts/", include("openwisp_users.accounts.urls")),
    path("api/v1/", include("openwisp_utils.api.urls")),
    path("api/v1/", include(("openwisp_users.api.urls", "users"), namespace="users")),
]

urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

openwisp2/asgi.py 为 Channels 提供 WebSocket 路由(通知实时推送依赖它):

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application

from openwisp_controller.routing import get_routes

application = ProtocolTypeRouter(
    {
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(URLRouter(get_routes()))
        ),
        "http": get_asgi_application(),
    }
)

openwisp2/wsgi.py 供 uWSGI 加载:

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openwisp2.settings")

application = get_wsgi_application()

核心配置文件 openwisp2/settings.py 全文如下(数据库口令、密钥等按本次实测环境填写,逐项含义见 《OpenWISP 1.3.2 配置参数详解》):

import os

from celery.schedules import crontab

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

DEBUG = False
SECRET_KEY = "eCoNcO14Z708OB5lBIWGTrANmXV5rZhl3881ees3bOphf6V4StGsfNOzCNz11GC6fRU"

ALLOWED_HOSTS = ["openwisp.example.com", "127.0.0.1", "localhost"]

DATABASES = {
    "default": {
        "ENGINE": "django.contrib.gis.db.backends.postgis",
        "NAME": "openwisp",
        "USER": "openwisp",
        "PASSWORD": "q1w2e3r4",
        "HOST": "127.0.0.1",
        "PORT": "5432",
    }
}

REDIS_URL = "redis://127.0.0.1:6379"

INSTALLED_APPS = [
    "daphne",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "django.contrib.gis",
    "django.contrib.sites",
    "openwisp_users.accounts",
    "allauth",
    "allauth.account",
    "allauth.socialaccount",
    "django_extensions",
    "openwisp_users",
    "openwisp_controller.config",
    "openwisp_controller.pki",
    "openwisp_controller.geo",
    "openwisp_controller.connection",
    "openwisp_controller.subnet_division",
    "openwisp_notifications",
    "openwisp_ipam",
    "openwisp_utils.admin_theme",
    "admin_auto_filters",
    "django.contrib.admin",
    "django.forms",
    "sortedm2m",
    "reversion",
    "leaflet",
    "flat_json_widget",
    "rest_framework",
    "rest_framework.authtoken",
    "rest_framework_gis",
    "django_filters",
    "drf_yasg",
    "channels",
    "import_export",
]
EXTENDED_APPS = ("django_x509", "django_loci")

AUTH_USER_MODEL = "openwisp_users.User"
SITE_ID = 1

STATICFILES_FINDERS = [
    "django.contrib.staticfiles.finders.FileSystemFinder",
    "django.contrib.staticfiles.finders.AppDirectoriesFinder",
    "openwisp_utils.staticfiles.DependencyFinder",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "allauth.account.middleware.AccountMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

INTERNAL_IPS = ["127.0.0.1"]
ROOT_URLCONF = "openwisp2.urls"
ASGI_APPLICATION = "openwisp2.asgi.application"

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {"hosts": [f"{REDIS_URL}/7"]},
    }
}

TIME_ZONE = "Asia/Shanghai"
LANGUAGE_CODE = "zh-hans"
USE_TZ = True
USE_I18N = True

STATIC_URL = "/static/"
MEDIA_URL = "/media/"
STATIC_ROOT = os.path.join(BASE_DIR, "static")
MEDIA_ROOT = os.path.join(BASE_DIR, "media")

CORS_ORIGIN_ALLOW_ALL = True

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "OPTIONS": {
            "loaders": [
                "django.template.loaders.filesystem.Loader",
                "openwisp_utils.loaders.DependencyLoader",
                "django.template.loaders.app_directories.Loader",
            ],
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
                "openwisp_utils.admin_theme.context_processor.menu_groups",
                "openwisp_notifications.context_processors.notification_api_settings",
            ],
        },
    }
]

LEAFLET_CONFIG = {"RESET_VIEW": False}
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "127.0.0.1"
EMAIL_PORT = 25
EMAIL_USE_TLS = False
DEFAULT_FROM_EMAIL = "openwisp@openwisp.example.com"

LOGIN_REDIRECT_URL = "admin:index"
ACCOUNT_LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL

OPENWISP_ORGANIZATION_USER_ADMIN = True
OPENWISP_ADMIN_DASHBOARD_ENABLED = True
OPENWISP_CONTROLLER_GROUP_PIE_CHART = True
OPENWISP_USERS_AUTH_API = True
OPENWISP_NOTIFICATIONS_EMAIL_ENABLED = True

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": f"{REDIS_URL}/6",
    },
    "sessions": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": f"{REDIS_URL}/8",
    },
}

SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "sessions"

CELERY_BROKER_URL = f"{REDIS_URL}/1"
CELERY_RESULT_BACKEND = f"{REDIS_URL}/2"
CELERY_TASK_TIME_LIMIT = 600

CELERY_BEAT_SCHEDULE = {
    "cleanup_unreferenced_whois_records": {
        "task": "openwisp_controller.config.whois.tasks.cleanup_unreferenced_whois_records",
        "schedule": crontab(hour=2, minute=0),
    },
}

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{levelname} {asctime} {module} {process:d} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {"class": "logging.StreamHandler", "formatter": "verbose"},
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "filename": "/var/log/openwisp/openwisp.log",
            "maxBytes": 10485760,
            "backupCount": 5,
            "formatter": "verbose",
        },
    },
    "root": {"handlers": ["console", "file"], "level": "INFO"},
    "loggers": {
        "django": {"handlers": ["console", "file"], "level": "INFO", "propagate": False},
    },
}

关键块说明:

  • DATABASES 的 ENGINE 必须是 django.contrib.gis.db.backends.postgis,用普通 django.db.backends.postgresql 会在迁移空间字段时报错。
  • INSTALLED_APPS 中 openwisp_utils.admin_theme 必须排在 django.contrib.admin 之前,否则后台主题不生效。
  • CHANNEL_LAYERS、CACHES、CELERY_BROKER_URL 分别占用 Redis 的 7、6/8、1 号库,避免键空间互相污染。
  • STATIC_ROOT 与 MEDIA_ROOT 落在项目目录内,便于 Nginx 用 alias 直接托管。

创建目录并赋权:

sudo mkdir -p /opt/openwisp/app/static /opt/openwisp/app/media /var/log/openwisp
sudo chown -R www-data:www-data /opt/openwisp/app/media /var/log/openwisp

语法检查:

cd /opt/openwisp/app
sudo -u www-data /opt/openwisp/venv/bin/python manage.py check

实测回显:

System check identified no issues (0 silenced).

⚠️ 注意:如果此时报 Error loading psycopg2 or psycopg module,说明 PostgreSQL 驱动缺失,执行 /opt/openwisp/venv/bin/pip install "psycopg[binary]" 后重试即可。本次实测正是先装了主包、后补驱动。

4.6 数据库初始化(迁移、超级用户、静态文件)

迁移会把 17 个应用的 189 条迁移记录写入数据库。执行时间取决于磁盘,本次约数十秒。

cd /opt/openwisp/app
sudo -u www-data /opt/openwisp/venv/bin/python manage.py migrate

实测输出节选(共 189 行):

Operations to perform:
  Apply all migrations: account, admin, auth, authtoken, config, connection, contenttypes, geo, openwisp_ipam, openwisp_notifications, openwisp_users, pki, reversion, sessions, sites, socialaccount, subnet_division
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0001_initial... OK
  ...
  Applying subnet_division.0004_index_rule_on_delete... OK
  Applying subnet_division.0005_number_of_subnets_and_ips... OK

创建后台管理员。--noinput 从环境变量读取密码,避免交互式输入:

cd /opt/openwisp/app
sudo -u www-data DJANGO_SUPERUSER_PASSWORD=q1w2e3r4 /opt/openwisp/venv/bin/python manage.py createsuperuser \
  --noinput --username admin --email admin@example.com

实测回显:

Superuser created successfully.

确认账号已落库:

sudo -u www-data /opt/openwisp/venv/bin/python manage.py shell -c "from openwisp_users.models import User; print(User.objects.filter(is_superuser=True).values_list('username','email'))"

实测回显:

<QuerySet [('admin', 'admin@example.com')]>

收集静态文件(后台主题、Leaflet、DRF 等资源):

sudo -u www-data /opt/openwisp/venv/bin/python manage.py collectstatic --noinput

实测回显:

413 static files copied to '/opt/openwisp/app/static'.

验证静态目录体积与文件数:

du -sh /opt/openwisp/app/static
find /opt/openwisp/app/static -type f | wc -l

实测回显:

31M	/opt/openwisp/app/static
413

4.7 uWSGI 与 systemd 托管

uWSGI 通过 Unix Socket 与 Nginx 通信,不直接暴露 TCP 端口。配置放在 /etc/openwisp/uwsgi.ini:

[uwsgi]
project = openwisp2
base = /opt/openwisp/app
chdir = %(base)
module = %(project).wsgi:application
home = /opt/openwisp/venv
pythonpath = %(base)
plugins = python3
master = true
processes = 4
threads = 2
socket = /run/uwsgi/openwisp.sock
chmod-socket = 660
chown-socket = www-data:www-data
vacuum = true
die-on-term = true
max-requests = 5000
harakiri = 60
logto = /var/log/openwisp/uwsgi.log

逐项说明:home 指向虚拟环境,保证能 import openwisp;processes=4 与 threads=2 对应 6 vCPU 的实测主机,可按 CPU 数调整为 2 × vCPU;harakiri=60 防止长请求挂死;max-requests=5000 让 worker 定期回收,规避第三方库内存缓慢增长。

systemd 单元 /etc/systemd/system/uwsgi-openwisp.service:

[Unit]
Description=uWSGI instance for OpenWISP Controller
After=network.target postgresql.service redis-server.service

[Service]
User=www-data
Group=www-data
RuntimeDirectory=uwsgi
RuntimeDirectoryMode=0755
ExecStart=/usr/bin/uwsgi --ini /etc/openwisp/uwsgi.ini
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

启用并启动:

sudo systemctl daemon-reload
sudo systemctl enable --now uwsgi-openwisp
sudo systemctl is-active uwsgi-openwisp
sudo tail -5 /var/log/openwisp/uwsgi.log

排障实录(首次启动失败):第一次启动时 is-active 返回 activating,日志报目录权限不足:

chdir() to /opt/openwisp/app
chdir(): Permission denied [core/uwsgi.c line 2625]
chdir(): Permission denied [core/uwsgi.c line 1649]

原因是 /opt/openwisp 为 755 但 /opt/openwisp/app 是 root:root 750,www-data 无法进入。修复:

sudo chmod 755 /opt/openwisp
sudo chown -R www-data:www-data /opt/openwisp/app
sudo systemctl restart uwsgi-openwisp

修复后实测回显:

active
WSGI app 0 (mountpoint='') ready in 0 seconds on interpreter 0x613b5ecd8800 pid: 7859 (default app)
*** uWSGI is running in multiple interpreter mode ***
spawned uWSGI master process (pid: 7859)
spawned uWSGI worker 1 (pid: 7863, cores: 2)
spawned uWSGI worker 2 (pid: 7865, cores: 2)
spawned uWSGI worker 3 (pid: 7867, cores: 2)
spawned uWSGI worker 4 (pid: 7869, cores: 2)

Socket 文件确认:

ls -l /run/uwsgi/

实测回显:

srw-rw---- 1 www-data www-data 0 Oct 27 18:10 openwisp.sock

4.8 Celery worker 与 beat

配置变更推送、通知生成、WHOIS 清理都依赖 Celery。worker 单元 /etc/systemd/system/celery-openwisp.service:

[Unit]
Description=Celery worker for OpenWISP Controller
After=network.target postgresql.service redis-server.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/openwisp/app
ExecStart=/opt/openwisp/venv/bin/celery -A openwisp2 worker --loglevel=info --concurrency=2
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

定时任务单元 /etc/systemd/system/celery-beat-openwisp.service:

[Unit]
Description=Celery beat scheduler for OpenWISP Controller
After=network.target redis-server.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/openwisp/app
ExecStart=/opt/openwisp/venv/bin/celery -A openwisp2 beat --loglevel=info
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

启动并验证:

sudo systemctl daemon-reload
sudo systemctl enable --now celery-openwisp
sudo systemctl enable --now celery-beat-openwisp
sudo systemctl is-active celery-openwisp celery-beat-openwisp
cd /opt/openwisp/app
sudo -u www-data /opt/openwisp/venv/bin/celery -A openwisp2 inspect ping --timeout 8

实测回显:

active
active
->  celery@openwisp-lab: OK
        pong

1 node online.

⚠️ 注意:beat 使用默认的 PersistentScheduler,调度项来自 CELERY_BEAT_SCHEDULE。如果改用数据库调度器,必须先安装 django-celery-beat 并把 django_celery_beat 加入 INSTALLED_APPS,否则会报 ModuleNotFoundError。

4.9 Nginx 反向代理

站点配置放 /etc/nginx/sites-available/openwisp.conf,静态与媒体由 Nginx 直接返回,其余请求转发给 uWSGI Socket:

server {
    listen 80;
    server_name openwisp.example.com;

    client_max_body_size 20M;
    access_log /var/log/nginx/openwisp.access.log;
    error_log /var/log/nginx/openwisp.error.log;

    location /static/ {
        alias /opt/openwisp/app/static/;
        expires 7d;
        access_log off;
    }

    location /media/ {
        alias /opt/openwisp/app/media/;
        expires 1d;
    }

    location / {
        include uwsgi_params;
        uwsgi_pass unix:/run/uwsgi/openwisp.sock;
        uwsgi_read_timeout 180;
        uwsgi_send_timeout 180;
    }
}

启用站点并移除默认站点:

sudo ln -s /etc/nginx/sites-available/openwisp.conf /etc/nginx/sites-enabled/openwisp.conf
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl restart nginx

实测回显:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

💡 说明:uwsgi_read_timeout 180 是给配置推送、导入导出这类长请求留余量。OpenWISP 后台的批量操作可能超过默认 60 秒,超时表现为 Nginx 返回 504 而应用日志仍在执行。

4.10 端到端验证

验证一:首页重定向与登录页

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -H "Host: openwisp.example.com" http://127.0.0.1/
curl -s -H "Host: openwisp.example.com" http://127.0.0.1/admin/login/ | grep -o "<title>[^<]*</title>"

实测回显:

302 http://127.0.0.1/admin/
<title>登录 | OpenWISP Admin</title>

验证二:静态资源

curl -s -o /dev/null -w "%{http_code} %{size_download}\n" -H "Host: openwisp.example.com" http://127.0.0.1/static/admin/css/base.css

实测回显:

200 22120

验证三:真实登录并进入后台

用带 CSRF 的会话完成登录(也可直接用浏览器,账号 admin、密码 q1w2e3r4):

/opt/openwisp/venv/bin/python - <<'PY'
import re, requests
s = requests.Session(); h = {"Host": "openwisp.example.com"}
s.get("http://127.0.0.1/admin/login/?next=/admin/", headers=h)
d = {"username": "admin", "password": "q1w2e3r4",
     "csrfmiddlewaretoken": s.cookies["csrftoken"], "next": "/admin/"}
r = s.post("http://127.0.0.1/admin/login/?next=/admin/", data=d,
           headers={**h, "Referer": "http://openwisp.example.com/admin/login/"},
           allow_redirects=False)
print("login", r.status_code, r.headers.get("Location"))
r2 = s.get("http://127.0.0.1/admin/", headers=h)
print("dash", r2.status_code, len(r2.text))
print(re.search(r"<title>(.*?)</title>", r2.text, re.S).group(1).strip())
PY

实测回显:

login 302 /admin/
dash 200 29719
Network Administration | OpenWISP Admin

验证四:业务页面可访问

登录后依次访问设备、模板、组织、VPN 列表页,均返回 200:

/admin/config/device/ 200 选择 Device 来修改 | OpenWISP Admin
/admin/config/template/ 200 选择 template 来修改 | OpenWISP Admin
/admin/openwisp_users/organization/ 200 选择 organization 来修改 | OpenWISP Admin
/admin/config/vpn/ 200 选择 VPN server 来修改 | OpenWISP Admin

后台显示中文菜单,说明 LANGUAGE_CODE = "zh-hans" 已生效。

验证五:服务状态

systemctl status uwsgi-openwisp --no-pager

实测输出节选(已隐去与业务无关的 cgroup 路径):

● uwsgi-openwisp.service - uWSGI instance for OpenWISP Controller
     Loaded: loaded (/etc/systemd/system/uwsgi-openwisp.service; enabled; vendor preset: enabled)
     Active: active (running) since Tue 2026-10-27 18:10:56 UTC; 3min 38s ago
   Main PID: 7859 (uwsgi)
      Tasks: 9 (limit: 2932)
     Memory: 206.5M
        CPU: 1.158s

worker 与 beat 的常驻内存实测分别为 224.3M 与 118.0M。整套服务(uWSGI + worker + beat)空闲内存约 550 MB,2 GB 内存的机器可跑,4 GB 更稳妥。

五、方案 B:离线 wheel 安装(内网方案)

5.1 方案说明

内网服务器无法访问 PyPI 时,在可联网机器上把全量依赖下载成 wheel 集合,拷入内网后用 --no-index 安装。该方案与方案 A 的差异只在“包从哪来”,数据库、配置、systemd、Nginx 完全相同。

5.2 在联网机准备 wheel 包与锁文件

先在一个与目标同版本(Ubuntu 22.04 / Python 3.10)的环境里装好方案 A,再导出锁文件:

/opt/openwisp/venv/bin/pip freeze > /opt/openwisp/requirements-lock.txt
wc -l /opt/openwisp/requirements-lock.txt

实测回显:

119 /opt/openwisp/requirements-lock.txt

下载全部依赖:

mkdir -p /opt/openwisp/wheels
/opt/openwisp/venv/bin/pip download -d /opt/openwisp/wheels -r /opt/openwisp/requirements-lock.txt

实测输出节选(末尾):

Successfully downloaded aiohappyeyeballs aiohttp async-timeout multidict yarl aiosignal amqp vine asgiref attrs autobahn ... openwisp-controller openwisp-ipam openwisp-notifications openwisp-users ... psycopg psycopg-binary ... setuptools

检查产物数量与体积:

ls /opt/openwisp/wheels | wc -l
du -sh /opt/openwisp/wheels

实测回显:

120
61M	/opt/openwisp/wheels

把 wheels/ 目录与 requirements-lock.txt 一起拷到内网服务器(U 盘、内网文件服务均可)。

5.3 内网安装与验证

在目标机创建独立虚拟环境,只从本地目录安装:

sudo python3 -m venv /opt/openwisp/venv-offline
sudo /opt/openwisp/venv-offline/bin/pip install --no-index --find-links=/opt/openwisp/wheels \
  -r /opt/openwisp/requirements-lock.txt
sudo /opt/openwisp/venv-offline/bin/pip install --no-index --find-links=/opt/openwisp/wheels "psycopg[binary]"

实测输出节选(末尾):

  Running setup.py install for dj-rest-auth: finished with status 'done'
Successfully installed ... django-5.2.18 ... openwisp-controller-1.3.2 ... psycopg-3.3.6 psycopg-binary-3.3.6 ...

用离线环境做一致性检查:

cd /opt/openwisp/app
/opt/openwisp/venv-offline/bin/python manage.py check

实测回显:

System check identified no issues (0 silenced).

确认可用后,把 4.7 与 4.8 节服务单元里的 home/ExecStart 路径从 /opt/openwisp/venv 改为 /opt/openwisp/venv-offline,重启三个服务即可。也可以直接把离线环境改名为 /opt/openwisp/venv,这样所有配置无需改动。

⚠️ 注意:pip download 的产物里可能混有源码分发(sdist),安装时需要 build-essential 与 python3-dev。本次实测离线安装时出现了 Running setup.py install for dj-rest-auth,说明该包走的是源码构建路径,内网机器必须预装编译工具链。

六、方案对比与选择建议

维度方案 A:在线 venv方案 B:离线 wheel
网络要求需访问 PyPI仅需一次性拷贝
安装耗时取决于网速,本次约数分钟本地安装,约 1 分钟
版本可复现性锁主包,依赖可能漂移锁全量依赖,完全可复现
维护成本低(pip install -U 即可升级)中(每次升级重做 wheel 集)
适用场景有公网或内网 PyPI 软件源隔离网络、审计要求高的环境

选择建议:能联网选方案 A,隔离网络选方案 B;无论选哪个,都应保留 requirements-lock.txt,它是回滚时唯一的版本依据。

七、配置说明(路径、端口与服务清单)

安装完成后,平台的“可变部分”集中在四个位置,日常运维只需记住这张表:

位置内容修改后动作
/opt/openwisp/app/openwisp2/settings.py数据库、缓存、时区、邮件、OPENWISP_* 开关重启 uWSGI、worker、beat
/etc/openwisp/uwsgi.ini进程数、超时、Socket 路径、日志重启 uwsgi-openwisp
/etc/systemd/system/*.service启动用户、工作目录、重启策略systemctl daemon-reload 后重启
/etc/nginx/sites-available/openwisp.conf域名、静态/媒体路径、超时nginx -t 后 reload

三个 systemd 单元的职责边界:

  • uwsgi-openwisp:唯一对外提供 HTTP 的进程,Nginx 通过 Socket 转发。
  • celery-openwisp:执行异步任务(推送配置、发通知、清理数据),不监听端口。
  • celery-beat-openwisp:只负责按 CELERY_BEAT_SCHEDULE 投递定时任务,不执行任务本身。

验证三者关系是否正常:

systemctl is-active uwsgi-openwisp celery-openwisp celery-beat-openwisp
sudo -u www-data /opt/openwisp/venv/bin/celery -A openwisp2 inspect registered | head -5

实测回显节选:

active
active
active
->  celery@openwisp-lab: OK
    * openwisp_controller.config.tasks.auto_add_template_to_existing_configs
    * openwisp_controller.config.tasks.bulk_invalidate_config_get_cached_checksum
    * openwisp_controller.config.tasks.change_devices_templates
    * openwisp_controller.config.tasks.create_vpn_dh
    * openwisp_controller.config.tasks.invalidate_controller_views_cache
    * openwisp_controller.config.tasks.invalidate_devicegroup_cache_change
    * openwisp_controller.config.tasks.invalidate_devicegroup_cache_delete
    * openwisp_controller.config.tasks.invalidate_vpn_server_devices_cache_change
    * openwisp_controller.config.tasks.trigger_vpn_server_endpoint
    * openwisp_controller.config.tasks.update_template_related_config_status
    * openwisp_controller.config.tasks_zerotier.trigger_zerotier_server_delete

八、升级与回滚

升级前先确认可用版本(联网环境):

/opt/openwisp/venv/bin/pip index versions openwisp-controller | head -3

实测回显:

openwisp-controller (1.3.2)
Available versions: 1.3.2, 1.3.1, 1.3, 1.2.3, ...
  INSTALLED: 1.3.2
  LATEST:    1.3.2

标准升级流程(以 1.3.2 → 下一个次版本为例):

sudo -u postgres pg_dump -Fc -d openwisp -f /var/backups/openwisp/openwisp-before-upgrade.dump
sudo /opt/openwisp/venv/bin/pip install --upgrade "openwisp-controller==<目标版本>"
cd /opt/openwisp/app
sudo -u www-data /opt/openwisp/venv/bin/python manage.py migrate
sudo -u www-data /opt/openwisp/venv/bin/python manage.py collectstatic --noinput
sudo systemctl restart uwsgi-openwisp celery-openwisp celery-beat-openwisp

回滚顺序与升级相反:停服务 → 卸载新版本并装回旧版本 → 用升级前的 dump 恢复数据库 → 重启。由于 OpenWISP 的迁移不可逆,回滚必须依赖备份,不能只靠 migrate <app> zero。

⚠️ 注意:本次实测环境已是 1.3.2(最新版),没有更高版本可升,因此升级步骤未实际执行;命令与版本查询结果均来自真实环境,恢复流程在 《OpenWISP 1.3.2 常用 CLI 与运维命令详解》 中有完整备份/恢复实测。

九、常见问题(排障实录)

9.1 报错 Error loading psycopg2 or psycopg module

现象:首次执行 manage.py check 直接抛异常,堆栈末尾为:

django.core.exceptions.ImproperlyConfigured: Error loading psycopg2 or psycopg module

定位:pip list 里没有 psycopg/psycopg2,说明只装了 openwisp-controller,未装数据库驱动。Django 5.2 要求显式安装 psycopg 或 psycopg2。

解决:/opt/openwisp/venv/bin/pip install "psycopg[binary]",再执行 manage.py check 即通过。

9.2 uWSGI 启动即失败,日志 chdir(): Permission denied

现象:systemctl is-active 卡在 activating,/var/log/openwisp/uwsgi.log 报:

chdir(): Permission denied [core/uwsgi.c line 2625]

定位:uWSGI 以 www-data 运行,但 /opt/openwisp/app 是 root:root 750,无法进入。

解决:chmod 755 /opt/openwisp,chown -R www-data:www-data /opt/openwisp/app,重启服务。

9.3 manage.py check --deploy 出现地理编码告警

现象:输出末尾出现:

?: Geocoding service is experiencing issues or is not properly configured
System check identified 5 issues (0 silenced).

定位:OpenWISP 注册了一个地理编码健康检查,会访问 geocode.arcgis.com;隔离网络下报 Network is unreachable,堆栈为 geopy.exc.GeocoderUnavailable。

解决:该告警不影响启动与登录。处理方式二选一:为服务器放通 geocode.arcgis.com:443;或接受告警,在设备位置里手工填经纬度,不走地址自动解析。另外 4 条告警是 Django 的 HTTPS 相关提示(HSTS、SSL 重定向、Secure Cookie),上生产启用 TLS 后按提示补齐即可。

9.4 后台能打开但样式全丢

现象:页面结构正常、无 CSS,浏览器控制台大量 404 指向 /static/...。

定位:collectstatic 未执行,或 Nginx 的 alias 路径与实际 STATIC_ROOT 不一致。

解决:确认 settings.py 中 STATIC_ROOT = /opt/openwisp/app/static,重新执行 collectstatic --noinput,再 curl -o /dev/null -w "%{http_code}" http://127.0.0.1/static/admin/css/base.css 应返回 200。

9.5 502 Bad Gateway

现象:Nginx 返回 502,/var/log/nginx/openwisp.error.log 报 connect() to unix:/run/uwsgi/openwisp.sock failed (2: No such file or directory)。

定位:uWSGI 未启动,或 Socket 路径与 Nginx 配置不一致。

解决:systemctl status uwsgi-openwisp 查看原因;若服务正常但 Socket 不存在,检查 uwsgi.ini 的 socket 行与 Nginx uwsgi_pass 是否指向同一路径。

十、卸载与清理

卸载按“停服务 → 删配置 → 删代码 → 删数据库”顺序执行,避免残留进程占用文件:

sudo systemctl disable --now uwsgi-openwisp celery-openwisp celery-beat-openwisp
sudo rm -f /etc/systemd/system/uwsgi-openwisp.service
sudo rm -f /etc/systemd/system/celery-openwisp.service
sudo rm -f /etc/systemd/system/celery-beat-openwisp.service
sudo systemctl daemon-reload
sudo rm -f /etc/nginx/sites-enabled/openwisp.conf /etc/nginx/sites-available/openwisp.conf
sudo systemctl reload nginx
sudo rm -rf /opt/openwisp
sudo rm -rf /var/log/openwisp
sudo -u postgres psql -c "DROP DATABASE openwisp;"
sudo -u postgres psql -c "DROP USER openwisp;"

⚠️ 注意:以上命令会永久删除数据库与全部配置,本次实测环境保留用于复查,未执行删除。若还要保留备份,先执行 pg_dump -Fc 并把文件移出 /var/backups/openwisp。PostGIS 扩展随数据库删除,系统包 postgresql-14-postgis-3 可按需保留给其他库使用。

十一、参考链接

  • OpenWISP 安装器总览:https://openwisp.io/docs/stable/installers.html
  • Controller 开发安装(依赖说明):https://openwisp.io/docs/stable/controller/developer/installation.html
  • Controller 配置项:https://openwisp.io/docs/stable/controller/user/settings.html
  • PyPI 包页:https://pypi.org/project/openwisp-controller/
  • GitHub Releases:https://github.com/openwisp/openwisp-controller/releases
  • Django 5.2 数据库后端文档:https://docs.djangoproject.com/en/5.2/ref/databases/
  • PostGIS 官方文档:https://postgis.net/documentation/
  • uWSGI 配置文档:https://uwsgi-docs.readthedocs.io/en/latest/Options.html

站内延伸阅读:《OpenWISP 介绍与选型指南》、《OpenWISP 1.3.2 配置参数详解》、《OpenWISP 1.3.2 常用 CLI 与运维命令详解》。