Firewall Configuration Compatible With Docker

由于更换了VPS供应商,折腾了一下防火墙的配置。主要解决了Debian 12的防火墙与Docker的兼容问题。

1. 概述

参考:

1.1. 主要问题

Debian 12与Docker在防火墙方面的不兼容:

  • Debian从10开始默认使用nftables配置和管理防火墙,而Docker还在使用iptables(且不支持nftables)。
  • Docker网络在防火墙上有一套自己的规则。
  • 防火墙规则,一般是清空再加载,不同的配置会出现被覆盖的问题。

1.2. 网络模型

OSI模型,Open Systems Interconnection model(即开放式系统互联模型),是一个描述网络功能的概念框架。其分为7层。这是大学计算机课程《计算机网络》里的基本知识。

TCP/IP模型,是互联网的基础,它是一系列网络协议的总称。分5层或4层。

OSI模型是学术上和法律上的国际标准,是完整的权威的网络参考模型。而TCP/IP参考模型是事实上的国际标准,即现实生活中被广泛使用的网络参考模型。

两个模型的层次划分如下:

OSI模型(7层) TCP/IP模型(5层) TCP/IP模型(4层)
第7层 应用层 第5层 应用层 第4层 应用层
第6层 表现层
第5层 会话层
第4层 传输层 第4层 传输层 第3层 传输层
第3层 网络层 第3层 网络层 第2层 网络层
第2层 数据链路层 第2层 数据链路层 第1层 网络接口层
第1层 物理层 第1层 物理层

1.3. netfilter框架

netfilter是Linux内核中的一个数据包处理框架,其工作在网络模型的上层协议(对应OSI模型第5层及其以上,或TCP/IP模型的应用层)。iptables和nftables是用于管理netfilter。

netfilter提供了5个hook点,数据包经过协议栈时会触发内核模块注册在这里的处理函数。iptables和nftables配置这些处理函数对数据包的管理,实现防火墙的功能。

netfilter提供5个Hook点如下图:

User space           上层协议堆栈(例如HTTP协议的Web服务)
              数据终点(收)                          数据起点(发)
                  ↑                                    |
------------------│------------------------------------|---------------
Kernel space      │                                    ⭣
                input                               output
         LOCAL_IN ↑                                    | LOCAL_OUT
                  |                                    ⭣
             目标IP是本机                            路由查询2
 PRE_ROUTING      |                                    ⭣ POST_ROUTING
  prerouting ⭢ 路由查询1 -目标IP不是本机⭢ forward ⭢ postrouting -┐
      ↑                                                |       |
------|------------------------------------------------|-------|------
      |                Network adapter                 |       |
------|------------------------------------------------|-------|------
User -┘                                                |       |
     ←-------------------------------------------------┘       └→ 转发给其它主机
  • PRE_ROUTING: 是所有接收数据包到达的第一个hook触发点,此处将进行数据包目的地转换 (DNAT), 决定数据包是发给 本地进程、其他机器、其他network namespace
  • LOCAL_IN: 经过路由判断后,目标地址是本机的接收数据包到达此hook触发点
  • FORWARD: 经过路由判断后,目标地址不是本机地址的数据包到达此hook触发点
  • LOCAL_OUT: 所有本地生成的发往其他机器的包, 在进入网络栈后首先到达此hook触发点
  • POST_ROUTING: 本机产生准备发出的包或者转发的包,在经过路由判断后到达此hook触发点

参考:

2. 问题

结合上面的原理,针对Debian 12部署Docker所出现的防火墙问题,整理解决方案。

2.1. 管理工具的冲突

  • 问题:Docker使用iptbales,Debian 12使用nftables,两个不同的防火墙管理工具。
  • 解决:
    • Debian 12虽然默认使用nftables,但其提供了iptables-nft,把iptbales的配置转为nftables,实现对iptables的兼容。
    • Debian 12安装iptables,默认采用iptables-nft。即Docker执行iptbales命令时,实际是调用了iptables-nft。
    • 另外,如果Debian 12上需要使用原来的iptbales,需要安装iptables-legacy。

