Author Archive for wzyboy

collectd + Graphite + Grafana 搭建网络质量监控系统

前段时间入手一台 Gen8 服务器,主要用来做网络存储。光做网络存储显然太浪费了,感谢 ESXi,一机多用很方便。本文介绍如何在家庭服务器上搭建简单好用的网络质量监控系统。

一、选材

说到网络质量监控,大部分人会想到著名的 SmokePing。SmokePing 的确是经典工具,但未免老旧,配置也略复杂。本文使用 collectd 作为收集工具,Graphite 作为存储工具,Grafana 作为展示工具。这些工具符合「专做一件事情并把这件事情做好」的 Unix 哲学,配置灵活、功能强大。

整体结构是这样的:

asciiflow-collectd-graphite-grafana-2

二、收集:collectd

正如其名字所暗示的那样,collectd 是一个收集系统各项指标的进程。它自带很多插件,也可以通过自定义插件和数据类型的方式增加更多的收集项。网络质量监控主要用到其中的 ping 插件,该插件依赖 Liboping 这个库。这两个项目在主流 GNU/Linux 发行版中都有打包。

使用你最喜爱的包管理器安装 collectd 和 liboping 之后,使用你最喜爱的编辑器打开 /etc/collectd.conf 文件。这是一个带有详尽注释的超长配置文件,要让 ping 插件工作,以下是一份示例配置:

Hostname "your-hostname"
FQDNLookup false

LoadPlugin ping
LoadPlugin write_graphite

<Plugin ping>
  Host "8.8.8.8"
  Host "8.8.4.4"
  Interval 1.0
  Timeout 0.9
</Plugin>

<Plugin write_graphite>
  <Node "localhost">
    Host "localhost"
    Port "2003"
    Protocol "tcp"
    LogSendErrors true
  </Node>
</Plugin>

意义很明确,声明自己的主机名(用于上报数据),加载 ping 和 write_graphite 插件,然后配置这两个插件。

ping 插件的配置项中,Host 用于添加需要被监控的目标,一行一个,IntervalTimeout 则分别指定多久 ping 一次,以及多久没收到回包认为是超时。如有特殊需求,还可以指定 SourceAddress, Device 等参数指定从哪个网络设备发出 ICMP 包。注意,这里 Interval 不是指多久上报一次,而是指多久 ping 一次,上报的话还是按全局的来(默认 10 秒),上报时的数据是这段时间 RTT 的算术平均数。

write_graphite 插件的配置项中,填写 carbon-cache.py 监听的地址和端口。本例中跑 collectd 的机器同时也跑了 carbon-cache.py,因此填 localhost 即可。如果它们不在同一台机器上(比如 collectd 跑在路由器上,carbon-cache.py 跑在配置更高的设备上),则需要填写相应的地址。

collectd 发出去的数据是很简单的 TCP 消息,如:

foo.bar.baz 123 1458372405

以空格分隔,第一段是指标名字,第二段是数值,第三段是时间戳。

改好配置之后保存。因为现在 carbon-cache.py 还没有运行,因此还不能启动 collectd。

三、接收、存储和查询:carbon-cache.py 和 graphite-api

Graphite 是一个较大项目,它的主要组件有:

  • Carbon。包括 carbon-cache.py, carbon-relay.py 等,用于接收数据点;
  • Whisper。数据点存储格式,Carbon 用它把数据点写入磁盘;
  • Graphite Web。基于 Django 的网页应用,既提供查询数据点的 API,也提供一个展示用的网页。

由于 Graphite Web 较为臃肿而功能比较弱,因此这里不使用它,而是使用 Graphite-API 这个第三方项目提供查询 API,用漂亮的 Grafana 提供展示页面。

Carbon 可以直接从 PyPI 安装,注意它只支持 Legacy Python。它是纯 Python 的,没有其他依赖,使用 pip2 install carbon 即可轻松安装。

Carbon 使用 Whisper 存储数据点,这一格式的文件大小是预分配的,并且是固定的。旧的数据可以设置自动降低精度,非常旧的数据可以设置丢弃。在 storage-schemas.conf 中可以这么设置:

[ping]
pattern = \.ping\.
retentions = 10s:1d,1m:30d,5m:180d,30m:1y

[default]
pattern = .*
retentions = 1m:1d,5m:30d,30m:180d,1h:1y

其中 pattern 匹配指标的名字,retentions 指定这个指标应该保留多久。以 [default] 为例,这个指标的数据,1 分钟精度的会保留 1 天,之后自动降为 5 分钟精度,保留 30 天,之后自动降为 30 分钟精度,保留 180 天,之后自动降为 1 小时精度,保留 1 年。而对于 [ping] 一节的数据,我希望能更精确一些,因此 1 天内的数据是 10 秒精度的。这样的设置可以使不同的指标按需自动降低精度以节省存储空间,既能查到近期的高精度数据,也能反观远期的大致趋势。

注意这些政策仅对新创建的 .wsp 文件有效,已有的文件的存储策略需要通过 whisper-resize.py 进行更改。
Graphite-API 因为带有非 Python 的图形库依赖,编译安装时较为麻烦。Ubuntu / Debian 用户可以用官方提供的 .deb 安装。Arch Linux 用户可以使用我打包的 aur/graphite-api 安装。

安装之后打开 graphite-api.yaml 文件。根据安装方式的不同,Carbon 和 Graphite-API 的存储路径、配置文件路径会有一些差别,请按照自己的情况将 whisper – directories 一节的路径填写正确。

另外推荐配置 carbonlink,查询那些在 carbon-cache.py 内存中还未写入磁盘的数据。最终我使用的 graphite-api.yaml 内容如下:

finders:
  - graphite_api.finders.whisper.WhisperFinder
whisper:
  directories:
    - /var/lib/carbon/whisper
carbon:
  hosts:
    - 127.0.0.1:7002
  timeout: 1
  retry_delay: 15
  carbon_prefix: carbon
  replication_factor: 1

配置完成后,可以启动 carbon-cache.py 和 graphite-api。上节配置的 collectd 也可以启动了。

四、展示:grafana-server

Grafana 是一个功能强大的图表绘制服务器,支持 Graphite, InfluxDB, OpenTSDB 等多种后端,支持多种图表绘制,几乎无外部依赖。

Grafana 由 Go 和 Node.js 写成,编译结果是一个单文件 Go 程序和一堆 HTML + CSS + JavaScript。官方提供了 .deb.rpm 可供安装。我在 Arch Linux 上安装的时候,本来直接用的 aur/grafana,后来发现 go get 和 npm install 的过程简直蛋疼,于是将官方提供的 .tar.gz 二进制包做了一份 aur/grafana-bin,直接装这个就省力多了。

安装之后启动服务,在浏览器中打开 :3000 端口,即可用 admin / admin 登录。在 grafana.ini 中也可以开启匿名登录。

登录后需要先添加数据源(data source),将 graphite-api 的地址和端口(默认是 8888)填写进去即可。

然后便可以开始画图了,通过简单的网页操作即可添加各种不同类型的图表,也可以方便地选用 Graphite 内置的各种函数。Grafana 的具体操作可以从官方文档了解到,这里不再赘述。

以下是我用 Grafana 绘制的网络质量监控的页面,其中用到 TemplatingSinglestat Panel 等功能:

grafana-dashboard-network-fullscreen-20160319-2-blurred

请原谅那一段 100% 丢包率的部分,是我的一台 VPS 所在的机房出了问题……

五、监控更多的指标

collectd 还可以做很多事情,只用它的 ping 插件太大材小用了。玩熟了 ping 插件之后,我又用它监控了局域网内各机器(自己的笔记本、Gen8 上运行的其他虚拟机等)的 CPU、内存、磁盘、网络等其他指标。collectd 的客户端也是移植性很强,我甚至在 Raspberry Pi 上也部署了一下。Windows 机器的话,则可以安装 SSC Serv,这是一个协议兼容的 collectd agent,免费版本有五分钟上报一次的限制,基本够用了。

HP ProLiant MicroServer Gen8 上手玩

曾经,一台共享空间的 PHP 「虚拟主机」在大部分人眼里就是很高级的「服务器」了,后来随着虚拟化技术的发展,OpenVZ, Xen, KVM 虚拟机越来越普及,Linode 和 DigitalOcean 更是将 $10/mo 的廉价 VPS 推广开来,VPS 可以做很多 PHP 共享主机做不到的事情。近两年,随着越来越多中小企业的发展,「微型服务器」的概念也慢慢出现了。HP ProLiant MicroServer Gen8 就是其中的一员。最近我也入手了一台,当家庭服务器使用。

c03760124

一、购买

Gen8 是 2013 年的产品了,现在的售价已经比刚出来时便宜不少了。爱折腾的可以去海淘一个便宜的版本,我为了省事就直接在□东上购买了。□东上提供了三个型号,分别是:

  • Intel G1610 CPU + 2 GB RAM
  • Intel G2020 CPU + 2 GB RAM
  • Intel E3-1220Lv2 CPU + 4 GB RAM

主要的区别就是在 CPU 了,三款 CPU 分别是赛扬(Celeron)、奔腾(Pentium)和至强(Xeon)系列的产品,价格也由低到高。然而这内存实在是令人郁闷,两条插槽明明可以可以最高 16 GB(甚至单条 16 GB 的话可以最高 32 GB,兼容性未知),但最高配置也就 4 GB 是什么鬼……考虑了一下自己主要是用来做 NAS 使用,但是同时又有虚拟化的需求,因此决定购买赛扬型号的,然后自己加内存。

最终我的购买清单是:

  • HP ProLiant Gen8, Intel G1610 CPU, 2 GB RAM
  • Kingston DDR3 1600 ECC RAM, 8 GB
  • Samsung 850 EVO SSD, 120 GB
  • WD Red NAS HDD, 2 TB
  • 六类网线,3 米 * 3

Gen8 一共四个 SATA 盘位外加一个光驱位,因此有心折腾的话最多可以扩展成五块硬盘。我按照我目前的需求就一块 SSD 一块 HDD 搞定了。网线我买了三根,是因为 Gen8 有三个 RJ-45 接口,两个是主网卡上的,另一个是 iLO 的管理接口。在 iLO 中也可以设置让 iLO 和主网卡共享网络接口。

二、开箱及组装

以上内容合计约 3700 人民币,下单后当天下午就送到了。以下多图杀猫,点击可看大图。

