使用 Zalando 将 PostgreSQL 部署到 GKE


本指南介绍如何使用 Zalando Postgres 运算符将 Postgres 集群部署到 Google Kubernetes Engine (GKE)。

PostgreSQL 是一种功能强大的开源对象关系型数据库系统,已持续数十年积极开发,使其在可靠性、功能稳健性和性能方面享有盛誉。

本指南适用于有兴趣将 PostgreSQL 作为数据库应用在 GKE 上运行(而不是使用 Cloud SQL for PostgreSQL)的平台管理员、云架构师和运维专业人员。

目标

  • 为 Postgres 规划和部署 GKE 基础架构
  • 部署和配置 Zalando Postgres 运算符
  • 使用运算符配置 Postgres,以确保可用性、安全性、可观测性和性能

优势

Zalando 具有以下优势:

  • 用于管理和配置 PostgreSQL 集群的声明式 Kubernetes 原生方法
  • Patroni 提供的高可用性
  • 使用 Cloud Storage 存储桶的备份管理支持
  • 对 Postgres 集群更改的滚动更新,包括快速次要版本更新
  • 使用自定义资源进行密码生成和轮替的声明式用户管理
  • TLS、证书轮替和连接池的支持
  • 集群克隆和数据复制

部署架构

在本教程中,您将使用 Zalando Postgres 运算符将一个高可用性 Postgres 集群部署和配置到 GKE。该集群具有一个主副本和两个备用(只读)副本(由 Patroni 管理)。Patroni 是由 Zalando 维护的开源解决方案,可为 Postgres 提供高可用性和自动故障切换功能。如果主副本发生故障,则一个备用副本会自动升级为主角色。

您还将部署 Postgres 的高可用性区域级 GKE 集群,其中多个 >Kubernetes 节点分布在多个可用区中。此设置有助于确保容错、可伸缩性和地理冗余。这样可支持滚动更新和维护,同时提供 SLA 以保证正常运行时间和可用性。如需了解详情,请参阅区域级集群

下图显示了在 GKE 集群中的多个节点和可用区上运行的 Postgres 集群:

在该图中,Postgres StatefulSet 部署在三个不同可用区的三个节点上。您可以通过以下方式控制 GKE 如何部署到节点:在 postgresql 自定义资源规范中设置所需的 Pod 亲和性和反亲和性规则。如果一个可用区发生故障,GKE 将使用推荐的配置在集群中的其他可用节点上重新调度 Pod。为了持久保留数据,您可使用 SSD 磁盘 (premium-rwo StorageClass),由于这类磁盘具备低延迟,高 IOPS 特征,因而在大多数情况下推荐用于高负载数据库。

费用

在本文档中,您将使用 Google Cloud 的以下收费组件:

您可使用价格计算器根据您的预计使用情况来估算费用。 Google Cloud 新用户可能有资格申请免费试用

完成本文档中描述的任务后,您可以通过删除所创建的资源来避免继续计费。如需了解详情,请参阅清理

准备工作