2.2. 防火墙配置冲突

  • 问题:按照一般的教程配置完防火墙,开机后会导致Docker的防火墙规则丢失。
  • 原因:
    • 一般的防火墙配置教程,会在开机后执行iptables-restorenft -f来加载一套自定义的防火墙规则。
    • 这个操作一般在Docker服务启动后执行,导致Docker配置好的防火墙规则被清除。
  • 解决:
    • 使用netfilter-persistent命令保存防火墙规则。
    • netfilter-persistent会在网络启动后,Docker服务启动前,加载用户的防火墙规则。然后Docker服务启动后再加载自己的防火墙规则,实现不冲突。
    • 另外,Docker在FORWARD链里创建了子链DOKCER-USER,提供给用户配置针对Docker的防火墙规则。Docker服务重启后,不会清除或修改DOKCER-USER链的规则。

2.3. Docker容器映射到宿主机端口的配置

  • 问题:Docker容器映射到宿主机端口,在INPUT链的规则对其无效。
  • 原因:
    • 防火墙的INPUT链,只针对本机运行服务所开启的端口。
    • 访问Docker容器映射到宿主机端口时,没有走INPUT链,而是走FORWARD链。
  • 解决:
    • Docker容器映射到宿主机端口的防火墙规则,配置在FORWARD链里的DOKCER-USER链。

2.4. DOKCER-USER链的配置

  • 问题:如何把DOKCER-USER链配置为“白名单”?
  • 解决:
    • 由于DOKCER-USER链是FORWARD链的子链,要先理解FORWARD链。FORWARD链就像混合了INPUT和OUTPUT的规则。
    • DOKCER-USER链配置为“白名单”时,先允许(ACCEPT)指定宿主机端口流入对应Docker容器端口,再禁止(REJECT)从宿主机网口流入到Docker容器,最后把其它数据包交给(RETURN)DOCKER-FORWARD链处理(例如从Docker容器流出)。

3. 配置

通常Debian 12预装了iptables,但需要安装netfilter-persistent命令,对应的安装包为iptables-persistent

sudo apt update && sudo apt -y install iptables-persistent

查看当前iptables配置

# 列出所有链的规则,简单信息
sudo iptables -L

# 列出所有链的规则,详细信息。`-n`端口、协议显示为数字,`-v`详细模式,`--line-numbers`各个链配置显示行号
sudo iptables -L -n -v --line-numbers

# 查看`DOCKER-USER`链的规则。`DOCKER-USER`是链的名称,可以替换为其它链。
sudo iptables -L DOCKER-USER -n -v --line-numbers

iptables配置参考

# 设置默认允许所有连接,方便配置
sudo iptables -P INPUT ACCEPT
sudo iptables -P FORWARD ACCEPT
sudo iptables -P OUTPUT ACCEPT

# 配置INPUT链
# INPUT链允许本地回环
sudo iptables -A INPUT -i lo -j ACCEPT
# INPUT链允许已建立的连接
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# INPUT链允许`ping`命令访问
sudo iptables -A INPUT -p icmp -j ACCEPT
# INPUT链开启SSH端口(22)
sudo iptables -A INPUT -p tcp -m conntrack --ctstate NEW --dport 22 -j ACCEPT
# INPUT链开启HTTP端口(80),如果是Docker容器提供HTTP服务,此配置可以不执行
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
# INPUT链开启HTTPS端口(443),如果是Docker容器提供HTTPS服务,此配置可以不执行
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# 配置DOCKER-USER链
# DOCKER-USER链清空配置
sudo iptables -F DOCKER-USER
# DOCKER-USER链允许已建立的连接
sudo iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# DOCKER-USER链允许HTTP服务(端口80)访问,且只允许数据包从宿主机80端口(参数`--ctorigdstport`的值)流入任意Docker容器80端口(参数`--dport`的值)
sudo iptables -A DOCKER-USER -p tcp --dport 80 -m conntrack --ctorigsrc 0.0.0.0/0 --ctorigdstport 80 -j ACCEPT
# DOCKER-USER链允许HTTP服务(端口443)访问,且只允许数据包从宿主机443端口(参数`--ctorigdstport`的值)流入任意Docker容器443端口(参数`--dport`的值)
sudo iptables -A DOCKER-USER -p tcp --dport 443 -m conntrack --ctorigsrc 0.0.0.0/0 --ctorigdstport 443 -j ACCEPT
# DOCKER-USER链禁止数据包从宿主机网口`enp1s0`(需按实际情况修改)流入,实现DOCKER-USER链“白名单”功能
sudo iptables -A DOCKER-USER -i enp1s0 -j REJECT
# DOCKER-USER链允许其它数据包,例如从Docker容器流出的数据包
sudo iptables -A DOCKER-USER -j RETURN