在装硬盘的时候我踩坑了。由于之前对 Gen8 的 SATA 背板不了解,想当然地买了块普通的 2.5 英寸转 3.5 英寸硬盘支架,想要把 SSD 装到 1 号槽、HDD 装到 2 号槽的时候发现 2.5 英寸的 SSD 套个普通的 3.5 英寸支架并不能插进硬盘槽里……于是最后的解决方案是把 1 号槽的背板拆下来,拉到顶上,把 SSD 塞在那里,于是最后把 SSD 放顶上了。好在那儿本来就是光驱的位置,所以并不影响盖上盖子。

另一种方案是把 SSD 接到闲置的光驱线上,这样下面四个盘位都可以插满大容量 HDD。这种方案需要自己购买 SATA 转接线。

在装好硬盘之后,需要在 BIOS 里配置 B120i RAID 控制器。说是 RAID 控制器,其实是个伪 RAID,加之我并没有 RAID 需求,我直接禁用掉了,这样操作系统看到的就是个普通的 SATA 控制器,能直接访问到下面挂载的硬盘。

三、搭建 ESXi 虚拟化环境

Gen8 作为一个微型服务器,定位于中小企业和家庭,配置远不如它那些安身于 IDC 里的同侪们,但是用作网络存储却是非常合适。四盘位使用 3 TB 硬盘可提供 12 TB 的存储空间;如果使用那些 6 TB 的硬盘的话,更可扩展至 24 TB 存储空间。我入手 Gen8 也是想作为网络存储的,但是同时又想一机多用,跑几个虚拟机玩玩。因此需要一套虚拟化方案。

虚拟化方案选择

纳入我考虑范围的有 VMware ESXiXenServerlibvirt + QEMU 这三种方案。综合我自己的需求、自身的兴趣(后两者都已经玩过了,第一个还没玩过),以及 Twitter 的投票,我选择了 VMware ESXi 作为虚拟化方案。

ESXi 的前身是 ESX,是 VMware 出品的优秀的 bare metal 虚拟化方案,在 VMware 网站上可以免费注册得到一份授权,可无限期使用。与 VirtualBox、QEMU、Hyper-V 等方案不同,ESXi 是 Type-1 Hypervisor,它直接运行于裸机上,不需要先安装一个操作系统,因此性能开销小,本身占地也很小,很适合安装到 TF 卡或 U 盘等小型存储器中。正巧,Gen8 的机箱内主板上有一个 USB 和一个 TF 卡接口,可以把 ESXi 安装进去。由于 ESXi 启动完成之后就在内存中运行了,不用担心 U 盘和 TF 卡的读写性能问题。

安装并连接到 ESXi

在 VMware 网站上可以下载到 ESXi 6 的安装镜像,但是 HP 为 Gen8 专门制作了一个定制版的 ESXi 镜像,自带了 Gen8 的驱动程序,建议使用 HP 版本包括我在内已经有两位用户发现 HP 定制版 ESXi 与 RDM 有兼容性问题,会导致 ESXi 虚拟机卡死。使用原版 ESXi 镜像无兼容性问题。安装镜像约 380 MiB,局域网内网络挂载安装也不会太慢,直接使用 iLO 的虚拟光驱挂载功能即可。注意,默认的 iLO 版本并不支持虚拟光驱功能,需要去 HP 那里申请一个 60 天 iLO Advanced 试用版授权。或者你可以本地起个一个 HTTP 服务器,然后使用 iLO 免费版里的从 URL 加载镜像的功能安装。

我不知道原版 ESXi 安装过程是什么样的,但是 HP 为 Gen8 定制的 ESXi 安装镜像在整个安装过程中除了问了我安装到哪里,其他什么也没问。我自然是选择安装到 U 盘了(记得提前插好)。安装完成后可以看到上灰下黄的屏幕上显示着 ESXi 通过 DHCP 自动获取的 IP 地址,用浏览器访问即可看到欢迎页面,然后……你几乎什么也不能做,不过你可以顺着欢迎页面上的链接去下载个 vSphere Client。

这是令我比较郁闷的部分。通过这几天的使用,我发现 ESXi 是个好东西,但是它提供的连接方式却不怎么样。目前你有四种方式去管理它上面的虚拟主机:

  • 使用 vSphere Client —— 这是一个 Windows-only 的客户端;
  • 使用 vSphere Web Client —— 你需要部署 vCenter,对于只跑一个 ESXi 实例的用户来说太不值得了;
  • 使用 vSphere CLI —— 也依赖 vCenter;
  • 安装 ESXi Web UI —— 似乎是个 2016 年新出的试验产品,功能还不完善,bug 也多。

作为一个 Arch Linux 用户,我自然想免客户端直接从浏览器里管理虚拟机了,但是安装并使用了 ESXi Web UI 之后我发现它实在是初级了一些,于是最后还是屈服于 VirtualBox + Windows + vSphere Client 了。

连接到 ESXi 之后需要先创建数据存储(Datastore)。前面说过,我的 Gen8 主板上插了个 16 GB 的 U 盘(ESXi 就装在里面),硬盘位插了一块 SSD 和一块 HDD,我直接把整块 SSD 都添加到 ESXi 里作为 Datastore 了。ESXi 会将其分区并格式化成 VMFS 作为虚拟机存储。剩下的 HDD 则通过 RDM 分配给虚拟机使用。

创建磁盘映射(RDM)

vSphere Client 的 GUI 还是不错的,功能强大、操作简单,创建虚拟机的过程不再详述。下面讲讲 RDM。

前面说过,NAS 将是 Gen8 的重要用途,因此大容量硬盘是少不了的,但是我的 NAS 是跑在虚拟机里的,考虑到数据迁移和恢复的便利性,最好能让虚拟机直接访问到物理磁盘,而不是再隔一层文件系统。让虚拟机直接访问到物理机的磁盘有两种思路:Passthrough 和 RDM (Raw Disk Mapping)。前者需要 CPU 支持 VT-d,并且按照 @Orz_C 的实验,似乎只能把整个 SATA 控制器(连同下面的四个盘位)一起直通进去,不能单独传递下面挂载的磁盘。很遗憾,我的赛扬 CPU 虽然支持 VT-x 却不支持 VT-d,并且我的 SATA 控制器下面挂的并不都是想传递给虚拟机的硬盘,因此此路不通。

那就用 RDM 吧。RDM 其实是 VMDK(VMware 开发的开放虚拟磁盘格式)的功能,原理是创建一个(几乎不占空间)的特殊 .vmdk 文件映射到一块物理磁盘,当虚拟机向这块 VMDK 写入时,实际写入的是后面的物理磁盘。在 VirtualBox 里也可以用这种方法让虚拟机直接使用物理磁盘。ESXi 支持在 GUI 中创建到 LUN 的 RDM,却必须要借助命令行才能创建到本地 SATA 磁盘的 RDM。

在 ESXi 的设置中打开 SSH 访问,用 SSH 登录之后看到 /dev/disks/ 底下有 Gen8 上所有的磁盘:

[root@ezsetupsystem3ca82a9fd134:~] ls -lh /dev/disks/
total 4160150040
-rw------- 1 root root 14.4G Mar 13 16:29 mpx.vmhba32:C0:T0:L0
-rw------- 1 root root 4.0M Mar 13 16:29 mpx.vmhba32:C0:T0:L0:1
-rw------- 1 root root 250.0M Mar 13 16:29 mpx.vmhba32:C0:T0:L0:5
-rw------- 1 root root 250.0M Mar 13 16:29 mpx.vmhba32:C0:T0:L0:6
-rw------- 1 root root 110.0M Mar 13 16:29 mpx.vmhba32:C0:T0:L0:7
-rw------- 1 root root 286.0M Mar 13 16:29 mpx.vmhba32:C0:T0:L0:8
-rw------- 1 root root 2.5G Mar 13 16:29 mpx.vmhba32:C0:T0:L0:9
-rw------- 1 root root 111.8G Mar 13 16:29 t10.ATA_____Samsung_SSD_850_EVO_120GB_______________S21XXXX_____
-rw------- 1 root root 111.8G Mar 13 16:29 t10.ATA_____Samsung_SSD_850_EVO_120GB_______________S21XXXX_____:1
-rw------- 1 root root 1.8T Mar 13 16:29 t10.ATA_____WDC_WD20XXXX_________________________WD2DXXXX
-rw------- 1 root root 1.8T Mar 13 16:29 t10.ATA_____WDC_WD20XXXX_________________________WD2DXXXX:1
lrwxrwxrwx 1 root root 20 Mar 13 16:29 vml.0000000000766d68626133323a303a30 -> mpx.vmhba32:C0:T0:L0
lrwxrwxrwx 1 root root 22 Mar 13 16:29 vml.0000000000766d68626133323a303a30:1 -> mpx.vmhba32:C0:T0:L0:1
lrwxrwxrwx 1 root root 22 Mar 13 16:29 vml.0000000000766d68626133323a303a30:5 -> mpx.vmhba32:C0:T0:L0:5
lrwxrwxrwx 1 root root 22 Mar 13 16:29 vml.0000000000766d68626133323a303a30:6 -> mpx.vmhba32:C0:T0:L0:6
lrwxrwxrwx 1 root root 22 Mar 13 16:29 vml.0000000000766d68626133323a303a30:7 -> mpx.vmhba32:C0:T0:L0:7
lrwxrwxrwx 1 root root 22 Mar 13 16:29 vml.0000000000766d68626133323a303a30:8 -> mpx.vmhba32:C0:T0:L0:8
lrwxrwxrwx 1 root root 22 Mar 13 16:29 vml.0000000000766d68626133323a303a30:9 -> mpx.vmhba32:C0:T0:L0:9
lrwxrwxrwx 1 root root 74 Mar 13 16:29 vml.0100000000202020202057442d574343344d30465a36374831574443205744 -> t10.ATA_____WDC_WD20XXXX_________________________WD2DXXXX
lrwxrwxrwx 1 root root 76 Mar 13 16:29 vml.0100000000202020202057442d574343344d30465a36374831574443205744:1 -> t10.ATA_____WDC_WD20XXXX_________________________WD2DXXXX:1
lrwxrwxrwx 1 root root 72 Mar 13 16:29 vml.0100000000533231564e58414831373636383944202020202053616d73756e -> t10.ATA_____Samsung_SSD_850_EVO_120GB_______________S21XXXX_____
lrwxrwxrwx 1 root root 74 Mar 13 16:29 vml.0100000000533231564e58414831373636383944202020202053616d73756e:1 -> t10.ATA_____Samsung_SSD_850_EVO_120GB_______________S21XXXX_____:1

那些 mpx 开头的是那只 16 GB 的 U 盘及其分区,四个 t10 开头的代表 SSD 和 HDD,剩下的那些是指向前面那些的符号链接。

