应用管理 / 应用工作流#

应用的生命周期里要做的事情, 在这里按照时间顺序进行详述.

撰写 Helm Values#

每一个 lain 应用都是一个合法的 Helm App, 因此应用上平台的第一件事就是使用 lain init 初始化出一份默认的 Helm Chart. 作为开发者, 你需要关心的只有 chart/values.yaml, 请仔细阅读注释, 然后参考示范来为你的应用撰写配置:

# 应用名称, 如果不是有意要 hack, 绝对不要修改, 每一个 Kubernetes 资源的名称都通过这个 appname 计算而来
appname: {{ appname }}
# releaseName 就是 helm release name, 如果你想要把 app 部署两份, 则在其他 values 文件里超载该字段
# releaseName: {{ appname }}

# # 某些应用的容器数众多, 查 7d 数据的话, 会直接 timeout, 这时考虑缩短一些
# prometheus_query_range: 7d

# # 上线以后发送通知到指定的 webhook
# webhook:
#   # 目前仅支持 feishu, 需要先添加 webhook 机器人才行:
#   # https://www.feishu.cn/hc/zh-CN/articles/360024984973
#   url: https://open.feishu.cn/open-apis/bot/v2/hook/c057c484-9fd9-4ed9-83db-63e4b271de76
#   # 可选, 不写则默认所有集群上线都发送通知
#   clusters:
#     - yashi

# # 通用的环境变量写在这里
# env:
#   AUTH_TYPE: "basic"
#   BASIC_AUTH_USER: "admin"
# # 包含敏感信息的内容则由 lain env 命令来管理, 详见 lain env --help

# 在 volumes 下声明出 volume, 每一个 volume 都是一个可供挂载的文件或者目录
# 如果你的项目并不需要额外的自定义挂载, 那么 volumes 可以留空
# 不过在这里留空, 并不表示你的应用没有任何 volumes, 因为 lain 默认会赠送你 lain secret 的 volume, 写死在 helm chart 内了

# 比方说, 如果你要挂载 JuiceFS, 那就需要先声明出 volumes, 然后让 SA 帮你把目录提前做好 mkdir + chown

# volumes:
#   - name: jfs-backup-dir
#     hostPath:
#       path: "/jfs/backup/{{ appname }}/"
#       type: Directory  # 如果要挂载文件, 则写成 File

# 顾名思义, volumeMounts 就是将 volume 挂载到容器内
volumeMounts:
  # 如果 name 留空, 默认就是 {{ appname }}-secret 这个 volume, 你可以用 lain secret 来上传需要挂载进去的文件
  - subPath: topsecret.txt  # lain secret 里可以存放多份文件, subPath 就是其中的文件名
    mountPath: /lain/app/deploy/topsecret.txt  # mountPath 则用来控制, 该文件/目录要挂载到容器内的什么路径
#   # 如果你在 volumes 里声明了定制 volume, 那就需要写清楚 name 了
#   - name: jfs-backup-dir  # name 就是 volumes 里声明的 volume name, 如果留空, 就是 lain secret
#     mountPath: /jfs/backup/{{ appname }}/
#   # 用 persistentVolumeClaims 定义的 claim 在这里可以直接 mount
#   - name: juicefs-pvc
#     mountPath: /jfs/

# # 如果你的应用需要持久存储,并且 SA 告诉你需要用 PersistentVolumeClaim 来申请存储资源,那么需要在这里写上
# # persistentVolumeClaims 来声明使用,参数如何配置合适请咨询 SA。
# # 在这里定义的 claim 都会自动创建一个同名的 volume ,不需要在 volumes 里再写了。
# #(别忘了用 volumeMounts 挂载上,pod 里才能访问到)

# persistentVolumeClaims:
#   juicefs-pvc:
#     accessModes:
#       - ReadWriteMany
#     resources:
#       requests:
#         storage: 1Gi
#     storageClassName: juicefs-sc

