普通视图

发现新文章,点击刷新页面。
昨天以前zhonger 前端开发者,喜爱运维管理

LDAP 集成之 Gitlab + Keycloak 篇

前言

  在之前的《LDAP 集成之 Gitlab 篇》《基于 LDAP 的统一认证服务 Keycloak》 中,分别探索了 LDAP 与 Gitlab、Keycloak 的集成。实际上,Gitlab 天然支持三者合一的认证方式,即 “LDAP 为 Gitlab 提供最底层的用户认证“和”Keycloak 提供统一的用户认证入口“。这样一来,

  • 管理员不再需要在 Gitlab 手动创建用户匹配 LDAP 账户;
  • 用户也不再需要在 Gitlab 中手动绑定 Keycloak 账户之后才能使用 Keycloak 统一认证。

实践

预先准备

  • 已架设好 LDAP 服务
  • 已架设好 Keycloak 服务,并配置 LDAP 集成认证
  • 已架设好 Gitlab 服务(使用 sameersbn/gitlab 镜像)

配置

services:
  ...
  gitlab:
    ...
    environment:
    ...
    - LDAP_ENABLED=true
    - LDAP_PREVENT_LDAP_SIGN_IN=true
    ...
    - OAUTH_AUTO_LINK_LDAP_USER=true
    - OAUTH_AUTO_LINK_USER=IDP
    - OAUTH_OIDC_LABEL=IDP
    - OAUTH_OIDC_ISSUE=https://<Keycloak domain>/realms/master
    - OAUTH_ODIC_CLIENT_ID=<id>
    - OAUTH_ODIC_CLIENT_SECRET=<secret>

  这里比较关键的配置就是要启用自动链接 LDAP 用户OAUTH_AUTO_LINK_LDAP_USER)和指定自动链接的第三方认证方式OAUTH_AUTO_LINK_USER)。为了规避用户直接使用 LDAP 认证登录,可以通过设置 LDAP_PREVENT_LDAP_SIGN_IN 来隐藏 LDAP 登录界面。

可能存在的问题

LDAP 用户被自动封禁

  通过以上设置就可以让用户使用 Keycloak 作为统一入口登录 Gitlab,但是因为 Gitlab 会自动处理 LDAP 后台用户和自动链接,当后台 LDAP 发生变化时(比如修改 RDN 但保持邮件不变), Gitlab 会自动将用户的状态修改为 禁用 LDAP。由于 Gitlab 界面上不提供对于这种特殊情况的解禁操作,所以必须通过后台手动修正

提示

  在后台手动修正之前,先通过界面进入后台手动将用户原先绑定的 RDN 修改为 LDAP 服务中正确的 RDN。

# 进入 Gitlab 容器
docker exec -ti gitlab-gitlab-1 bash

# 进入 Gitlab 控制台,可能需要等待 1 分钟
root@gitlab:/home/git/gitlab# RAILS_ENV=production bundle exec rails console
--------------------------------------------------------------------------------
 Ruby:         ruby 3.2.9 (2025-07-24 revision 8f611e0c46) [x86_64-linux]
 GitLab:       18.3.1 (bccd1993b5d) FOSS
 GitLab Shell: 14.44.0
 PostgreSQL:   16.9
------------------------------------------------------------[ booted in 41.09s ]
Loading production environment (Rails 7.1.5.1)
irb(main):001>

# 查看被禁用 LDAP 的用户
irb(main):002> User.where(state: 'ldap_blocked')

# 解禁单个用户
irb(main):003> u = User.find_by_username('username')
irb(main):004> u.activate!
irb(main):005> u.save!

# 退出控制台
irb(main):006> exit

如果想要批量解禁 LDAP 用户可以在控制台执行以下命令:

# 批量解禁
User.where(state: 'ldap_blocked').find_each do |u|
  u.activate!
  u.save!
  puts "✅ Activated #{u.username}"
end

自定义 Gravatar

  由于 Gitlab 默认使用 Gravatar 为用户提供头像,且 Gravatar 访问一直不稳定,推荐使用自定义的 Gravatar 地址,如下设置:

services:
  ...
  gitlab:
    ...
    environment:
    ...
    - GITLAB_GRAVATAR_ENABLED=true
    - GITLAB_GRAVATAR_HTTP_URL=https://weavatar.com/avatar/%{hash}?s=%{size}&d=identicon
    - GITLAB_GRAVATAR_HTTPS_URL=https://weavatar.com/avatar/%{hash}?s=%{size}&d=identicon

Kubernetes 应用之 JupyterHub 搭建和运维

前言

  之前在《JupyterLab 的搭建与运维》一文中,尝试了在单机上搭建部署 JupyterHub。不得不说,的确方便了团队内部共同使用同一台 GPU 服务器。但也有比较大的限制:

  • 运行中的实例对于 CPU、GPU、内存、硬盘等资源完全共享。当所有用户都申请的资源总和超出服务器所拥有的资源时,任务的运行效率将会大打折扣。甚至可能会容易出现內存溢出的问题,造成宿主机出现 BUG。
  • 难以同时管理多台服务器。在有多台不同 CPU/GPU 服务器时,单机部署的方案会造成多个入口,且很难实现用户数据在多机间的实时同步。
  • 资源回收和重置存在一定的难度。在单机部署方案中虽然也可以通过 JupyterHub 来限制闲置时间不超过多久,但是实例只会被关闭,而非销毁。如果用户实例出现了某些未知的配置问题,只能依靠管理员手动销毁实例来解决。

  其实,JupyterHub 官方很早就意识到了这些,并通过拥抱 Kubernetes (以下简称“K8S”)来解决以上限制。可以说 K8S 天然是为 JupyterHub 多机资源管理调度而生,可以:

  • 对运行实例的资源进行严格地限制,防止运行实例申请资源总和超出节点资源。
  • 根据集群实际运行情况来自动分布部署运行实例,在具有很大的节点池的情况下非常有效。
  • 共享持久化存储,平稳迁移运行实例到任一节点,自由切换 CPU/GPU 节点。
  • 自动销毁超过一定闲置时间的实例,并且在每次启动运行实例时都会拉取最新镜像