这里我选择 WDC 的那个块设备(即我的 WD 硬盘),创建 RDM:

vmkfstools -r /dev/disks/XXXX /vmfs/volumes/EVO/WDYYYY.vmdk

第一个参数是代表物理磁盘的块设备(注意不带冒号,即整块硬盘,而不是其中的分区),第二个参数是创建的 .vmdk 文件的路径,我这里保存到上一节创建的名为 EVO 的 Datastore(即我的 SSD)根目录下名为 WDYYYY.vmdk 的文件(我是拿硬盘序列号作为文件名的)。

另外,-r 参数创建的是 Virtual Compabilitiy Mode RDM,即 ESXi 会截获除 READ / WRITE 之外所有 SATA 指令;如果换成 -z 参数,则是创建 Physical Compability Mode RDM,即 ESXi 除了 LUN REPORT 指令,其他全部原样传递给物理磁盘。没啥特殊需求用 -r 即可。两种模式只有在 hdparm 等涉及到磁盘本身参数的命令才会有区别,数据层面没有区别。

将这样创建得到的特殊 .vmdk 文件分配给虚拟机,便可在虚拟机中访问到外层 ESXi 的硬盘。

四、搭建 NFS 和 Samba

接下来需要将硬盘中的文件分享给其他主机。如果你的网络里只有 Linux 主机的话,那么搭个 NFS 就足够了。初版 NFS 于 1984 年由 Sun 研发,几十年来久经考验,十分适合局域网内文件共享。

在 Linux 发行版中,NFS 服务通常由 nfs-utils 包提供,安装后修改 /etc/exports 文件,将硬盘挂载点共享出去即可。比如这是我的配置:

/media/disk1/public 192.168.2.0/24(ro,sync,no_subtree_check,no_root_squash)
/media/disk1/incoming 192.168.2.0/24(rw,sync,no_subtree_check)

共享了 public 和 incoming 两个目录,前者允许局域网内读写操作,后者则是只读。修改此文件用后可以用 exportfs -rav 命令重载。注意,no_root_squash 使得拥有 NFS 客户机 root 权限的人也可以 root 身份对 NFS 服务器的共享目录进行操作(与 MooseFS 的默认行为一致),如果不需要这一特性可以关闭。

在我的 Linux 笔记本上,可将 NFS 挂载项写个 /etc/fstab 实现开机自动挂载(voila 是文件共享服务器的主机名):

# NFS @ voila
voila:/media/disk1 /nfs/voila nfs4 rsize=8192,wsize=8192,timeo=14,_netdev 0 0

因为我还需要向 Windows 用户分享文件,因此我还装了一个 Samba。Samba 的配置比 NFS 复杂多了,以下是我用的最简配置:

# /etc/samba/smb.conf
[global]
workgroup = WORKGROUP
server string = Voila Samba Server
hosts allow = 192.168.2. 127.
log file = /var/log/samba/%m.log
max log size = 50
security = user
map to guest = Bad User

[public]
comment = Public Read-only
path = /media/disk1/public
public = yes
browseable = yes
read only = yes

[incoming]
comment = Public Read-write
path = /media/disk1/incoming
public = yes
only guest = yes
browseable = yes
read only = no
writable = yes

启动 Samba 服务后,列在 hosts allow 里的主机即可无密码访问 Samba 共享。Windows 用户可以将 Samba 目录映射为网络驱动器,方便日后访问。

五、家庭服务器的其他用途

本节列举一些我能想到家庭服务器的其他用途,可能是我已经实现的、正在实现的、想要实现的,以及暂时不想实现的。排名不分先后,仅供参考:

六、致谢

以下推友作为 Gen8 的先驱玩家,在本文作者折腾 Gen8 的过程中给予了诸多帮助和指导,特此感谢(排名由 sort 提供):

Beancount —— 命令行复式簿记

本文介绍复式簿记的基本概念以及如何使用 Beancount 记账。本文适合的读者:

  • 想要记账的;
  • 曾经或正在记账但是目前对记账方式/软件不满意的;
  • 控制欲强的。

一、为什么要记账

记账能让自己了解自己的财务状况,用大白话来说就是能回答以下问题:

  • 我的钱从哪来?
  • 我的钱在哪?
  • 我的钱去哪了?

一本维护良好的账本能生成很多有用的财务报表,其中最有用的是「损益表」和「资产负债表」,前者能回答第一个和第三个问题,后者能回答第二个问题。为了维护一本良好的账本,你需要科学的记账方法和科学的记账软件,本文将向你安利一种科学的记账方法(复式簿记)和一套科学的记账软件(Beancount)。阅读以下内容之前,你需要做好以下准备:

  • 有基础的会计知识,至少听说过「会计恒等式」;
  • 能熟练地在终端里编辑文本文件,无论 Vim 或是 Emacs;
  • 对自己的财务状况有基本了解,并愿意对此做出优化。

有以下技能会更方便:

  • 基础的 Python 知识,或是其他适合于文本处理的编程语言知识(用于导入银行账单);
  • 熟练使用 Git 等版本管理工具(用于跨设备同步)。

二、什么是复式簿记

复式簿记是一种把每笔交易都记录到复数个账户中的簿记方法。举个例子,想像你面前有两个桶,分别是「资产」(Assets)和「费用」(Expenses),左边的桶里装满了豆子,右边的桶是空的。你用 1000 元听了一场演唱会,为了记录这笔费用,你把 1000 粒豆子从 Assets 桶里转移到了 Expenses 桶里,代表你的资产减少了 1000 元,而你花在演唱会上的费用增加了 1000 元,在这个过程中,豆子的总量没有变化(资产减少的豆子与费用增加的豆子数量一致),这便是最简单的复式簿记。

实际上,复式簿记系统中,一般有五个大桶,每个桶里又可以放很多个小桶,这五个大桶分别是:

  • 资产 Assets —— 现金、银行存款、有价证券等;
  • 负债 Liabilities —— 信用卡、房贷、车贷等;
  • 收入 Income —— 工资、奖金等;
  • 费用 Expenses —— 外出就餐、购物、旅行等;
  • 权益 Equity —— 用于「存放」某个时间段开始前已有的豆子,下文详述。

豆子(或是货币)在这五个桶里倒来倒去,出入相抵,这便是会计恒等式。这些桶里剩余的货币数量,则是生成损益表和资产负债表的重要依据。

与传统的复式簿记不同,Beancount 及其前辈们用的复式簿记方法使用了正负号而不是拗口的「借」(debit)和「贷」(credit)来表示五个桶之间的豆子变动,更加容易理解和思考,也不容易出错。本文所介绍的复式簿记采用 Beancount 的方案。

虽然复式簿记可以用来记任何东西的变动,但主要还是用来记货币的变动,因此桶中的数字是可以为负数的。

具体怎么记呢?再举几个例子:

  • 收入→资产:小明是个无业游民,有天他在路上捡到 100 元,没有交给警察叔叔——收入桶倒出 100 元,资产桶增加 100 元;
  • 负债→资产:小明看中一件新衣服,但是买不起,于是问大明借了 200 元——负债桶倒出 200 元,资产桶增加 200 元;
  • 资产→费用:小明用 300 元买了一件衣服——资产桶倒出 300 元,费用桶增加 300 元;
  • 费用→资产:小明发现衣服不合适,要退货,老板说你穿了好几天了,只能退你 250 元——费用桶倒出 250 元,资产桶增加 250 元;

小明完成了这四笔交易之后,四个桶的状态:

  • 收入:-100 元
  • 负债:-200 元
  • 资产:250 元
  • 费用:50 元

这四个桶里的数字加起来是——0 元。因为这四个桶里的数字之和一开始就是 0,而每笔交易都是在桶之间加减,负数和正数的绝对值相等(和为 0),因此总量并没有变化。每笔交易在不同账户的数字加起来和为 0 是复式簿记的重要特性和原则,也是用来检验账目正确性的重要依据。复式簿记这一特性在企业账目管理中有着重要的意义——不同账户的交易内容记在不同的账本上,由不同的财务人员管理,使账目之间互相制约、不容易出错(无论是有意的还是无意的)。

在上面的例子中,「负债 -100 元」和「资产 250 元」挺容易理解的,但是「收入 -100 元」和「费用 50 元」可能不是那么容易一下子想通。如果上面倒豆子的想像没能让你信服的话,以下两个方案有助于理解(但可能并没有倒豆子那么欢乐):

  • 把收入(Income)想像成一个装着你一生(过去和未来)所有劳动成果的桶,每次你的收入都是从桶里取出东西(通常以货币的形式),一直取啊取啊,直到某一天……所以收入桶的数字通常是负数
  • 把费用(Expenses)想像成一个装着你一生(过去和未来)所有消费的桶,每次你的支出都是往桶里放东西(以货币的形式表现),和朋友出去唱歌转换成快乐存进去,看过的电子书转换成精神食粮存进去,吃过的饭转换成……所以费用桶的数字通常是正数

一旦接受了「收入和负债通常为负数」、「资产和费用通常为正数」这两个设定,那你便很容易理解这条等式了:

(Income + Liabilities) + (Assets + Expenses) + Equity = 0

用大白话来说就是:你赚的钱(Income),加上你借来的钱(Liabilities),最终要么变成你自己的钱(Assets),要么就是花掉了(Expenses),最终得到的是个零。这就是人的一辈子……

等下,Equity 是怎么来的?仔细想想小明的例子,他的四个桶要满足这个等式,前提是桶里都是空的。但是小明不是一个刚出生的婴儿,他已经活了二十多年了,之前的 Income、Liabilities、Assets、Expenses 怎么算呢?答案就是放到 Equity 里。当小明决定开始用复式簿记的时候,他从 Equity 里倒一些豆子其他桶里(或从其他桶倒一些豆子到 Equity 里),将其他桶的数字调节成符合当前实际情况即可。实际操作中,人们一般只关心 Income 和 Expenses 桶的数字在某段时间内的变化,并不关心它的总数(除非你想统计你出生到现在一共收入多少、支出多少),只要把 Assets 和 Liabilities 调节准就行了。这便是 Equity 的作用——存放已有的「权益」。

更一般地,Equity 可以用来存放所选取的时间范围之前的「汇总」。比如小明从 2012 年开始用 Beancount,一直用到 2016 年,他想只看 2016 年的财务状况,那 Beancount 便会把他 2016 之前四年的数据「调节」到 Equity 里,来维持 2016 年会计恒等式的平衡。

三、Ledger-like 和 Beancount