Cloud Shell 预安装了本教程所需的软件,包括 kubectlgcloud CLIHelmTerraform。如果您不使用 Cloud Shell,则必须安装 gcloud CLI。

  1. 登录您的 Google Cloud 账号。如果您是 Google Cloud 新手,请创建一个账号来评估我们的产品在实际场景中的表现。新客户还可获享 $300 赠金,用于运行、测试和部署工作负载。
  2. 安装 Google Cloud CLI。
  3. 如需初始化 gcloud CLI,请运行以下命令:

    gcloud init
  4. Create or select a Google Cloud project.

    • Create a Google Cloud project:

      gcloud projects create PROJECT_ID

      Replace PROJECT_ID with a name for the Google Cloud project you are creating.

    • Select the Google Cloud project that you created:

      gcloud config set project PROJECT_ID

      Replace PROJECT_ID with your Google Cloud project name.

  5. 确保您的 Google Cloud 项目已启用结算功能

  6. Enable the Compute Engine, IAM, GKE, Backup for GKE APIs:

    gcloud services enable compute.googleapis.com iam.googleapis.com container.googleapis.com gkebackup.googleapis.com
  7. 安装 Google Cloud CLI。
  8. 如需初始化 gcloud CLI,请运行以下命令:

    gcloud init
  9. Create or select a Google Cloud project.

    • Create a Google Cloud project:

      gcloud projects create PROJECT_ID

      Replace PROJECT_ID with a name for the Google Cloud project you are creating.

    • Select the Google Cloud project that you created:

      gcloud config set project PROJECT_ID

      Replace PROJECT_ID with your Google Cloud project name.

  10. 确保您的 Google Cloud 项目已启用结算功能

  11. Enable the Compute Engine, IAM, GKE, Backup for GKE APIs:

    gcloud services enable compute.googleapis.com iam.googleapis.com container.googleapis.com gkebackup.googleapis.com
  12. 向您的 Google 账号授予角色。对以下每个 IAM 角色运行以下命令一次: roles/storage.objectViewer, roles/container.admin, roles/iam.serviceAccountAdmin, roles/compute.admin, roles/gkebackup.admin, roles/monitoring.viewer

    gcloud projects add-iam-policy-binding PROJECT_ID --member="user:EMAIL_ADDRESS" --role=ROLE
    • PROJECT_ID 替换为您的项目 ID。
    • EMAIL_ADDRESS 替换为您的电子邮件地址。
    • ROLE 替换为每个角色。

设置您的环境

如需设置您的环境,请按以下步骤操作

  1. 设置环境变量:

    export PROJECT_ID=PROJECT_ID
    export KUBERNETES_CLUSTER_PREFIX=postgres
    export REGION=us-central1
    

    PROJECT_ID 替换为您的 Google Cloud 项目 ID

  2. 克隆 GitHub 代码库:

    git clone https://github.com/GoogleCloudPlatform/kubernetes-engine-samples
    
  3. 切换到工作目录:

    cd kubernetes-engine-samples/databases/postgres-zalando
    

创建集群基础架构

在本部分中,您将运行 Terraform 脚本以创建高可用性专用区域级 GKE 集群。

您可以使用 Standard 或 Autopilot 集群安装操作器。

Standard

下图显示了部署在三个不同可用区中的专用区域级 Standard GKE 集群:

部署此基础设施:

export GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token)
terraform -chdir=terraform/gke-standard init
terraform -chdir=terraform/gke-standard apply \
  -var project_id=${PROJECT_ID} \
  -var region=${REGION} \
  -var cluster_prefix=${KUBERNETES_CLUSTER_PREFIX}

出现提示时,请输入 yes。完成此命令并使集群显示就绪状态可能需要几分钟时间。

Terraform 会创建以下资源:

  • Kubernetes 节点的 VPC 网络和专用子网
  • 用于通过 NAT 访问互联网的路由器
  • 专用 GKE 集群(在 us-central1 区域中)
  • 启用了自动扩缩的节点池(每个可用区一个到两个节点,每个可用区最少一个节点)
  • 具有日志记录和监控权限的 ServiceAccount
  • 用于灾难恢复的 Backup for GKE
  • Google Cloud Managed Service for Prometheus,用于监控集群

输出类似于以下内容:

...
Apply complete! Resources: 14 added, 0 changed, 0 destroyed.
...

Autopilot

下图显示了专用区域级 Autopilot GKE 集群:

部署基础架构:

export GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token)
terraform -chdir=terraform/gke-autopilot init
terraform -chdir=terraform/gke-autopilot apply \
  -var project_id=${PROJECT_ID} \
  -var region=${REGION} \
  -var cluster_prefix=${KUBERNETES_CLUSTER_PREFIX}

出现提示时,请输入 yes。完成此命令并使集群显示就绪状态可能需要几分钟时间。

Terraform 会创建以下资源:

  • Kubernetes 节点的 VPC 网络和专用子网
  • 用于通过 NAT 访问互联网的路由器
  • 专用 GKE 集群(在 us-central1 区域中)
  • 具有日志记录和监控权限的 ServiceAccount
  • Google Cloud Managed Service for Prometheus,用于监控集群

输出类似于以下内容:

...
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
...

连接到集群

配置 kubectl 以与集群通信:

gcloud container clusters get-credentials ${KUBERNETES_CLUSTER_PREFIX}-cluster --region ${REGION}