JupyterHub for K8S 架构图(来自 https://z2jh.jupyter.org/)

搭建

  这里我们以一个简单的 CPU/GPU 科学计算集群为例:

  • 登录节点 l0:提供服务入口(Web)
  • CPU/GPU 共用节点 l1、l2:运行实例部署池(可以根据实际情况和需求扩充或缩小)
  • 存储节点 nas:提供持久化存储(独立存储方案优于登录节点 NFS 服务)

网络规划

  以下为集群节点对应的 IP 地址信息:

节点主机名 IP 地址 备注
l0 192.168.120.100 登录节点,K8S 控制节点
l1 192.168.120.101 CPU/GPU 节点,K8S 工作节点
l2 192.168.120.102 CPU/GPU 节点,K8S 工作节点
nas 192.168.120.99 存储节点,NFS 服务

K8S 集群节点子网为 192.168.120.0/24。另外Pod 子网设置为 192.168.144.0/20Service 子网设置为 192.168.244.0/20

K8S 集群搭建

  集群搭建过程请见《Kubernetes 不完全入门》一文,需配置好节点识别 NVIDIA 显卡和 NFS CSI 存储。

Helm 部署 JupyterHub

安装 Helm

Helm 是什么?

  类似于操作系统的 APT 等包管理器,Helm 是 Kubernetes 的包管理器,一般定义了部署在 K8S 集群中的应用所需的所有配置文件。

  Helm 可以通过系统包管理工具安装或者直接下载二进制文件使用。Ubuntu 系统如下操作:

curl https://baltocdn.com/helm/signing.asc | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null
sudo apt-get install apt-transport-https --yes
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt-get update
sudo apt-get install helm -y

二进制文件请自行前往 https://github.com/helm/helm/releases 下载。

添加 Chart

Chart 是什么?

  Chart 是 Helm 使用的包格式,可以被认为是“软件源中的软件名”(实际是多种软件的集合)。这主要是因为如果要编写部署一整套应用所需的配置文件实在太复杂、耗时了,使用 Chart 只需要写一个自定义配置文件来覆盖想要修改的默认配置即可。

helm repo add jupyterhub https://hub.jupyter.org/helm-chart/
helm repo update

准备自定义配置文件

  自定义配置文件可以是任意文件名,但必须是 yaml 格式,比如 config.yaml。对于以下配置我们可能需要进行自定义:

  • 对外代理服务:一般来说,JupyterHub 只有 Web 访问端口需要由 K8S 集群在控制节点暴露给反向代理服务(比如 Nginx)。这里的 proxy.service.nodePorts.http 配置为 34567 端口。另外,我们可以将 proxy.chp.networkPolicy.enabled 置为 false 来取消 K8S 网络限制。为了安全,在 1.0.0 版本之前也许手动设置 proxy.secretToken 字段(使用 openssl rand -hex 32 命令生成)。
  • hub 配置:(1)设置 hub.networkPolicy.enabledfalse 取消网络限制;(2)(可选)使用 hub.extraVolumes 字段来添加指定的持久化卷名;(3)(可选,推荐)配置 hub.config 来启用 Oauth2 认证登录,目前官方支持 Github、Gitlab 在内的多款认证方式,详细请见 The OAuthenticator。这里我们使用自建 Gitlab 来测试。
  • 全局配置:(1)(可选)可以修改 prePuller.hook.enabledfalse 来禁用节点预拉取运行实例镜像。启用的情况下,当有新节点加入可用集群时可以自动拉取,以避免第一次在新节点部署实例时用户需要等待一段时间。(2)(可选)限制实例最长可运行时间 cull.maxAge和最长闲置时间cull.timeout,通过自动销毁来提升集群的可用率。cull.enabled字段也需要置为 true 从而生效。cull.every 字段可以设置每分钟检查是否超出限制。
  • 用户实例配置:(1)NFS 持久化,通过设置 singleuser.extraPodConfig.securityContext 中的 fsGroup (值为 100) 和 fsGroupChangePolicy (值为 OnRootMismatch) 来实现启动实例跳过每次修改文件夹权限,仅当文件夹父目录不为 root 用户 (id 为 100) 拥有时才会修改文件夹权限。(2)基本配置,包括网络策略、环境变量、启动超时最长限制(即最长等待启动时间)。(3)动态存储卷配置,设置 singleuser.storage.dynamic.storageClassnfs-csi 来启用自动动态存储卷,可以用 singleuser.storage.capacity 来设置默认卷大小限制。由于实例中默认的缓冲区较小,在內存有限的情况下某些任务可能用缓冲区,因此可以挂载较大的本地临时卷来充当 /dev/shm/dev/fuse。(4)可用资源配置方案,相比单机部署的单一选择,K8S 部署方案可以提供多样化的资源配置方案,不仅包括 CPU、内存资源的集合,还有 GPU 资源。甚至于还可以通过 K8S 的节点标签来由用户手动选择哪个节点(当然仅在资源满足的情况下会成功创建)。

以下为一个样例:

proxy:
  chp:
    networkPolicy:
      enabled: false
  service:
    nodePorts:
      http: 34567
  secretToken: "<GENERATE SECRET TOKEN BY YOURSELF>"

hub:
  networkPolicy:
    enabled: false
  extraVolumes:
    - name: hub-db-dir
      persistentVolumeClaim:
        claimName: hub-db-dir
  config:
    JupyterHub:
      authenticator_class: oauthenticator.gitlab.GitLabOAuthenticator
    GitLabOAuthenticator:
      client_id: "<COPY IT FROM YOUR OAUTH2 SERVER>"
      client_secret: "<COPY IT FROM YOUR OAUTH2 SERVER>"
      oauth_callback_url: "https://jupyter.lisz.me/hub/oauth_callback"
      gitlab_url: "https://git.lisz.me"
      login_service: "Gitlab"
      scope:
        - read_user
        - read_api
        - api
        - openid
        - profile
        - email
      admin_users:
        - <adminer_username>
      allowed_gitlab_groups:
        - <group_name>

prePuller:
  hook:
    enabled: false

cull:
  enabled: true
  maxAge: 172800
  timeout: 600
  every: 60

singleuser:
   extraPodConfig:
    securityContext:
      fsGroup: 100
      fsGroupChangePolicy: "OnRootMismatch"
  networkPolicy:
    enabled: false
  extraEnv:
    EDITOR: "vim"
    SHELL: "/bin/zsh"
    PYTHONUNBUFFERED: "1"
  startTimeout: 300
  storage:
    capacity: 100Gi
    dynamic:
      storageClass: nfs-csi
    extraVolumes:
      - name: shm-volume
        emptyDir:
          medium: Memory
          sizeLimit: "20Gi"
      - name: fuse-device
        hostPath:
          path: /dev/fuse
          type: CharDevice
    extraVolumeMounts:
      - name: shm-volume
        mountPath: /dev/shm
      - name: fuse-device
        mountPath: /dev/fuse
  image:
    name: quay.io/zhonger/base-notebook
    tag: v3
    pullPolicy: Always
  profileList:
    - display_name: "CPU 分区"
      description: '包含 Conda、Python 环境(8核16G)'
      default: true
      kubespawner_override:
        cpu_gurantee: 1
        memo_gurantee: "1G"
        cpu_limit: 8
        mem_limit: "16G"
      profile_options:
        image:
          display_name: "主机"
          choices:
            lab6:
              display_name: "l1"
              kubespawner_override:
                node_selector: {'kubernetes.io/hostname': 'l1'}
            lab9:
              display_name: "l2"
              kubespawner_override:
                node_selector: {'kubernetes.io/hostname': 'l2'}
    - display_name: "GPU 分区"
      description: "包含 Conda、Python、CUDA 环境(8核16G)"
      kubespawner_override:
        image: quay.io/zhonger/gpu-notebook:v3
        image_pull_policy: Always
        cpu_gurantee: 1
        mem_gurantee: "1G"
        cpu_limit: 8
        mem_limit: "16G"
      profile_options:
        image:
          display_name: "资源配置"
          choices:
            A100x1:
              display_name: "A100 (Python 3.11, CUDA 12) GPU x1"
              kubespawner_override:
                node_selector: {'gputype': 'A100'}
                extra_resource_limits:
                  nvidia.com/gpu: "1"
            P100x1:
              display_name: "P100 (Python 3.11, CUDA 12) GPU x1"
              kubespawner_override:
                node_selector: {'gputype': 'P100'}
                extra_resource_limits:
                  nvidia.com/gpu: "1"
小提示

  如果用标签来选择节点的话,需要通过类似 kubectl label node l1 gputype=A100 命令预先配置好标签。

启动 JupyterHub

  准备好以上配置文件后,可以使用以下命令启动。

helm upgrade --cleanup-on-fail \
  --install <helm-release-name> jupyterhub/jupyterhub \
  --namespace <k8s-namespace> \
  --create-namespace \
  --version=<chart-version> \
  --values config.yaml
小提示

  建议先下载好 JupyterHub 所需的镜像,可以通过 helm show values jupyterhub 来查看所有的镜像列表。或者可以用 helm pull jupyterhub/jupyterhub --version 4.2.0 来下载原始 Chart 文件,解压后查看 values.yaml 文件即可。如果想要使用国内镜像的话,就修改 values.yaml 文件里的镜像名再启动 JupyterHub。这里可以用本地的文件夹名称或压缩包名称来替代 jupyterhub/jupyterhub

配置 Nginx

  当 JupyterHub 启动后,默认用户还是无法从本地访问服务器上部署的 JupyterHub 的,还需要使用 Nginx 代理一下。以下是 Nginx 虚拟主机配置样例。这样一来,就可以在用户端通过域名来直接访问部署好的 JupyterHub 了。

server {
    listen 443 ssl;
    server_name jupyter.lisz.me;

    ssl_certificate /home/ubuntu/ssl/jupyter.lisz.me.cert.pem;
    ssl_certificate_key /home/ubuntu/ssl/jupyter.lisz.me.key.pem;

    # SSL settings (optional but recommended)
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    client_max_body_size 10G;

    # Logging
    access_log /var/log/nginx/jupyter_access.log;
    error_log /var/log/nginx/jupyter_error.log;

    location / {
        proxy_pass http://localhost:30000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket support
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name jupyter.lisz.me;

    return 301 https://$host$request_uri;
}
小提示

  JupyterHub 的 proxy 本身也可以提供对外访问的 HTTPS,详见 JupyterHub for Kubernetes – Administrator Guide/Security/HTTPS。其他反向代理软件也都适用。

如果 Nginx 不在控制节点能反向代理 JupyterHub 吗?

  由于 proxy 配置使用了 nodePorts 来创建端口映射,默认是可以在其他节点访问到指定的端口的。如果想要仅允许 Nginx 代理所在主机访问,可以通过 ingress 来支持更精细的访问控制,详见 JupyterHub for Kubernetes – Resources/ingress

运维

基本管理

  部署完成后,我们需要通过 K8S 的 kubectl 命令来查看、管理 JupyterHub 应用。以下为一些常见的命令:

## 假设为 JupyterHub 创建的 namespace 为 jhub

# 查看 JupyterHub 所有 Pod 状态
╰─$ kubectl get pod -n jhub
NAME                             READY   STATUS    RESTARTS        AGE
continuous-image-puller-76bkq    1/1     Running   0               5d1h
continuous-image-puller-hntww    1/1     Running   0               5d1h
hub-6867b9b6c7-slg9c             1/1     Running   0               5d1h
proxy-cc45cd6f6-g2t24            1/1     Running   0               5d1h
user-scheduler-7b465896b-bq4l6   1/1     Running   0               5d1h
user-scheduler-7b465896b-rvqgx   1/1     Running   0               5d1h

# 查看节点资源使用情况
╰─$ kubectl describe node l1

# 查看用户实例状态或启动问题
╰─$ kubectl descirbe -n jhub pod jupyter-zhonger

# 查看用户动态存储卷情况
╰─$ kubectl get -n jhub pvc

备份和恢复存储卷

  由于使用动态存储卷,卷配置显得尤为重要。(毕竟 NFS 存储在远端,独立于 K8S 集群。)可以通过以下命令备份和恢复存储卷。

# 备份所有 PV 和 PVC
kubectl get pv -o yaml > all_pvs.yaml
kubectl get pvc --all-namespaces -o yaml > all_pvc_by_namespace.yaml

# 从备份文件中恢复所有 PV 和 PVC
kubectl apply -f all_pvs.yaml
kubectl apply -f all_pvc_by_namespace.yaml

更改存储卷大小

  从查阅的资料来看,NFS 存储是无法动态更新存储卷大小的。换句话说,重新定义存储卷就可以手动更改存储大小。举个例子,现在想要为用户 zhonger 从默认的存储卷大小 100G 更改到 1T。那么我们先要获得用户 zhonger 的存储卷配置文件 pvc 和 pv。

# 保存 PVC 配置到 YAML 文件
kubectl get pvc claim-zhonger -n jhub -o yaml > claim-zhonger-pvc.yaml

# 从 claim-zhonger-pvc.yaml 获知 PV_NAME
kubectl get pv <PV_NAM> -o yaml > claim-zhonger-pv.yaml

# 确保实例已经被销毁后,删除 PVC 和 PV
kubectl delete -f claim-zhonger-pvc.yaml
kubectl delete -f claim-zhonger-pv.yaml

# 修改存储卷大小
sed -i "s/100Gi/1Ti/" claim-zhonger-*.yaml

# 重新定义存储卷
kubectl apply -f claim-zhonger-pv.yaml
kubectl apply -f claim-zhonger-pvc.yaml
注意

  这里需要注意的是,PV 和 PVC 之间的依赖关系。PV 是先定义的,不属于任何命名空间。PVC 是依托于 PV 定义的,必须属于某个命名空间。所以删除的时候要先 PVC 再 PV,定义的时候要先 PV 再 PVC。

资源配置方案

  对于资源配置方案,我们可以根据镜像CPU 核数内存大小GPU 块数的不同来创建出多样化方案。可以参考 Amazon 提供的丰富示例 jupyterhub-values-dummy.yaml 了解更多。

利用情况监控与统计

  目前可以使用 Grafana + Prometheus 的方式来对 K8S 集群中所有的资源利用情况进行监控,也可以自行设计一个 Grafana 面板来展示当前 JupyterHub 应用中启动的用户实例情况。但对于更加进一步详细、细致的监控与统计还有待设计(类似于“单个用户的利用报告”、“全平台的利用报告”等)。

总结

  JupyterHub 在 K8S 平台上散发出越来越强大的魅力,使得研究团队搭建自己的科学计算平台越来越容易。当然目前依然还是有一些挑战,比如“多节点 GPU 的调用”。类似于“机器学习模型训练任务”通常需要调试后再放在大规模的 GPU 集群上训练,而 JupyterHub 长于调试代码,是否可以调试完成后直接提交给更大规模的 GPU 集群后台计算呢?

参考资料

《手解量子化学》练习题 1-2

练习题1-2

判断下列算子是否可交换

\[[1]\ [\hat{x},\hat{p}_x] \quad [2]\ [\hat{l}_x, \hat{l_y}] \quad [3]\ [\hat{\boldsymbol{l}}^2, \hat{l}_z]\]

解决本题首先要了解可交换的定义,对于任意两个算子有:

\[\hat{f}\hat{g}\psi(\boldsymbol{r})=\hat{g}\hat{f}\psi(\boldsymbol{r})\ 或\ (\hat{f}\hat{g}-\hat{g}\hat{f})\psi(\boldsymbol{r})=0\]

那么这两个算子可交换,否则不可交换。其中下列式子称为交换子:

\[[\hat{f}, \hat{g}] \equiv \hat{f}\hat{g}-\hat{g}\hat{f}\]

除此之外,还需要了解以下观测量在古典力学中的变量和量子力学中的算子对应:

观测量 变量 算子
位置 \(x\ (\boldsymbol{r})\) \(\hat{x}\ (\hat{\boldsymbol{x}})\)
动量 \(p_x\ (\boldsymbol{p})\) \(\hat{p}_x=-\mathrm{i}\hbar{d \over dx}\ (\hat{\boldsymbol{p}})\)
角动量 \(\boldsymbol{l}^2=l_x^2+l_y^2+l_z^2\) \(\hat{\boldsymbol{l}}^2=\hat{l}_x^2+\hat{l}_y^2+\hat{l}_z^2\)
    \(\hat{l}_x=-\mathrm{i}\hbar\left(y\frac{\partial}{\partial z}-z\frac{\partial}{\partial y}\right)\)
    \(\hat{l}_y=-\mathrm{i}\hbar\left(z\frac{\partial}{\partial x}-x\frac{\partial}{\partial z}\right)\)
    \(\hat{l}_z=-\mathrm{i}\hbar\left(x\frac{\partial}{\partial y}-y\frac{\partial}{\partial x}\right)\)

算子组一

将 \(\hat{p}_x=-\mathrm{i}\hbar{d \over dx}\) 代入可得

\[\begin{align} [\hat{x}, \hat{p}_x]\psi(x)&=(\hat{x}\hat{p}_x-\hat{p}_x\hat{x})\psi(x) \\\\ &= -\mathrm{i}\hbar x \frac{d}{dx}\psi(x)-(-\mathrm{i}\hbar)\frac{d}{dx}[x\psi(x)] \end{align}\]

这里需要注意算子 \(\frac{d}{dx}\) 是求导算子,根据链式法则应该对 \([x\psi(x)]\) 分别对 \(x\) 求导,于是:

\[\begin{align} 原式&=-\mathrm{i}\hbar x \frac{d}{dx}\psi(x) + \mathrm{i}\hbar\left[x\frac{d}{dx}\psi(x)+\psi(x)\right] \\\\ &= \mathrm{i}\hbar\psi(x) ≠ 0 \end{align}\]

因此,这两个算子不可交换

算子组二

将 \(\hat{l}_x\) 和 \(\hat{l}_y\) 代入可得 (注意 \(\mathrm{(-i)}^2=-1\))

\[\begin{align} \hat{l}_x\hat{l}_y&=(-\mathrm{i}\hbar)^2\left(y\frac{\partial}{\partial z}-z\frac{\partial}{\partial y}\right)\left(z\frac{\partial}{\partial x}-x\frac{\partial}{\partial z}\right) \\\\ &=-\hbar^2\left(y\frac{\partial}{\partial z}z\frac{\partial}{\partial x}-z\frac{\partial}{\partial y}z\frac{\partial}{\partial x}-y\frac{\partial}{\partial z}x\frac{\partial}{\partial z}+z\frac{\partial}{\partial y}x\frac{\partial}{\partial z}\right) \end{align}\]
求偏导中的注意点

  这里需要注意“求偏导的函数中是否包含了偏导的对象”,如果不包含则可以直接将变量左移,如果包含则需要根据链式法则分别求导。

接着有

\[\begin{align} \hat{l}_x\hat{l}_y&=-\hbar^2\left[y\left(z\frac{\partial^2}{\partial z \partial x}+\frac{\partial}{\partial x}\right)-z^2\frac{\partial^2}{\partial y \partial x}-xy\frac{\partial^2}{\partial z^2}+xz\frac{\partial^2}{\partial y \partial z}\right] \\\\ &=-\hbar^2\left[yz\frac{\partial^2}{\partial z \partial x}+y\frac{\partial}{\partial x}-z^2\frac{\partial^2}{\partial y \partial x}-xy\frac{\partial^2}{\partial z^2}+xz\frac{\partial^2}{\partial y \partial z}\right] \end{align}\]

同理

\[\begin{align} \hat{l}_y\hat{l}_x&=(-\mathrm{i}\hbar)^2\left(z\frac{\partial}{\partial x}-x\frac{\partial}{\partial z}\right)\left(y\frac{\partial}{\partial z}-z\frac{\partial}{\partial y}\right) \\\\ &=-\hbar^2\left(z\frac{\partial}{\partial x}y\frac{\partial}{\partial z}-x\frac{\partial}{\partial z}y\frac{\partial}{\partial z}-z\frac{\partial}{\partial x}z\frac{\partial}{\partial y}+x\frac{\partial}{\partial z}z\frac{\partial}{\partial y}\right) \\\\ &=-\hbar^2\left[yz\frac{\partial^2}{\partial x \partial z}-xy\frac{\partial^2}{\partial z^2}-z^2\frac{\partial^2}{\partial x \partial y}+x\left(\frac{\partial}{\partial y}+z\frac{\partial^2}{\partial z \partial y}\right)\right] \\\\ &=-\hbar^2\left(yz\frac{\partial^2}{\partial x \partial z}-xy\frac{\partial^2}{\partial z^2}-z^2\frac{\partial^2}{\partial x \partial y}+x\frac{\partial}{\partial y}+xz\frac{\partial^2}{\partial z \partial y}\right) \end{align}\]

因此交换子为(减法抵消相同项)

\[\begin{align} [\hat{l}_x, \hat{l}_y]&=\hat{l}_x\hat{l}_y-\hat{l}_y\hat{l}_x \\\\ &=-\hbar^2\left(y\frac{\partial}{\partial x}-x\frac{\partial}{\partial y}\right) \\\\ &=\mathrm{i}^2\hbar^2\left(y\frac{\partial}{\partial x}-x\frac{\partial}{\partial y}\right) \\\\ &=\mathrm{i}\hbar\left[-\mathrm{i}\hbar\left(x\frac{\partial}{\partial y}-y\frac{\partial}{\partial x}\right)\right] \\\\ &=\mathrm{i}\hbar\hat{l}_z≠0 \end{align}\]

因此,这两个算子不可交换

算子组三

通过算子组二可以类似推理得到:

\[[\hat{l}_y, \hat{l}_z]=\mathrm{i}\hbar\hat{l}_x\] \[[\hat{l}_z, \hat{l}_x]=\mathrm{i}\hbar\hat{l}_y\]

将其代入可得:

\[\begin{align} [\hat{\boldsymbol{l}}^2, \hat{l}_z]&=\hat{\boldsymbol{l}}^2\hat{l}_z-\hat{\boldsymbol{l}}^2\hat{l}_z \\\\ &=(\hat{l}_x^2+\hat{l}_y^2+\hat{l}_z^2)\hat{l}_z-\hat{l}_z(\hat{l}_x^2+\hat{l}_y^2+\hat{l}_z^2) \\\\ &=\hat{l}_x\hat{l}_x\hat{l}_z+\hat{l}_y\hat{l}_y\hat{l}_z+\hat{l}_z\hat{l}_z\hat{l}_z-\hat{l}_z\hat{l}_x\hat{l}_x-\hat{l}_z\hat{l}_y\hat{l}_y-\hat{l}_z\hat{l}_z\hat{l}_z \\\\ &=\hat{l}_x\hat{l}_x\hat{l}_z+\hat{l}_y\hat{l}_y\hat{l}_z+\bcancel{\hat{l}_z\hat{l}_z\hat{l}_z}-\hat{l}_z\hat{l}_x\hat{l}_x-\hat{l}_z\hat{l}_y\hat{l}_y-\bcancel{\hat{l}_z\hat{l}_z\hat{l}_z} \\\\ &=\hat{l}_x\hat{l}_x\hat{l}_z+\color{red}{\hat{l}_x\hat{l}_z\hat{l}_x-\hat{l}_x\hat{l}_z\hat{l}_x}\color{black}{-\hat{l}_z\hat{l}_x\hat{l}_x}+\hat{l}_y\hat{l}_y\hat{l}_z+\color{red}{\hat{l}_y\hat{l}_z\hat{l}_y-\hat{l}_y\hat{l}_z\hat{l}_y}\color{black}{-\hat{l}_z\hat{l}_y\hat{l}_y} \\\\ &=\hat{l}_x(\hat{l}_x\hat{l}_z-\hat{l}_z\hat{l}_x)+(\hat{l}_x\hat{l}_z-\hat{l}_z\hat{l}_x)\hat{l}_x+\hat{l}_y(\hat{l}_y\hat{l}_z-\hat{l}_z\hat{l}_y)+(\hat{l}_y\hat{l}_z-\hat{l}_z\hat{l}_y)\hat{l}_y \\\\ &=\hat{l}_x(-[\hat{l}_z, \hat{l}_x])+(-[\hat{l}_z, \hat{l}_x])\hat{l}_x+\hat{l}_y[\hat{l}_y, \hat{l}_z]+[\hat{l}_y, \hat{l}_z]\hat{l}_y \\\\ &=-\mathrm{i}\hbar\hat{l}_x\hat{l}_y-\mathrm{i}\hbar\hat{l}_y\hat{l}_x+\mathrm{i}\hbar\hat{l}_y\hat{l}_x+\mathrm{i}\hbar\hat{l}_x\hat{l}_y = 0 \end{align}\]

因此,这两个算子可交换

《手解量子化学》练习题 1-1

练习题1-1

判断下面的算子是否厄米(Hermitian)或为厄米算子(Hermite Operator)。

\[[1]\ {d \over dx} \quad [2]\ {\mathrm{i}{d \over dx}} \quad [3]\ {d^2 \over dx^2}\]

解答本题首先要理解厄米的判断条件:

\[\int\psi_{i}^*(\boldsymbol{r}) \hat{f}\psi_{j}(\boldsymbol{r})d\boldsymbol{r}=\int\psi_{j}(\boldsymbol{r}) \hat{f}^*\psi_{i}^*(\boldsymbol{r})d\boldsymbol{r}\]

其中 \(\hat{f}^*\) 是 \(\hat{f}\) 的复共轭或伴随算子, \(\psi_{i}(\boldsymbol{r})\) 和 \(\psi_{j}(\boldsymbol{r})\) 为基底函数,其对应的复共轭函数为 \(\psi_{i}^*(\boldsymbol{r})\) 和 \(\psi_{j}^*(\boldsymbol{r})\)。

知识点补充一

  基底函数符合正交归一化条件,即“任意两个不同基底函数正交”和“任意一个基底函数在全空间上的积分为 1”。形式化可以表示为 \(\int\psi_{i}^*(\boldsymbol{r})\psi_{j}(\boldsymbol{r})d\boldsymbol{r}=0\) 和 \(\int|\psi(\boldsymbol{r})|^2 d\boldsymbol{r}=1\)。

知识点补充二

  求导数时的链式法则:\((uv)'=u'v+uv'\)。转换为积分形式: \(uv=\int{u'v}d\boldsymbol{r}+\int{uv'}d\boldsymbol{r}\),将右边的第一项移到左边于是有 \(\int{uv'}d\boldsymbol{r}=uv-\int{u'v}d\boldsymbol{r}\)。

算子一

现在开始考虑第一个算子 \(\hat{f}={d \over dx}\),显然这个算子就是求导算子(这里是对后面的函数微分求导),于是

\[\begin{align} 左边&= \int\psi_{i}^*(x)\left({d \over dx}\psi_{j}(x)\right)dx \\\\ &=[\psi_{i}^*(x)\psi_{j}(x)]_{-\infty}^{+\infty}-\int\left({d \over dx}\psi_{i}^*(x)\right)\psi_{j}(x)dx \end{align}\]

由于 \(\displaystyle \lim_{x \to \pm\infty} \psi_{i}^*(x)=0\) 和 \(\displaystyle \lim_{x \to \pm\infty }\psi_{j}(x)=0\)(有限,作为波函数的基底函数在无穷处必须快速衰减),所以有

\[[\psi_{i}^*(x)\psi_{j}(x)]_{-\infty}^{+\infty}=0\]

\[\begin{align} 左边&=-\int\left({d \over dx}\psi_{i}^*(x)\right)\psi_{j}(x)dx \\\\ &=-\int\psi_{j}(x){d \over dx}\psi_{i}^*(x)dx \\\\ &≠右边 \end{align}\]

因此第一个算子不是厄米算子

算子二

类似第一个算子,对于第二个算子 \(\hat{f}=\mathrm{i}{d \over dx}\) 有

\[\begin{align} 左边&=\int\psi_{i}^*(x)\left(\mathrm{i}{d \over dx}\psi_{j}(x)\right)dx \\\\ &=\mathrm{i}[\psi_{i}^*(x)\psi_{j}(x)]_{-\infty}^{+\infty}-\int\left(\mathrm{i}{d \over dx}\psi_{i}^*(x)\right)\psi_{j}(x)dx \end{align}\]
知识点补充三
\[\left(\mathrm{i}{d \over dx}\right)^*=-\mathrm{i}{d \over dx}\]

应用基底函数的有限条件和上述的伴随算子可得

\[\begin{align} 左边&=0-\int\left(-\left(\mathrm{i}{d \over dx}\right)^*\psi_{i}^*(x)\right)\psi_{j}(x)dx \\\\ &=\int\left(\left(\mathrm{i}{d \over dx}\right)^*\psi_{i}^*(x)\right)\psi_{j}(x)dx=右边 \end{align}\]

因此第二个算子是厄米算子

算子三

知识点补充四

二阶导的伴随算子还是它本身,于是有

\[\left( {d^2 \over dx^2} \right)^*={d^2 \over dx^2}\]
知识点补充四

根据链式法则,求一阶导有: \((uv')'=u'v'+uv''\) 和 \((u'v)'=u'v'+u''v\)。 对应的积分形式:\(\int{uv''}=uv'-\int{u'v'}\) 和 \(\int{u'v'}=u'v-\int{u''v}\)。

第三个算子是二阶导数,有

\[\begin{align} 左边 &=\int\psi_{i}^*(x){d^2 \over dx^2}\psi_{j}(x)dx \\\\ &=[\psi_{i}^*(x){d \over dx}\psi_{j}(x)]_{-\infty}^{+\infty}-\int\left({d \over dx}\psi_{i}^*(x)\right)\left({d \over dx}\psi_{j}(x)\right)dx \\\\ &=-\int\left({d \over dx}\psi_{i}^*(x)\right)\left({d \over dx}\psi_{j}(x)\right)dx \\\\ &=-\left(\left[\left({d \over dx}\psi_{i}^*(x)\right)\psi_{j}(x)\right]_{-\infty}^{+\infty}-\int\left({d^2 \over dx^2}\psi_{i}^*(x)\right)\psi_{j}(x)dx\right) \\\\ &=0+\int\left({d^2 \over dx^2}\psi_{i}^*(x)\right)\psi_{j}(x)dx \\\\ &=\int\left(\left({d^2 \over dx^2}\right)^*\psi_{i}^*(x)\right)\psi_{j}(x)dx=右边 \end{align}\]

因此,第三个算子是厄米算子

生活中的小问题——公交计费问题

前言

  谈到生活中经常坐的公交车,比较常见的算法问题可能是寻找“耗时最少公交路线”、“最少换乘公交路线”、“最便宜公交路线”、“综合最优公交路线”等。这些算法由于在地图软件中经常被使用,已经被大家研究得非常透彻,比如 Dijkstra 算法就可以用来计算“最短距离公交路线”。如想了解更多,可以阅读参考资料 2 给出的中文文献。

  相比这些常见的算法问题,不如让我们来一起看看不大被人提及的“公交计费问题”。笔者经常在下雨的时候乘坐公交车,每次上车前会先取一张票,然后下车前可以看屏幕显示来知道票价。于是笔者就有了一个小问题:票价是如何正确显示的,是否可以对其建模并写个小程序模拟一下。

问题描述

  如图 1 所示为公交计费问题描述。

  • (a) 公交路线图(图中为右循环路线,但对应的左循环路线也存在),0~20 为站点编号,且 1~3 与 20~18 分别对应相同。公交从站点 0 出发,按照箭头所指的方向依次行进,最终回到站点 0 并停止运行。
  • (b) 相邻站点间距离,以 km 为距离计算单位,给出的数值为公交在站点间实际行进距离。
  • (c) 公交票价计算方式,距离不足 2 km 为基本票价 190 日元,超过 2 km 的话每超过 1 km 加收 50 日元,如不足 1 km 按照 1 km 计算。注意,此处的距离为循环线路中上车站点与下车站点的有效距离,即站点间在循环线路上的最短距离而非实际运行距离。比如,即使从站点 0 上车,经过一圈循环后再从站点 0 下车,也只能收取基本票价 190 日元,因为有效距离为 0 km。(以上计算方式参考自资料 3)
  • (d) 公交最后返回起始站点时计费状态,灰底色为站点编号,白底色为票价。

图 1. 公交计费问题描述。 The description of bus ticket problem.

问题目标

  1. 打印欢迎消息,提示是否从站点 0 发车;
  2. 发车后,通过回车或其他操作在下一站点停车,打印当前票价状态,尚未抵达站点票价为空;
  3. 经过循环后回到站点 0,通过回车或其他操作停车,打印当前票价状态,如图 1(d) 所示。

解决方案

问题分析

  解决公交计费问题,首先要将图 1 中给出的信息进行集成,可得如下图 2。其中,站点间的橙色数字为相邻站点间距离,红色数字为几个关键(0~2 km,2~3 km 和 3~4 km 的阈值站点)站点与出发站点 0 之间的有效距离

图 2. 信息集成后的公交路线图。 The route including the distances and some importance valid distances.

  其实整个问题的核心就在于对有效距离的理解。从题干可知,有效距离并非是实际行进距离。这主要是因为给出的公交路线是环线而非直线,即出发站点与结束站点为同一站点。除此之外,刚开始的 1~3 与最后的 20~18 三个站点是重合的。根据给出的例子解释,我们可以将这里的“有效距离”粗略定义为“上车站点与下车站点在公交路线上正反距离的最小值”。我们不妨从以下示例中进一步加深对于“有效距离”的理解:(~ 表示“大约”)

  • 例 1:上车站点为站点 3,下车站点为站点 17。因为站点 3 与站点 18 重合,所以有效距离等同于站点 17 和 18 之间的距离 0.45 km。
  • 例 2:上车站点为站点 5,下车站点为站点 18。如例 1 方式计算可得有效距离为 0.85 km。
  • 例 3:上车站点为站点 4,下车站点为站点 17。虽然按照路线实际行进距离很远,但是实际两站之间路线上最短距离大约为 0.45 km,即有效距离为 ~0.45 km。
  • 例 4:上车站点为站点 5,下车站点为站点 15。如例 2 方式计算可得有效距离为 ~1.6 km。
  • 例 5:上车站点为站点 6,下车站点为站点 14。按照图中方向实际行进距离计算,可知正向距离为 2.75 km,按照路线上最短反向距离为 ~2.25 km,因此有效距离为 ~2.25 km。

得出总结:

  • 从例 1、2 可以看出,当上车站点和下车站点分别在重合直线和循环圈上时,位于重合直线上的站点需要注意切换到对应站点进行双重计算正反向距离,从而得到正确的有效距离。
  • 从例 3~5 可以看出,当上车站点和下车站点均在循环圈上时,计算反向距离不涉及直线站点(即跨过出发站点 0)。即使站点 17 到站点 4 的实际行进路线不存在,也需以站点 17 到站点 4 闭合的循环圈来进行计算反向距离。
为何不跨过出发站点 0 计算反向距离?

  题干中给出信息“公交从站点 0 出发最终回到站点 0 并停止运行”,鉴于任何跨过出发站点 0 计算的距离实际上只可能由两辆公交车完成,不可能出现在一辆公交的票价计算方式中,当只在循环圈上的站点上下车时应该不考虑直线上的站点(0~3、18~20)。

  说句题外话,如例 3~5 所示,可能直接走过去还更快更方便,而非坐这趟公交。

算法描述

变量声明

变量名 变量类型 描述
distances list 站点列表,[0, 0.2, 0.3, …]
currentStop int 当前站点编号,0
lineStops list 直线站点,[1, 2, 3, 18, 19, 20]
circleStops list 循环圈站点,[4, 5, …, 17]
ticketBase float 基础票价,190.00
ticketStep float 票价梯度,50.00
ticketUnit str 票价单位,JPY
baseDistance float 基础距离,2.00
stepDistance float 基础距离,1.00
distanceUnit str 距离单位,km
validDistances list 有效距离,长度为 21,默认值为 None
prices list 票价,长度为 21,默认值为 None
validStops list 有效站点,经过站点时将编号添加到列表里,默认为 [0]

步骤描述

  程序整体步骤:

  1. 初始化变量,当前站点编号为 0,询问是否启动;接受到启动指令(回车)后开始行进(提示)。
  2. 遇到停车指令(回车)后,切换站点编号为下一站点(+1),添加站点编号到 validStops。
  3. 循环计算 validStops 中各站点的有效距离 validDistances(具体见下)。
  4. 循环计算各站点的票价同时更新 prices。
  5. 打印计费矩阵。
  6. 接受到启动指令(回车)后继续行进(提示),重复 2~5 步骤直至重新回到站点 0。
  7. 打印到达终点站提示信息,结束程序。

  计算任意两个上下车站点间的有效距离的步骤:

  1. 已知上车站点 a 和下车站点 b,当两站点相同时有效距离为 0,如不同进入下一步骤。
  2. 利用 lineStops 和 circleStops 两个变量判断 a 和 b 位于直线部分或循环圈部分。
  3. 如果两站点都是直线部分,利用对称方式标准化为 1~3 的站点编号,直接计算之间距离为有效距离。
  4. 如果两站点都是循环圈部分,利用 circleStops 进行循环遍历叠加计算正反距离,取较小的值为有效距离。
  5. 如果一站点在直线部分、一站点在循环圈部分,对直线部分的站点(标准化后的 1~3 站点)计算正反距离,取较小的值为有效距离。
  6. 返回有效距离。

程序模拟

  根据以上思路,笔者采用 Python 实现了解决方案。源代码请见 Github Gist。以下为程序模拟运行效果:

结语

  虽然现有的公交线路大部分还是很规则的,不同时存在循环圈和直线的情况,计费也较为简单,但是思考特殊公交线路的计费方式也不失为一件有趣的事情。上面给出的分析和算法描述,也可以用其他编程语言实现,比如用前端编程语言就可以直接可视化整个公交计费过程。

参考资料

  1. 图文详解 Dijkstra 最短路径算法
  2. 周文峰等,《运筹与管理》,最优公交线路选择问题的数学模型及算法,2018
  3. 筑波大学循环线票价表

命令行工具开发指南——入门篇

前言

  命令行工具(Command Line,Cli)作为我们日常开发常用的辅助性工具,几乎遍布于各种操作中。根据使用目的的不同大致可以分为以下几类:

  • 从模板中生成项目:比如使用 npm init 从空模版创建一个新的 NodeJS 项目,使用 composer create-project laravel/laravel example-app 创建一个全新的 Laravel 项目(PHP 项目)等。
  • 启用开发者服务模式:比如使用 python -m http.server 8000 在 8000 端口开启一个临时 HTTP 服务器,使用 bundle exec jekyll s 在 4000 端口开启一个临时 Jekyll 服务器等。
  • 特定功能交互:比如流行的 IP 信息查询工具 nali、磁盘空间利用率和空余空间查询工具 duf、快速磁盘使用分析工具 gdu 等。

  其实任何编程语言都可以用来开发命令行工具,无论是常见的 Golang、Python、NodeJS、PHP、Java,还是 Rust、Ruby、C++、C 或者是古老的 Fortran 等。只是取决于所要实现的功能和具体的使用场景,开发者会采用合适的编程语言开发命令行工具。比如说,Linux 系统中包含了大量的命令行工具,基本上都是用 C 语言编写的,主要是因为 C 语言在 Linux 系统中的执行效率相对更高。对于一般高级编程语言,自带的包管理工具也是由自身高级编程语言编写的命令行工具。类似 Rust、Fortran 等编译型语言则需要通过编译生成二进制可执行文件后才能执行相应的任务。

二进制可执行文件与源文件有何不同?

   二进制可执行文件是指源代码通过编译器编译成计算机可以直接识别的二进制码文件。二进制码文件是无法使用任何源码编辑器打开的,只能由操作系统调用执行或特别的二进制码查看器打开。一般来说,二进制可执行文件是很难跨越操作系统的,即针对不同的操作系统需要分别编译生成对应的二进制可执行文件。尤其是当有其他静态库或者动态链接库依赖时,二进制可执行文件甚至无法跨主机运行。 而源文件是可以在任何操作系统用源码编辑器打开的。大多时候商业公司为了保证源代码的商业版权,只会为用户提供应用的二进制可执行文件。(当然一般可能是包含图形用户界面的。)

为何命令行而非图形界面

  命令行可以说是操作系统应用和编程语言编写应用最基本的形式,图形(用户)界面(Graphic User Interface,GUI)则是在源代码的基础上提供可视化的交互方式、通过键鼠操作来降低用户使用的门槛。这也是为什么 Windows 操作系统比 Linux 操作系统更加流行的原因之一。但是有的时候,界面也有可能会成为用户学习和使用的累赘。

简单界面 vs 复杂界面

  就拿代码编辑器来说,我们所熟知的“宇宙第一编辑器” Visual Studio 几乎支持所有编程语言,尤其是对于构建 C# 项目来说可以半代码半可视化修改。尽管这在很大程度上降低了开发者使用成本,但是学习 Visual Studio 编辑器本身的成本却很高。(说句老实话,笔者从大学本科开始接触 Visual Studio 到现在都没怎么学会使用,😂只会最基本的功能而已。)而且,在普通笔记本电脑上使用 Visual Studio 编辑器运行大型项目时,CPU 和内存资源极大可能会被大量占用,打开一个浏览器页面可能也很艰难。

  相比而言,同样由微软推出的 Visual Studio Code 则是简单界面的优秀代表。化繁为简,Visual Studio Code 本身仅支持最简单的功能:文件目录区、编辑区、终端区三部分布局,基本的代码高亮功能,插件功能,主题功能等。无论是 Python 开发者,还是 Golang 开发者,都能一打开直接上手,只是需要根据编程语言不同安装一些插件来提升开发效率而已。在系统占用资源方面,Visual Studio Code 比 Visual Studio 显著降低,尽管可能也会受安装插件的少量影响。当然有得也有舍,Visual Studio Code 中支持更多文件定义配置或命令行配置,对于没有学过 Linux 的用户可能会有点学习难度。

Linux 哲学

  “一切皆文件”。任何系统、项目、工具都是由一系列的文件组成的,通过配置文件可以实现直接管理。

  虽然这是 Linux 系统设计的哲学思想,但其实是所有操作系统设计的哲学思想,只是顶层封装的程度有所不同。Windows 系统也是“一切皆文件”的,不然那些编辑器的配置都存在哪里了呢。相比 Linux 和 MacOS 系统而言,Windows 系统的顶层封装程度最高,用户对于底层文件的直接管理非常少,尤其是对系统级别的配置管理只能通过图形界面交互完成。MacOS 系统则是介于两者之间,顶层封装程度虽然高但也提供对大部分系统级别配置的直接管理,即可以通过修改文件来实现管理。尽管依旧存在有些系统级别配置难以直接修改,比如说操作系统启动项。

更简单的命令行

  界面在执行系列任务时一般需要多步操作,一顿点点点之后才能完成。当然如果图形界面和功能设计的比较合理的话,可能也只需要一步操作。当我们需要进行批量操作时,即使图形界面只需要一步操作,依旧需要一顿点点点。命令行则没有这种问题,只需要简单写个有循环的脚本即可循环调用命令行工具批量执行。

  另外,命令行工具仅在执行时会占用系统资源,一旦完成即可完全释放。图形界面应用一般需要常驻后台,虽然优化得好的时候所占用的系统资源也可忽略不计,但是还是会有后台进程的。

  尽管命令行工具极少会有显式的界面交互,但是也可以在终端提供非常丰富的命令行交互、功能解释、自动补全、自动建议等。用户使用起来一般没有太大问题,只需要调用子命令和参数即可实现操作。

命令行工具开发

设计标准和规范

  命令行工具开发通常依据两个标准和规范进行:POSIX (Protable Operating System Interface,可移植操作系统接口) 标准和 GNU (GNU’s Not Unix) 项目。POSIX 标准是 IEEE 为维护操作系统间适配性而制定的一系列标准,其中一个标准定义了命令行程序的语法和语义。GNU 旨在创建与 Unix 兼容的自由软件,其中一个子项目 GNU Coreutils 提供了很多常用的命令行程序,比如 lscpmv 等。据此为命令行程序建立了以下的设计标准和规范:

  • 单字母标志 (single-letter flag) 以一个短横线 - 开始,且可以合并使用:比如 -d (全称 --debug) 和 -v (全称 --version) 合并使用 -dv 来以调试模式输出命令行版本号。
  • 长标志 (long flag) 以两个短横线 -- 开始,但无法合并使用:比如 --debug--version 可以被命令行正常解析,但 --debugversion 无法被正常解析。
  • 选项 (Options) 跟在单字母标志后没有分隔符,但跟在长标志后使用等号 = 来分隔标志和选项值:比如 -n example--name=example 分别为单字母标志和长标志的选项用法,二者完全等价。
  • 参数 (Args) 跟在标志或选项之后时没有任何分隔符,仅有空格:比如 curl -o out.html https://www.google.com 中的 https://www.google.com 是参数,一般用法类似 curl [Options] <Arg>
  • 子命令 (Sub command) 与主命令之间没有分隔符,仅有空格:比如 git commitgit 是主命令、commit 是子命令。
  • 单独的两个短横线 --(后不接标志)表示标志或选项的结束和参数的开始:比如 rm -- -f 中的 -f 表示的是要删除的文件 -f,而不是强制删除文件的选项。
提示

  按照以上设计标准和规范开发的命令行工具使用体验会与常用的命令保持一致,对于用户来说非常容易上手,这也是制定设计标准和规范的主要原因。

功能设计

  核心功能设计主要是指命令行工具所支持的子命令、参数、选项设计。其中,子命令表示功能集合,参数表示输入输出变量,选项表示功能的微调。如下所示为笔者开发的命令行工具 pictl (基于 Python 语言开发)的帮助信息。目前提供四个子命令:config(配置基本信息),compress(压缩任意图片为 webp 格式),upload(上传图片)和 cup(压缩并上传图片)。全局只支持 -h--help)打印帮助信息和 -V--version)打印版本信息两个选项。

╰─$ pictl
Usage: pictl [OPTIONS] COMMAND [ARGS]...

  A command line tool for image processing and uploading (ex. S3-type).

  Now it supports:
    - transformation from other image types to `webp` image as well as
      image compression.
    - image file uploading to AWS S3 or Cloudflare R2.

Options:
  -V, --version  Show the pictl version.
  -h, --help     Show this message and exit.

Commands:
  compress  Compress any image into `webp` image.
  config    Operations for the config file `~/.pictlrc`.
  cup       Compress image and upload to remote storage (compress and...
  upload    Upload the file to remote storage.

子命令

  子命令是否越少越好或者越多越好?亦或是不多不少比较好?其实,根据实际功能需求的不同子命令的数量会有很大的差异。比如上面提到的 pictl 目前所支持的子命令只有 4 个,curl 不支持子命令但支持选项超过 20 个,git 支持的常用子命令多达 22 个(如下所示,实际子命令可能接近 100 个),docker 支持的子命令超过 30 个。子命令实际上是可以多层迭代调用的,即可以存在多层级。但是在功能设计时,一般将对同一对象操作的功能归类到同一子命令下面,形成多层级子命令。当然为了简化子命令的层级调用,最多的实践方式就是类似于 git 中的用选项来代替多层的子命令。比如 git branch 子命令是对分支(branch)的列举(git branch -l)、创建(git branch <name>)和删除(git branch -d <name>)的功能集合。

╰─$ git -h
usage: git [-v | --version] [-h | --help] [-C <path>] [-c <name>=<value>]
           [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
           [-p | --paginate | -P | --no-pager] [--no-replace-objects] [--bare]
           [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
           [--super-prefix=<path>] [--config-env=<name>=<envvar>]
           <command> [<args>]

These are common Git commands used in various situations:

start a working area (see also: git help tutorial)
   clone     Clone a repository into a new directory
   init      Create an empty Git repository or reinitialize an existing one

work on the current change (see also: git help everyday)
   add       Add file contents to the index
   mv        Move or rename a file, a directory, or a symlink
   restore   Restore working tree files
   rm        Remove files from the working tree and from the index

examine the history and state (see also: git help revisions)
   bisect    Use binary search to find the commit that introduced a bug
   diff      Show changes between commits, commit and working tree, etc
   grep      Print lines matching a pattern
   log       Show commit logs
   show      Show various types of objects
   status    Show the working tree status

grow, mark and tweak your common history
   branch    List, create, or delete branches
   commit    Record changes to the repository
   merge     Join two or more development histories together
   rebase    Reapply commits on top of another base tip
   reset     Reset current HEAD to the specified state
   switch    Switch branches
   tag       Create, list, delete or verify a tag object signed with GPG

collaborate (see also: git help workflows)
   fetch     Download objects and refs from another repository
   pull      Fetch from and integrate with another repository or a local branch
   push      Update remote refs along with associated objects

'git help -a' and 'git help -g' list available subcommands and some
concept guides. See 'git help <command>' or 'git help <concept>'
to read about a specific subcommand or concept.
See 'git help git' for an overview of the system.

  功能设计中对子命令的设计是由核心功能驱动的。在条件允许的情况下,尽可能压缩子命令列表是有利于用户上手使用的。为了命令行工具使用时命令不会过长,建议提供比较常用的默认选项值从而减少用户自定义的可能性。当然,对全部选项的单字母标志支持也是有效缩短命令长度的方法之一。除此之外,提供配置文件也是非常可取的方法。gitcurlwgetdocker 等都提供对应的配置文件 .gitconfig.curlrc.wgetrc.dockerrc 进行全局配置定义,当然 pictl 也提供 .pictlrc 配置文件。

代码架构

  尽管不同编程语言因为自身原因(编译型或解析型语言,面向对象或面向过程等),可能会有不同的代码架构偏好,我们可能依然可以采用一个宽泛且可行的通用代码架构思路——面向功能开发。这里的功能可以是一个操作或者一个对象,比如说压缩图片这样一个功能,主要是将输入图片转换成想要的图片格式输出。除此之外,可能还可以提供压缩质量控制、尺寸控制、自动重命名、自动加水印等微调功能。这些微调功能虽然实际上可以完全独立,但由于是压缩图片功能的附属功能,最好采用选项调用的方式来实现。代码架构上,子命令调用对应的函数会成为顶级函数。其他微调功能虽然是独立函数或对象,但仅在子命令函数中被调用。实际开发过程中,微调功能并非一开始就包括所有,大部分会作为一些特性逐步增加到主代码中。这意味着,对用户来说新增一个微调功能仅仅多了一个子命令下的选项支持,不需要重新学习和适应。

  如果压缩图片功能的基础(图片转换)需要自行编写代码,那么可能需要考虑到很多种图片格式的相互转换,这在具体代码实践中是非常麻烦的。比较可行的方法之一是,可以采用一种图片格式作为中间标准格式,每次新增一种图片格式的支持只需要增加与中间标准格式的相互转换即可。当然这里采用的中间标准格式可能是需要高保真的(或者高分辨率的),避免在用了中间标准格式转换之后图片质量自动下降。

错误处理和自动建议

  当我们打算开发一款命令行工具时,除了核心功能是必不可少的,错误处理和自动建议也是需要考虑在内的。了解这点最简单的办法就是从实例中学习。如下所示,是将 git 提交修改(commit)的命令 git commit 故意打成为 git commi 的输出结果。

╰─$ git commi
git: 'commi' is not a git command. See 'git --help'.

The most similar commands are
 commit
 column
 config

  当命令行工具接收到用户输入时,首先需要做的就是对输入的合法性进行验证:一方面,是否存在不可用的子命令或非法使用(比如错误迭代调用);另一方面,尝试解析参数并验证完整性。这两部分的验证会尽可能地将错误的原因和可能有用的建议提示给用户。类似上面,命令行工具会提示用户使用的子命令不存在,请使用 git --help 了解更多。另外,会将输入的子命令字符串与所有合法的子命令字符串进行对比,根据相似性大小排列向用户自动建议。至于对选项的解析相对来说可以比较宽容一点,即直接忽略不合法的选项声明、仅读取合法的选项声明,因此可以不返回相关错误提醒及帮助。

  当子命令、参数、选项均通过验证之后,命令行工具的功能代码执行时也会发生错误。如下所示,在一个非 git 项目文件夹内执行 git commit 时,命令行工具会将具体的错误直接提示:当前目录或任何父目录不是一个 git 项目,不存在 .git 文件夹。这里需要注意的是,通常我们可能对错误或异常的处理会直接使用编程语言本身提供的方式,比如 Python 语言中的 raise ValueError("'element' parameter is not defined.")。当然这种错误处理本身没有任何问题,只不过同时还会输出错误发生的代码位置等其他与用户使用无关的信息,尽管这种信息在开发过程中有利于开发者调试代码。出于为用户考虑,错误处理信息默认应该以简单可读的方式打印出来、且仅限于提示关键信息。如果用户或者开发者想要了解更多,可以通过 -v 或者 --verbose 选项来打印更多调试信息。

╰─$ git commit
fatal: not a git repository (or any of the parent directories): .git
-v 和 -V

  通常来说,-v--verbose)和-V--version)会被认为是不同的选项,分别用于开启调试模式和打印版本信息。当用户发现命令行工具使用出现不可预知的问题(错误提示与实际原因不符或其他不在开发者知晓范围内的问题)时,需要开启调试模式来排除本地环境的问题,同时也可能在向开发者反馈问题时提供足够的信息来帮助定位问题的原因。版本信息通常也是提交反馈问题时所需的必要信息。

技术选型

  技术选型指的是用哪种编程语言以及哪种框架来实现命令行工具第一种是从开发者自身熟悉和掌握的编程语言出发,尽可能降低编程语言上的学习成本。不过对于已经掌握一门或多门编程语言的开发者来说,学习新编程语言可能也不是件难事。第二种是从应用场景出发:如果是开发为机器学习应用的前置或后置步骤的数据准备、数据处理、可视化等功能的命令行工具,采用 Python 语言可能会更加方便有效;如果是开发包含与操作系统交互的网络分析、磁盘分析等功能的命令行工具,采用 Rust 或者 Golang 语言可能会比较得心应手;如果是开发与平台接口交互(如 Web API 调用)的命令行工具,采用 NodeJS 语言可能适配性更佳。详细请查看参考资料 [1-5]。

结语

  诚然,命令行工具并非是所有用户的喜爱,但的确是最小化操作步骤、提升效率的方式之一。以上所述的命令行工具开发指南入门篇大部分是在理论层面上的,至于在不同编程语言上的实践后续也计划展开:

  • 《命令行工具开发指南 —— Python 实践篇》
  • 《命令行工具开发指南 —— Rust 实践篇》
  • 《命令行工具开发指南 —— Golang 实践篇》
  • 《命令行工具开发指南 —— NodeJS 实践篇》

  有关于命令行工具开发进阶的依赖管理、编译构建、信号和日志处理、用户输入验证、自动补全(Auto Completion)、文档、测试和发布等也将在实践篇中分别具体阐述。

参考资料

  1. 快手数平前端团队 – 掌握 Node CLI 工具开发,为团队研发提效!
  2. 阮一峰的网络日志 – Node.js 命令行程序开发教程
  3. Rust 中的命令行应用
  4. 命令行应用 - Python 最佳实践指南
  5. Tony Bai – Go 开发命令行程序指南
  6. Wikipedia – POSIX

Modules 配置之 Python

前言

  近年来得益于其轻量、易学易用、第三方支持依赖库多的特点,Python 语言大量被用于机器学习相关的研究、项目开发。在学术界,有以 Scikit-Learn 为代表的全能机器学习库;在产业界,有以 TensorFlowPyTorch 为代表的生产级机器学习模型计算框架。(当然,学术界实际上构建大规模深度学习模型时也会用到 PyTorch 等计算框架。)但对于大多数人来说,学习这些库、框架或者借助它们从事某些研究、项目开发时,可能还是在用自己的笔记本、台式机。哪怕是在高校的实验室里,这种事情也是屡见不鲜。因此,有交互界面、相对容易上手的 Anaconda 可能会作为大家管理 Python 环境的首选。

  当我们在用 Python 编写一些代码,而代码一次运行不可能在短短几秒、几分钟内得到结果时,将任务提交给高性能工作站或者集群作业系统就显得格外有效。尤其是当应用规模较大、计算迭代次数较多时,非交互式的作业提交方式会变得更加有利。毕竟如果是用自己的笔记本运行着这么大的计算,资源基本上都被计算占用了,根本没办法用笔记本去干点别的事情。甚至说,计算还会使得 CPU 等核心部件温度上升,从而影响计算性能。这样比较下来,不得不说提交任务给高性能工作站或者集群作业系统是多么明智的选择。

  其实,Anaconda 在没有交互界面的服务器操作系统上也还是可以使用的,我们可以使用其免费的精简版 —— miniconda。虽然 miniconda 已经是精简版了,但和原生 Python 环境比起来还是要多不少东西的。从高性能计算环境的角度来看,使用 Modules 直接管理 Python 环境实际上更加贴近原生,也更加有利于用户与其他环境搭配使用。比如说安装 Python 的 MPI 支持库 —— MPI4PY,仅需要通过 Modules 管理工具加载 Python 和 MPI 两个基础环境,使用 pip3 install mpi4py 命令即可安装。

实践

  将 Python 环境纳入 Modules 管理的步骤就是两步:第一步,编译源代码及安装;第二步,添加 Modules 配置文件。当然,最开始还是需要确认一下编译环境是否完备以及文件夹是否准备好。

环境及文件夹准备

# 安装编译环境
sudo apt install -y build-essential libbz2-dev libdb-dev \
  libreadline-dev libffi-dev libgdbm-dev liblzma-dev \
  libncursesw5-dev libsqlite3-dev libssl-dev \
  zlib1g-dev uuid-dev tk-dev wget
  
# 准备文件夹
sudo mkdir -p /opt/python/3.10.6

编译及安装

# 下载源代码
cd /tmp
wget -c https://www.python.org/ftp/python/3.10.6/Python-3.10.6.tar.xz

# 解压源代码
tar xf Python-3.10.6.tar.xz

# 配置安装路径及编译选项
cd Python-3.10.6
./configure --prefix=/opt/python/3.10.6 --enable-optimizations --with-lto 

# 编译及安装
make && sudo make install

配置 Modules

sudo mkdir -p /opt/modules/modulefiles/py
sudo vim /opt/modules/modulefiles/py/3.10.6

  首先如上命令准备文件夹,并新建 module 配置文件,内容如下:

#%Module
proc ModulesHelp { } {
    puts stderr \tThis module file will load Python 3.10.6"
}

module-whatis  "Enable Python 3.10.6"

eval set  [ array get env HOME ]
set basedir /opt/python/3.10.6
prepend-path PATH "${basedir}/bin"
prepend-path LIBRARY_PATH "${basedir}/lib"
prepend-path LD_LIBRARY_PATH "${basedir}/lib"
prepend-path INCLUDE_PATH "${basedir}/include"
prepend-path LD_INCLUDE_PATH "${basedir}/include"

验证

# 查看所有可用模块
╰─$ module ava
-------------------------- /opt/modules/modulefiles ---------------------------
dot  module-git  module-info  modules  null  py/3.10.6  use.own

Key:
modulepath
# 加载 python 3.10.6 环境,并确认已加载模块
╰─$ module load py/3.10.6
╰─$ module list
Currently Loaded Modulefiles:
 1) py/3.10.6
# 确认目前 python 版本
╰─$ python3 -V
Python 3.10.6

使用

  由于以上操作将 Python 3.10.6 安装到了一个系统文件夹中,编译完成后会出现如下警告提示。不过无须担心,普通用户可以通过 venv 虚拟环境工具正常使用。

Installing collected packages: setuptools, pip
  WARNING: The scripts pip3 and pip3.10 are installed in '/opt/python/3.10.6/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
Successfully installed pip-22.2.1 setuptools-63.2.0
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv

  如下所示,当已经如验证部分加载好 python 3.10.6 模块后,使用以下命令新建虚拟环境、激活后即可使用。

小提示

   新建虚拟环境时最后的参数 env 是指虚拟环境的名字,我们可以取任意符合 python 规则的字符串作为虚拟环境名字。值得注意的是,python 虚拟环境有关的文件将会被安装在命令执行的当前目录下的同名文件夹中。为了便于管理和使用,建议将所有的 python 虚拟环境都放置在同一目录下。

# 新建 env 虚拟环境
╰─$ python3 -m venv env

# 激活 env 虚拟环境
╰─$ source env/bin/activate

# 可以看到 <env> 的环境提示
# 尝试升级 pip,可以看到成功升级 
╭─zhonger@lep-u ~ ‹env›
╰─$ pip3 install -U pip
Requirement already satisfied: pip in ./env/lib/python3.10/site-packages (22.2.1)
Collecting pip
  Using cached pip-23.0.1-py3-none-any.whl (2.1 MB)
Installing collected packages: pip
  Attempting uninstall: pip
    Found existing installation: pip 22.2.1
    Uninstalling pip-22.2.1:
      Successfully uninstalled pip-22.2.1
Successfully installed pip-23.0.1

# 取消激活 env 虚拟环境
╰─$ deactivate

有趣的问题

  如果使用 module 提供的 python 模块创建了虚拟环境后,实际运行虚拟环境时还需要使用 module 加载 python 模块吗?答案是不需要。虚拟环境的本质是拷贝运行相同命令所需的必要文件,如下对比查看一下 python 模块和 env 虚拟环境的顶级目录。可以发现,两者的差别不是很大。env 虚拟环境少了 share 目录,多了 pyvenv.cfg 文件。查看该文件可知,存在与 python 模块之间的关系的声明。再查看 bin 目录,可以看到 python 可执行命令用了链接的方式,pip 命令则是直接从原来的 python 模块复制过来的。于是,这就能允许普通用户自行管理 pip 命令和 python 库了。

╰─$ ls /opt/python/3.10.6
bin  include  lib  share

╰─$ ls env
bin  include  lib  lib64  pyvenv.cfg

╰─$ cat env/pyvenv.cfg
home = /opt/python/3.10.6/bin
include-system-site-packages = false
version = 3.10.6

╰─$ ll env/bin
total 36K
-rw-r--r-- 1 zhonger zhonger 8.9K Mar 20 15:20 Activate.ps1
-rw-r--r-- 1 zhonger zhonger 2.0K Mar 20 15:20 activate
-rw-r--r-- 1 zhonger zhonger  908 Mar 20 15:20 activate.csh
-rw-r--r-- 1 zhonger zhonger 2.1K Mar 20 15:20 activate.fish
-rwxrwxr-x 1 zhonger zhonger  234 Mar 20 15:20 pip
-rwxrwxr-x 1 zhonger zhonger  234 Mar 20 15:20 pip3
-rwxrwxr-x 1 zhonger zhonger  234 Mar 20 15:20 pip3.10
lrwxrwxrwx 1 zhonger zhonger    7 Mar 20 15:20 python -> python3
lrwxrwxrwx 1 zhonger zhonger   30 Mar 20 15:20 python3 -> /opt/python/3.10.6/bin/python3
lrwxrwxrwx 1 zhonger zhonger    7 Mar 20 15:20 python3.10 -> python3

参考资料

LDAP 集成之 OpenVPN 篇

前言

Easy Connect

  公司、学校、云服务等一般需要将内外网进行分离,如果想要从外部网络访问某些内部应用,通常需要使用公司、学校、云服务提供的专用网络接入服务。国内公司、学校比较常用的是由深信服开发的 Easy Connect,一种 SSL VPN 技术的实现。虽然每年需要支付一定的费用来维护、升级 Easy Connect 服务,但是毕竟它能够提供比较细粒度的权限控制,比如说对目标 IP、目标端口的特别指定,能够有效保护内网服务器只有 Web 应用本身能被用户接入,而类似于 SSH 等服务及端口则可以通过单独申请和配置来实现。总而言之,除了需要付费,似乎没有什么不好的地方。

  实际上如果是在大公司或者学校的话,可能在内网里面还会有更深的内网存在。举个例子,正常的内网是日常的办公或开发网络,服务器所处的内网是独立的网络,即使是已经连接了办公网络,还是需要通过专用网络接入服务器内网才能进行服务器的维护。如果是以数据中心的模式运营的话,甚至说每一次访问服务器都是需要经过临时审批和登录密码发放的。一旦过了有效时间或者完成了任务,访问都将会被拒绝。

OpenVPN

  虽然 Easy Connect 可以用于上述的场景,但是似乎显得有些大材小用了,毕竟还是要支付一定费用的。为了尽量降低成本,开源的 OpenVPN 或许是一种不错的选择。据笔者所知,Easy Connect 根据购买的许可不同允许的同时在线人数可能也会不同,实际上可能存在“需大于供”的问题。为了缓解这一可能存在的问题,还是会搭建一套 OpenVPN 来作为冗余接入方式。其实 OpenVPN 的商业版本许可也是会有人数限制的,只不过因为只是备份方式也没有太大关系。

  OpenVPN 除了开源免费之外,还支持大部分主流的认证方式,比如说 LDAP 认证、微软的商业级目录服务 Active Directory(简称 AD)认证等。近年来,基于 Identify Provider(简称 IdP)、Single Sign On(简称 SSO)、Central Authentication Service(简称 CAS)等的国产化的一站式登录服务解决方案也在逐渐替换原来的 LDAP 或 AD 直接认证,LDAP 或 AD 将作为底层的基础认证方式存在。所以说,开源免费的 LDAP 目录服务在一般的团队中还是足够的,作为 OpenVPN 的认证方式也是完全能满足要求的。

提醒

  在公网上搭建专用网络接入服务是需要有工信部颁发的专门资格许可的,一般公司、学校、云服务都是有该类资格许可,所以可以对外提供该项服务。而个人是无法获得这类许可,除非注册公司并申请该类许可。如果个人在云服务上搭建该类服务,将会面临被云服务提供商警告甚至单方面停止服务的风险。

实践

环境要求

  在实践前请务必保证具备以下环境:

  • Docker
  • docker-compose
  • 可用的 LDAP 目录服务

启动实例

  为了方便部署和测试,这里采用 wheelybird/openvpn-ldap-otp 提供的 Docker 镜像。这个镜像比较小,同时也支持 x64 和 arm 两种体系架构,能满足大部分主流服务器平台。

# docker-compose.yml

version: '3'
services:
  openvpn:
    cap_add:
     - NET_ADMIN
    image: wheelybird/openvpn-ldap-otp
    container_name: openvpn
    ports:
     - "1194:1194/udp"
    restart: always
    volumes:
     - /etc/localtime:/etc/localtime:ro
     - /etc/timezone:/etc/timezone:ro
     - ./openvpn-data:/etc/openvpn
    environment:
     - OVPN_SERVER_CN=oc-vpn.example.com
     - LDAP_URI=ldap://ldap.example.com
     - LDAP_BASE_DN=ou=users,dc=example,dc=com
     - LDAP_BIND_USER_DN=cn=admin,dc=example,dc=com
     - LDAP_BIND_USER_PASS=password
     - LDAP_LOGIN_ATTRIBUTE=uid
     - LOG_TO_STDOUT=false
     - OVPN_DNS_SEARCH_DOMAIN=example.com

  使用以下 docker-compose.yml 文件和 docker-compose up -d 命令启动实例。为了能够避免实例在重新创建后证书发生改变,将 Docker 实例中 /etc/openvpn 的目录持久化(与本地目录绑定)是非常重要的。在这里给出的环境变量(environment)中,前三项 OVPN_SERVER_CN、LDAP_URI、LDAP_BASE_DN 是必须要有的。如果 LDAP 目录服务默认是不能被匿名查找的,也必须包含 LDAP_BIND_USER_DN 和 LDAP_BIND_USER_PASS 变量的(即管理员账户名和密码)。当然,如果你想要指定匹配登录用户名字段,则需要新增 LDAP_LOGIN_ATTRIBUTE 变量。该变量默认是 uid 字段,也可以指定为其他 LDAP 目录服务中包含的字段,比如 email。这个镜像默认是会将服务的实时输出打印在终端,如果想要以日志文件的形式保存下来,则将变量 LOG_TO_STDOUT 置为 false 即可。

  一般来说,服务器内网为了管理方便,会根据服务器的 ip 和编号来配置对应的域名解析及反向域名解析,形如 ec2-1-1-1-1.aws.com,也有可能就是简单的 c1.sever.aws.com。所以当接入服务器内网后,我们可能会期望用 c1 来作为这台服务器的标签,而在终端我们也可能通过 ping c1 来测试通路。实际上只要在启动实例时新增变量 OVPN_DNS_SEARCH_DOMAIN 就可以实现,当然这里变量对应的值也应该变成 server.aws.com。相当于,有了这个配置后,本地 DNS 解析没有记录时会自动尝试加入后缀来解析。这样一来,是不是方便了很多呢?

  除此之外,该镜像还支持其他一些特性,比如 OTP,请访问 wheelybird/openvpn-ldap-otp 了解更多。

生成配置文件

  在生成配置文件上,wheelybird/openvpn-ldap-otp 要比 kylemanna/docker-openvpn 更复杂一些,可以手动从 Docker 实例的日志文件或终端输出内容中看到内容,大致内容形式如下所示:

#---------- Start of client.ovpn ----------

client
tls-client
dev tun
persist-key
persist-tun
remote-cert-tls server
key-direction 1
auth SHA512
proto tcp
reneg-sec 0

comp-lzo
redirect-gateway def1
auth-user-pass

# Set log file verbosity
verb 3

<connection>
remote oc-vpn.example.com 1194 udp
float
nobind
</connection>

<ca>
-----BEGIN CERTIFICATE-----
.........
.........
-----END CERTIFICATE-----
</ca>
<tls-auth>
#
# 2048 bit OpenVPN static key
#
-----BEGIN OpenVPN Static key V1-----
.........
.........
-----END OpenVPN Static key V1-----
</tls-auth>
key-direction 1
#----------  End of client.ovpn  ----------

  将以上内容复制保存在 oc-vpn.example.com.ovpn 文件中即可。

测试

  通常来说使用 OpenVPN 专用或者兼容客户端来加载配置文件 oc-vpn.example.com.ovpn,当然也可以用终端命令连接,如下所示:

sudo openvpn --config oc-vpn.example.com.ovpn

  执行上述命令后会提示输入用户名和密码进行认证,认证通过后会建立连接。默认分配的是 10.50.50.0/24 段中的某个 IP,网关为 10.50.50.254,当然这个也可以在启动实例时自行设置。

再次提醒

  以上内容比较适用于团队办公或开发网络与服务器网络独立分离的情况(内网环境)。请勿在未获得工信部的资质许可的情况下在公网部署类似服务,一旦被云服务提供商监测到,云服务提供商有权进行警告、断网、关停等操作,并且无法申诉。

参考资料

提示

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:加入链接

LDAP 集成之 Squid 篇

前言

  Squid cache,简称 Squid,是一款高性能的代理缓存软件。Squid 本身支持包括 HTTP、HTTPS、FTP、SSH 在内的多种协议,且采用一个单独的、非模块化的、I/O 驱动的进程来处理所有的客户端请求,从而提供主动缓存加速的功能。除此之外,Squid 还可以提供应用层过滤控制的功能,也可与其他的防病毒软件一起使用。在一些大公司、学校、研究机构内部,一般采用 Squid 代理上网的方式,可以过滤危险内容或操作、节省网络带宽、提升访问速度。

  但实际上 Squid 代理也可用于正向代理,即为外来用户访问内网应用提速。这也是 CDN(内容分发网络)的加速原理,利用位于全球网络边缘的节点提供服务,而实际上的应用内容则通过边缘节点间的内网来缓存提速。

  虽然 Squid 一般部署在团队或公司网络内部,但是由于用户的权限不同可能需要应用不同的规则,所以能够与 LDAP 认证服务结合就变得非常重要了。

实践

环境要求

  在进行正式的实践之前,务必确保已有以下环境:

  • Docker
  • docker-compose

启动实例

  笔者已经编译并公开了在多种体系架构上可用的 Docker 镜像 zhonger/squid。这里直接使用以下 docker-compose.yml 配置文件和 docker-compose up -d 命令启动实例。

# docker-compose.yml
version: '3.2'

services: 
  squid:
    image: zhonger/squid
    container_name: squid
    hostname: squid
    ports:
      - "3128:3128"
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    environment: 
      - SQUID_SSH=true
      - SQUID_RSYNC=false
      - LDAP_ENABLE=true
      - LDAP_HOST=yourldap.domain.com
      - LDAP_PORT=389
      - LDAP_BindDN="cn=admin,dc=yourdomain,dc=com"
      - LDAP_BindPass="********"
      - LDAP_DN="ou=Users,dc=yourdomain,dc=com"
      - LDAP_ATTRIBUT="uid=%s"
      - PROXY_NAME="Proxy Display Name"
    restart: always

  上面有关的配置变量及其含义可以访问 zhonger/squid 了解更多。

测试

  通过浏览器访问 http://ip:3128 并输入对应的用户名和密码可以看到类似下面的内容。

nUOnpb

客户端连接

  由于我们未指定实际需要访问的地址,Squid 会直接报错。一般使用时,需要使用操作系统的网络设置中的代理来配置好 Squid。如下图所示,勾选“网页代理(HTTP)”并填写相关的 IP、端口、用户名及密码。如果想要同样应用在访问 HTTPS 站点,则还需要勾选“安全网页代理(HTTPS)”及填写相关信息。然后点击保存生效。

oXWsLE

  这样一来就可以将访问的流量完全交给 Squid 来控制了。当然,如果不确定客户端连接是否成功,可以通过访问 ip.sb 来确认当前客户端流量出口 IP 是否为 Squid 服务器 IP。

参考资料

内网 HTTPS 可信证书

前言

内网 HTTPS 化的必要性

  开发团队或者公司内部一般会采用内外网隔离、上网行为过滤等措施,比较可靠地保证了内部设备无法被外部网络所侦测,从而可能认为 HTTP 内网站点是一个相对安全的存在。即使在 HTTPS 证书如此盛行的今天,也还暂时不考虑内部站点的 HTTPS 化。IP + Port 或者 http://本地域名 的访问方式依旧是座上宾。当然,如果考虑到购买 HTTPS 证书的成本或者团队内网站点采用 Letsencrypt 等免费证书过于麻烦(只能采用 DNS 验证的方式每三个月申请一次新证书),那么自签名 SSL 证书则成为首选了。不过,如果为每一个内网站点都生成一个 SSL 证书,然后让大家都手动把 HTTPS 标为可信,那么当面临大量内网站点时,大家可能要被搞崩溃。更为可行的办法是,生成一个内网用的根证书,只标记该根证书可信

根证书

  与其相信别人根证书生成的 SSL 证书,不如相信自己根证书生成的。我们的目的毕竟不是要任何一个人都把我们自签名的证书标为可信,只要在内网内使用内网站点的设备能够信任即可。而且成为一个受到公众信任的根证书是非常困难的一件事,即使经过几十年可能也没有办法做到。如今现有的根证书实际上有限,像我们平常熟悉的 Letsencrypt、ZeroSSL、Cloudflare 等等并不是根证书而是中间证书。有点类似总代理和分代理的感觉,根证书在业界具有广泛的公信力,但是让根证书去给个人或者企业生成证书可能有点忙不过来。于是根证书生成若干个中间证书,再由中间证书来为个人或者企业生成实际的证书。

  一般来说,操作系统或浏览器的产商会预置国际上认可的根证书。如下所示,为 Mac OS 上预置的根证书列表。

Mac OS 预置根证书 Root Certificate

实践

  话不多说,让我们来实践一下如何生成自己的根证书和签发 SSL 证书吧。

生成根证书

安装 OpenSSL (可选)

  此处只考虑 Mac OS 和 Ubuntu,其他环境如何安装可以自行搜索。

# Mac OS
brew install openssl

# Ubuntu
sudo apt install -y openssl

创建根密钥

  使用以下命令创建根密钥 zhonger-key.pem

openssl genrsa -out zhonger-key.pem 4096

创建根证书并签名

  使用刚创建好的根密钥 zhonger-key.pem 生成根证书,并输入相关信息。

openssl req -new -x509 -days 3600 -key zhonger-key.pem -out zhongerca.pem
╰─$ openssl req -new -x509 -days 3600 -key zhonger-key.pem -out zhongerca.pem 
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:Shanghai
Locality Name (eg, city) []:Shanghai
Organization Name (eg, company) [Internet Widgits Pty Ltd]:zhonger
Organizational Unit Name (eg, section) []:zhonger
Common Name (e.g. server FQDN or YOUR name) []:lisz.me
Email Address []:contact@lisz.me

验证根证书

$ openssl x509 -text -in zhongerca.pem -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            44:48:03:56:ff:15:57:03:00:34:1f:85:61:ca:f7:7a:1e:4f:38:8f
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = CN, ST = Shanghai, L = Shanghai, O = zhonger, OU = zhonger, CN = lisz.me, emailAddress = contact@lisz.me
        Validity
            Not Before: Aug  3 05:25:47 2022 GMT
            Not After : Jun 11 05:25:47 2032 GMT
        Subject: C = CN, ST = Shanghai, L = Shanghai, O = zhonger, OU = zhonger, CN = lisz.me, emailAddress = contact@lisz.me
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (4096 bit)
                Modulus:
                    00:b2:d1:47:73:8a:83:48:e3:47:1a:41:01:f6:63:
                    69:43:39:71:eb:2b:74:be:dc:63:f3:df:79:66:ee:
                    00:30:65:b3:4f:7e:58:88:00:13:09:e6:4f:74:57:
                    fa:a3:56:24:cd:b6:1f:53:25:77:98:bf:9f:45:64:
                    7c:6c:04:23:c4:8f:0f:bf:2e:b3:d1:2e:4c:05:4d:
                    4c:e6:65:54:ad:0c:35:b7:d9:c8:74:97:19:c7:a5:
                    cd:9a:a4:73:37:13:71:80:34:7c:bc:b3:41:5a:34:
                    bb:16:82:44:18:a1:0a:a5:f5:f1:07:ca:8d:b3:9a:
                    ef:74:fb:a0:6c:72:4a:53:5c:59:74:6f:aa:c7:bc:
                    48:26:af:1b:70:f3:5f:7f:c7:df:8d:e5:da:e4:f4:
                    d2:fa:90:d3:e2:67:e1:9a:df:c7:c4:c7:53:6f:62:
                    25:ed:ff:0a:17:cf:8d:4d:84:6b:38:cb:49:e7:3d:
                    c5:2b:15:76:e6:eb:cc:17:94:40:20:7d:ee:8c:36:
                    6d:cf:9c:d7:1f:a6:41:20:9d:45:cd:57:8f:a8:61:
                    f8:8b:e9:31:6a:a9:96:c1:db:57:64:0b:09:da:ca:
                    b3:07:d9:55:ed:fe:69:a0:9c:78:5b:59:a5:7b:a1:
                    2b:4d:68:22:b4:7f:db:c6:c1:12:ee:eb:9b:29:38:
                    ae:7b:4c:0d:2a:ab:33:3f:af:a8:7b:ca:89:2c:62:
                    0f:a8:ef:89:60:9e:fd:a2:df:36:6d:70:82:8b:fa:
                    b3:ee:79:7e:fd:3f:e7:90:84:58:85:7e:7e:69:07:
                    1e:50:05:0b:87:4d:66:e4:17:6b:c2:97:03:48:e4:
                    7d:08:b4:81:a6:05:80:60:5c:eb:8d:53:db:7c:62:
                    a8:6d:a7:75:f1:56:b6:d9:0d:6b:3b:be:8b:72:39:
                    8d:e7:2d:77:74:e3:4d:a1:fd:8b:44:f9:ee:fd:0d:
                    04:ec:6a:fc:f3:d2:15:fc:18:ff:7d:33:44:2b:6d:
                    7f:3c:33:21:e1:d8:5f:08:fa:53:fd:26:fb:6e:74:
                    d7:4b:51:62:d3:15:1b:3b:44:78:78:9b:91:c7:ba:
                    82:2b:12:d7:b2:83:0a:39:ec:5e:a9:a9:c1:04:a6:
                    2e:64:a5:ea:15:c3:85:e9:ac:38:6b:22:eb:3b:08:
                    b8:0a:31:10:df:45:1d:76:81:e0:0f:88:e4:00:ef:
                    6e:90:59:8c:d8:36:e9:77:bf:4a:0e:3d:03:02:4d:
                    5d:a7:90:16:81:11:e0:81:bb:e0:18:a3:bb:dc:8d:
                    7d:c6:cf:c6:0b:d2:80:53:ea:d0:27:e6:6a:cc:8e:
                    2b:b3:72:e4:ab:84:88:e2:e9:a5:bb:72:9a:c6:a2:
                    0e:5a:cb
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Subject Key Identifier:
                EE:EF:AE:DB:73:45:9A:6E:82:00:3C:A7:05:0D:60:E4:20:81:3B:02
            X509v3 Authority Key Identifier:
                keyid:EE:EF:AE:DB:73:45:9A:6E:82:00:3C:A7:05:0D:60:E4:20:81:3B:02

            X509v3 Basic Constraints: critical
                CA:TRUE
    Signature Algorithm: sha256WithRSAEncryption
         98:cf:f4:23:61:d2:2a:64:ce:51:57:1d:fb:61:2f:34:68:86:
         c9:02:5a:c8:97:80:58:c1:7f:04:e1:97:f5:0b:35:d5:c4:91:
         fa:98:8c:73:16:43:b3:af:63:af:2c:30:cf:6a:8e:10:99:bc:
         fd:3d:84:c7:3d:01:e0:8d:8d:d8:76:74:12:69:1a:f5:e5:ec:
         ef:eb:dc:f8:08:0c:c7:03:19:de:c5:e8:c7:4e:b4:5c:67:39:
         9f:33:11:6f:29:e1:03:d8:4e:70:09:7a:69:bd:3a:db:96:71:
         2b:38:c4:46:87:f6:59:34:f9:dc:5c:6d:34:9a:ba:ea:36:13:
         d8:e3:e3:91:ea:70:3b:ea:39:cb:fc:fd:08:0f:73:e5:16:c3:
         0d:9a:62:20:3f:5a:28:90:e6:b2:65:23:a1:ba:d0:77:c0:8e:
         16:51:55:44:f6:4b:16:b9:a1:97:bc:f8:95:70:af:a6:d4:07:
         27:21:96:78:0b:58:18:51:45:a6:ea:07:c8:09:1b:ad:f3:e1:
         16:be:64:bf:8f:b7:4c:d1:e6:d0:c6:c1:db:cd:3d:e9:88:ec:
         e2:87:ff:bd:c3:7b:31:23:00:c3:71:53:90:68:46:99:7d:1d:
         e1:78:26:76:6a:41:8d:9e:9a:55:97:63:a5:df:86:fc:03:9b:
         28:13:55:ff:74:f2:56:d9:20:02:e8:c9:90:4f:b1:5d:1b:66:
         57:4e:f7:c6:50:4f:c9:8b:ff:39:a1:9e:b4:ee:2b:8a:bf:46:
         b4:3e:65:cb:34:12:73:bc:ae:ba:a5:41:20:d4:b9:c5:c4:da:
         89:bd:50:83:27:71:7a:9f:2c:3e:cf:de:db:13:b1:39:cf:4a:
         39:62:68:b3:f5:dc:49:44:3e:c1:cf:0c:a4:9a:4b:cb:5e:ec:
         aa:33:a5:57:ae:c6:f3:4f:69:01:d1:6a:a7:12:90:88:05:e9:
         18:d8:3a:a7:89:70:55:ab:18:ba:4f:28:74:5b:5f:21:8e:66:
         bc:ae:ff:1b:c7:ed:42:73:c1:1c:a4:97:f2:e6:c7:5a:8f:a8:
         44:a5:ed:b7:76:ac:cf:40:f0:a4:4f:22:03:d0:db:db:6e:18:
         32:33:4a:79:c2:bb:98:20:71:03:a7:9c:ea:4e:7e:0a:28:79:
         30:f3:3f:ef:03:b2:e0:00:b0:2b:71:27:8b:fc:f9:a0:e5:b9:
         a0:9e:6f:93:3a:f3:d3:1c:87:8a:b7:2d:5c:38:ab:f9:ff:39:
         8b:52:a5:9a:95:2f:a0:82:b9:b6:f8:9a:c3:e3:55:dd:4b:b5:
         e4:e3:fb:f8:8b:10:50:f8:42:7d:03:fe:72:40:c1:d3:f7:26:
         a7:f9:de:b9:9d:30:26:94

安装根证书

  首次打开刚刚生成的根证书 zhongerca.pem 会像下面这样显示“此根证书不被信任”,我们可以将下面的使用此证书时的使用系统默认改成始终信任,然后输入操作系统用户密码即可保存修改。改完之后再次打开如下下图所示,显示“此证书已标记为受此账户信任”。这样一来,由该根证书签发的证书就都会被信任了。

打开根证书 Open Root Certificate file

始终信任根证书 Always trust Root Certificate file

签发证书

  这里我们打算采用 jsha/minica 来辅助快速签发证书。

安装 minica

# Mac OS
brew install minica

# Other OS
go install github.com/jsha/minica@latest

签发 SSL 证书

# 给域名签发 SSL 证书
minica -ca-cert zhongerca.pem -ca-key zhonger-key.pem --domains "sni.lisz.me,zhonger.io,*.zhonger.io"

# 给 IP 签发 SSL 证书
minica -ca-cert zhongerca.pem -ca-key zhonger-key.pem --ip-addresses "127.0.0.1"

  minica 提供了非常简单的方式来签发 SSL 证书,比如说指定根证书和根密钥、指定单个或多个域名、通配符域名以及 IP。minica 签发的证书默认时效为 2年30天(相信可能是考虑到 30天 的缓冲期所以多了一个月)。这里,我们模仿了 Cloudflare 的 SSL 证书生成方式,第一个域名是 sni.根证书域名,第二个开始才是真正想要签发的域名。由于 minica 默认会将第一个域名作为文件夹的名字生成 SSL 证书 cert.pemkey.pem 文件,如果采取这种方式在同一目录执行以上签发命令势必会使得旧文件被覆盖,因此推荐像 Certonly 或者 acme.sh 那样修改目录名来区分。

其他

mkcert

  除了 minica 之外,FiloSottile/mkcert 提供的 mkcert 工具也非常简单方便。mkcert 会自行生成根证书,然后签发证书。个人觉得,相比 minica 而言,mkcert 更适合个人本地开发 HTTPS 化,而非团队内网 HTTPS 化。

根证书被伪造

  自生成根证书比较令人担心的地方可能就是任何人都可以用同样的方法伪造出相似的根证书。实际上,根证书是独一无二的,即使所有的信息都设置成一样,还是两个根证书。我们需要做的是:

  • 保护好根证书密钥,因为采用同一个密钥是可以生成比较相似的根证书的。当然两个根证书的序列 ID有效时间是不会完全一样的。如果密钥不同,自然两个根证书的密钥 ID 也不会相同。
  • 告诉用户真的根证书是什么样的(序列 ID、密钥 ID、有效时间等)、应该从哪里下载到。这里需要在内网建立一个用于提供下载根证书的站点,而这个站点的SSL 证书最好采用购买的或申请的证书。也就是说,从可靠站点下载的内网 HTTPS 化根证书也是可靠的。

  如下所示,是上面生成的根证书和签发的 SSL 证书的密钥 ID 对比。可以看到,两者完全一样,即可信任的 SSL 证书。

根证书密钥 ID Root Certificate Key ID

SSL 证书密钥 ID the Key ID in SSL Certificate

其他平台安装根证书

  上面已经提到了在 Mac OS 中如何安装根证书,其他平台比如 Windows、Linux、Android、IOS 等也是可以按照类似的方式,略微有些差别。考虑到 Windows 和 Linux 桌面版安装根证书的步骤几乎与 Mac OS 一样就不再赘述,这里主要讲一下 Linux Server、Android、IOS 平台的安装方法。

Linux Server

sudo cp zhongerca.pem /usr/local/share/ca-certificates/zhongerca.pem
sudo update-ca-certificates

Android 或 HarmonyOS

  以华为鸿蒙系统(HarmonyOS)为例,首先下载根证书到设备上,然后在 设置 > 安全 > 更多安全设置 > 加密和凭据 > 从存储设备上安装 中选择已下载的根证书完成安装。安装完成之后可以在同级别的 受信任的凭据 > 用户 下面看到安装好的根证书。

IOS 或 iPad OS

  首先下载根证书到设备上,在文件中点击打开(会自动跳转到 设置 > 通用 > VPN与设备管理)。可以在 配置描述文件 列表中看到根证书,点击进去输入密码并验证即可。

参考资料

❌
❌