Beancount 是一个 Ledger-like 软件。Ledger 是这一类复式簿记软件的开创者。他们共有的特点是:

  • 采用改进的复式簿记方案(使用正负号而不是「借」和「贷」来表示账户之间的变化);
  • 使用纯文本文件作为账本,用户用文本编辑器即可记账;
  • 账本既是用户输入的文件,同时也是软件的「数据库」;
  • 软件读取账本并生成报表,账本本身也可供人类直接阅读。

市面上的复式簿记软件不少(如 GnuCash),但是大部分都是提供一个 GUI,用户在一堆文本框里输入各种数字和文字,软件接受输入然后存储到自己的数据库里(SQLite、MySQL 等)。用户无法直接看到或操作他们的数据,必须通过软件来操作;一旦软件停止更新,用户的数据就危在旦夕:难以导出,难以复用,很难跨平台或跨设备同步。

而 Ledger-like 软件则直接使用文本文件作为账本,用户直接用最喜爱的编辑器打开账本即可记账。软件只是读取你的账本并生成报表,即使软件停止更新,用户依然可以直接阅读账本。你可以方便地在各在平台上记账,甚至跨设备问题也可以用 Dropbox 等同步工具,或是 Git 等版本管理工具轻松解决。

Beancount 是 Ledger-like 软件中优秀的一员,相比用 C++ 写成的 Ledger,用 Python 写成的 Beancount 更轻便,更方便增加插件和二次开发,也增加了很多功能,如灵活强大的多「货币」支持。这里为加上引号是因为,Beancount 其实并不知道什么是「货币」,它记录的只是「通货」(commodity)的变化,所有的 commodity 皆由用户自己定义,因此 Beancount 可以用来记录包括货币在内任何东西的变化,比如年假天数、股票、航空里程、信用卡积分,当然了,还可以用来数豆子。这也是 Beancount 名字的来源。

四、Beancount 基础

Beancount 是个 Python 软件,可以从 PyPI 安装。建议同时安装另一个相关软件 Fava,是 Beancount 的一个漂亮的 Web UI:

virtualenv BEANCOUNT
source BEANCOUNT/bin/active
pip install beancount
pip install beancount-fava

装好之后便可以开始写你的第一个账本了。怎么写?Beancount 作者写了非常非常详细的文档:

比如小明如果用 Beancount 的话,他的账本将是这样的:

1970-01-01 open Income:Windfall
1970-01-01 open Assets:Cash
1970-01-01 open Liabilities:Da-Ming
1970-01-01 open Expenses:Clothing

2016-01-01 * "捡到钱了"
  Income:Windfall                            -100.00 CNY
  Assets:Cash                                +100.00 CNY

2016-01-01 * "向大明借钱"
  Liabilities:Da-Ming                        -200.00 CNY
  Assets:Cash                                +200.00 CNY

2016-01-01 * "XX 百货商店" "买衣服"
  Assets:Cash                                -300.00 CNY
  Expenses:Clothing                          +300.00 CNY

2016-01-02 * "XX 百货商店" "退衣服"
  Expenses:Clothing                          -250.00 CNY
  Assets:Cash                                +250.00 CNY

首先小明需要设立账户。开户日期可随自己喜好定,只需比最早一笔涉及到该账户的交易更早即可。这里小明都使用了 1970 年 1 月 1 日作为开户日期,保证今后记录的各种交易都会发生在这个日期之后。

然后便可以真正开始记了,交易的格式如上所示。其中日期后面的星号(*)代表这是一笔已确认的交易,如果换成感叹号(!)的话,则代表这笔交易有疑惑,后期对账时应注意。对账标志后面则是跟着收款人(Payee)和备注(Narration),需要用引号包起来。Payee 是可选的,只有一个字符串的话,这串字符就是 Narration 了。

小明的账本已经写完了,手工书写,也能肉眼阅读。那么 Beancount 有什么用?当然是生成报表:

(BEANCOUNT) bean-report xiaoming.bean balances
Assets:Cash               250.00 CNY
Equity              
Expenses:Clothing          50.00 CNY
Income:Windfall          -100.00 CNY
Liabilities:Da-Ming      -200.00 CNY

于是小明对自己的财务状况一目了然:有 250 元现金,在衣服上花了 50 元,一共收入了 100 元,欠着大明 200 元。bean-report 没有报错,说明账是平的(总和为 0)。bean-report 还能生成很多报表,使用 bean-report -h 查看帮助。

Beancount 自带了一个朴素的 Web UI,能以交互式的方式查看各种财务报表,执行 bean-web xiaoming.bean 命令,然后在浏览器中打开 http://localhost:8080/ 即可:

小明的资产负债表

小明的资产负债表

小明的损益表

小明的损益表

如果你之前安装了 Fava,还可以用 Fava 看到一个更华丽的 Web UI,执行 fava xiaoming.bean 命令,然后在浏览器中打开 http://localhost:5000/ 即可。由于小明的数据还比较单薄,这里贴两张 Fava 作者的示例图:

Fava 展现的资产负债表

Fava 展现的资产负债表

Fava 展现的损益表

Fava 展现的损益表

在资产负债表(balance sheet)里,你可以一目了然地看到自己有多少资产、资产分别在哪些账户里、有多少负债、是对哪些银行的负债。

在损益表(income statement)里,你可以一目了然地看到自己的每月有哪些收入、收入来自于哪些地方、有多少支出、支出花在了什么地方。

在这些页面里还有更多报表等待着你去探索。

五、Beancount 进阶

以下举几个例子,展现一下 Beancount 和复式簿记能处理多么复杂的交易。这些复杂的交易用单式簿记来记录是困难而极易出错的,但是在复式簿记里却是自然而流畅的。之前说过,复式簿记的「复」是指一笔交易会涉及到复数个账户。小明的例子都是两个账户间「一对一」交易,如一个 Income 账户一个 Assets 账户,或一个 Assets 账户一个 Expenses 账户等。但实际上,生活中会遇到各种「一对多」或「多对一」或「多对多」的交易:购买大件物品时因银行支付限额而使用多张银行卡合并付款;朋友出去唱歌、聚餐每人付的钱不同,事后 AA 平摊等。以下几个例子是有意构造的涉及到两个以上账户的交易,让我们一起来看看小红的账本。

第一个例子

2016-01-31 * "工资 2016-01"
  Income:SomeCompany:Salary                -20000.00 CNY ; 应发工资
  Income:SomeCompany:Reimbursement          -1000.00 CNY ; 餐补
  Income:SomeCompany:Reimbursement           +100.00 CNY ; 餐补扣除
  Expenses:Government:Pension               +1500.00 CNY ; 养老保险
  Expenses:Government:Unemployment           +100.00 CNY ; 失业保险
  Expenses:Government:MedicalCare            +500.00 CNY ; 医疗保险
  Expenses:Government:HousingFund           +3000.00 CNY ; 住房公积金
  Expenses:Government:IncomeTax             +3000.00 CNY ; 个人所得税
  Assets:CMB:C1234                         +12800.00 CNY ; 实发工资

这个例子展现了如何在 Beancount 里体现工资条上的内容。每个月的工资条上总会有各种各样的名目。小红在使用了 Beancount 之后,可以方便地把工资、餐补、三险一金、个税等信息都记录进去,以后能很方便地统计每个月有多少工资是喂狗的。

本例子中有 Income:* 账户有三条记录,Expenses:* 账户有五条记录,Assets:* 账户有一条记录,共八条记录,总和为 0。

第二个例子

2016-02-01 * "XX 购物中心" "购物"
  Liabilities:CMB:CreditCards               -1000.00 CNY ; 信用卡刷卡
  Expenses:Clothing:Pants                    +200.00 CNY ; 长裤一条
  Expenses:Clothing:Shirts                   +200.00 CNY ; 衬衫一条
  Assets:Receivables:Xiao-Mei                +600.00 CNY ; 帮室友小美付钱

小红拿到工资第二天就和小美去购物中心逛街,买了一件衣服一条裤子,花了 400 元,小美没带卡,身上现金不够,于是让小红帮她付钱,以后再还她,于是小红把 1000 元的东西一起刷了信用卡。

本例子中小红的 Liabilities 桶里倒出了 1000 元,往两个 Expenses 桶里各倒了 200 元进去,又往 Assets 桶里倒了 600 元。帮小美付了钱,算是小美欠小红的钱,所以算作资产。所有数字加起来和为 0。

第三个例子

2016-02-05 * "XX 黑心饭店" "和小美吃饭"
  Assets:Cash:Wallet                         -300.00 CNY ; 钱包现金
  Assets:Receivables:Xiao-Mei                -200.00 CNY ; 小美帮我付的现金
  Expenses:Food:DiningOut                    +250.00 CNY ; AA 我的一半
  Assets:Receivables:Xiao-Mei                +250.00 CNY ; AA 她的一半

过了几天小红和小美去一家饭店吃饭。本以为人均消费 100 元左右就可以搞定,没想到了这是家黑心饭店,老板说两人共消费了 500 元,还只能付现金,不能刷卡。小红和小美掏空了钱包,总算凑齐了 500 元现金,其中小红付了 300 元,小美付了 200 元。这顿饭两人还是打算 AA 平分掉。

本例中,倒豆子的桶有两个,分别是代表「小红的钱包」的桶,和代表「小美欠小红的钱」的桶,豆子倒去哪儿了?一半进了「小红的消费」桶,另一半回到了「小美欠小红的钱」桶。整个交易中,数字的总和依然为 0。

第四个例子

2016-02-10 * "在免税店买东西"
 Assets:Cash                                 -200.00 USD
 Liabilities:CMB:CreditCards                 -650.00 CNY @@ 100.00 USD
 Expenses:Clothing:Pants                     +150.00 USD
 Expenses:Clothing:Shoes                     +150.00 USD

小红去国外出差了,回国前为了把兑换的美元现金花掉,忍不住又在免税店大肆购物,结果现金不够,于是 300 美元的商品用现金支付了 200 美元,用信用卡支付了 100 美元。小红的信用卡开通了外币消费人民币入账功能,刷美元也出人民币账单。

本例中,涉及到了合并付款和货币转换。小红的信用卡被扣掉了 650 人民币,这其实是由 100 美元转换而来。在 Beancount 中使用 @@ 即可连接两种互相转换的 commodity。在本次交易中,负数共 -200.00 USD + (-100.00 USD) = -300.00 USD,正数共 +150.00 USD + (+150.00 USD) = +300.00 USD,正负相加依然得到的是 0。

使用 bean-query 进行复杂查询

