feat: публикация портов не-HTTP сервисами, проверка портов до деплоя, пример RustDesk
serverus: app_ports.py проверяет опубликованные порты приложения до изменений - по docker compose config (переменные из .env подставлены) против слушающих сокетов сервера и портов контейнеров других приложений. Занятый порт больше не роняет up после замены файлов. Конвенция: только мост; не-HTTP сервисы публикуют свои порты, 80 и 443 - у caddy; bind-mount только своих файлов из data/<сервис>/ с :ro. Описано, что упавший up после синхронизации файлы не откатывает. App: uploads.check предупреждает о 80/443 вместо любых портов и о bind-mount из папки приложения вне data/ или без :ro. docs/apps/rustdesk: приложение (hbbs, hbbr, ключ генерируем сами), genkey.py и check.py - проверка сервера по протоколу RustDesk снаружи. Проверено на стенде Debian 13: деплой, повторный деплой, отказ второго приложения на тех же портах до изменений, перезагрузка сервера. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8d1f286d1
commit
fd4b05097a
16 files changed
+526
-13
No files matched your search
@@ -1,9 +1,10 @@
|
||||
# extravars: apps_dir, app, archive (tar.gz на управляющей машине: полный набор файлов приложения)
|
||||
#
|
||||
# 1. Файлы распаковываются во временную папку, compose-файл и caddy.conf (caddy validate на копии конфигурации
|
||||
# с новым блоком) проверяются там: ошибка - приложение не тронуто.
|
||||
# 1. Файлы распаковываются во временную папку, compose-файл, его порты (app_ports.py: не заняты ли на сервере)
|
||||
# и caddy.conf (caddy validate на копии конфигурации с новым блоком) проверяются там: ошибка - приложение не тронуто.
|
||||
# 2. Папка приложения приводится к присланной копии по месту (app_sync.py), лишние файлы удаляются.
|
||||
# 3. docker compose up -d --remove-orphans: compose сам пересоздает сервисы с изменившейся конфигурацией.
|
||||
# Упал (например, образ не скачался) - файлы уже новые, отката нет.
|
||||
# 4. Сервисы, у которых изменилось что-то в data/<сервис>/, пересоздаются: конфиги они читают при старте.
|
||||
# У нового приложения - нет: up только что создал все контейнеры.
|
||||
# 5. caddy.conf приложения становится блоком ingress. Caddy все же не принял - прежний блок возвращается, ошибка.
|
||||
@@ -42,6 +43,11 @@
|
||||
chdir: "{{ stage }}"
|
||||
changed_when: false
|
||||
|
||||
# переменные из .env подставлены compose: занятый порт иначе уронил бы up уже после замены файлов
|
||||
- name: Check published ports
|
||||
ansible.builtin.command: python3 {{ apps_dir }}/.serverus/app_ports.py {{ stage | quote }} {{ app | quote }}
|
||||
changed_when: false
|
||||
|
||||
- name: Check new caddy.conf
|
||||
ansible.builtin.stat:
|
||||
path: "{{ stage }}/caddy.conf"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# extravars: apps_dir, caddy_image, local_certs
|
||||
#
|
||||
# Docker из репозитория Docker, логи контейнеров с ротацией, автоматические обновления безопасности, папка приложений,
|
||||
# сеть web и ingress: приложение caddy, единственное, которое публикует порты. Caddy подключает блоки приложений
|
||||
# сеть web и ingress: приложение caddy, которое публикует 80 и 443. Caddy подключает блоки приложений
|
||||
# из data/caddy/sites/ и сам выпускает сертификаты для их доменов. local_certs - сертификаты своего CA Caddy.
|
||||
# Фаервол настраивается первым: nftables при старте стирает все правила, Docker ставит свои поверх.
|
||||
- name: Install Docker platform
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Проверка портов приложения до изменений: app_ports.py <stage> <app>.
|
||||
|
||||
Порты берутся из docker compose config (переменные из .env подставлены, диапазоны раскрыты) и сверяются с занятыми
|
||||
на сервере: слушающие сокеты (ss) и порты контейнеров других приложений. Порты, которые держит само приложение,
|
||||
занятыми не считаются: при деплое их контейнеры пересоздаются.
|
||||
Занят - печатает причины в stderr и выходит с кодом 1.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# 0.0.0.0:9000-9001->9000-9001/udp, [::]:80->80/tcp
|
||||
DOCKER_PORT_RE = re.compile(r':(\d+)(?:-(\d+))?->[^/,]+/(tcp|udp)')
|
||||
|
||||
|
||||
def run(*args, cwd=None):
|
||||
result = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout).strip()[-500:])
|
||||
return result.stdout
|
||||
|
||||
|
||||
def published(stage, app):
|
||||
"""{(порт, протокол): [сервисы]} из отрендеренного compose-файла."""
|
||||
config = json.loads(run('docker', 'compose', '-p', app, 'config', '--format', 'json', cwd=stage))
|
||||
ports = {}
|
||||
for name, service in (config.get('services') or {}).items():
|
||||
for entry in service.get('ports') or []:
|
||||
if entry.get('published'):
|
||||
key = (int(entry['published']), entry.get('protocol', 'tcp'))
|
||||
ports.setdefault(key, []).append(name)
|
||||
return ports
|
||||
|
||||
|
||||
def listening():
|
||||
"""Все слушающие порты сервера, включая docker-proxy опубликованных портов."""
|
||||
ports = set()
|
||||
for line in run('ss', '-Hlntu').splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) >= 5:
|
||||
port = fields[4].rsplit(':', 1)[-1]
|
||||
if port.isdigit():
|
||||
ports.add((int(port), fields[0]))
|
||||
return ports
|
||||
|
||||
|
||||
def containers():
|
||||
"""[(проект, контейнер, {(порт, протокол)})] всех контейнеров с опубликованными портами."""
|
||||
result = []
|
||||
for line in run('docker', 'ps', '--format', '{{json .}}').splitlines():
|
||||
container = json.loads(line)
|
||||
ports = set()
|
||||
for first, last, protocol in DOCKER_PORT_RE.findall(container.get('Ports', '')):
|
||||
ports.update((port, protocol) for port in range(int(first), int(last or first) + 1))
|
||||
labels = dict(label.split('=', 1) for label in container.get('Labels', '').split(',') if '=' in label)
|
||||
result.append((labels.get('com.docker.compose.project', ''), container.get('Names', ''), ports))
|
||||
return result
|
||||
|
||||
|
||||
def main(stage, app):
|
||||
wanted = published(stage, app)
|
||||
if not wanted:
|
||||
return
|
||||
own, owners = set(), {}
|
||||
for project, name, ports in containers():
|
||||
if project == app:
|
||||
own |= ports
|
||||
else:
|
||||
owners.update(dict.fromkeys(ports, name))
|
||||
busy = (listening() - own) | owners.keys()
|
||||
errors = [
|
||||
f'{port}/{protocol} ({", ".join(services)}): ' + (f'занят контейнером {owners[port, protocol]}'
|
||||
if (port, protocol) in owners else 'занят на сервере')
|
||||
for (port, protocol), services in sorted(wanted.items()) if (port, protocol) in busy
|
||||
]
|
||||
if errors:
|
||||
print('порты заняты: ' + '; '.join(errors), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
@@ -10,4 +10,4 @@
|
||||
src: "files/{{ item }}"
|
||||
dest: "{{ apps_dir }}/.serverus/{{ item }}"
|
||||
mode: "0755"
|
||||
loop: [ app_sync.py, app_status.py ]
|
||||
loop: [ app_sync.py, app_status.py, app_ports.py ]
|
||||
Reference in new issue
Block a user