# deployments 描述了你的应用有哪些进程 (lain 的世界里叫做 proc), 以及这些进程如何启动, 需要占用多少资源
deployments:
  web:
    env:
      FOO: BAR
    # 如果你真的需要, 当然也可以在 proc 级别定义 volumeMounts, 但一般而言为了方便管理, 请尽量都放在 global level
    # volumeMounts:
    #   - mountPath: /lain/app/deploy/topsecret.txt
    #     subPath: topsecret.txt
    # 开发阶段建议设置为单实例, 等顺利上线了, 做生产化梳理的时候, 再视需求进行扩容
    replicaCount: 1
    # hpa 用来自动扩缩容, 也就是 HorizontalPodAutoscaler, 详见:
    # https://unofficial-kubernetes.readthedocs.io/en/latest/tasks/run-application/horizontal-pod-autoscale-walkthrough/
    # hpa:
    #   # 默认 minReplicas 就是 replicaCount
    #   maxReplicas: 10
    #   # 默认的扩容规则是 80% 的 cpu 用量
    #   # 以下属于高级定制, 不需要的话就省略
    #   metrics: []  # https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#metricspec-v2beta2-autoscaling
    #   behavior: {}  # https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#horizontalpodautoscalerbehavior-v2beta2-autoscaling
    # 以 hard-code 方式指定 image, 而不是用 lain build 构建出镜像
    # image: kibana:7.5.0
    # 以 hard-code 方式指定 imageTag, 相当于还是在用该应用的镜像, 只是固定了版本
    # imageTag: specific-version
    # 为了支持同一个版本反复修改构建上线, 每次部署都会重新拉取镜像
    # imagePullPolicy: Always
    # lain 默认用 1001 这个低权限用户来运行你的应用, 如需切换成其他身份, 可以在 podSecurityContext 下声明 runAsUser
    # 比如用 root:
    # podSecurityContext: {'runAsUser': 0}
    podSecurityContext: {}
    # 有需要的话, 建议在 cluster values 里进行统一超载, 一般应用空间都统一用一个 sa 吧, 便于管理
    # serviceAccountName: default
    # 优雅退出时间默认 100 秒
    # terminationGracePeriodSeconds: 100
    # resources 用于声明资源的预期用量, 以及最大用量
    # 如果你不熟悉你的应用的资源使用表现, 可以先拍脑袋 requests 和 limits 写成一样
    # 运行一段时间以后, lain lint 会依靠监控数据, 计算给出修改建议
    resources:
      limits:
        # 1000m 相当于 1 核
        # ref: https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu
        cpu: 1000m
        # memory 千万不要写小 m 啊, m 是一个小的要死的单位, 写上去一定会突破容器的最低内存导致无法启动, 要写 M, Mi, G, Gi 这种才好
        # ref: https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory
        memory: 80Mi
      requests:
        cpu: 10m
        memory: 80Mi
    # 仅支持 exec 写法, 如果你用一个 shell 脚本作为执行入口, 可以搜索 bash, 这份模板下方会有示范
    command: ["/lain/app/run.py"]
    # 默认的工作目录是 /lain/app, 允许超载
    # workingDir: /lain/app
    # web 容器肯定要暴露端口, 对外提供服务
    # 这里为了书写方便, 和照顾大多数应用的习惯, 默认应用最多只需要暴露一个 TCP 端口
    containerPort: 5000
    # 如果该容器暴露了 prometheus metrics 接口的话, 则需要用 podAnnotations 来声明
    # 当然啦, 前提是集群里已经支持了 prometheus
    # podAnnotations:
    #   prometheus.io/scrape: 'true'
    #   prometheus.io/port: '9540'
    # 如果你的应用不走统一流量入口, 而是需要从上层 LB 别的端口走流量转发, 那么你需要:
    # * 声明 nodePort, 注意, 需要在 30000-32767 以内, Kubernetes 默认只让用大端口
    # * (可选地)声明 containerPort, 留空则与 nodePort 相同
    # * 需要联系 sa, 为这个端口特地设置一下流量转发
    # nodePort: 32001
    # protocol: TCP
    # 一些特殊应用可能需要使用 host network, 你就不要乱改了
    # hostNetwork: false
    # 对于需要暴露端口提供服务的容器, 一定要声明健康检查, 不会写的话请参考文档
    # https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-http-request
    # readinessProbe:
    #   httpGet:
    #     path: /my-healthcheck-api
    #     port: 5000
    #   initialDelaySeconds: 25
    #   periodSeconds: 2
    #   failureThreshold: 1
    # https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-command
    # livenessProbe:
    #   exec:
    #     command:
    #     - cat
    #     - /tmp/healthy
    #   initialDelaySeconds: 5
    #   periodSeconds: 5
    # https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-startup-probes
    # startupProbe:
    #   httpGet:
    #     path: /healthz
    #     port: liveness-port
    #   failureThreshold: 30
    #   periodSeconds: 10
    # 部署策略, 一般人当然用不到, 但若你的应用需要部署上百容器, 滚动升级的时候可能就需要微调, 否则容易产生上线拥堵, 压垮节点
    # https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy
    # minReadySeconds: 10
    # strategy:
    #   type: RollingUpdate
    #   rollingUpdate:
    #     maxSurge: 25%
    #     maxUnavailable: 25%
    # 配置节点亲和性: 如果声明了 nodes, 则该进程的容器仅会在指定的节点上运行
    # 这个字段由于是集群相关, 所以最好拆分到 values-[CLUSTER].yaml 里, 而不是直接写在 values.yaml
    # 具体节点名叫什么, 你需要用 kubectl get nodes 查看, 或者咨询 sa
    # nodes:
    # - node-1
    # - node-2
    # 除了节点亲和性之外, 如果还有其他的亲和性需求, 可以在这里声明自行书写
    # 参考: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/
    # affinity: {}

# # statefulSets 的大部分配置同 deployments, 此处仅列出有区别的部分
# statefulSets:
#   worker:
#     # sts 的部署策略写法与 deploy 略有不同
#     # https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#update-strategies
#     updateStrategy:
#       rollingUpdate:
#         partition: 0
#       type: RollingUpdate

# # jobs 会随着 lain deploy 一起创建执行
# # 各种诸如 env, resources 之类的字段都支持, 如果需要的话也可以单独超载
# jobs:
#   migration:
#     ttlSecondsAfterFinished: 86400  # https://kubernetes.io/docs/concepts/workloads/controllers/job/#clean-up-finished-jobs-automatically
#     activeDeadlineSeconds: 3600  # 超时时间, https://kubernetes.io/docs/concepts/workloads/controllers/job/#job-termination-and-cleanup
#     backoffLimit: 0  # https://kubernetes.io/docs/concepts/workloads/controllers/job/#pod-backoff-failure-policy
#     annotations:
#       # 一般来说, job 都会搭配 hooks 一起使用, 比方说 pre-upgrade 能保证 helm 在 upgrade 之前运行该 job, 不成功不继续进行部署
#       # 更多请见 https://helm.sh/docs/topics/charts_hooks/#the-available-hooks
#       "helm.sh/hook": post-install,pre-upgrade
#       "helm.sh/hook-delete-policy": before-hook-creation
#     command:
#       - 'bash'
#       - '-c'
#       - |
#         set -e
#         # 下方以数据库 migration 为例, 变更表结构之前, 先做数据库备份
#         # 如果需要用 jfs, 则需要在上方的 volumes / volumeMounts 里声明, 才能使用
#         mysqldump --default-character-set=utf8mb4 --single-transaction --set-gtid-purged=OFF -h$MYSQL_HOST -p$MYSQL_PASSWORD -u$MYSQL_USER $MYSQL_DB | gzip -c > /jfs/backup/{{ appname }}/$MYSQL_DB-backup.sql.gz
#         alembic upgrade heads

# 上线多了, 人喜欢 deploy 完了以后看都不看一眼就溜走, 导致线上挂了无法立刻获知
# 如果定义了 tests, 那么在 lain deploy 过后, 会自动执行 helm test, 失败的话立刻就能看见
# 如果啥 tests 都没写, lain deploy 过后会直接进入 lain status, 你也可以肉眼看到 url 和容器状态绿灯以后, 再结束上线任务
# tests:
#   simple-test:
#     image: docker.io/timfeirg/lain:latest
#     command:
#       - bash
#       - -ecx
#       - |
#         curl -v {{ appname }}-web