bean-web 的朴素 Web UI 和 fava 的华丽 Web UI 已经能展现很多有用的财务报表,满足大部分用户的需求,如果用户需要进行一些更复杂的数据统计,比如「我 2015 年吃过的饭店按次数排列」,则可以使用 bean-query 工具用 SQL 语句进行查询,详见 Beancount 作者的文档:Beancount – Query Language。这是一个用来统计光顾麦当劳次数的例子:

bean-query-mcdonalds-blurred

六、Beancount 最佳实践

目前我的 Beancount 账本中已经导入了好多个月的数据,在使用过程中也总结了一些最佳实践。以下内容说说我个人是怎么用 Beancount 的,它们中的一部分或全部或许可以为你所用。

编辑器支持

Beancount 的作者是 Emacs 用户,因此自己写了 Emacs 插件。Vim 用户可以使用第三方的插件:nathangrigg/vim-beancount。安装插件之后会为 *.bean*.beancount 文件加上语法高亮和账户名字补全(比如输入 I:S:S 即可补全出 Income:SomeCompany:Salary),还可以将货币那一列的小数点自动对齐。以下是我的 .vimrc 中相关的配置:

let b:beancount_root = '/path/to/your.beancount'
autocmd FileType beancount inoremap . .<C-O>:AlignCommodity<CR>
autocmd FileType beancount inoremap <Tab> <c-x><c-o>

其他编辑器如 Sublime 等也有各自的插件,请自行 Google。

开户日期的选择

账户的开户日期需要在该账户第一笔交易之前。小明为了省事将所有的账户全部开在了 1970-01-01 这个日期。其实可以有一些更有创意的选择:

  • Expenses 账户可以使用自己的生日作为开户日期;
  • Income 账户下可以按来源分类,如 Income:SomeCompany:Salary, Income:AnotherCompany:Salary 等,然后以公司入职时间作为开户日期;
  • Assets 和 Liabilities 账户中的借记卡和信用卡,可以以在银行的开户日期作为 Beancount 中的开户日期,如果记不得具体日子,写成那个月的 1 号也行。

不要惧怕开账户。即使是一些短时间用的小账户(比如只用两个月的储值卡),也可以开账户,因为账户是可以关闭的。关闭后的账户不会出现在关闭后的报表里,不会触发你的强迫症……

多货币账户

在 Beancount 中,一个账户中可以有多种 commodity,比如现在小红的 Expenses:Clothing:Pants 账户就存放了 200.00 CNY 和 100.00 USD。她在出差前想必 Assets:Cash 里也同时存在着 CNY 和 USD 两种 commodity。

如果有多货币的使用,建议将自己主要使用的货币定义到账本中,在账本中添加 option "operating_currency" "CNY" 这一行即可将 CNY 定义为主要货币,在 bean-web 和 fava 中会单独列出来,而其他的 USD、CAD、JPY 等则会列到 Other 里。主要货币可以定义多个。

另外,在账户开户的时候,可以在账户名后面跟上这个账户里允许出现的货币的名字。如人民币-美元的双币信用卡,消费 JPY、CAD 等其他货币的时候,也是以 USD 入账的,为了防止自己在记录一些外币交易时忘记转换货币或是搞错账户,可以在开户时写成 2012-01-01 open Liabilities:CMB:CreditCards CNY,USD 这样,限定这个账户里只能出现 CNY 和 USD 两种货币,如果不慎记入了其他货币,Beancount 会报错。单币信用卡同理,如果你的信用卡不管刷什么外币都是以 CNY 记账,可以在开设账户的时候加上 CNY 这个限制,防止出错(不小心把外币消费没加 @@ 直接记进来)。

账本文件的分割

随着时间的积累,账本文件会越来越大,编辑起来不太方便。Beancount 有 include 语句,可以在一个账本文件里包含另一个账本文件。我的主账本文件里只有一些 option 条目,其他都是 include,各种打开/关闭账户的的条目放单独的文件里,然后每个月的账本是一个单独的文件,也 include 进来。

Beancount 会把所有交易都读到内存里后按日期重新排序,所以每条交易在文件里出现的顺序并不重要。

导入银行账单

不同人的记账习惯不同,有的人喜欢消费完一笔立刻就记账,有的人喜欢定期(每天、每周、每月)把之前的收支汇总到账本上。在我看来,所有「有据可查」的交易,如走银行卡的交易,是可以定期汇总的,但是那些无账单无票据的交易,如现金交易,要么就是干脆不记,要么就应该想办法立刻记下来,否则当你定期回忆的时候一定会因为各种原因出错,从而打击记账的信心。

更新:如果你喜欢发生一笔交易立刻就记下来,而不是定期导入账单,并且你是一位 OS X 用户,可以试试 @blaulan 制作的 Alfred Workflow:blaulan/alfred-beancount

就我自己而言,因为不喜欢现金找零,我会尽量避免现金消费,我的绝大部分交易全部都是刷卡消费,全部都能从银行账单里查到,现金类交易每月通常不超过 10 笔,因此我的记账主要是靠导入银行账单。现金部分则是在手机上安装一个简单的记账软件,里面只有一个账户,就是我的现金账户,每月那少量的现金交易就立刻用它记下来。由于对功能性要求非常少,几乎任何一个手机记账软件都可胜任,随便挑一个就行。

那么怎么导入银行账单呢?一些银行提供了 OFX(Open Financial Exchange)格式的账单,Beancount 可以直接导入,但是据我观察,中国大陆的银行没有一家是支持 OFX 的,都是自己搞一套自己的账单,能有个 CSV 导出已经不错了。所以只能自己写脚本解析了。这是我写的导入招行信用卡账单的脚本:cmb_credit_cards.py

由于 Beancount 的账本是文本文件,将银行账单转换成 Beancount 账本只是对字符串的操作,这方面各种脚本语言都可以大显神通。我自己的做法将银行账单中的每笔交易的日期、内容和金额提取出来,全部拼成从该账户往 Equity:Uncategorized 里倒豆子的交易,然后再用 Vim 配合插件手工将账户名根据实际情况改好。这样的操作我每月末做一次。处理完银行账单之后,我在后面再追加现金交易,将手机中的记录中的那几笔现金交易录入到账本里。由于 Beancount 会对交易按日期重新排列,所以直接追加到后面即可,不用管文件中的顺序。

值得一说的是,现在一些第三方支付服务很流行,比如杭州某公司推出的带聊天功能的支付服务、深圳某公司推出的带支付功能的聊天服务等。这些支付服务也会发所谓的「对账单」给用户,我对它们是一向无视的。我在第三方支付服务里是不留余额的,所有的「经过」第三方支付服务的交易都是从银行扣款的,因此我导入银行账单就够了,不用再导入第三方支付服务的账单。

导入银行账单时,需要注意的一个地方是去重。如果两个银行账户间有转账操作的话,会出现重复的账目,比如用借记卡对信用卡进行还款,在导入的借记卡账单和信用卡账单中都会有体现,然而这两笔交易其实是同一笔,这时候就需要去重。

我现在每月账本由三部分构成:

  • 信用卡账单(从银行账单导入后配合 Vim 插件半自动填写 Expenses 账户)
  • 借记卡账单(从银行账单导入后配合 Vim 插件半自动填写 Income 账户)
  • 现金交易记录(平时用手机记录,月末手工录入)

我的每次导入只需要去重两次,一次是每月借记卡自动还款信用卡,一次是每月 ATM 取款。如果你的账单构成比较复杂,是时候考虑优化一下了,比如第三方支付服务里不留余额,省得还要导入它们的账单并去重……

更新:我开设了一个 GitHub 项目 awesome-beancount,用于收集私有格式账单的导入脚本,如何下载各银行账单,以及其他的一些 Beancount 的最佳实践。目前里面已经有了一些用户分享的中国大陆银行及第三方支付服务的导入脚本。如果你使用的银行已经在这个项目里了,你可以直接使用;如果不在,你可以写出你自己的导入脚本并提交一个 pull request。

定期断言

一本维护良好的账本应当定期做断言(assertion),标记在某个日期某个账户(通常是 Assets 或 Liabilities 账户)里有多少豆子。断言的例子如下:

2016-02-01 balance Assets:Cash 500.00 CNY
2016-02-01 balance Assets:Cash 100.00 USD
2016-02-01 balance Assets:CMB:C1234 1000.00 CNY

断言语句告诉 Beancount,这个账户在这个日期凌晨 00:00:00 时间点(也就是前一天深夜 24:00:00),余额为这个数字。小红账本里以上断言告诉 Beancount,截止一月底,小红钱有 500 人民币、100 美元的现金,同时招行尾号 1234 的借记卡里有 1000 人民币的存款。

Beancount 的时间精度是「日」,所以这里必须强调,诸如 open, close, balance 等带日期的语句,均发生在当日的第一笔交易之前,你可以想像它们都是在凌晨发生的,而普通的交易都是发生在白天。因此,要断言一月份的余额,日期应写作 02-01 而不是 01-31。同样地,信用卡等通常为负数的账户也能进行断言,比如小红的信用卡账单日为 20 日,2 月份账单应还款 5000 元,那她的断言应该这样写(注意日期是第二天,也就是 21 日):

2016-02-21 balance Liabilities:CMB:CreditCards -5000.00 CNY

添加了断言之后,Beancount 便会检查那个账户的数字是否与断言的数字相等,如果不相等就会报错。人总是会犯错的,当你因为各种原因在账目上出现了错误,断言能帮助你缩小查错范围——你只需要检查最后一次成功的断言之后的发生的交易即可。

合理填充

Beancount 另一个有趣的功能是填充(padding),填充是配合断言一起用的,当 Beancount 解析到填充语句时,会自动在这条语句和下一条断言语句之间插入一条填充交易,使得断言成功。在填充语句所在日期和断言语句所在日期之间不能再有其他交易。例子如下:

2015-11-30 pad Assets:Cash Expenses:Food:Drinks
2015-12-01 balance Assets:Cash 200.00 CNY

小红 11 月底做账目核对的时候,发现钱包里的现金是 200 元,但是根据 10 月底的余额,以及 11 月的交易记录,钱包里应该剩 200 多元才对,她想了下,可能是有几次在路边买了饮料喝忘记记录了,因此她使用填充功能来解决这个问题,在 11 月最后一笔交易和 12-01 的断言之间插入一条 pad 语句,这样 Beancount 便会自动插入一条交易,使 Assets:Cash 里的余额调整为 200.00 CNY,而因此产生的货币变化,则记录到 Expenses:Food:Drinks 账户里。在本例中,自动插入的交易内容即是从 Assets 账户倒出了一些货币到 Expenses 账户里。