将 Zalando 运算符部署到您的集群

使用 Helm 图表将 Zalando 运算符部署到您的 Kubernetes 集群。

  1. 添加 Zalando 运算符 Helm 图表代码库:

    helm repo add postgres-operator-charts https://opensource.zalando.com/postgres-operator/charts/postgres-operator
    
  2. 为 Zalando 运算符和 Postgres 集群创建命名空间:

    kubectl create ns postgres
    kubectl create ns zalando
    
  3. 使用 Helm 命令行工具部署 Zalando 运算符:

    helm install postgres-operator postgres-operator-charts/postgres-operator -n zalando \
        --set configKubernetes.enable_pod_antiaffinity=true \
        --set configKubernetes.pod_antiaffinity_preferred_during_scheduling=true \
        --set configKubernetes.pod_antiaffinity_topology_key="topology.kubernetes.io/zone" \
        --set configKubernetes.spilo_fsgroup="103"
    

    您无法直接在表示 Postgres 集群的自定义资源上配置 podAntiAffinity 设置。请改为在运算符设置中以全局方式为所有 Postgres 集群设置 podAntiAffinity 设置。

  4. 使用 Helm 检查 Zalando 运算符的部署状态:

    helm ls -n zalando
    

    输出类似于以下内容:

    NAME                 NAMESPACE    REVISION    UPDATED                                STATUS      CHART                       APP VERSION
    postgres-operator    zalando     1           2023-10-13 16:04:13.945614 +0200 CEST    deployed    postgres-operator-1.10.1    1.10.1
    

部署 Postgres

Postgres 集群实例的基本配置包括以下组件:

  • 三个 Postgres 副本:一个主副本和两个备用副本。
  • 一个 CPU 请求和两个 CPU 限制的 CPU 资源分配,具有 4 GB 内存请求和限制。
  • 为每个工作负载配置容忍设置、nodeAffinitiestopologySpreadConstraints,利用它们各自的节点池和不同的可用区来确保跨 Kubernetes 节点进行适当分布。

此配置表示创建可直接用于生产环境的 Postgres 集群所需的最少设置。

以下清单描述了一个 Postgres 集群:

apiVersion: "acid.zalan.do/v1"
kind: postgresql
metadata:
  name: my-cluster
spec:
  dockerImage: ghcr.io/zalando/spilo-15:3.0-p1
  teamId: "my-team"
  numberOfInstances: 3
  users:
    mydatabaseowner:
    - superuser
    - createdb
    myuser: []
  databases:
    mydatabase: mydatabaseowner
  postgresql:
    version: "15"
    parameters:
      shared_buffers: "32MB"
      max_connections: "10"
      log_statement: "all"
      password_encryption: scram-sha-256
  volume:
    size: 5Gi
    storageClass: premium-rwo
  enableShmVolume: true
  podAnnotations:
    cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
  tolerations:
  - key: "app.stateful/component"
    operator: "Equal"
    value: "postgres-operator"
    effect: NoSchedule
  nodeAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
    - weight: 1
      preference:
        matchExpressions:
        - key: "app.stateful/component"
          operator: In
          values:
          - "postgres-operator"
  resources:
    requests:
      cpu: "1"
      memory: 4Gi
    limits:
      cpu: "2"
      memory: 4Gi
  sidecars:
    - name: exporter
      image: quay.io/prometheuscommunity/postgres-exporter:v0.14.0
      args:
      - --collector.stat_statements
      ports:
      - name: exporter
        containerPort: 9187
        protocol: TCP
      resources:
        limits:
          cpu: 500m
          memory: 256M
        requests:
          cpu: 100m
          memory: 256M
      env:
      - name: "DATA_SOURCE_URI"
        value: "localhost/postgres?sslmode=require"
      - name: "DATA_SOURCE_USER"
        value: "$(POSTGRES_USER)"
      - name: "DATA_SOURCE_PASS"
        value: "$(POSTGRES_PASSWORD)"