# cronjob 则是 Kubernetes 管理 job 的机制, 如果你的应用需要做定时任务, 则照着这里的示范声明出来
# cronjobs:
#   daily:
#     suspend: false  # 暂停该 cronjob
#     ttlSecondsAfterFinished: 86400  # https://kubernetes.io/docs/concepts/workloads/controllers/job/#clean-up-finished-jobs-automatically
#     activeDeadlineSeconds: 3600  # https://kubernetes.io/docs/concepts/workloads/controllers/job/#job-termination-and-cleanup
#     successfulJobsHistoryLimit: 1  # 保留多少个成功执行的容器
#     failedJobsHistoryLimit: 1  # 运行失败的话, 保留多少个出错容器
#     # 书写 schedule 的时候注意时区, 不同集群采用的时区可能不一样
#     # 如果你不确定自己面对的集群是什么时区, 可以登录到机器上, 用 date +"%Z %z" 打印一下
#     schedule: "0 17 * * *"
#     # 默认的定时任务调度策略是 Replace, 这意味着如果上一个任务还没执行完, 下一次 job 就开始了的话,
#     # 则用新的 job 来替代当前运行的 job.
#     # 声明 cronjob 的时候, 一定要注意合理配置资源分配和调度策略, 避免拖垮集群资源
#     # ref: https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/#concurrency-policy
#     concurrencyPolicy: Replace
#     # 重试次数, 默认不做任何重试, 如果你的应用能保证不因为资源问题失败, 可以加上宽容的重试
#     backoffLimit: 0
#     # 与其他资源类型不同, cronjobs 默认不重复拉取镜像, 这也是为了减少开销
#     imagePullPolicy: IfNotPresent
#     resources:
#       limits:
#         cpu: 1000m
#         memory: 1Gi
#       requests:
#         cpu: 1000m
#         memory: 1Gi
#     command: ["python3", "manage.py", "process_daily_stats_dag"]

# ingress 是 Kubernetes 的世界里负责描述域名转发规则的东西
# 一个 ingress rule 描述了一个域名要转发到哪个 Kubernetes service 下边
# 但是在 values.yaml 中, 已经贴心的帮你把生成 service 的细节写到 templates/service.yaml 这个模板里了
# 如果你想更进一步了解 service 是什么, 可以参看模板里的注释, 以及相应的 Kubernetes 文档:
# https://kubernetes.io/docs/concepts/services-networking/service/#motivation

# ingresses 用来声明内网域名
ingresses:
  # host 这个字段, 既可以写 subdomain (一般 appname), 在模板里会帮你展开成对应的集群内网域名
  # 也可以写完整的域名, 总之, 如果 host 里边发现了句点, 则作为完整域名处理
  - host: {{ appname }}
    # # 可以这样为该 ingress 定制 annotations
    # annotations:
    # 你想把这个域名的流量打到哪个 proc 上, 就在这里写哪个 proc 的名称
    deployName: web
    paths:
      - /

# externalIngresses 用来声明公网域名, 但是这个字段建议你写到 {{ chart_name }}/values-[CLUSTER].yaml 里, 毕竟这属于集群特定的配置
# externalIngresses:
#   # 这里需要写成完整的域名, 因为每个集群的公网域名都不一样, 模板不好帮你做补全
#   - host: [DOMAIN]
#     # 可以这样为该 ingress 定制 annotations
#     annotations:
#     deployName: web
#     paths:
#       - /

# 添加自定义 labels
labels: {}

# 一般没有人需要写这里的, 但本着模板精神, 还是放一个入口
# serviceAnnotations: {}

# ingressAnnotations / externalIngressAnnotations 里可以声明各种额外的 nginx 配置, 比方说强制 https 跳转
# 详见 https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#annotations
# ingressAnnotations:
#   nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# externalIngressAnnotations:
#   nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
#   nginx.ingress.kubernetes.io/server-snippet: |
#     location /api/internal {
#         return 404;
#     }

# # 如果你需要用到金丝雀, 则需要自己定义好金丝雀组
# # 详见 https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#canary
# canaryGroups:
#   internal:
#     # 内部分组, 当请求传入 canary: always 的时候, 便会把流量打到金丝雀版本
#     nginx.ingress.kubernetes.io/canary-by-header-value: canary
#   small:
#     # 第二组赋予金丝雀版本 10% 的流量
#     nginx.ingress.kubernetes.io/canary-weight: '10'
#   big:
#     nginx.ingress.kubernetes.io/canary-weight: '30'

# 如果你的应用不需要外网访问, 则 ingresses 这一块留空即可, 删了也没问题啦
# 别的应用如果需要在集群内访问 {{ appname }}, 可以直接通过 {{ appname }}-{{ deployName }} 来访问
# 只要你在 deployment 里声明了 containerPort, chart 模板就会帮你创建出免费的 service, 作为集群的内部访问域名

# 注入 /etc/hosts, 需要就写
hostAliases:
  - ip: "127.0.0.1"
    hostnames:
      - "localhost"

# 变态设计, 一个应用可以给自己指定额外的 envFrom, 以引用别的应用的环境变量, 一般人用不到的
# extraEnvFrom:
#   - secretRef:
#       name: another-env

build:
  base: python:latest
  # # build / prepare 下都可以声明 env, 会转化为 Dockerfile 里的 ENV clause, 这样一来, 镜像本身就会携带这些 ENV
  # # 可以在 value 中直接引用系统 env, lain 会进行解析, 传入 docker build 命令
  # env:
  #   PATH: "/lain/app/node_modules/.bin:${PATH}"
  prepare:
    # prepare 完成以后, 会删除 working directory 下所有的文件, 如果你有舍不得的东西, 记得在 keep 下声明, 才能保留下来
    # 比如前端项目, 一般会保留 node_modules
    keep:
    - treasure.txt
    script:
    # 凡是用包管理器安装依赖, 建议在 prepare.script 和 build.script 重复书写
    # 避免依赖文件更新了, 但没来得及重新 lain prepare, 导致依赖文件缺失
    - pip3 install -r requirements.txt
    - echo "treasure" > treasure.txt
  script:
  - pip3 install -r requirements.txt