Beancount 作者便是这样来使用的填充功能的。他的现金账户几乎只用来购买烟酒和饮料,但是他又懒得记录现金支出,于是他就在月底的时候将现金账户 pad 到 Expenses:Food 一次,然后用断言语句记下月底现金账户的实际余额,中间的差值会由 Beancount 自动算出来并插入。

填充功能另一个用途是开户时设定初始余额。比如小红的借记卡是 2010 年开户的,她从 2015-06-01 开始用 Beancount,她就可以这么写:

2010-01-01 open Assets:CMB:C1234
2015-05-31 pad Assets:CMB:C1234 Equity:Opening-Balances
2015-06-01 balance Assets:CMB:C1234 1000.00 CNY

这样 Beancount 会自动插入一条交易,把 Assets:CMB:C1234 在 2015-06-01 (凌晨)的余额的调整为 1000.00 CNY,因此产生的货币变化(新开的账户余额默认是 0),记录到 Equity:Opening-Balances 账户里。在本例中,自动插入的交易内容即是从 Equity 账户倒出了一些货币到 Assets 账户里。

填充功能比手工写一笔交易有什么好呢?你不需要去计算两个数字之间的差额了——Beancount 会自动算出差额并帮你插入交易。此外,这个差额是动态计算的,在上面两个例子中,如果小红想起了在哪天买了什么饮料,重新记上去,那么这个差额会自动变小;如果小红后来又导入了 2010-01-01 到 2015-05-31 之间的账单,那这个开户余额也会自动根据实际情况调整大小。

七、尾声

我从 2011 年大学一年级开始用手机记账,至今也快五年了。其间换过几次记账软件,但其实一直在凑合着用,因为这些记账软件总有一些功能没有办法覆盖到,因此我总是想着各种方法去曲线救国。比如最常见的与室友出去 AA 聚餐,一个人付钱,其他人把钱给付款人——这种交易在付款人的银行卡里是体现为一笔交易,但是实际上在软件里却要为每个单独记一笔,否则到月底一看,「哇,我这个月在吃饭上花了这么多钱」,其实只是把帮别人付的钱都加入了「聚餐」这个类别而已。但如果真的按照实际情况付一次钱记多笔的话,拿银行卡账单对账的时候又会让人很焦躁。

大约在 2015 年,我开始逐渐意识这些困难不是记账软件本身的问题,而是记账方法的问题。我用过的那些手机记账软件,不管 UI 多么好看,它们本质都是个单式簿记系统,因此只能处理「一对一」的简单交易,像 AA 聚餐、合并付款这种「一对多」和「多对一」的交易,就没法合理优雅地记录了,更别说上文小红和小美在黑心饭店遇到的「多对多」交易了,根本应付不来。

于是我开始接触复式簿记。发现手机上唯一堪用的复式簿记软件是 GnuCash 的 Android 版。然而它虽然堪用,却不堪重用——Bug 实在太多了。Beancount 作者曾吐槽过 GnuCash 的电脑版,他说他每隔几个月就会去尝试一下它,但总能在一小时内发现新的 bug。电脑版尚且如此,手机版的 bug 数量更难想像——这些有着复杂 GUI 的传统复式簿记软件太难用了。

在 2015 年底的时候,我看到 @yegle 提到了 Beancount 这个软件,便去了解了一下,这才打开了新世界的大门——这才是「double-entry accounting done right」啊!没有复杂的 GUI,只有亲切的 CLI、强大的功能、简明的语法。这才意识到原来复式簿记可以如此简单好用。

用了几个月 Beancount 之后,我对它十分满意,因此写了这么一篇博客,希望能将它推广给更多的用户。

使用 nghttpx 搭建 HTTP/2 代理

HTTP/1.1,定义于 1999 年,至今仍在流行。纵使人们试图在它上面添加各种黑科技,但它依然有各种各样的不足。终于,在 2015 年 5 月,HTTP/2 发布了。HTTP/2 基于 SPDY 而建,性能和特性较 HTTP/1.1 有了极大的提升,此外,虽然 HTTP/2 标准本身并没有强制 TLS 加密(HTTPS),但主流实现(Google Chrome, Mozilla Firefox)均要求 HTTP/2 被包裹在 TLS 中,因此,HTTP/2 + TLS(HTTPS)已是事实上的标准

本文中,如无特殊说明,「HTTPS」指代「HTTP + TLS」,其中的 HTTP 可以是 HTTP/1.1, SPDY/3.1 或 HTTP/2;但由于几乎所有的 HTTP/2 实现全部要求 TLS,因此单说「HTTP/2」的话,一般指自带了 TLS 的 HTTP/2。

本文介绍使用 nghttpx 配合 Squid 搭建一个支持 HTTP/2 的 HTTPS 代理的方法。

nghttpx 本身并不是一个代理,它只是一个翻译器,因此如果我们需要一个支持 HTTP/2 的正向 HTTPS 代理,可以用一个 HTTP/1.1 的正向代理(如 Squid)和 nghttpx 接在一起实现。使用这样一个 HTTPS 代理,既可以享受 HTTP/2 对多连接的优化(提高客户端和代理服务器之间的连接流畅度),又可以享受外层 TLS 带来的加密和安全。且由于流量特征是 HTTPS,不仅额外开销小,而且在一些封锁严重的 ISP 里也能应用自如。(如封锁了 DTLS 流量的情况下,OpenConnect / AnyConnect 只能 TCP over TCP,效率很低)

一、需求

需求有两种,一种是客户端原生支持 HTTP/2 的,以下以 Chrome 为例:

+------------+    +------------+    +------------+    +------------+                  
|            |    |            |    |            |    |            |                  
|   Chrome   +----+  nghttpx   +----+   Squid    +----+  Internet  |                  
|            |    |            |    |            |    |            |                  
+------------+    +------------+    +------------+    +------------+                  

如图,nghttpx 与 Squid 部署于服务器上,客户端的 Chrome 与 nghttpx 用 HTTP/2 交流,nghttpx 将请求翻译成 HTTP/1.1 发给 Squid,最后 Squid 抓取了结果返回。

另一种是客户端不支持 TLS 的,以下以 Pidgin 为例:

+------------+    +------------+    +------------+    +------------+    +------------+
|            |    |            |    |            |    |            |    |            |
|   Pidgin   +----+  nghttpx   +----+  nghttpx   +----+   Squid    +----+  Internet  |
|            |    |            |    |            |    |            |    |            |
+------------+    +------------+    +------------+    +------------+    +------------+

如图,Pidgin 将 HTTP/1.1 请求发给本机的 nghttpx,本机的 nghttpx 翻译成 HTTP/2 之后发给服务器上的 nghttpx。之后的过程和上一种相同。

二、工具

nghttp2 是一个很优秀的 HTTP/2 的 C 类实现。它的前身是 SPDY 库 spdylay,作者都是 Tatsuhiro Tsujikawa,同时他也是著名下载工具 Aria2 的作者。nghttp2 含有多个组件,其中的 nghttpx 程序,可以进行 HTTP/2 和 HTTP/1.1 之间的翻译,如果编译时链接了 spdylay,它也可以支持 SPDY/3.1。

如果你是 Arch Linux 用户,可以直接使用我维护aur/nghttp2 包,直接 yaourt -S nghttp2 即可,吃豆人会帮你照料好剩下的一切。

如果你是 Debian / Ubuntu 用户,请按照官方 README 完成编译操作,编译完成后在 contrib 目录里可以找到 Upstart 配置文件。注意:nghttp2 库默认是不带 SPDY/3.1 支持的,如果需要 SPDY 支持,请先编译 spdylay 再编译 nghttp2,后者会自动检测到 spdylay 的存在并链接。

如果你是 CentOS 用户,祝您今天有个好心情

三、服务器配置

无论是哪种需求,服务器上都需要 nghttpx 和 Squid。

nghttpx

服务器上 nghttpx,前端接受的是来自客户端的 HTTP/2 请求,后端是 Squid,最小配置是这样:

frontend=0.0.0.0,443;tls
backend=127.0.0.1,3128;no-tls
private-key-file=/path/to/private/key
certificate-file=/path/to/certificate
http2-proxy=yes

其中私钥和证书必须是客户端认可的。你可以选择:

  • NameCheap 之类的网站上买一个商业证书,低至 $9 一年;
  • 自己用 OpenSSL / GnuTLS 等工具签一个,然后在你的客户端里强制设置为信任;
  • 如果你不愿意花钱也不愿意折腾 OpenSSL,那你可以尝试去找家免费的 CA 给你签一个。

需要说明的是,GFW 曾被报道会区分商业证书和野证书并对后者做定点清除。试图使用野证书的同学请将此因素考虑在内。

以上只是最小配置,我个人使用的配置还加上了以下内容,是我在 nghttpx 的文档中挑出来觉得比较有用的选项:

# 使用四个 worker,请根据自己服务器的 CPU 合理调整,太小性能差,太大机器挂
workers=4
# 开启客户端 TLS 认证
verify-client=yes
verify-client-cacert=/path/to/client/ca
# 不添加 X-Forwarded-For 头
add-x-forwarded-for=no
# 不添加 Via 头
no-via=yes
# 不查询 OCSP 服务器
no-ocsp=yes
# 指定 NPN / ALPN 的顺序
#npn-list=spdy/3.1,h2 →这一行已经不用加了,见下
# 只使用 TLS 1.2
tls-proto-list=TLSv1.2
# 只使用 ECDHE 交换(目前性能安全比最优)和 AES 加密,指定 128 位是因为它已经足够安全但性能比 256 位稍优一些
ciphers=ECDHE+AES128
# 开启日志功能
accesslog-file=/var/log/nghttpx/access.log
accesslog-format=$remote_addr [$time_iso8601] "$request" $status $body_bytes_sent $alpn "$http_user_agent"