此清单包含以下字段:

  • spec.teamId:您选择的集群对象的前缀
  • spec.numberOfInstances:集群的实例总数
  • spec.users:具有权限的用户列表
  • spec.databases:数据库列表,格式为 dbname: ownername
  • spec.postgresql:postgres 参数
  • spec.volume:永久性磁盘参数
  • spec.tolerations:容忍 Pod 模板,允许在 pool-postgres 节点上调度集群 Pod
  • spec.nodeAffinitynodeAffinity Pod 模板,向 GKE 告知首选在 pool-postgres 节点上调度集群 Pod。
  • spec.resources:集群 Pod 的请求和限制
  • spec.sidecars:Sidecar 容器列表,其中包含 postgres-exporter

如需了解详情,请参阅 Postgres 文档中的集群清单参考文档

创建基本 Postgres 集群

  1. 使用基本配置创建新的 Postgres 集群:

    kubectl apply -n postgres -f manifests/01-basic-cluster/my-cluster.yaml
    

    此命令会创建 Zalando 运算符的 PostgreSQL 自定义资源,其中包含以下内容:

    • CPU 和内存请求和限制
    • 在 GKE 节点之间分发预配的 Pod 副本的污点及亲和性。
    • 一个数据库
    • 两个具有数据库所有者权限的用户
    • 一个没有任何权限的用户
  2. 等待 GKE 启动所需工作负载:

    kubectl wait pods -l cluster-name=my-cluster  --for condition=Ready --timeout=300s -n postgres
    

    此命令可能需要几分钟时间才能完成。

  3. 验证 GKE 是否已创建 Postgres 工作负载:

    kubectl get pod,svc,statefulset,deploy,pdb,secret -n postgres
    

    输出类似于以下内容:

    NAME                                    READY   STATUS  RESTARTS   AGE
    pod/my-cluster-0                        1/1     Running   0         6m41s
    pod/my-cluster-1                        1/1     Running   0         5m56s
    pod/my-cluster-2                        1/1     Running   0         5m16s
    pod/postgres-operator-db9667d4d-rgcs8   1/1     Running   0         12m
    
    NAME                        TYPE        CLUSTER-IP  EXTERNAL-IP   PORT(S)   AGE
    service/my-cluster          ClusterIP   10.52.12.109   <none>       5432/TCP   6m43s
    service/my-cluster-config   ClusterIP   None        <none>      <none>  5m55s
    service/my-cluster-repl     ClusterIP   10.52.6.152 <none>      5432/TCP   6m43s
    service/postgres-operator   ClusterIP   10.52.8.176 <none>      8080/TCP   12m
    
    NAME                        READY   AGE
    statefulset.apps/my-cluster   3/3   6m43s
    
    NAME                                READY   UP-TO-DATE   AVAILABLE   AGE
    deployment.apps/postgres-operator   1/1     1           1           12m
    
    NAME                                                MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
    poddisruptionbudget.policy/postgres-my-cluster-pdb   1              N/A             0                   6m44s
    
    NAME                                                            TYPE                DATA   AGE
    secret/my-user.my-cluster.credentials.postgresql.acid.zalan.do  Opaque              2   6m45s
    secret/postgres.my-cluster.credentials.postgresql.acid.zalan.do   Opaque            2   6m44s
    secret/sh.helm.release.v1.postgres-operator.v1                  helm.sh/release.v1   1      12m
    secret/standby.my-cluster.credentials.postgresql.acid.zalan.do  Opaque              2   6m44s
    secret/zalando.my-cluster.credentials.postgresql.acid.zalan.do  Opaque              2   6m44s
    

操作器创建以下资源:

  • 一个 Postgres StatefulSet,控制 Postgres 的三个 Pod 副本
  • 一个 PodDisruptionBudgets,确保至少有一个可用副本
  • my-cluster Service,仅以主副本为目标
  • my-cluster-repl Service,公开 Postgres 端口以用于传入连接以及 Postgres 副本之间的复制
  • my-cluster-config 无头 Service,用于获取正在运行的 Postgres Pod 副本的列表
  • 包含用户凭据的 Secret,用于访问数据库以及在 Postgres 节点之间进行复制

向 Postgres 进行身份验证

您可以创建 Postgres 用户并为其分配数据库权限。例如,以下清单描述了一个分配用户和角色的自定义资源:

apiVersion: "acid.zalan.do/v1"
kind: postgresql
metadata:
  name: my-cluster