# INPUT链和FORWARD链默认禁止,即配置为“白名单”模式
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP

# 保存配置,保存在`/etc/iptables/rules.v4`和`/etc/iptables/rules.v6`
sudo netfilter-persistent save

注意:

  • iptables命令是即时生效。最好确认能直接操作实体机或者VPS,避免防火墙配置失败而不能访问。
  • 保证配置正确后,才执行“保存配置”的命令。即使配置错误,可以通过重启系统进行还原防火墙。
  • Docker的防火墙配置,会在Docker服务器重启后自动配置,所以这里保存配置时不用理会是否包含了Docker的防火墙配置。另一层意思是,即使搞坏了Docker的防火墙配置,重启Docker服务即可。

Baked Sweet Potatoes

使用空气炸锅做出好吃的烤红薯,香甜软糯、皮肉分离,记录一下。

材料

  • 红薯:约200g/个,选“蜜薯”品种的长条型

制作

  1. 红薯洗干净(去除表面泥土),清水浸泡15分钟(避免烤出来太干)。
  2. 取出红薯,用叉子随意扎洞。
  3. 选用带架子的空气炸锅,倒入半碗水,架子上平铺放入红薯(不要堆叠)。
  4. 200摄氏度烤20分钟,翻面后再200摄氏度烤20分钟。

总结

  • 空气炸锅选用带架子的,并且可以装水的。
  • 红薯最多铺满空气炸锅,不能堆叠起来。
  • 红薯品种不会买的话,问下摊主(老板)。
  • 长条型的红薯,主要是好烤、容易熟。

Redmi Note 12 Turbo Flashing Lineageos 22

刷上LineageOS 22.2(Android 15)一段时间了,日常使用也基本没大问题。这里记录一下相关信息。

1. 关于LineageOS

LineageOS是一个开源的Android定制ROM,是CyanogenMod(开源的Android定制ROM始祖)的继承者,是很多定制ROM的基础。

LineageOS的红米Note 12 Turbo(Marble)移植版,一开始是非官方版本。后来合并到官方版本,才敢尝试使用。

该ROM的优点:

  • 开源、精简、稳定、每周更新。
  • 体验接近Android原生系统。

目前缺点:

  • 没有自带支持KernelSU。目前刷第三方Kernel解决。
  • 微信和支付宝都不支持指纹支付。据说可以使用Meow模块解决,但未尝试。

2. 刷机准备

下载相关文件:

文件说明:

  • 一般只要刷LineageOS本身即可。建议下载zip包,并使用Recovery进行刷ROM。

    参考文件名: lineage-22.2-20250930-nightly-marble-signed.zip

  • OrangeFox Recovery是为了方便刷ROM和后续管理、升级。

    参考文件名: OrangeFox-R11.3_19-Unofficial-marble.img

  • 刷Kernel是为了支持KernelSU。如果使用Magisk或者不需要root,可以不刷。Glow是编译好的版本,目前建议刷v4.1.2,因为v4.2有bug。

    参考文件名: Glow-Kernel-v4.1.2.zip

  • Gapps采用MindTheGapps,是LineageOS官方推荐,若不需要可以不刷。

    参考文件名: MindTheGapps-15.0.0-arm64-20250214_082511.zip