有关 --npn-list 选项:前文已经说明了,nghttp2 如其名字所示,是一个 HTTP/2 的库,但是由于 Chromium / Google Chrome 的一个 bug(发稿时最新的 v45 仍未修复已在 v46 中修复,Cr 对 HTTP/2 代理的支持有点问题(Firefox nightly 没有问题),而 nghttpx 默认的 NPN / ALPN 顺序是 h2 优先的,所以需要在这里把 spdy/3.1 的优先级调成最高,以便让 Cr 能用 SPDY/3.1 协商……如果它在编译时链接了 spdylay 的话,也能向下支持(已过时的)SPDY。这一选项是调节优先使用哪种协议的。Chrome 46 之后对 HTTP/2 代理的支持已经正常了,这个选项可以不用调了。

有关 --verify-client 功能:请看下文「有关鉴权」一节。

Squid

Squid 是一个久经考验的正向代理。在我们的用例中,它是 nghttpx 的后端,只需监听 localhost 即可。我用的最小配置如下:

http_port 127.0.0.1:3128
http_access allow localhost

# 关闭缓存功能和日志功能
cache deny all
access_log none

# 优先访问 IPv4 站点,有完整 IPv6 支持的机器可以去掉
dns_v4_first on
# 不添加 Via 头
via off
# 删除 X-Forwarded-For 头
forwarded_for delete

我曾试图让 nghttpx 把源 IP 地址发给 Squid 然后让 Squid 记到日志里,但是未能成功,于是 Squid 始终只能记到一堆来自 127.0.0.1 的请求,干脆就把 Squid 的日志关闭,让 nghttpx 去记日志了。其实是可以在 nghttpx 配置里保留 X-Forwarded-For 然后在 Squid 配置里加上 follow_x_forwarded_for allow localhost让 Squid 能记录到原始的来源 IP 地址。感谢 @JmyXu 的指正。

一个可能会让强迫症不爽的地方是,Squid 默认的错误页面会引用 Squid 官网的图片(一只乌贼),而这个图片资源是 http:// 的,因此页面会带有「混合内容」,强迫症用户可以通过编辑 errorpage.css 把这个去掉:

background: url('http://www.squid-cache.org/Artwork/SN.png') no-repeat left;

或者像我一样把这个图片换成 data:image/png;base64 嵌在 CSS 里……

有关鉴权

只按照最小配置来做的话,配置出来的 HTTP/2 代理是没有任何鉴权的,任何人都可以把这个地址填进 Chrome 里当代理用,也就是说,这是一个开放代理。但实践证明:

  • 如果你在公网上搭一个不带 TLS 的 HTTP/1.1 开放代理的话,分分钟各种爬虫就会把你的地址撸走,教你做人;
  • 如果你在公网上搭一个带 TLS 的 HTTP/1.1 开放代理的话,来光顾你的爬虫就非常非常少了,几个月也遇不到几只;
  • 如果你在公网上搭一个只允许 TLS 1.2 的 HTTP/2 开放代理的话,根本不会有爬虫来光顾你……

所以,如果不想弄鉴权的话,问题也不大,因为目前根本没有 TLS 1.2 + HTTP/2 的爬虫,除非你主动把地址告诉别人,否则不会有人来用你的代理。不过,这样毕竟只是迷宫,而不是门锁,所以为了安全还是可以配置一下鉴权。

在这种 TLS 1.2 + HTTP/2 的结构下,鉴权可以在两个阶段做:TLS 和 HTTP(感觉是废话),也就是 nghttpx 和 Squid(好像还是废话)。

在 TLS 层面做鉴权的话,就是用上文所述的 --verify-client 了。你需要自己维护一个 CA,然后把 CA 的根证书放到服务器上,持有该 CA 根证书的私钥签出的证书对应的私钥的用户可以使用该代理,否则根本完成不了 TLS 握手,直接被拒绝。CA 的搭建和管理又是一个巨大的话题了,在此不多做叙述,只是推荐一下两个软件:适用于 GNU/Linux 用户的 XCA,和适用于 OS X 用户的 Keychain。这两个都是能够管理中小型 CA 的 GUI 程序。我个人使用的则是 EasyRSA。当然如果你足够硬核,也可以直接使用命令行的 OpenSSL 去管理 CA。再次强调,这个 CA 只是客户端认证所用的,和你买证书的那种商业 CA 没有也不应该有联系

在 HTTP 层面做鉴权的话,请照着 Squid 官方文档做。

推荐用 TLS 鉴权,你会爱上它的。而且 TLS 鉴权的话,Chrome 能用 AutoSelectCertificateForUrls 策略自动选证书,不用每次开 Chrome 的时候点一下。

四、客户端配置

客户端配置分为两种。Chrome 和 Firefox 等直接支持 HTTP/2 代理的,直接填进去就行。大部分不支持 TLS 的程序,需要在本地再起一个 nghttpx,翻译一下,在本地生成一个 HTTP/1.1 的代理,供程序使用。

无需翻译的程序

Chromium / Google Chrome 理论上支持 HTTPS 代理的,但是如上文所述,目前由于一个 bug 的存在,对 HTTP/2 代理的支持有问题,暂时只能用 SPDY/3.1;Firefox 曾经不能正常使用 HTTPS 代理(当年 Chrome 是唯一能使用 HTTPS 代理的浏览器),现在它的 nightly 版本反而是支持 HTTP/2 了而超越了 Cr……(bug 已经修复)

令人郁闷的是,无论是 Cr 还是 Fx,都不能方便地通过 GUI 配置 HTTPS 代理,只能通过命令行或插件的方式来使用 HTTPS 代理。比如这样一个 pac 文件便可以让 Cr 和 Fx 使用 HTTPS 代理了:

function FindProxyForURL(url, host) {
  return "HTTPS proxy.example.org:443";
}

当然,pac 文件可以写得非常复杂,也可以使用浏览器插件进行更灵活的代理配置。Cr 用户推荐使用 SwitchyOmega

需要翻译的程序

如前文所述,目前除了 Cr 和 Fx,大部分软件是不支持 HTTP/2 的,而 nghttpx 是个 HTTP/1.1 和 HTTP/2 的翻译器,因此我们可以在本机起一个 nghttpx 生成一个 HTTP/1.1 的代理供不支持 HTTP/2 的程序使用。这种情况下,nghttpx 的前端接收 HTTP/1.1 的请求,然后翻译成 HTTP/2 发给服务器上的另一个 nghttpx 实例。最小配置如下:

frontend=127.0.0.1,8080;no-tls
backend=proxy.example.org,443;proto=h2;tls
http2-proxy=yes

同样地,这只是最小配置,我个人使用的配置中还有以下选项:

# 认证用证书和私钥,如果你没用 TLS 认证则不需要
client-cert-file=/path/to/certificate
client-private-key-file=/path/to/private/key
# 四个 worker,请根据自己计算机/手机性能调整
workers=4
# 不添加 X-Forwarded-For 头
add-x-forwarded-for=no
# 不添加 Via 头
no-via=yes
# 不查询 OCSP
no-ocsp=yes
# NPN / ALPN 优先使用 h2
npn-list=h2

另外可能有用的选项是 -k。如果你用了野证书,这个选项让 nghttpx 放弃证书校验(不安全!),此外它在手机等慢速网络下也有缩短启次握手时间的效果。另外如果你想看实时请求情况的话,加上 -L INFO 能看到漂亮的彩色输出。这个 nghttpx 跑起来之后,别的程序设置 http://127.0.0.1:8080 为代理即可使用。

刚才提到了手机。是的,手机。Tatsuhiro Tsujikawa 大大的程序都是为 Android 交叉编译优化过的。(什么,您是 iOS 用户?您还是用您的 APN 代理,也就是 HTTP 明文代理吧……)

在 nghttp2 的文档中,提供了两种方便的交叉编译 Android 版 nghttpx 的方法,一种是自己装 Android NDK 和依赖,然后用 android-configandroid-make 脚本自动做。另一种是用 Dockerfile.android 文件,在 Docker 容器里装上乱七八糟的编译环境和依赖,最后产出珍贵的 nghttpx 文件并复制到容器外面来。编译完的二进制文件记得 strip 一下,能从 11 MiB 减到 2 MiB……

在 Android 上运行起 nghttpx 之后(可以用 JuiceSSH 之类的起一个,无需 root),推荐配合 Drony 使用(也无需 root),该应用使用 VpnService() 捕获所有应用流量,然后再按照你定的规则(来源地址、应用名、目标主机名、目标端口号、HTTP 方法等),将这些流量进行分流(直连、截断、传给代理、交给 pac 处理等)。

五、尾声

我竟然已经整整一年没有写博客了。一年里可以写的东西其实不少,但是由于各种原因的确没怎么写。今天因某人提醒我一年没更新了,又正好手头的事情告一段落,于是便这么写了一篇。也算是能造福一些人吧。

最后,Google Chrome 使用 HTTP/2 代理看 YouTube 4k 效果如下:

youtube-4k-nghttpx

本文原载于: https://wzyboy.im/post/1052.html 。如有转载请注明。

BIOS + GPT + GRUB + Linux + Windows 折腾笔记

其实从标题就能看出来我有多蛋疼了。我不期望还有别的人和我有同样的奇怪需求,但是希望本文的一部分或几部分能对部分折腾者有一定有作用。

一、为什么会有这样的需求

要 BIOS 不要 UEFI

虽说现在的主板都采用 UEFI 了;虽说 BIOS 是很古老的东西了……但是,我实在不喜欢 UEFI 的复杂设计。说是 Unified 但是我感觉它一点也不统一。最重要的是:UEFI 对 Linux 不够友好。

要 GPT 不要 MBR

虽说严格来说 GPT 也是 UEFI 的一部分,但是我对它的印象好多了——MBR 只支持 4 个主分区而 GPT 默认情况就能支持 128 个分区,再也不用小心翼翼地折腾扩展分区和逻辑分区——这也是我所讨厌的。

要 Steam.exe 不要 Steam.deb

虽然我已经用惯了 Arch Linux;虽然 Valve 也有出 Steam for Linux 甚至 SteamOS,但是至少到目前为止,毕竟 Windows 才是正经的玩游戏的操作系统。

二、BIOS + GPT

2009 年之后的主板基本是 BIOS + UEFI 双配置,为了不让 UEFI 来瞎捣乱,我在主板设置里会选择 BIOS Only 以堵死 UEFI 的路。至于 2009 年以前的那些不支持 UEFI 的主板,倒有些需要小心:虽然理论上,只要不是古董电脑,都能支持 GPT,但是有一小部分有问题的 BIOS 会无法从 GPT 启动。 GPT 的分区工具首选 gdisk,不要用太旧的版本,默认就能 4k 对齐。

三、GRUB + Linux

虽然受到软件无政府主义的困扰,但是 GRUB 依旧是一款功能强大且十分流行的引导器。本文所指的 GRUB 一律指 GRUB 版本 2,而不是曾经的 GRUB Legacy。 要想让 BIOS + GPT + GRUB 工作,你需要一个 EF02 分区。由于没有了 post-MBR gap,这个分区是给 GRUB 放置它的 core.img 的,不需要文件系统。事实上,把 core.img 放在一个单独的分区里比放在 post-MBR gap 里稳定、整洁多了。在 gdisk 里新建分区时将分区标识符改为 EF02 即可,大小的话,2 MiB 足够了。 创建完 EF02 分区之后,其他的分区正常创建即可,比如我这样:

Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048            6143   2.0 MiB     EF02  BIOS boot partition
   2            6144        20977663   10.0 GiB    8300  Linux filesystem
   3        20977664       230692863   100.0 GiB   8300  Linux filesystem
   4       230692864       500117503   128.5 GiB   0700  Microsoft basic data

然后使用 grub-install 即可自动将 core.img 嵌入那个迷你小分区。 至于 Linux 的安装,无需多言。

四、+ Windows

如无特别说明,以下提到的 Windows 指 NT 内核 6.0 以上版本。本文以 Windows 8.1 为例。

傲娇的 Windows

MSDN 明确指出,Windows 只能安装于 BIOS + MBR 或是 UEFI + GPT 的组合上,而 BIOS + GPT 和 UEFI + MBR 是不允许的。这实在是太傲娇了——因为 BIOS + GPT + GRUB + Linux 是完全没有问题的。事实上,我的笔记本电脑刚安装的时候并没有考虑到往硬盘里灌 Windows,因此之前一直是 BIOS + GPT + GRUB 的配置,在这样的情况想让 Windows 入驻,简直是逼我上梁山…… 为什么 MSDN 声称 Windows 不能在 BIOS + GPT 工作?经过我的试验,发现其实只是 bootmgr 读不了 GPT 而已。直到 bootmgr 被唤醒之前,一切都是没有问题的,而 bootmgr 应该去读取 \Boot\BCD 然后再根据 BCD 去加载 \Windows\System32\winload.exentoskrnl.exe。可是 bootmgr 读不了 GPT,直接导致它找不到 \Boot\BCD…… 那么怎么办呢?

  • 换一个能读 GPT 的引导器,读取 BCD 之后正常加载 Windows 内核。——不好意思,这样的引导器不存在。在得出这个结论之前,我吃了很多苦。
  • 将 BCD 放在 bootmgr 能读的地方。——比如一(小)块 MBR 存储设备,它不一定要是物理的,也能是虚拟的。在得出这个结论之前,我流了很多泪。

而 Windows 默认的安装程序要求又高、功能又弱,根本不会给你选择启动文件安装到哪里的,所以必须要手工安装。

要 imagex 不要 Setup.exe

Windows 默认安装框架是在 Windows PE 里使用 Setup.exe。该程序会运行一系列烦人的检查并增加一堆不合理的限定,比如要求 .NET Framework,比如强制添加 300 MiB 的系统分区等。而实际上它所做的事情也不过是:解压 install.wim 到指定分区、写入引导扇区、写入 BCD 这三样。可是这三件事,能自定义的部分还很少或很麻烦,那为啥不自己来呢?所以我更喜欢的方法是启动到 Windows PE 之后手工安装。

Windows PE 哪里来?TechNet 提供的方法是从 WAIK 里生成。但前面说了,Windows 的安装框架是 Windows PE,而 Windows 安装镜像文件也不过就是 Windows PE 和 install.wim 的组合而已,所以直接从安装镜像里就可以释放一个 Windows PE 出来。用 wimlib 提供的 mkwinpeimg 可以很方便地做到这一点,能直接从 Windows 安装镜像中获取 Windows PE 所需的 boot.wim 并制成可启动的镜像文件。如果你懒得自己提取 Windows PE,那么这里有我做好的两份。两份 Windows PE 除了添加了一个 imagex.exe 之外没有任何改动,十分纯洁干净:(其实不添加也行,用更加高大上的 dism 也能释放文件)

install.wim 哪里来?Windows 安装镜像里就有,使用 7z e Windows.iso sources/install.wim 即可把它解压出来。在移动存储设备里保存 install.wim 而不是完整的 iso,甚至能省下 500 MiB 以上的空间。

接下来就是安装了。通过可启动媒体启动进 Windows PE,用 diskpart 进行合理的分区。以下为带注释的操作过程:

# 确认当前硬盘情况
DISKPART> list disk
# 假设系统硬盘为 Disk 0 (Windows 将安装于此)
# Windows PE 所在的盘为 Disk 1 (请忽略)
# 过会儿要创建的 VHD 为 Disk 2 (Windows 启动文件将安装于此)
DISKPART> select disk 0
DISKPART> list partition
DISKPART> select partition 4
DISKPART> format label="Windows 8.1" quick
DISKPART> assign letter=c
# 以上命令格式化要安装 Windows 的分区并分配卷标 C:
# 接下来创建并挂载 VHD
DISKPART> create vdisk file=c:\bootmgr.vhd maximum=32 type=fixed
DISKPART> attach vdisk
# 然后在 VHD 里创建并激活分区。offset 不是必须的,但是我喜欢
DISKPART> select disk 2
DISKPART> create partition primary offset=1024
DISKPART> active
DISKPART> format label=bootmgr quick
# 分配一个卷标 B:
DISKPART> assign letter=b
# 退出 diskpart
DISKPART> exit

然后就是安装 Windows 和 Windows 启动文件了:

# 解压文件,注意根据实际情况选择 index,此处为 1
X:\> imagex /apply install.wim 1 c:
# dism 的等效命令是:(长多了)
X:\> dism /Apply-Image /ImageFile:install.wim /Index:1 /ApplyDir:C:\
# 写入启动代码和启动文件
X:\> bootsect /nt60 b: /mbr
X:\> bcdboot c:\Windows /s b:

至此 Windows 部分就算完成了,可以在 diskpart 里 detach vdisk 然后重启进 Linux 继续操作。

用 MEMDISK 引导硬盘镜像

在上一节中,我们得到了一个装好了 Windows 的分区,以及一块 32 MiB 的虚拟硬盘镜像,这块虚拟硬盘采用 MBR,有且只有一个主分区,主分区里装着大约 19 MiB 的 Windows 启动文件。其实这些启动文件真正核心的只有不到 1 MiB,其他的都是语言包和字体等,如果你闲得无聊可以挂载来删除它们,当然硬盘镜像文件大小不会自动缩小就是了。

那么,MEMDISK 能直接引导 vhd 么?我一开始也觉得不能,因此查到了用 VirtualBox 或 QEMU 来把 vhd 转成 raw image 的方法:VBoxManage clonehd --type raw bootmgr.vhd bootmgr.img。但是后来经 @tjmao 的提醒,我才发现原来当 type=fixed 的时候,vhd 其实就是 raw image 加上 512 字节的 footer。切掉这个尾巴之后,得到的东西和 dd 式的 raw image 是一模一样的。而就算不切掉尾巴,这一部分也会被认为是未分区空间从而被忽略掉。所以,其实 type=fixed 的 vhd 是不用转换,直接可以当 raw image 用喂给 MEMDISK 的。

那么怎么喂呢?根据调用 MEMDISK 的方法不同,具体的语法也有一定差别,完整的叙述可在这里找到。以下是 GRUB 的方法:

menuentry "bootmgr.vhd" {
  linux16 /boot/syslinux/memdisk harddisk
  initrd16 /boot/bootmgr.vhd
}

把这一段复制到 /boot/grub/grub.cfg 中即可使用。如果要使它可在 grub-mkconfig 后自动生成的话,复制到 /etc/grub.d/40_custom 中即可。

至此 Windows 的引导就算是做好了。重启计算机在 GRUB 菜单中选择对应的 menuentry 即可进入。第一次进入的时候会自动安装驱动等,安装完自动重启,然后就功德圆满了。

五、一些可以改动的部分

如导言中所说,有和我完全一样的需求的人应该不存在,但是本文的思路可用于一些别的折腾过程中。以下列举了部分可以改动的部分,适用于不同的具体情况。

Linux 不是必须的

如果你只是想安装 BIOS + GPT 的 Windows,自然不用装 Linux。但是你可能需要备一个 Notepad.exe 在 Windows PE 环境中,用来编辑引导器的配置文件。

GRUB 可以换成别的

如果不装 Linux 了,那单独装个 GRUB 也没啥意思,还不如换点别的引导器。诸如 Syslinux, Grub4Dos 等,都可以用来加载 MEMDISK。具体的语法依然戳这里不过如果换成别的启动器的话,那个启动器得要像 GRUB 一样对 GPT 有支持……

VHD 的大小问题

在想到 VHD 的方法之前,我曾试过的是把装的引导分区的 U 盘整个 dd_rescue 复制下来。这是一个较老的 U 盘,容量只有 4 GB,但是就这样产生的镜像文件也太大了,无法被 MEMDISK 正确加载。虽然最后我成功地把这个镜像挂载之后缩小文件系统和分区使它「瘦身」,但步骤较复杂。想到 VHD 的方法之后就简单了,直接创建一个小一点的 VHD 然后在里面操作即可。现在已知镜像文件不能太大,否则 MEMDISK 加载不了,那么它最小能多小呢?

核心的 Windows 启动文件只有 bootmgrBoot\BCD 这两个,加起来不到 1 MiB。我也试过,引导分区里只放这两个文件,依然能正常引导,但是实际上 VHD 不能只有 1 MiB。这是因为:

  • bcdboot 复制启动文件的时候,默认会复制那些语言包和字体,总共 19 MiB 左右
  • NTFS 分区最小需要 8 MiB

我实验成功的最小大小是 10 MiB 的 VHD,里面装着一个 8 MiB 的 NTFS。

当代的计算机大部分都是 4 GB 和 8 GB 的内存了,所以不用克扣这么一点点空间(毕竟它引导完就被释放掉了),所以创建 VHD 时选择 32 MiB 是个比较好的选择。记得 type=fixed 就行。

六、吐槽和尾巴

好久没写有关 Windows 的博文了。本来以为自己的对 Windows 的需求在 VirtualBox 里就能完全满足了,因此笔记本电脑安装系统的时候并没有为 Windows 考虑过,直接就是 BIOS + GPT + Linux 的组合了。可是自从 2014 年初在迷上了 Call of Duty 之后就入了 Steam 的大坑了,因此对独立的 Windows OS 的需求再次浮现。之前尝试 BIOS + GPT + Linux 既有配置下再塞个 Windows 怎么都没成功,因此选择了 Windows To Go 的解决方案——将整个 Windows 及其引导文件装到外置 USB 硬盘里。可是 USB 机械硬盘的速度又怎么能和 Samsung 840 Pro SSD 相比……于是发愤图强,努力研究,终于实现了 Windows 本体安装在 GPT SSD 里而把引导文件装到 MBR U 盘里的方法。慢慢地这样的方法又不能满足我的要求了,于是继续发愤图强,在各种来源都说这是不可能的情况下,我最终还是曲线救国成功了。

于是便有了本文。

转载请注明出处: https://wzyboy.im/post/1049.html