spec:
  ...
  users:
    mydatabaseowner:
    - superuser
    - createdb
    myuser: []
  databases:
    mydatabase: mydatabaseowner

在此清单中:

  • mydatabaseowner 用户具有 SUPERUSERCREATEDB 角色,这些角色允许获得完整管理员权限(例如管理 Postgres 配置、创建新数据库、表和用户)。您不应与客户端共享此用户。例如,Cloud SQL 不允许客户访问具有 SUPERUSER 角色的用户。
  • myuser 用户尚未分配角色。这遵循了使用 SUPERUSER 创建具有最小权限的用户的最佳实践。精细权限由 mydatabaseowner 授予给 myuser。为了维护安全性,您应该只与客户端应用共享 myuser 凭据。

存储密码

您应该使用 scram-sha-256 推荐方法存储密码。例如,以下清单描述了一个使用 postgresql.parameters.password_encryption 字段指定 scram-sha-256 加密的自定义资源:

apiVersion: "acid.zalan.do/v1"
kind: postgresql
metadata:
  name: my-cluster
spec:
  ...
  postgresql:
    parameters:
      password_encryption: scram-sha-256

轮替用户凭据

您可以使用 Zalando 轮替存储在 Kubernetes Secret 中的用户凭据。例如,以下清单描述了一个使用 usersWithSecretRotation 字段定义用户凭据轮替的自定义资源:

apiVersion: "acid.zalan.do/v1"
kind: postgresql
metadata:
  name: my-cluster
spec:
  ...
  usersWithSecretRotation:
  - myuser
  - myanotheruser
  - ...

身份验证示例:连接到 Postgres

本部分介绍如何部署示例 Postgres 客户端,并使用来自 Kubernetes Secret 的密码连接到数据库。

  1. 运行客户端 Pod 以与您的 Postgres 集群进行交互:

    kubectl apply -n postgres -f manifests/02-auth/client-pod.yaml
    

    myusermydatabaseowner 用户的凭据从相关 Secret 中获取,并作为环境变量装载到 Pod。

  2. 在 Pod 准备就绪后连接到 Pod:

    kubectl wait pod postgres-client --for=condition=Ready --timeout=300s -n postgres
    kubectl exec -it postgres-client -n postgres -- /bin/bash
    
  3. 使用 myuser 凭据连接到 Postgres 并尝试创建新表:

    PGPASSWORD=$CLIENTPASSWORD psql \
      -h my-cluster \
      -U $CLIENTUSERNAME \
      -d mydatabase \
      -c "CREATE TABLE test (id serial PRIMARY KEY, randomdata VARCHAR ( 50 ) NOT NULL);"
    

    该命令应该失败,并显示类似于以下内容的错误:

    ERROR:  permission denied for schema public
    LINE 1: CREATE TABLE test (id serial PRIMARY KEY, randomdata VARCHAR...
    

    该命令失败是因为默认情况下,未分配权限的用户只能登录 Postgres 和列出数据库。

  4. 使用 mydatabaseowner 凭据创建表,并将表的所有权限都授予给 myuser

    PGPASSWORD=$OWNERPASSWORD psql \
      -h my-cluster \
      -U $OWNERUSERNAME \
      -d mydatabase \
      -c "CREATE TABLE test (id serial PRIMARY KEY, randomdata VARCHAR ( 50 ) NOT NULL);GRANT ALL ON test TO myuser;GRANT ALL ON SEQUENCE test_id_seq TO myuser;"
    

    输出类似于以下内容:

    CREATE TABLE
    GRANT
    GRANT
    
  5. 使用 myuser 凭据将随机数据插入表中:

    for i in {1..10}; do
      DATA=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13)
      PGPASSWORD=$CLIENTPASSWORD psql \
      -h my-cluster \
      -U $CLIENTUSERNAME \
      -d mydatabase \
      -c "INSERT INTO test(randomdata) VALUES ('$DATA');"
    done
    

    输出类似于以下内容:

    INSERT 0 1
    INSERT 0 1
    INSERT 0 1
    INSERT 0 1
    INSERT 0 1
    INSERT 0 1
    INSERT 0 1
    INSERT 0 1
    INSERT 0 1
    INSERT 0 1
    
  6. 获取插入的值:

    PGPASSWORD=$CLIENTPASSWORD psql \
      -h my-cluster \
      -U $CLIENTUSERNAME \
      -d mydatabase \
      -c "SELECT * FROM test;"
    

    输出类似于以下内容:

    id |  randomdata
    ----+---------------
      1 | jup9HYsAjwtW4
      2 | 9rLAyBlcpLgNT
      3 | wcXSqxb5Yz75g
      4 | KoDRSrx3muD6T
      5 | b9atC7RPai7En
      6 | 20d7kC8E6Vt1V
      7 | GmgNxaWbkevGq
      8 | BkTwFWH6hWC7r
      9 | nkLXHclkaqkqy
     10 | HEebZ9Lp71Nm3
    (10 rows)
    
  7. 退出 Pod shell:

    exit
    