3. 刷机说明

  • 先做好数据备份。
  • 建议双清(格式化system和data分区),特别是已刷了其它ROM的情况。
  • 进入fastboot模式,刷OrangeFox Recovery。
    • 参考命令sudo fastboot flash recovery OrangeFox-R11.3_19-Unofficial-marble.img
  • 进入Recovery模式,把LineageOS和后续需要刷机的文件,复制到/sdcard/rom目录下。
  • 在OrangeFox Recovery里选择LineageOS的ROM文件,并刷入,然后重新进入Recovery模式。
    • 在A/B分区模式下,LineageOS会自动刷入当前未激活的分区。例如,原来的ROM使用A分区,刷入LineageOS后会刷入B分区,并激活B分区。
    • 在A/B分区模式下,Recovery会在ROM里面。ROM的分区切换并激活后,需要运行新刷入ROM的Recovery。
    • OrangeFox Recovery默认在刷入ROM后,自动替换其自带的Recovery。
  • 在OrangeFox Recovery执行后续输入,包括Kernel、Gapps。
  • 最后清除缓存,并重启进入系统。

4. 问题

4.1. 不支持指纹支付

目前唯一问题是,“支付宝”和“微信”不能开启指纹支付。后面解决后再更新此文档。

4.2. A/B分区相关指令

手机进入Fastboot模式,在电脑端执行如下命令:

# 查看当前激活的分区
fastboot getvar current-slot

# 切换到a分区
fastboot set_active a

Using a Router as a Switch

遇到“主路由”的有线网口不够用时,可以把“旧路由”作为交换机使用,实现扩展有线网口。如果“旧路由”是无线路由,还可以作为AP(Access Point)使用。

参考:

设置概述:

  • 重置(reset)一下“旧路由”。
  • “旧路由”设置静态IP,并与“主路由”在同一网段。
  • “旧路由”关闭DHCP服务,或者设置为DHCP客户端。
  • 重启“旧路由”。
  • “旧路由”通过LAN口接入“主路由”,其他设备通过LAN口接入“旧路由”。
  • 此时如果“旧路由”是无线路由,只要开启WiFi,就是AP模式。

注意:

  • 不要使用太老的旧路由,设置后可能使用集线器模式,即数据广播,会导致整个网络堵塞。
  • 尽量避免把“旧路由”接入在另一个交换机,可能会导致“旧路由”不能使用。

Sago Soup

西米露(Sago soup)是一道很普通平凡,但也很难做的甜品。既要把西米(Sago)煮至透明,也要保持Q弹口感,是技巧所在。

材料

  • 西米:50g,小粒(约3人份量)
  • 水:1500ml(宜多不不宜少)

制作

  1. 1500ml的水,大火烧至沸腾(约3分钟),倒入西米。再大火煮3分钟,熄火焖10分钟,捞出沥水。
  2. 1500ml的水,大火烧至沸腾(约3分钟),倒入西米后,转小火煮15分钟,熄火焖15分钟,捞出沥水。
  3. 如果作为冷甜品享用,建议把西米倒进冰水,过一下“冷河”(让其口感更加Q弹),沥水备用。
  4. 享用时,可以搭配糖水、牛奶、椰奶、红豆沙等,冷热皆可。当然,作为茶饮的小料,也不错。

总结

  • 选小粒的西米,更容易熟。
  • 西米不要泡水,而是直接倒入沸腾的水去煮。
  • 水要够多,让西米有足够的空间翻腾,避免粘锅。
  • 采用煮+焖,避免煮烂。

Electric Mosquito Swatter Repair

N年前,第一次在亲戚家接触到“电蚊拍”,就觉得是个神器,并玩上了一天。后来家里买过几个,电力都不持久。拆开一看,里面是个“三无”电池,据说是铅酸蓄电池,不耐用的。于是找了教程,改装为使用18650电池,且带USB口可充电。

参考教程:电蚊拍啪啪啪不给力,直接改18650电池,加锂电充放电保护板

1. 工具和材料

  • 充放电模块
    • 参考型号:4057锂电池充电模块,18650充电器,TYPE-C接口
    • 参考价:3.90 CNY,包邮
  • 18650电池
    • 参考型号:亿纬 ICR 18650/26V,2550mAh
    • 参考价:5.80 CNY,包邮
  • 工具或材料
    • 电烙铁
    • 焊锡丝
    • 助焊膏
    • 电线两条,最好是红、黑两色

2. 过程

2.1. 电池焊接电线

