使用Tekton重构自动化流水线

使用 Tekton 重构自动化流水线

作者:阳明 2021-06-28 06:32:46

云计算

自动化 在 Tekton 中我们就可以将这些阶段直接转换成 Task 任务,clone 代码在 Tekton 中不需要我们主动定义一个任务,只需要在执行的任务上面指定一个输入的代码资源即可

前面我们讲解了使用 Jenkins 流水线来实现 Kubernetes 应用的 CI/CD,现在我们来将这个流水线迁移到 Tekton 上面来,其实整体思路都是一样的,就是把要整个工作流划分成不同的任务来执行,前面工作流的阶段划分了以下几个阶段:Clone 代码 -> 单元测试 -> Golang 编译打包 -> Docker 镜像构建/推送 -> Kubectl 部署服务。

在 Tekton 中我们就可以将这些阶段直接转换成 Task 任务,Clone 代码在 Tekton 中不需要我们主动定义一个任务,只需要在执行的任务上面指定一个输入的代码资源即可。下面我们就来将上面的工作流一步一步来转换成 Tekton 流水线,代码仓库同样还是 http://git.k8s.local/course/devops-demo.git。

Clone 代码

虽然我们可以不用单独定义一个 Clone 代码的任务,直接使用 git 类型的输入资源即可,由于这里涉及到的任务较多,而且很多时候都需要先 Clone 代码然后再进行操作,所以最好的方式是将代码 Clone 下来过后通过 Workspace 共享给其他任务,这里我们可以直接使用 Catalog git-clone 来实现这个任务,我们可以根据自己的需求做一些定制,对应的 Task 如下所示:

  
 
 
 
  1. # task-clone.yaml 
  2. apiVersion: tekton.dev/v1beta1 
  3. kind: Task 
  4. metadata: 
  5.   name: git-clone 
  6. spec: 
  7.   workspaces: 
  8.     - name: output 
  9.       description: The git repo will be cloned onto the volume backing this Workspace. 
  10.     - name: basic-auth 
  11.       optional: true 
  12.       description: | 
  13.         A Workspace containing a .gitconfig and .git-credentials file. These 
  14.         will be copied to the user's home before any git commands are run. Any 
  15.         other files in this Workspace are ignored. It is strongly recommended 
  16.         to use ssh-directory over basic-auth whenever possible and to bind a 
  17.         Secret to this Workspace over other volume types. 
  18.   params: 
  19.     - name: url 
  20.       description: Repository URL to clone from. 
  21.       type: string 
  22.     - name: revision 
  23.       description: Revision to checkout. (branch, tag, sha, ref, etc...) 
  24.       type: string 
  25.       default: "" 
  26.     - name: refspec 
  27.       description: Refspec to fetch before checking out revision. 
  28.       default: "" 
  29.     - name: submodules 
  30.       description: Initialize and fetch git submodules. 
  31.       type: string 
  32.       default: "true" 
  33.     - name: depth 
  34.       description: Perform a shallow clone, fetching only the most recent N commits. 
  35.       type: string 
  36.       default: "1" 
  37.     - name: sslVerify 
  38.       description: Set the `http.sslVerify` global git config. Setting this to `false` is not advised unless you are sure that you trust your git remote. 
  39.       type: string 
  40.       default: "true" 
  41.     - name: subdirectory 
  42.       description: Subdirectory inside the `output` Workspace to clone the repo into. 
  43.       type: string 
  44.       default: "" 
  45.     - name: sparseCheckoutDirectories 
  46.       description: Define the directory patterns to match or exclude when performing a sparse checkout. 
  47.       type: string 
  48.       default: "" 
  49.     - name: deleteExisting 
  50.       description: Clean out the contents of the destination directory if it already exists before cloning. 
  51.       type: string 
  52.       default: "true" 
  53.     - name: verbose 
  54.       description: Log the commands that are executed during `git-clone`'s operation. 
  55.       type: string 
  56.       default: "true" 
  57.     - name: gitInitImage 
  58.       description: The image providing the git-init binary that this Task runs. 
  59.       type: string 
  60.       default: "cnych/tekton-git-init:v0.24.1" 
  61.     - name: userHome 
  62.       description: | 
  63.         Absolute path to the user's home directory. Set this explicitly if you are running the image as a non-root user or have overridden 
  64.         the gitInitImage param with an image containing custom user configuration. 
  65.       type: string 
  66.       default: "/root" 
  67.   results: 
  68.     - name: commit 
  69.       description: The precise commit SHA that was fetched by this Task. 
  70.     - name: url 
  71.       description: The precise URL that was fetched by this Task. 
  72.   steps: 
  73.     - name: clone 
  74.       image: "$(params.gitInitImage)" 
  75.       env: 
  76.       - name: HOME 
  77.         value: "$(params.userHome)" 
  78.       - name: PARAM_URL 
  79.         value: $(params.url) 
  80.       - name: PARAM_REVISION 
  81.         value: $(params.revision) 
  82.       - name: PARAM_REFSPEC 
  83.         value: $(params.refspec) 
  84.       - name: PARAM_SUBMODULES 
  85.         value: $(params.submodules) 
  86.       - name: PARAM_DEPTH 
  87.         value: $(params.depth) 
  88.       - name: PARAM_SSL_VERIFY 
  89.         value: $(params.sslVerify) 
  90.       - name: PARAM_SUBDIRECTORY 
  91.         value: $(params.subdirectory) 
  92.       - name: PARAM_DELETE_EXISTING 
  93.         value: $(params.deleteExisting) 
  94.       - name: PARAM_VERBOSE 
  95.         value: $(params.verbose) 
  96.       - name: PARAM_SPARSE_CHECKOUT_DIRECTORIES 
  97.         value: $(params.sparseCheckoutDirectories) 
  98.       - name: PARAM_USER_HOME 
  99.         value: $(params.userHome) 
  100.       - name: WORKSPACE_OUTPUT_PATH 
  101.         value: $(workspaces.output.path) 
  102.       - name: WORKSPACE_BASIC_AUTH_DIRECTORY_BOUND 
  103.         value: $(workspaces.basic-auth.bound) 
  104.       - name: WORKSPACE_BASIC_AUTH_DIRECTORY_PATH 
  105.         value: $(workspaces.basic-auth.path) 
  106.       script: | 
  107.         #!/usr/bin/env sh 
  108.         set -eu 
  109.  
  110.         if [ "${PARAM_VERBOSE}" = "true" ] ; then 
  111.           set -x 
  112.         fi 
  113.  
  114.         if [ "${WORKSPACE_BASIC_AUTH_DIRECTORY_BOUND}" = "true" ] ; then 
  115.           cp "${WORKSPACE_BASIC_AUTH_DIRECTORY_PATH}/.git-credentials" "${PARAM_USER_HOME}/.git-credentials" 
  116.           cp "${WORKSPACE_BASIC_AUTH_DIRECTORY_PATH}/.gitconfig" "${PARAM_USER_HOME}/.gitconfig" 
  117.           chmod 400 "${PARAM_USER_HOME}/.git-credentials" 
  118.           chmod 400 "${PARAM_USER_HOME}/.gitconfig" 
  119.         fi 
  120.  
  121.         CHECKOUT_DIR="${WORKSPACE_OUTPUT_PATH}/${PARAM_SUBDIRECTORY}" 
  122.  
  123.         cleandir() { 
  124.           # Delete any existing contents of the repo directory if it exists. 
  125.           # 
  126.           # We don't just "rm -rf ${CHECKOUT_DIR}" because ${CHECKOUT_DIR} might be "/" 
  127.           # or the root of a mounted volume. 
  128.           if [ -d "${CHECKOUT_DIR}" ] ; then 
  129.             # Delete non-hidden files and directories 
  130.             rm -rf "${CHECKOUT_DIR:?}"/* 
  131.             # Delete files and directories starting with . but excluding .. 
  132.             rm -rf "${CHECKOUT_DIR}"/.[!.]* 
  133.             # Delete files and directories starting with .. plus any other character 
  134.             rm -rf "${CHECKOUT_DIR}"/..?* 
  135.           fi 
  136.         } 
  137.  
  138.         if [ "${PARAM_DELETE_EXISTING}" = "true" ] ; then 
  139.           cleandir 
  140.         fi 
  141.  
  142.         /ko-app/git-init \ 
  143.           -url="${PARAM_URL}" \ 
  144.           -revision="${PARAM_REVISION}" \ 
  145.           -refspec="${PARAM_REFSPEC}" \ 
  146.           -path="${CHECKOUT_DIR}" \ 
  147.           -sslVerify="${PARAM_SSL_VERIFY}" \ 
  148.           -submodules="${PARAM_SUBMODULES}" \ 
  149.           -depth="${PARAM_DEPTH}" \ 
  150.           -sparseCheckoutDirectories="${PARAM_SPARSE_CHECKOUT_DIRECTORIES}" 
  151.         cd "${CHECKOUT_DIR}" 
  152.         RESULT_SHA="$(git rev-parse HEAD)" 
  153.         EXIT_CODE="$?" 
  154.         if [ "${EXIT_CODE}" != 0 ] ; then 
  155.           exit "${EXIT_CODE}" 
  156.         fi 
  157.         printf "%s" "${RESULT_SHA}" > "$(results.commit.path)" 
  158.         printf "%s" "${PARAM_URL}" > "$(results.url.path)" 

一般来说我们只需要提供 output 这个个用于持久化代码的 workspace,然后还包括 url 和 revision 这两个参数,其他使用默认的即可。

单元测试

单元测试阶段比较简单,正常来说也是只是单纯执行一个测试命令即可,我们这里没有真正执行单元测试,所以简单测试下即可,编写一个如下所示的 Task:

  
 
 
 
  1. # task-test.yaml 
  2. apiVersion: tekton.dev/v1beta1 
  3. kind: Task 
  4. metadata: 
  5.   name: test 
  6. spec: 
  7.   steps: 
  8.     - name: test 
  9.       image: golang:1.14-alpine 
  10.       command: ['echo'] 
  11.       args: ['this is a test task'] 

编译打包

然后第二个阶段是编译打包阶段,因为我们这个项目的 Dockerfile 不是使用的多阶段构建,所以需要先用一个任务去将应用编译打包成二进制文件,然后将这个编译过后的文件传递到下一个任务进行镜像构建。

我们已经明确了这个阶段要做的事情,编写任务也就简单了,创建如下所的 Task 任务,首先需要通过定义一个 workspace 把 clone 任务里面的代码关联过来:

  
 
 
 
  1. # task-build.yaml 
  2. apiVersion: tekton.dev/v1beta1 
  3. kind: Task 
  4. metadata: 
  5.   name: build 
  6. spec: 
  7.   workspaces: 
  8.     - name: go-repo 
  9.       mountPath: /workspace/repo 
  10.   steps: 
  11.     - name: build 
  12.       image: golang:1.14-alpine 
  13.       workingDir: /workspace/repo 
  14.       script: | 
  15.         go build -v -o app 
  16.       env: 
  17.         - name: GOPROXY 
  18.           value: https://goproxy.cn 
  19.         - name: GOOS 
  20.           value: linux 
  21.         - name: GOARCH 
  22.           value: amd64 

这个构建任务也很简单,只是我们将需要用到的环境变量直接通过 env 注入了,当然直接写入到 script 中也是可以的,或者直接使用 command 来执行任务都可以,然后构建生成的 app 这个二进制文件保留在代码根目录,这样也就可以通过 workspace 进行共享了。

Docker 镜像

接下来就是构建并推送 Docker 镜像了,前面我们介绍过使用 Kaniko、DooD、DinD 3种模式的镜像构建方式,这里我们直接使用 DinD 这种模式,我们这里要构建的镜像 Dockerfile 非常简单:

  
 
 
 
  1. FROM alpine 
  2. WORKDIR /home 
  3.  
  4. # 修改alpine源为阿里云 
  5. RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories && \ 
  6.   apk update && \ 
  7.   apk upgrade && \ 
  8.   apk add ca-certificates && update-ca-certificates && \ 
  9.   apk add --update tzdata && \ 
  10.   rm -rf /var/cache/apk/* 
  11.  
  12. COPY app /home/ 
  13. ENV TZ=Asia/Shanghai 
  14.  
  15. EXPOSE 8080 
  16.  
  17. ENTRYPOINT ./app 

就行直接将编译好的二进制文件拷贝到镜像中即可,所以我们这里同样需要通过 Workspace 去获取上一个构建任务的制品,当然要使用 DinD 模式构建镜像,需要用到 sidecar 功能,创建一个如下所示的任务:

  
 
 
 
  1. # task-docker.yaml 
  2. apiVersion: tekton.dev/v1beta1 
  3. kind: Task 
  4. metadata: 
  5.   name: docker 
  6. spec: 
  7.   workspaces: 
  8.     - name: go-repo 
  9.   params: 
  10.     - name: image 
  11.       description: Reference of the image docker will produce. 
  12.     - name: registry_mirror 
  13.       description: Specific the docker registry mirror 
  14.       default: "" 
  15.     - name: registry_url 
  16.       description: private docker images registry url 
  17.   steps: 
  18.     - name: docker-build # 构建步骤 
  19.       image: docker:stable 
  20.       env: 
  21.         - name: DOCKER_HOST # 用 TLS 形式通过 TCP 链接 sidecar 
  22.           value: tcp://localhost:2376 
  23.         - name: DOCKER_TLS_VERIFY # 校验 TLS 
  24.           value: "1" 
  25.         - name: DOCKER_CERT_PATH # 使用 sidecar 守护进程生成的证书 
  26.           value: /certs/client 
  27.         - name: DOCKER_PASSWORD 
  28.           valueFrom: 
  29.             secretKeyRef: 
  30.               name: harbor-auth 
  31.               key: password 
  32.         - name: DOCKER_USERNAME 
  33.           valueFrom: 
  34.             secretKeyRef: 
  35.               name: harbor-auth 
  36.               key: username 
  37.       workingDir: $(workspaces.go-repo.path) 
  38.       script: | # docker 构建命令 
  39.         docker login $(params.registry_url) -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 
  40.         docker build --no-cache -f ./Dockerfile -t $(params.image) . 
  41.         docker push $(params.image) 
  42.       volumeMounts: # 声明挂载证书目录 
  43.         - mountPath: /certs/client 
  44.           name: dind-certs 
  45.   sidecars: # sidecar 模式,提供 docker daemon服务,实现真正的 DinD 模式 
  46.     - image: docker:dind 
  47.       name: server 
  48.       args: 
  49.         - --storage-driver=vfs 
  50.         - --userland-proxy=false 
  51.         - --debug 
  52.         - --insecure-registry=$(params.registry_url) 
  53.         - --registry-mirror=$(params.registry_mirror) 
  54.       securityContext: 
  55.         privileged: true 
  56.       env: 
  57.         - name: DOCKER_TLS_CERTDIR # 将生成的证书写入与客户端共享的路径 
  58.           value: /certs 
  59.       volumeMounts: 
  60.         - mountPath: /certs/client 
  61.           name: dind-certs 
  62.       readinessProbe: # 等待 dind daemon 生成它与客户端共享的证书 
  63.         periodSeconds: 1 
  64.         exec: 
  65.           command: ["ls", "/certs/client/ca.pem"] 
  66.   volumes: # 使用 emptyDir 的形式即可 
  67.     - name: dind-certs 
  68.       emptyDir: {} 

这个任务的重点还是要去声明一个 Workspace,当执行任务的时候要使用和前面构建任务同一个 Workspace,这样就可以获得上面编译成的 app 这个二进制文件了。

部署

接下来的部署阶段,我们同样可以参考之前 Jenkins 流水线里面的实现,由于项目中我们包含了 Helm Chart 包,所以直接使用 Helm 来部署即可,要实现 Helm 部署,当然我们首先需要一个包含 helm 命令的镜像,当然完全可以自己去编写一个这样的任务,此外我们还可以直接去 hub.tekton.dev 上面查找 Catalog,因为这上面就有很多比较通用的一些任务了,比如 helm-upgrade-from-source 这个 Task 任务就完全可以满足我们的需求了:

helm tekton

这个 Catalog 下面也包含完整的使用文档了,我们可以将该任务直接下载下来根据我们自己的需求做一些定制修改,如下所示:

  
 
 
 
  1. # task-deploy.yaml 
  2. apiVersion: tekton.dev/v1beta1 
  3. kind: Task 
  4. metadata: 
  5.   name: deploy 
  6. spec: 
  7.   params: 
  8.     - name: charts_dir 
  9.       description: The directory in source that contains the helm chart 
  10.     - name: release_name 
  11.       description: The helm release name 
  12.     - name: release_namespace 
  13.       description: The helm release namespace 
  14.       default: "" 
  15.     - name: overwrite_values 
  16.       description: "Specify the values you want to overwrite, comma separated: autoscaling.enabled=true,replicas=1" 
  17.       default: "" 
  18.     - name: values_file 
  19.       description: "The values file to be used" 
  20.       default: "values.yaml" 
  21.     - name: helm_image 
  22.       description: "helm image to be used" 
  23.       default: "docker.io/lachlanevenson/k8s-helm:v3.3.4@sha256:e1816be207efbd342cba9d3d32202e237e3de20af350617f8507dc033ea66803" #tag: v3.3.4 
  24.   workspaces: 
  25.     - name: source 
  26.   results: 
  27.     - name: helm-status 
  28.       description: Helm deploy status 
  29.   steps: 
  30.     - name: upgrade 
  31.       image: $(params.helm_image) 
  32.       workingDir: /workspace/source 
  33.       script: | 
  34.         echo current installed helm releases 
  35.         helm list --namespace "$(params.release_namespace)" 
  36.  
  37.         echo installing helm chart... 
  38.         helm upgrade --install --wait --values "$(params.charts_dir)/$(params.values_file)" --create-namespace --namespace "$(params.release_namespace)" $(params.release_name) $(params.charts_dir) --debug --set "$(params.overwrite_values)" 
  39.  
  40.         status=`helm status $(params.release_name) --namespace "$(params.release_namespace)" | awk '/STATUS/ {print $2}'` 
  41.         echo ${status} | tr -d "\n" | tee $(results.helm-status.path) 

因为我们的 Helm Chart 模板就在代码仓库中,所以不需要从 Chart Repo 仓库中获取,只需要指定 Chart 路径即可,其他可配置的参数都通过 params 参数暴露出去了,非常灵活,最后我们还获取了 Helm 部署的状态,写入到了 Results 中,方便后续任务处理。

回滚

最后应用部署完成后可能还需要回滚,因为可能部署的应用有错误,当然这个回滚动作最好是我们自己去触发,但是在某些场景下,比如 helm 部署已经明确失败了,那么我们当然可以自动回滚了,所以就需要判断当部署失败的时候再执行回滚,也就是这个任务并不是一定会发生的,只在某些场景下才会出现,我们可以在流水线中通过使用 WhenExpressions 来实现这个功能,之前版本中是使用 Conditions,不过已经废弃了。要只在满足某些条件时运行任务,可以使用 when 字段来保护任务执行,when 字段允许你列出对 WhenExpressions 的一系列引用。

WhenExpressions 由 Input、Operator 和 Values 几部分组成:

  • Input 是 WhenExpressions 的输入,它可以是一个静态的输入或变量(Params 或 Results),如果未提供输入,则默认为空字符串
  • Operator 是一个运算符,表示 Input 和 Values 之间的关系,有效的运算符包括 in、notin
  • Values 是一个字符串数组,必须提供一个非空的 Values 数组,它同样可以包含静态值或者变量(Params、Results 或者 Workspaces 绑定)

当在一个 Task 任务中配置了 WhenExpressions,在执行 Task 之前会评估声明的 WhenExpressions,如果结果为 True,则执行任务,如果为 False,则不会执行该任务。

我们这里创建的回滚任务如下所示:

  
 
 
 
  1. # task-rollback.yaml 
  2. apiVersion: tekton.dev/v1beta1 
  3. kind: Task 
  4. metadata: 
  5.   name: rollback 
  6. spec: 
  7.   params: 
  8.     - name: release_name 
  9.       description: The helm release name 
  10.     - name: release_namespace 
  11.       description: The helm release namespace 
  12.       default: "" 
  13.     - name: helm_image 
  14.       description: "helm image to be used" 
  15.       default: "docker.io/lachlanevenson/k8s-helm:v3.3.4@sha256:e1816be207efbd342cba9d3d32202e237e3de20af350617f8507dc033ea66803" #tag: v3.3.4 
  16.   steps: 
  17.     - name: rollback 
  18.       image: $(params.helm_image) 
  19.       script: | 
  20.         echo rollback current installed helm releases 
  21.         helm rollback $(params.release_name) --namespace $(params.release_namespace) 

流水线

现在我们的整个工作流任务都已经创建完成了,接下来我们就可以将这些任务全部串联起来组成一个 Pipeline 流水线了,将上面定义的几个 Task 引用到 Pipeline 中来,当然还需要声明 Task 中用到的 resources 或者 workspaces 这些数据:

  
 
 
 
  1. # pipeline.yaml 
  2. apiVersion: tekton.dev/v1beta1 
  3. kind: Pipeline 
  4. metadata: 
  5.   name: pipeline 
  6. spec: 
  7.   workspaces: # 声明 workspaces 
  8.     - name: go-repo-pvc 
  9.   params: 
  10.     # 定义代码仓库 
  11.     - name: git_url 
  12.     - name: revision 
  13.       type: string 
  14.       default: "master" 
  15.     # 定义镜像参数 
  16.     - name: image 
  17.     - name: registry_url 
  18.       type: string 
  19.       default: "harbor.k8s.local" 
  20.     - name: registry_mirror 
  21.       type: string 
  22.       default: "https://ot2k4d59.mirror.aliyuncs.com/" 
  23.     # 定义 helm charts 参数 
  24.     - name: charts_dir 
  25.     - name: release_name 
  26.     - name: release_namespace 
  27.       default: "default" 
  28.     - name: overwrite_values 
  29.       default: "" 
  30.     - name: values_file 
  31.       default: "values.yaml" 
  32.   tasks: # 添加task到流水线中 
  33.     - name: clone 
  34.       taskRef: 
  35.         name: git-clone 
  36.       workspaces: 
  37.         - name: output 
  38.           workspace: go-repo-pvc 
  39.       params: 
  40.         - name: url 
  41.           value: $(params.git_url) 
  42.         - name: revision 
  43.           value: $(params.revision) 
  44.     - name: test 
  45.       taskRef: 
  46.         name: test 
  47.     - name: build # 编译二进制程序 
  48.       taskRef: 
  49.         name: build 
  50.       runAfter: # 测试任务执行之后才执行 build task 
  51.         - test 
  52.         - clone 
  53.       workspaces: # 传递 workspaces 
  54.         - name: go-repo 
  55.           workspace: go-repo-pvc 
  56.     - name: docker # 构建并推送 Docker 镜像 
  57.       taskRef: 
  58.         name: docker 
  59.       runAfter: 
  60.         - build 
  61.       workspaces: # 传递 workspaces 
  62.         - name: go-repo 
  63.           workspace: go-repo-pvc 
  64.       params: # 传递参数 
  65.         - name: image 
  66.           value: $(params.image) 
  67.         - name: registry_url 
  68.           value: $(params.registry_url) 
  69.         - name: registry_mirror 
  70.           value: $(params.registry_mirror) 
  71.     - name: deploy # 部署应用 
  72.       taskRef: 
  73.         name: deploy 
  74.       runAfter: 
  75.         - docker 
  76.       workspaces: 
  77.         - name: source 
  78.           workspace: go-repo-pvc 
  79.       params: 
  80.         - name: charts_dir 
  81.           value: $(params.charts_dir) 
  82.         - name: release_name 
  83.           value: $(params.release_name) 
  84.         - name: release_namespace 
  85.           value: $(params.release_namespace) 
  86.         - name: overwrite_values 
  87.           value: $(params.overwrite_values) 
  88.         - name: values_file 
  89.           value: $(params.values_file) 
  90.     - name: rollback # 回滚 
  91.       taskRef: 
  92.         name: rollback 
  93.       when: 
  94.         - input: "$(tasks.deploy.results.helm-status)" 
  95.           operator: in 
  96.           values: ["failed"] 
  97.       params: 
  98.         - name: release_name 
  99.           value: $(params.release_name) 
  100.         - name: release_namespace 
  101.           value: $(params.release_namespace) 

整体流程比较简单,就是在 Pipeline 需要先声明使用到的 Workspace、Resource、Params 这些资源,然后将声明的数据传递到 Task 任务中去,需要注意的是最后一个回滚任务,我们需要根据前面的 deploy 任务的结果来判断是否需要执

当前标题:使用Tekton重构自动化流水线
URL地址:http://www.shufengxianlan.com/qtweb/news3/387103.html

网站建设、网络推广公司-创新互联,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等

广告

声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 创新互联