了解 Prometheus 如何收集 Postgres 集群的指标

下图展示了 Prometheus 指标收集的工作原理:

在该图中,GKE 专用集群包含:

  • Postgres Pod,用于通过路径 / 和端口 9187 收集指标
  • 基于 Prometheus 的收集器,用于处理 Postgres Pod 中的指标
  • 将指标发送到 Cloud Monitoring 的 PodMonitoring 资源

Google Cloud Managed Service for Prometheus 支持 Prometheus 格式的指标收集。Cloud Monitoring 使用集成式信息中心来处理 Postgres 指标。

Zalando 使用 postgres_exporter 组件作为 Sidecar 容器,采用 Prometheus 格式公开集群指标。

  1. 创建 PodMonitoring 资源,以按 labelSelector 爬取指标:

    kubectl apply -n postgres -f manifests/03-prometheus-metrics/pod-monitoring.yaml
    
  2. 在 Google Cloud 控制台中,转到 GKE 集群信息中心页面。

    转到 GKE 集群信息中心

    信息中心显示非零指标提取率。

  3. 在 Google Cloud 控制台中,转到信息中心页面。

    转到“信息中心”

  4. 打开 PostgreSQL Prometheus 概览信息中心。信息中心会显示提取的行数。信息中心可能需要几分钟时间才能完成自动预配。

  5. 连接到客户端 Pod:

    kubectl exec -it postgres-client -n postgres -- /bin/bash
    
  6. 插入随机数据:

    for i in {1..100}; do
      DATA=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13)
      PGPASSWORD=$CLIENTPASSWORD psql \
      -h my-cluster \
      -U $CLIENTUSERNAME \
      -d mydatabase \
      -c "INSERT INTO test(randomdata) VALUES ('$DATA');"
    done
    
  7. 刷新页面。 图表会更新,以显示实际数据库状态。

  8. 退出 Pod shell:

    exit
    

清理

删除项目

    删除 Google Cloud 项目:

    gcloud projects delete PROJECT_ID

删除各个资源

  1. 设置环境变量。

    export PROJECT_ID=${PROJECT_ID}
    export KUBERNETES_CLUSTER_PREFIX=postgres
    export REGION=us-central1
    
  2. 运行 terraform destroy 命令:

    export GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token)
    terraform  -chdir=terraform/FOLDER destroy \
      -var project_id=${PROJECT_ID} \
      -var region=${REGION} \
      -var cluster_prefix=${KUBERNETES_CLUSTER_PREFIX}
    

    FOLDER 替换为 gke-autopilotgke-standard

    出现提示时,请输入 yes

  3. 查找所有未挂接的磁盘:

    export disk_list=$(gcloud compute disks list --filter="-users:* AND labels.name=${KUBERNETES_CLUSTER_PREFIX}-cluster" --format "value[separator=|](name,zone)")
    
  4. 删除磁盘:

    for i in $disk_list; do
      disk_name=$(echo $i| cut -d'|' -f1)
      disk_zone=$(echo $i| cut -d'|' -f2|sed 's|.*/||')
      echo "Deleting $disk_name"
      gcloud compute disks delete $disk_name --zone $disk_zone --quiet
    done
    
  5. 删除 GitHub 代码库:

    rm -r ~/kubernetes-engine-samples/
    

后续步骤