把电池两极焊上电线,一般红色正极,黑色负极。注意,电烙铁不能接触电池太久!导致电池温度过高的话,轻则损坏电池,重则引起电池爆炸。如果是新手,建议找专业人士焊接。或者改用免焊接的电池盒。

参考视频:「盘丝法」无伤焊接锂电池,3秒搞定,用电烙铁即可

焊接过程简要说明:

  • 电线焊接处,挂上助焊剂,焊上一点焊锡丝。
  • 用砂纸打磨电池的焊接处,依次放上一点助焊剂、一小段盘好的焊锡丝、电线焊接处。
  • 电烙铁调至最大功率(例如450°C),分别点一下电线焊接处左边、右边和中间。注意,每次不能超过1秒,看见焊锡丝熔化即可松开。
  • 焊接牢固即完成,再以此焊接电池的另一端。

2.2. 充放电模块焊接

按照接口提示焊接即可:

  • OUT+,接电蚊拍原来电池的正极线。
  • BAT+,接电池正极线。
  • BAT-,接电池负极线。
  • OUT-,接电蚊拍原来电池的负极线。

2.3. 组装

主要是把电池和充放电模块塞进电蚊拍内部,把充放电模块的USB口露出来。由于不同的电蚊拍,内部结构不同,没有统一的做法。另外,一定要注意电池的安全,包括保护好两极、避免尖利的东西碰到等。

3. 总结

焊接电池时,确实捏了把汗,一是不容易焊上,二是怕电池温度过高。最后总算焊好,电蚊拍也恢复可用了。

Fiil Key Repairing

用了大概一年的Fiil Key蓝牙耳机,“右耳”突然坏了。前一晚还能用,第二天就不工作(灯不亮、不能连接)。于是计划买一个“右耳”。

维修方案:

  1. 换电池。查了下,说是一般电池坏了,换了就好。但这是2022年上市的产品,而且自己维修有风险,还不如直接换一个。
  2. 换个“右耳”。但是查到的信息是,可能会遇到双耳的固件版本(1.5.0.0与1.4.0.0)不一致,导致不能配对互连。

纠结了一下,选择了方案2。“右耳”到手后,“双耳”不能互连,但能各自单独使用。幸好找到了配对互连的操作视频:Fiil Key 重置步骤视频

“双耳”配对互连的操作总结:

  1. 两个耳机充满电。防止操作过程没电。
  2. 两个耳机从充电取出,白灯闪烁,即处于待配对状态。
  3. 两个耳机同时长按触控区域8秒,看到白灯亮3秒后熄灭,即可松手。此时,两个耳机都进入关机状态。
  4. 两个耳机同时长按触控区域15秒左右,直到白灯快速闪烁,即可松手。此操作使耳机开机并进行配对互连。
  5. 两个耳机放回充电仓,可以看到白灯都在同时闪烁,表示配对成功。此时用手机蓝牙进行配对,只看到一个Fiil Key。
  6. 手机蓝牙连上Fiil Key后,使用“Fiil+”app,可以看到两个耳机的固件版本。

Deploy Certbot Using Docker Compose

服务器上,使用Docker Compose部署和管理各种服务,体验相当好。部署Certbot,申请和更新免费SSL证书,有些不同,记录一下相关操作。

首先,很多DNS服务商已经在提供DNS服务的同时,提供了免费SSL服务。如果这种服务能解决SSL免费证书的需求,就没必要部署Certbot了。例如Cloudflare、DNSPod等。

使用Docker Compose部署Certbot的特点:

  • Docker Compose部署和管理容器,比较清晰和方便。
    • 比如直接运行容器(即使用docker run命令运行容器),能记录容器的配置,方便查看和维护。
  • Certbot容器无需一直保持运行,只需启动一下,申请或续签SSL免费证书。
    • 只要得到SSL免费证书的文件,即可停止运行。
    • 需要设置定时运行,执行续签证书。
  • 需要使用SSL免费证书的服务(例如Nginx),需要把证书文件mount到对应的容器。

1. Docker Compose部署Certbot

创建好部署目录,以下例子采用/opt/docker/certbot