# # 如果你的构建和运行环境希望分离, 可以用 release 步骤来转移构建产物
# # 一般是前端项目需要用到该功能, 因为构建镜像庞大, 构建产物(也就是静态文件)却很小
# release:
#   # env:
#   #   PATH: "/lain/app/node_modules/.bin:${PATH}"
#   dest_base: python:latest
#   copy:
#     - src: /etc/nginx
#     - src: /path
#       dest: /another

# # 如果你是一个敏感应用, 不希望被别的容器访问, 或者你希望限制你的应用做外部访问, 那么可以用 network policy 进行限制
# # ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/
# networkPolicy:
#   # network policy 作为一项高级功能, 直接暴露完整的 network policy spec, 未做任何封装
#   spec:
#     ingress:
#     # 默认的规则, 允许 ingress controller 访问, 这样你的应用才能将服务暴露到集群外
#     - from:
#       - namespaceSelector: {}
#         podSelector:
#           matchLabels:
#             # SA 注意, 这里的 labels 可能需要根据你集群的情况进行定制
#             app.kubernetes.io/name: ingress-nginx
#     podSelector:
#       matchLabels:
#         app.kubernetes.io/name: {{ appname }}
#     policyTypes:
#     - Ingress

多集群配置#

团队面对的肯定不止一个集群, 应用在不同集群需要书写不同的配置, 这也是天经地义. 幸好 helm 早就帮我们考虑好了这类需求, 可以书写多份 values.yaml, 在其中对需要的地方进行(递归地)超载. 用一个过度简化的例子来说明:

# chart/values.yaml
deployments:
  web:
    command: ["/lain/app/run.py"]

# chart/values-test.yaml
deployments:
  web:
    command: ["/lain/app/run.py", "--debug"]

上方示范的写法, 作用就是: 在 test 集群里修改 web 容器的启动命令, 加上 --debug 参数. 用类似这样的递归超载写法, 我们便可以随心所欲地为不同的集群定制任何配置, 比如:

  • 希望 a 进程仅在 test 集群运行: 把 deployments.a 的配置块整个挪到 values-test.yaml

  • 希望在不同集群用不同的构建命令: 没问题, 在 values-[CLUSTER].yaml 里书写定制的 build.script, 这样一来, 当你把 lain 指向该集群, 然后运行 lain build 的时候, 便会以递归覆盖后的 build 来渲染 Dockerfile, 这样一来就实现了不同集群的定制化构建.

用 docker-compose 进行本地调试#

写好了 values 之后, 可以用 lain compose 生成一份 docker-compose.yaml 样例, 方便你使用 docker-compose 来进行本地开发调试:

version: '3'
services:

  web:
    # lain push will overwrite the latest tag every time
    image: ccr.ccs.tencentyun.com/yashi/dummy:latest
    command:
      - /lain/app/run.py
    volumes:
      - .:/lain/app
    environment:
      FOO: BAR
    working_dir: lain/app
    # depends_on:
    #   - redis
    #   - mysql

  # redis:
  #   image: "redis:3.2.7"
  #   command: --databases 64

  # mysql:
  #   image: "mysql:8"
  #   command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
  #   environment:
  #     MYSQL_ROOT_PASSWORD: root
  #     MYSQL_DATABASE: dummy

配置文件虽然渲染出来了, 但由于 lain 并不清楚你本地的调试过程, 比如基础设施 (mysql, redis 等), 或者调试用的环境变量, 因此这些都需要你对 docker-compose.yaml 进行仔细 review 修改. 完成这步以后, 自行使用 docker-compose 进行本地调试即可. 在本地开发环境的事情上, lain 目前只能帮你到这一步了, 抱歉.

构建镜像#

lain build 无非就是用 values.build 的内容渲染出 Dockerfile, 然后直接为你执行相应的 docker build 命令. 以上边的 values 作为示范, 生成的 Dockerfile 如下:

FROM ccr.ccs.tencentyun.com/yashi/dummy:prepare AS build
WORKDIR /lain/app
ENV LAIN_META=1619925143-97f3d5f810a61823de72ea0a6f3fdd06f9f3cce9
ADD --chown=1001:1001 . /lain/app
RUN (pip3 install -r requirements.txt)
USER 1001

懂 Dockerfile 的人肯定一看就能明白这里做了些什么, 仅仅是把代码仓库拷贝到镜像里, 然后安装好 Python 依赖, 便是一个可以运行的镜像了. 不过这里的 FROM dummy:prepare 镜像是个啥? 是怎么来的呢? 那么再来介绍下 lain prepare:

应用的生命周期里要不停地修改代码, 重新构建镜像. 为了节约资源, 可以先把不常变动的部分做成一个 “prepare 镜像”, 再以该镜像为 base, 构建最终用于上线的镜像. 我们仍以上边的 dummy values 为例, prepare 镜像对应的 Dockerfile 如下:

FROM python:latest AS prepare
WORKDIR /lain/app
ADD --chown=1001:1001 . /lain/app
RUN (pip3 install -r requirements.txt) && (echo "treasure" > treasure.txt)
RUN rm -rf /tmp/* && mv treasure.txt /tmp/ &&  ls -A1 | xargs rm -rf && mv /tmp/treasure.txt .

构建完成以后, 如果不放心, 还可以用 lain run 来启动一个调试容器, 看看构建的结果是否正确:

$ lain run -- cat treasure.txt
docker run -it ccr.ccs.tencentyun.com/yashi/dummy:xxx cat treasure.txt
qxWGsNOpT

特别地, 由于 lain 支持集群定制配置, 因此应用当然也可以在不同集群使用不同的构建策略, 举个例子, 一个 python3.8 的应用想要在 test 集群使用 python3.9 镜像, 其他集群保持不变, 则可以这样超载:

# values-test.yaml
build:
  # 这样一来, 在 test 集群做 lain build, 用的就是 python:3.9
  base: python:3.9
  # 特意将 prepare 覆写为空, 否则在 values.yaml 里的 prepare 镜像仍会生效, 让最终构建得到的仍是 python3.8
  prepare:
  script:
    - apt-get update
    - pip3 install -r requirements.txt

Note

lain build 产生的镜像 tag 形如 1634199833-f02f668bbfb0a630e3d06dcd843a534c29015ca4, 这其实就是 git log -1 --pretty=format:%ct-%H 的输出结果. 之所以在头部放一个时间戳, 是为了方便镜像排序: 直接按照 ASCII 序排列, 就是镜像的时间序, 一下子就知道新旧了.

Warning

  • 如果你修改了 base, 请务必记得重新 lain prepare, 否则缓存一直不更新, 你的新 base 也不会生效. 当然, 如果你没有用到 prepare 功能, 忽略此提示.

ENV (环境变量) 管理#

按照心智负担顺序, 最简单办法便是把环境变量写到 values.yaml:

# global level
env:
  FOO: "bar"

在 global level 定义的 env, 将会应用于该应用的所有容器, 如果有定制需求, 那么也可以在 proc 级别定义 env:

# global level
env:
  FOO: "bar"

deployments:
  web:
    env:
      SPAM: egg

继续往下想, 一个 lain app 往往要部署在若干个不同的集群上, 如果不同的集群希望使用不同的配置, 可以考虑书写 values-[CLUSTER].yaml, 把需要超载的配置(递归地)写进去. 这点在 多集群配置 有更详细的介绍. 比方说在 prod 集群超载 deployments.web 的环境变量, 可以这样写:

# chart/values-prod.yaml
deployments:
  web:
    env:
      SPAM: EGG

上边介绍的各种写法, 都是在 values.yaml 里就能编辑和管理. 这样方便是很方便, 但密码类的敏感配置可不能这么随便写在 values.yaml 里, 毕竟代码仓库不应该包含敏感信息, 这时候就需要借助于 lain env 了:

$ lain env show
apiVersion: v1
data:
  MYSQL_PASSWORD: ***
kind: Secret
metadata:
  name: dummy-env
  namespace: default
type: Opaque
$ lain env edit  # 打开编辑器, 修改 data 下的内容, 就能编辑环境变量

lain env 中面对的 yaml, 是解密过的 Kubernetes Secret, 容器将会直接引用这个 Secret, 将 data 下的内容加载为环境变量. Secret 是一个 Kubernetes 资源, 因此有很多开发者并不关心的字段, 如果你不熟悉 Kubernetes, 那么只修改 data 下边的内容即可, 不要乱动别的内容.

配置文件管理#

lain env 相仿, 我们也有配套的功能来管理配置文件, 也就是 lain secret, 使用方法非常相似:

$ lain secret show
apiVersion: v1
data:
  topsecret.txt: |-
    I
    AM
    BATMAN
kind: Secret
metadata:
  name: dummy-secret
  namespace: default
type: Opaque
$ lain secret edit  # 编辑流程与 lain env 一样, 但要注意 yaml 语法, 别忘了冒号后边的竖线 |

在你初次使用 lain secret 的时候, 他会贴心地帮你生成一份默认的配置文件 topsecret.txt, 主要是为了防呆, 同时向大家示范 secret file 的书写语法.

总之, 现在配置文件已经写入集群, 那么如何挂载到容器内啊? 请看 values.yaml 示范:

# global level
volumeMounts:
  - mountPath: /lain/app/deploy/topsecret.txt
    subPath: topsecret.txt

deployments:
  web:
    # 如果你真的需要, 当然也可以在 proc 级别定义 volumeMounts, 但一般而言为了方便管理, 请尽量都放在 global level
    volumeMounts:
      - mountPath: /lain/app/deploy/topsecret.txt
        subPath: topsecret.txt

上边示范的写法, 就是在把 topsecret.txt 挂载到 /lain/app/deploy/topsecret.txt 这个目录. 至于 volumeMounts, subPath 等名词, 都是 底层的 Kubernetes 配置块, 如果你感到费解, 其实也不必深究, 按照这个示范格式来书写就不会有问题.

Warning

修改 env / secret 以后, 容器内的配置需要 Pod 重建以后才会生效! 重建 Pod 可以用以下办法:

  • lain --auto-pilot [secret|env] edit 会在编辑结束后, 自动优雅重启应用, 使配置生效. 详见 Auto Pilot.

  • 重新部署该应用的当前版本: lain redeploy, 如果你的应用做好了多实例和健康检查, 那多半是平滑上线的效果, 但某些情况下, Kubernetes 并不会重建这些 Pod (你可以用 lain status 来确认), 如果这种情况真的发生了, 那就只好用 lain restart 来重建容器了.

  • lain restart --graceful 将会挨个删除所有 Pod, 每删一个都会等待”绿灯”, 也就是观察到所有 Pod 都 up and running, 才会继续删除下一个 Pod.

部署上线, 以及生产化梳理#

如果之前的步骤都没做错, 那么 lain deploy 就能把你的应用部署到 Kubernetes 集群了, 以下是一些对新手的建议:

  • 第一次上线时, 建议以单实例部署(replicaCount: 1), 否则万一出了啥问题, 实例太多了怕不好排查.

  • 出问题的时候, lain status 会是你最好的朋友, 他会在命令行里打开一个综合的信息面板, 呈现出容器状态, 异常容器日志, 以及 ingress endpoint 的 HTTP 可访问性.

  • lain status 里也有显示日志的板块, 但很可能因为面板大小显示不全, 这时候就要用 lain logs 来阅读完整日志.

  • 同样为了方便排查, 可以考虑先删去 livenessProbe 配置, 否则应用不健康的时候, Kubernetes 会无限重启你的应用, 不太方便用 lain x 钻进容器排查.

  • 上线成功以后, 最好安排给应用做”生产化梳理”, 根据线上情况调整应用资源需求, 或者增加实例数.

    但毕竟你才刚上线, 没法立刻弄清 workload, 因此建议刚开始的时候, 把 memory limits 做宽松一些, 并且在稳定前, 时常运行 lain lint, 从监控系统获取数据, 让 lain 来帮你书写 resources. lain lint 会努力做到贴心智能, 让你不需要拍脑袋, 或者手动查询监控, 才能写好应用资源声明. 具体可以阅读 lain 如何管理资源?.

日志和监控#

lain logs 会调用 kubectl (或者更易用的 stern) 来为你实时地打印日志, 但若你想看历史日志, 那就需要你的集群搭建日志收集系统了. 在 集群配置 里声明 kibana, lain 就会在合适的时候, 提示用户使用 Kibana 来看日志了.

  • lain status -s 除了会一次性打印应用状态, 还会附上看日志的 Kibana URL (注意完整复制 URL, 不要漏了最后的括号)

  • 对于 job 或者 cronjob 进程, lain logs 可能没那么好用了, job 容器转瞬即逝, 而 lain logs 是实时日志, 运行的时候, 很可能容器早就回收了. 这种情况你只好去看 Kibana 了.

  • 在容器启动失败的情况下, stern 未必能获取到容器日志, 因为此时容器 stdout 还没来得及 attach 吧, 这种情况必须用 kubectl logs 才能顺利获取日志了. 这也是为什么 lain logs 默认调用的是 kubectl.

至于监控, lain 与 Prometheus 进行了强大的整合:

  • 如果集群部署了 Prometheus (并且 SA 做好了相应配置), 那么其实所有应用都”免费赠送”了监控, 不需要你特意做什么设置, 平台就会有容器基础指标的告警(比如容器 OOM, Crash), 以及流量入口的告警(比如大量 HTTP 5xx)

  • 如果集群支持, 你可以用 lain status -s 打印出 Grafana URL, 点击查看容器的基础指标监控图

  • lain status 里调用了 kubectl top pod, 打印出容器的资源占用.

  • lain lint 会帮你查询 Prometheus, 用实际资源占用, 对比你在 chart/values.yaml 里写的资源声明, 给出合适的修改建议. 详见 lain 如何管理资源?.

  • 如果免费赠送的监控满足不了你的应用, 那么可以在应用空间实现 metrics API, 让 Prometheus 来抓取. 为了让 Prometheus 来抓取你的应用自己的 metrics, 需要在 podAnnotations, 里做相应的配置声明, 具体就是 prometheus.io/scrape, prometheus.io/port 两个字段, 参考示范进行填写吧.

回滚#

回滚有很多种姿势, 每一种的适用场景略微有所不同:

  • 如果没什么特别需要, 只是线上应用版本错了, 需要回滚镜像, 那其实推荐直接再做一次上线 (lain deploy --set imageTag=xxx), 如果你不清楚 imageTag 是多少, 可以用 lain version 查看.

  • lain rollback 会直接调用 helm rollback, 所有 helm 管理的资源都会回滚.

    什么是”helm 管理的资源”? 简单来说就是除了 lain secret / env 以外, 你的应用的所有配置. 因此要注意, 如果你修改了 lain secret / env, 则应该先操作这部分配置回滚, 再重新上线, 才能让配置生效.

    可是这样做又和 lain deploy 有何不同呢? 区别如下:

    • helm rollback 会整体打包回滚 (lain secret / env 除外), 也就是说, 你对 chart 下所做的修改也会一并回滚, 比如 values.yaml.

    • lain deploy --set imageTag=xxx 会使用当前代码仓库下的 helm chart, 仅仅把 imageTag 用参数进行超载, 你在 values.yaml 里书写的配置, 都会生效, 不会被重置成旧版的状态.

  • 要知道, lain rollback 为了防呆和易用, 设计上只允许回滚一个版本. 如果你要回滚到多个版本, 可以这样做:

    • git checkout 到代码仓库中希望上线的版本.

    • 正常用 lain deploy 命令进行上线.

    你也可以直接用 helm rollback, 但请务必提前熟悉 helm 的使用.

查看部署历史#

你可以用 helm 方便地查看历次部署的信息:

# 打印出部署历史, 找出你关心的 revision, 把 id 复制一下
helm history APPNAME
# 用 id 查询部署配置, 比如镜像 tag, 操作者的 $USER, 都记录在这里
helm get values --revision=15 APPNAME

内置环境变量#

lain 会注入如下环境变量, 方便你使用:

  • LAIN_CLUSTER 就是当前容器所在的集群名. 你可以用 lain use 打印出所有的集群名.

  • K8S_NAMESPACE 用来标记容器所在的 Kubernetes Namespace, 如果 SA 没有特别配置, lain 鼓励直接用 default namespace.

  • IMAGE_TAG 就是镜像 tag, lain build 产生的镜像 tag, 其实就是 git log -1 --pretty=format:%ct-%H 的输出, 形如 1634699031-11766623c3f7773b4d1031060f6f2cc0603ace5f, 头部的时间戳是为了方便按照 commit 时间排序.

运行时的应用管理#

runtime 期间也有许许多多的事情需要开发者处理, 比如:

  • lain update-image 用来部署单个进程, 比如说你的应用有 web, worker 两个进程, 但只希望更新 worker 容器, 便可以用该命令实现.

  • lain restart 重启所有容器. 虽说是重启, 但其实是调用 kubectl delete pod 来删除容器, 然后 Kubernetes 会进行重建.

  • lain x 进入容器内执行命令, 该功能仅用于调试, 原则上不鼓励用于进行生产环境操作, 因为运行资源难以保证.

  • lain job 会启动一个 Kubernetes Job 容器, 来执行你给定的命令. 如果未给定命令, 则进入容器内, 打开 shell 进行交互操作.

以上也仅仅是对 lain 比较常用的功能做简单介绍. 需求千奇百怪, 在文档里也很难覆盖全, 建议你时不时阅读 lain --help, 来探索还有什么别的好用的功能.

与 CI 协同工作#

lain 的命令行属性使其天然适合于在 CI 里使用. 由于我们团队目前使用 GitLab CI, 下方的例子也都基于 GitLab CI.

构建 lain 镜像, 作为 GitLab CI Image#

下方就是用于构建 lain 镜像的 Dockerfile, 如你所见, 除了 lain 本人外, 还有各种乱七八糟的好东西, 比如 git, docker, mysql-client. 这个镜像是一个”瑞士军刀”, 尽可能去覆盖各种常用的 CI 需求, 这一切都是为了业务大哥们使用 CI 更方便.

FROM ubuntu:focal

ENV DEBIAN_FRONTEND=noninteractive LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 LAIN_IGNORE_LINT="true" PS1="lain# "

ARG HELM_VERSION=3.8.0
ARG PYTHON_VERSION_SHORT=3.9
ARG TRIVY_VERSION=0.23.0

WORKDIR /srv/lain

ADD docker-image/apt/sources.list /etc/apt/sources.list
RUN apt-get update && \
    apt-get install -y --no-install-recommends tzdata locales gnupg2 curl jq ca-certificates python${PYTHON_VERSION_SHORT} python3-pip && \
    ln -s -f /usr/bin/python${PYTHON_VERSION_SHORT} /usr/bin/python3 && \
    ln -s -f /usr/bin/python${PYTHON_VERSION_SHORT} /usr/bin/python && \
    ln -s -f /usr/bin/pip3 /usr/bin/pip && \
    ln -s -f /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
    sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \
    dpkg-reconfigure --frontend=noninteractive locales && \
    update-locale LANG=en_US.UTF-8 && \
    curl -L https://github.com/getsentry/sentry-cli/releases/download/2.3.0/sentry-cli-Linux-x86_64 --output /usr/local/bin/sentry-cli && \
    chmod +x /usr/local/bin/sentry-cli && \
    curl -LO https://github.com/aquasecurity/trivy/releases/download/v$TRIVY_VERSION/trivy_${TRIVY_VERSION}_Linux-64bit.deb && \
    dpkg -i trivy_${TRIVY_VERSION}_Linux-64bit.deb && \
    curl -LO https://mirrors.huaweicloud.com/helm/v${HELM_VERSION}/helm-v${HELM_VERSION}-linux-amd64.tar.gz && \
    tar -xvzf helm-v${HELM_VERSION}-linux-amd64.tar.gz && \
    mv linux-amd64/helm /usr/local/bin/helm && \
    chmod +x /usr/local/bin/helm && \
    rm -rf linux-amd64 *.tar.gz *.deb && \
    curl -fsSL http://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | apt-key add - && \
    curl https://mirrors.aliyun.com/kubernetes/apt/doc/apt-key.gpg | apt-key add - && \
    echo "deb https://mirrors.aliyun.com/kubernetes/apt/ kubernetes-xenial main" >> /etc/apt/sources.list.d/kubernetes.list && \
    echo "deb [arch=amd64] http://mirrors.aliyun.com/docker-ce/linux/ubuntu focal stable" >> /etc/apt/sources.list && \
    apt-get update && apt-get install -y \
    kubectl=1.20.11-00 python${PYTHON_VERSION_SHORT}-dev docker-ce-cli docker-compose mysql-client mytop libmysqlclient-dev redis-tools iputils-ping dnsutils \
    zip zsh fasd silversearcher-ag telnet rsync vim lsof tree openssh-client apache2-utils git git-lfs && \
    chsh -s /usr/bin/zsh root && \
    apt-get clean
ADD docker-image/.pip /root/.pip
COPY docker-image/git_askpass.sh /usr/local/bin/git_askpass.sh
ENV GIT_ASKPASS=/usr/local/bin/git_askpass.sh
COPY docker-image/.zshrc /root/.zshrc
COPY docker-image/requirements.txt /tmp/requirements.txt
COPY setup.py ./setup.py
COPY lain_cli ./lain_cli
RUN pip3 install -U -r /tmp/requirements.txt && \
    git init && \
    rm -rf /tmp/* .git

CMD ["bash"]

lain 镜像构建好了, 接下来需要在 GitLab CI Runner 配置里执行成为默认 image, 你不这么做也没事, 但那就要每一个 CI Job 都单独声明 Image 了.

concurrent = 4
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "ci-1"
  output_limit = 99999
  url = "https://gitlab.example.com"
  token = "xxx"
  executor = "docker"
  environment = ["DOCKER_AUTH_CONFIG={}"]
  # 这是为了在镜像里能顺利 docker push
  pre_build_script = "  mkdir -p $HOME/.docker\n  echo $DOCKER_AUTH_CONFIG > $HOME/.docker/config.json\n  "
  [runners.custom_build_dir]
  [runners.cache]
    Type = "s3"
    Path = "gitlab-runner"
    [runners.cache.s3]
      ServerAddress = "xxx"
      AccessKey = "xxx"
      SecretKey = "xxx"
      BucketName = "gitlab"
      BucketLocation = "xxx"
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "lain:latest"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/jfs:/jfs:rw", "/cache"]
    shm_size = 0
    helper_image = "gitlab-runner-helper:x86_64-bleeding"

在 GitLab CI 里使用 lain#

当你按照类似上边的步骤配置好 Runner 以后, 所有的 Job 都默认在用 lain image 来执行了. 镜像里已经安装了如此多工具, 因此书写 .gitlab-ci.yml 的时候, 内容相当简洁:

stages:
  - test
  - deploy

test_job:
  image: [APPNAME]:prepare
  stage: test
  script:
    - pytest tests

deploy_job:
  stage: deploy
  script:
    - lain use test
    - lain --auto-pilot deploy

prepare_job:
  # 依赖发生修改的时候, 重新 prepare, 为之后的构建做缓存
  stage: .post
  only:
    changes:
      - requirements*
  script:
    - lain use test
    - lain prepare

上边三个 Job, 每一个都仅有短短几行配置, 便完成了 CI 构建, 测试和上线的工作. 不过这仅仅是简单的示范, 你还可以参考 应用镜像的构建, 以及 CI 配置.

SCM MR 的功能补充#

lain 和 SCM 打交道 (虽然目前仅支持 GitLab), 因此也实现了不少 SCM 的周边功能, 比如:

  • lain assign-mr $CI_PROJECT_PATH $CI_MERGE_REQUEST_IID 可以为 MR 自动指派 Assignee 以及 Reviewer

  • lain wait-mr-approval $CI_PROJECT_PATH $CI_MERGE_REQUEST_IID 会等待 MR Approval, 当检测到 MR Approved 状态, 则命令成功返回. 这个命令放到 CI 里执行, 配合 “Pipeline 未成功, MR 不许合并”, 可以实现 MR Approval

这些功能的由来, 都是 GitLab 缺少某些特性, 或者说 CE 版本不提供, 只能利用其 API 进行自研. 比方说 GitLab 不支持自动指派 MR Reviewer, 而且 CE 版本不支持 MR Approval.

lain assign-mr 会从代码仓库的贡献者里随机挑选两位 GitLab 用户(需要是 Active 状态), 然后分别指派为 Assignee 和 Reviewer. 这个设计的初衷如下:

  • 大家创建 MR 的时候, 一般都会按照代码本身的情况, 来手动来指定 Assignee 和 Reviewers, 但如果此人犯懒, 没有做任何指派, 那么 lain assign-mr 就可以在最后关头介入, 做一个兜底, 让该 MR 不至于”没人照顾”

  • GitLab 不支持指派多个 Reviewer, 但我们团队又希望能促进大家积极 Review, 因此只好将 Assignee 也用上, lain assign-mr 如果将你指派为 Assignee, 并不是说这个 MR 就交给你负责了(我们团队目前也并不存在 “MR 负责人”的概念), 而仅仅是希望你参与进 Review

以 GitLab CI 为例, 给出一份完整示范:

check_ci_approval:
  stage: check_ci_approval
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - lain use test
    - lain assign-mr $CI_PROJECT_PATH $CI_MERGE_REQUEST_IID
    - lain wait-mr-approval $CI_PROJECT_PATH $CI_MERGE_REQUEST_IID
  interruptible: true

打开 Docker daemon 的 TCP 监听, 支持 –remote-docker#

CI 机器上做了这么多构建, 天然有不少镜像缓存, 因此十分适合开启 TCP 监听, 允许远程调用. 如果集群的网络状况合适(指的是网络安全方面), 可以仿照下方的示范开启:

$ systemctl cat docker | grep -i execstart
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock  -H tcp://0.0.0.0:2375
# 确认开启监听
$ telnet localhost 2375

确认开启以后, 还需要在 集群配置 添加相应的设置, 也就是:

# lain_cli/cluster_values/values-test.yaml
remote_docker: xxx.xxx.xxx.xxx:2375

添加完毕以后, 发版更新 lain, 然后就能用 lain --remote-docker 了, 各种与 docker 相关的操作, 都会自动用 CI 机器上的 docker daemon, 极大节约本地资源.

用三方安全检查工具进行镜像扫描#

lain 本身没有什么漏扫的功能, 但你可以用 $(lain image) 轻松获得镜像 tag, 然后传参给第三方漏扫工具, 比如 Trivy:

# 此处以 gitlab-ci 为例, 默认使用 lain image 作为 executor, 里边预装了 trivy
build_job:
  variables:
    TRIVY_CACHE_DIR: /jfs/trivycache  # 用 JuiceFS 来共享缓存
  script:
    - lain use test
    - lain build --push
    - export IMAGE="$(lain image)"
    # 为了加速运行, 我们不在 job 里更新 trivy db, 而是在别处用专门的定时任务来做定期更新
    - trivy --cache-dir $TRIVY_CACHE_DIR image --skip-db-update=true --exit-code 1 --severity CRITICAL "${IMAGE}"

lain 镜像的其他用途#

lain image 的存在几乎完全是为了简化 CI 的使用, 但如果你的程序要以某种方式来使用 lain, 也可以考虑直接采用 lain image 作为你的 base 镜像.

同时, 由于 lain image 里边富集了如此多的生产力工具, 你甚至可以直接在 lain 容器里使用 lain, 只需要类似这样的小脚本就够了:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

IMAGE='lain:latest'
CONTAINER_NAME=lain

if [[ -z "$@" ]]; then
  cmd=zsh
else
  cmd="$@"
fi

docker pull $IMAGE

set +e
docker run -it --rm \
  --name lain \
  --net host \
  -v "$PWD:/src" \
  -v "/var/run/docker.sock:/var/run/docker.sock" \
  -v /tmp:/tmp \
  -w /src \
  -e TERM=xterm \
  "${IMAGE}" zsh -c $cmd

正常情况下还是不推荐在本地用 lain image 的, 最好还是乖乖用 pip 安装, 毕竟这样更快捷.

Review 与审计#

团队做事情, 肯定少不了 Review 和审计, 这里介绍 lain 体系下的一些实践:

项目配置的维护和 Review#

简单讲, 就是尽量把配置放在代码仓库, 这样就能随着 MR 进行 Review. 如果各种环境变量和应用配置都存放在 lain [env|secret], 那就没有审查的机会了, 这点在 ENV (环境变量) 管理, 配置文件管理 里也有介绍到. 总而言之, 非敏感信息尽量在代码库里维护. 当然, 若你是 One Man Project, 自己看着办即可, 不一定要遵循最佳实践.

那么 lain secret / env 就完全无法审计了吗? 那也不至于, lain 做了简易的修改通知功能: 在 values.yaml 里设置 webhook, 那么后续有人操作 lain secret / env 的时候, lain 会将修改的部分 (只有 keys, 没有携带敏感内容的 values) 发送 webhook notification, 让团队知悉你的改动.

定位某次部署的操作者(是谁上线的)#

作为最佳实践, 所有生产性质的项目都应该声明 webhook, 这样一来, 每次 lain deploy, lain (secret|env) edit 都会发送通知, 记录操作者和其他信息.

但如果要追溯历史操作, 可以直接用 helm:

# 查看最近的一次部署是谁操作的
helm get values APPNAME | grep "^user: "
# 如果要查看某一次历史部署是谁操作的, 则需用这个命令先罗列出所有的 helm release
helm history APPNAME
# 选择自己感兴趣的 release, 用 id 进行查询, user 这个字段就是上线人的本地 $USER 环境变量
helm get values --revision=15 APPNAME | grep "^user: "