先创建Cloudflare的认证配置文件:/opt/docker/certbot/etc/letsencrypt/certbot-dns-cloudflare-credentials.ini。该文件格式如下:

dns_cloudflare_api_token=<your-cloudflare-api-token>

注意,/opt/docker/certbot/etc/letsencrypt最好设置拥有者为root用户,并保证只有root可读写,提高安全性。

sudo chown -R root:root /opt/docker/certbot/etc/letsencrypt

再创建docker-compose.yaml文件,该文件内容如下:

# https://hub.docker.com/r/certbot/dns-cloudflare
services:
  certbot:
    image: certbot/dns-cloudflare:latest
    volumes:
      - ./etc/letsencrypt:/etc/letsencrypt
      - ./log:/var/log/letsencrypt
    command: certonly --agree-tos --non-interactive -m xxx@abc.com --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/certbot-dns-cloudflare-credentials.ini -d example.com,*.example.com

networks:
  default:
    name: default-net
    external: true

注意:

  • 由于采用Cloudflare的DNS服务,所以采用dns-cloudflare的Docker镜像。
  • --agree-tos,同意ACME订阅协议(the ACME server’s Subscriber Agreement)
  • --non-interactive,非交互式运行,即运行过程中不需要询问用户输入。
    • 当客户端发现参数缺失时会给出相应的说明。
  • -m,即--email,设置域名所有者的EMail。
  • -d,设置SSL证书相关域名。
    • 多个域名可以设置多个-d实现。
    • 也可以1个-d设置多个域名,但域名之间需要英文逗号(,)分隔,并且主域名放第一位,子域名随后。
    • 支持泛域名,即*.+主域名,避免设置多个子域名。
  • 最后的networks可以不设。
    • 这里设置了默认加入网络default-net,方便通过网络访问其它容器。

最后启动。由于无需一直保持运行,所以不用添加-d参数。如果无错误输出,则成功运行。

docker compose up

2. 使用SSL证书

对于HTTP服务的容器,只需把SSL证书文件mount到相关容器即可。

比如Nginx,其docker-compose.yamlvolumes配置如下:

volumes:
  - ../certbot/etc/letsencrypt/archive:/etc/letsencrypt/archive:ro
  - ../certbot/etc/letsencrypt/live:/etc/letsencrypt/live:ro

对应网站的server配置SSL证书:

server {
  server_name example.com;
  listen 443 ssl;
  ...
  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  ...

3. 自动续签

这里使用Systemd Timer实现定时自动renew证书。

创建服务管理单元,文件/opt/docker/certbot/deploy_conf/certbot-renew.service

[Unit]
Description=Certbot renew
Requires=network-online.target

[Service]
Type=oneshot
ExecStart=docker compose -f /opt/docker/certbot/docker-compose.yaml start 

[Install]
WantedBy=multi-user.target

创建定时器单元,文件/opt/docker/certbot/deploy_conf/certbot-renew.timer

[Unit]
Description=Auto run certbot renew
Requires=certbot-renew.service

[Timer]
# 周一和周四的凌晨3点执行
OnCalendar=Mon,Thu *-*-* 03:00:00
# 在5分钟以内,随机延迟执行
RandomizedDelaySec=5min
Unit=certbot-renew.service

[Install]
WantedBy=multi-user.target

部署:

sudo ln -s /opt/docker/certbot/deploy_conf/certbot-renew.service /etc/systemd/system/certbot-renew.service
sudo ln -s /opt/docker/certbot/deploy_conf/certbot-renew.timer /etc/systemd/system/certbot-renew.timer
sudo systemctl daemon-reload 
sudo systemctl enable certbot-renew.timer
sudo systemctl start certbot-renew.timer

注意:这里没实现自动续签成功后(即SSL证书文件更新后),自动更新Nginx。

Simplest HTTP Server for MicroPython

开发过一个项目,在刷了MicroPython的ESP32C3开发板上,运行一个HTTP服务和一个定时提交数据的自动任务。一开始,HTTP服务经常不定时宕掉。后来才发现,是内存不足。由于定时提价数据的自动任务,是通过HTTP协议POST数据,不能再精简。那么,只能对这个HTTP服务“开刀”。

这个HTTP服务,一开始是用TinyWeb开发,升级MicroPython v1.24.1后,改为使用microdot。两个框架的GitHub项目如下:

这些Web框架,功能比较全面,自然占用的内存就比较大。想要最大限度精简内存占用,最好的做法就是不使用Web框架开发,从底层实现HTTP服务。参考了以下代码:

最后编写出可以在MicroPython v1.24.1上运行的HTTP服务:

from micropython import const
import socket
import time
import logging

_logger = None
_RESP_STATUS = {
    200: 'HTTP/1.1 200 OK\n'
    ,302: 'HTTP/1.1 302 Found\n'
    ,400: 'HTTP/1.1 400 Bad Request\n'
    ,403: 'HTTP/1.1 403 Forbidden\n'
    ,500: 'HTTP/1.1 500 Internal Server Error\n'
}

def _getRequest(reqRaw: byte):
    '''获取整理后的请求数据
    
    Args:
        reqRaw (byte): 请求的原始字符串。
    
    Returns:
        dict: 整理后的请求dict对象。method:请求方法;path:请求Path;protocol:请求协议;form:POST的form数据。
	'''
    charset = 'utf-8'
    rArr = reqRaw.split(b'\r\n')
    r = str(rArr[0], charset).split()
    r = {'method':r[0].upper(), 'path':r[1], 'protocol':r[2]}
    if r['method'] == 'POST' and b'Content-Type: application/x-www-form-urlencoded' in rArr:
        r['form'] = dict(
            [tuple(item.split('=')) for item in str(rArr[-1], charset).split('&')]
        )
    return r

def _getPage(html: str, redirectUrl: str = '', redirectSecond: int = 0) -> str:
    '''生成HTML页面。主要是把传入的HTML数据,套上模板,或者实现自动跳转功能
	
    Args:
        html (str): HTML数据。
        redirectUrl (str): 跳转URL。
        redirectSecond (int): 等待指定秒数后自动跳转。

    Returns:
        str: 添加模板后的HTML页面
	'''
    redirectMeta = '' if len(redirectUrl) <= 0 else f'<meta http-equiv="Refresh" content="{redirectSecond}; URL={redirectUrl}" />'
    return f'<html><head><meta name="viewport" content="width=device-width,initial-scale=1">{redirectMeta}<title>Simple Web</title><link rel="icon" href="data:;base64,="></head><body>{html}</body></html>'

def _pageIndex(request):
    '''处理访问首页的请求。
	'''
    html = _getPage('''
<div style="text-align:center;">
<h1>Hello!</h1>
<p>Page index</p>
</div>
''')

    # Send response
    # Response header set boot status, for Shell script reading
    return html, 200

def _pageTips(request):
    '''处理提示页的请求,会在5秒后自动跳转首页。
	'''
    html = _getPage('<div style="text-align:center;">Forbidden.</div>', redirectUrl = 'index', redirectSecond = 3)
    return html, 200

# Start web server application
def run(logger: logging.Logger):
    '''启动HTTP服务

    Args:
        logger (logging.Logger): 传入logger对象。
    '''
    global _logger
    _logger = logger

    # Set up socket and start listening
    try:
        addr = socket.getaddrinfo(conf.WEB_HOST, conf.WEB_PORT)[0][-1]
        s = socket.socket()
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(addr)
        s.listen()
        _logger.info(f'Web server on {addr}')
    except Exception as e:
        _logger.error(f'Web server start failed: {e}')
        return

    # Main loop to listen for connections
    while True:
        try:
            conn, addr = s.accept()
            _logger.debug(f'Request from: {addr}')
            
            # Receive and parse the request
            reqRaw = conn.recv(1024)
            _logger.debug(f'Request content: {reqRaw}')
            req = _getRequest(reqRaw)
                       
            # Process the request
            if req['path'] in ['/','/index'] and req['method'] == "GET":
                respHtml, respStatus, respHeader = _pageIndex(req)
            elif req['path'] == '/tips' and req['method'] == "GET":
                respHtml, respStatus, respHeader = _pageTips(req)
            else:
                # Url path of request is not support
                _logger.debug(f"Path {req['path']}, 400")
                conn.send(_RESP_STATUS[400])
                conn.close()
                continue

            # Send the HTTP response and close the connection
            conn.send(_RESP_STATUS[respStatus])
            conn.send('Content-Type: text/html\n')
            if respHeader is not None:
                for k,v in respHeader.items():
                    conn.send(f'{k}: {v}\n')
            # Respone set no cache
            # Notice: Two newline characters ("\n") indicate the end of the header.
            conn.send('Cache-Control: no-cache\nExpires: 0\n\n')
            conn.write(respHtml)
        except Exception as e:
            _logger.error(f'WebServer Exception: {e}')
            try:
                conn.send(_RESP_STATUS[500])
            except:
                pass
        finally:
            try:
                if conn is not None:
                    conn.close()
            except:
                pass

注意:

  • 关于节省内存
    • 首先不要import没用到的库。甚至可以import指定库并用完后,执行del
    • 使用byte类型,代替str类型。
    • 适当的时候,手工清理内存,即执行gc.collect()
    • py文件编译为mpy,加载mpy文件更快更省内存。
  • 关于稳定性
    • 使用try ... except捕获所有异常,避免程序因异常而意外终止。

Use Systemd Timers Instead of Cron Jobs

某次逛论坛,发现使用Systemd管理的Linux发行版,应该使用Systemd Timers管理定时任务,并替代原来的Cron Jobs。

1. Systemd Units

Systemd Units(单元),是System的最小功能单位。其按功能划分为12种类型,例如target、service、timer等。“单元”之间互相依赖和调用,组成Systemd这个任务管理系统。

System Units存放路径:

  • /usr/lib/systemd/system,系统默认的单元文件
  • /etc/systemd/system,用户安装的软件的单元文件

注意:

  • Debian 12中,/usr/lib映射到/lib,所以/lib/systemd/system/usr/lib/systemd/system的文件相同。
  • Systemd读取Unit文件时,先读/usr/lib/systemd/system,再读/etc/systemd/system。所以修改原来的Unit文件,或者自定义的Unit文件,建议放在/etc/systemd/system
  • 编辑Unit文件,建议使用命令systemctl edit --full xxx.service,保存后会自动reload。

配置Systemd定时器时,需要创建两个Unit:

  • xxx.service,service类型,用于配置需要执行的任务。如果该任务已配置,则不用创建此文件。
  • xxx.timer,timer类型,对xxx.service执行定时操作。

2. 创建Systemd Service

对于Cron Job,会把定时执行的脚本或命令,与定时配置写在一起。配置是简单直接,但是不好管理。Systemd需要把定时执行的任务,配置为oneshot的Service。

例如创建文件/etc/systemd/system/sample-job.service

[Unit]
Description=sample job

[Service]
Type=oneshot
ExecStart=/bin/bash /path/to/sample-job.sh

[Install]
WantedBy=multi-user.target

注意:

  • Type=oneshot,表示只运行一次,不用长期运行。
  • Requires,配置了需要网络服务联网成功,才能执行。
  • WantedBy,配置了多用户命令状态时,才能执行。

3. 创建Systemd Timer

例如创建文件/etc/systemd/system/sample-job.timer

[Unit]
Description=Auto run sample job

[Timer]
OnCalendar=Mon,Thu *-*-* 01:23:49
Unit=sample-job.service

[Install]
WantedBy=multi-user.target

注意:

  • Unit,配置对应的Service。
  • Requires,需要依赖的Service,可不配。
  • OnCalendar,按配置的日期时间进行定时运行。格式是DOW yyyy-MM-dd HH:mm:ss,其中DOW可以是Mon,Fri(周一和周五这2天)或者Mon..Fri(周一到周五这5天)
    • 详细参考:https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html#Calendar%20Events

4. 管理

# 启动定时器
sudo systemctl start sample-job.timer
# 停止定时器
sudo systemctl stop sample-job.timer
# 查看定时器状态
systemctl status sample-job.timer
# 列出正在运行的定时器
systemctl list-timers
# 设置开机启动
sudo systemctl enable sample-job.timer
# 取消开启启动
sudo systemctl disable sample-job.timer

参考