Mở khóa sức mạnh của EC2 Graviton với GitLab CI/CD và EKS Runners

Nhiều khách hàng của AWS đang sử dụng GitLab cho nhu cầu DevOps của họ, bao gồm quản lý nguồn, và liên tục tích hợp và liên tục triển khai (CI/CD). Nhiều khách hàng của chúng tôi đang sử dụng GitLab SaaS (phiên bản được lưu trữ), trong khi những người khác đang sử dụng GitLab tự quản lý để đáp ứng yêu cầu về bảo mật và tuân thủ của họ.

Khách hàng có thể dễ dàng thêm runners vào phiên bản GitLab của họ để thực hiện các công việc CI/CD khác nhau. Các công việc này bao gồm biên dịch mã nguồn, xây dựng gói phần mềm hoặc hình ảnh container, thực hiện kiểm tra đơn vị và kiểm tra tích hợp, v.v.—thậm chí cả đến việc triển khai sản phẩm. Đối với phiên bản SaaS, GitLab cung cấp các runners được lưu trữ, và khách hàng cũng có thể cung cấp các runners riêng của họ. Những người chạy GitLab tự quản lý phải tự cung cấp các runners của họ.

Trong bài viết này, chúng ta sẽ thảo luận về cách khách hàng có thể tối ưu hóa khả năng CI/CD của họ bằng cách quản lý GitLab runner và bộ thực thi của họ với Amazon Elastic Kubernetes Service (Amazon EKS). Chúng ta sẽ tận dụng cả runners x86 và Graviton, cho phép khách hàng lần đầu tiên xây dựng và kiểm tra ứng dụng của họ trên cả x86 và trên AWS Graviton, dòng sản phẩm mạnh mẽ, hiệu quả về chi phí và bền vững nhất của chúng tôi. Theo triết lý “trả tiền chỉ cho những gì bạn sử dụng” của AWS, chúng ta sẽ giữ cho các máy chủ Amazon Elastic Compute Cloud (Amazon EC2) của mình càng nhỏ càng tốt, và khởi chạy các runners tạm thời trên các máy chủ Spot. Chúng ta sẽ thể hiện việc xây dựng và kiểm tra một ứng dụng minh họa đơn giản trên cả hai kiến trúc. Cuối cùng, chúng ta sẽ xây dựng và cung cấp một hình ảnh container đa kiến trúc có thể chạy trên các máy chủ Amazon EC2 hoặc AWS Fargate, cả trên x86 và Graviton.

Hình 1. Tổng quan kiến trúc Managed GitLab runner.

Hãy cùng đi qua các thành phần:

Runners

Một runner là một ứng dụng mà GitLab gửi các công việc đã được xác định trong một pipeline CI/CD tới. Runner nhận các công việc từ GitLab và thực thi chúng—hoặc bằng chính nó, hoặc bằng cách chuyển chúng đến một executor (chúng ta sẽ thăm executor ở phần tiếp theo).

Trong thiết kế của chúng tôi, chúng tôi sẽ sử dụng một cặp self-hosted runners. Một runner sẽ chấp nhận các công việc cho kiến trúc CPU x86, và runner khác sẽ chấp nhận các công việc cho kiến trúc CPU arm64 (Graviton). Để giúp chúng tôi định tuyến các công việc đến runner phù hợp, chúng tôi sẽ áp dụng một số thẻ (tags) cho mỗi runner để chỉ ra kiến trúc mà nó sẽ đảm nhiệm. Chúng tôi sẽ gắn thẻ x86 cho runner x86 với các từ khóa x86, x86-64, và amd64, phản ánh những tên gọi phổ biến nhất cho kiến trúc, và chúng tôi sẽ gắn thẻ arm64 cho runner arm64.

Hiện tại, những runners này phải luôn chạy để có thể nhận các công việc khi chúng được tạo ra. Runners của chúng tôi chỉ yêu cầu một lượng nhỏ bộ nhớ và CPU, để chúng tôi có thể chạy chúng trên các EC2 instances nhỏ để giảm thiểu chi phí. Điều này bao gồm t4g.micro cho các công việc build trên Graviton, hoặc t3.micro hoặc t3a.micro cho các công việc build trên x86.

Để tiết kiệm tiền cho những runners này, hãy xem xét việc mua Savings Plan hoặc Reserved Instances cho chúng. Savings Plans và Reserved Instances có thể giúp bạn tiết kiệm đến 72% so với giá on-demand, và không cần yêu cầu mức tiêu thụ tối thiểu để sử dụng chúng.

Kubernetes executors

Trong GitLab CI/CD, công việc của executor là thực hiện quá trình xây dựng thực tế. Runner có thể tạo ra hàng trăm hoặc hàng nghìn executors khi cần để đáp ứng nhu cầu hiện tại, tuân theo các giới hạn đồng thời mà bạn chỉ định. Executors chỉ được tạo ra khi cần, và chúng là tạm thời: sau khi công việc kết thúc trên một executor, runner sẽ chấm dứt nó.

Trong thiết kế của chúng tôi, chúng tôi sẽ sử dụng executor Kubernetes được tích hợp sẵn trong GitLab runner. Executor Kubernetes đơn giản là lên lịch cho một pod mới để chạy mỗi công việc. Khi công việc hoàn thành, pod sẽ chấm dứt, giải phóng node để chạy các công việc khác.

Executor Kubernetes có tính tùy chỉnh cao. Chúng tôi sẽ cấu hình mỗi runner với nodeSelector để đảm bảo rằng các công việc chỉ được lên lịch trên các node đang chạy kiến trúc CPU được chỉ định. Các tùy chỉnh khác có thể bao gồm đặt trước CPU và bộ nhớ, tolerations node và pod, tài khoản dịch vụ, gắn volume, và nhiều tùy chỉnh khác.

Mở rộng các node worker

Đối với hầu hết khách hàng, các công việc CI/CD có lẽ không chạy suốt thời gian. Để tiết kiệm chi phí, chúng ta chỉ muốn chạy các node worker khi có công việc cần thực thi.

Để thực hiện điều này, chúng ta sẽ sử dụng Karpenter. Karpenter cung cấp các phiên bản EC2 ngay lập tức khi cần để phù hợp với các pod được lên lịch mới. Nếu một pod executor mới được lên lịch và không có một phiên bản phù hợp nào còn đủ sức chứa, thì Karpenter sẽ nhanh chóng và tự động khởi chạy một phiên bản mới để phù hợp với pod đó. Karpenter cũng sẽ định kỳ quét cụm và chấm dứt các node trống không, do đó tiết kiệm chi phí. Karpenter có thể chấm dứt một node trống không trong vòng chưa đầy 30 giây.

Karpenter có thể khởi chạy cả các phiên bản Amazon EC2 theo yêu cầu hoặc Spot tùy thuộc vào nhu cầu của bạn. Với các phiên bản Spot, bạn có thể tiết kiệm đến 90% so với giá phiên bản theo yêu cầu. Vì các công việc CI/CD thường không cần phải thời gian, phiên bản Spot có thể là lựa chọn tuyệt vời cho các pod thực thi GitLab. Karpenter sẽ tự động tìm kiếm loại phiên bản Spot tốt nhất để tăng tốc quá trình khởi chạy một phiên bản và giảm thiểu khả năng gián đoạn công việc.

Triển khai giải pháp của chúng ta

Để triển khai giải pháp của chúng ta, chúng ta sẽ viết một ứng dụng nhỏ bằng AWS Cloud Development Kit (AWS CDK) và thư viện EKS Blueprints. AWS CDK là một framework phát triển phần mềm mã nguồn mở để định nghĩa các tài nguyên ứng dụng điện toán đám mây của bạn bằng các ngôn ngữ lập trình quen thuộc. EKS Blueprints là một thư viện được thiết kế để đơn giản hóa việc triển khai các tài nguyên Kubernetes phức tạp lên một cụm Amazon EKS với ít mã lập trình nhất.

Mã cơ sở hạ tầng ở mức cao—mà bạn có thể tìm thấy trong kho lưu trữ GitLab của chúng tôi—rất đơn giản. Tôi đã bao gồm các ghi chú để giải thích cách nó hoạt động.

// All CDK applications start with a new cdk.App object.

const app = new cdk.App();

// Create a new EKS cluster at v1.23. Run all non-DaemonSet pods in the 

// `kube-system` (coredns, etc.) and `karpenter` namespaces in Fargate

// so that we don’t have to maintain EC2 instances for them.

const clusterProvider = new blueprints.GenericClusterProvider({

  version: KubernetesVersion.V1_23,

  fargateProfiles: {

    main: {

      selectors: [

        { namespace: ‘kube-system’ },

        { namespace: ‘karpenter’ },

      ]

    }

  },

  clusterLogging: [

    ClusterLoggingTypes.API,

    ClusterLoggingTypes.AUDIT,

    ClusterLoggingTypes.AUTHENTICATOR,

    ClusterLoggingTypes.CONTROLLER_MANAGER,

    ClusterLoggingTypes.SCHEDULER

  ]

});

// EKS Blueprints uses a Builder pattern.

blueprints.EksBlueprint.builder()

  .clusterProvider(clusterProvider) // start with the Cluster Provider

  .addOns(

    // Use the EKS add-ons that manage coredns and the VPC CNI plugin

    new blueprints.addons.CoreDnsAddOn(‘v1.8.7-eksbuild.3’),

    new blueprints.addons.VpcCniAddOn(‘v1.12.0-eksbuild.1’),

    // Install Karpenter

    new blueprints.addons.KarpenterAddOn({

      provisionerSpecs: {

        // Karpenter examines scheduled pods for the following labels

        // in their `nodeSelector` or `nodeAffinity` rules and routes

        // the pods to the node with the best fit, provisioning a new

        // node if necessary to meet the requirements.

        //

        // Allow either amd64 or arm64 nodes to be provisioned 

        ‘kubernetes.io/arch’: [‘amd64’, ‘arm64’],

        // Allow either Spot or On-Demand nodes to be provisioned

        ‘karpenter.sh/capacity-type’: [‘spot’, ‘on-demand’]

      },

      // Launch instances in the VPC private subnets

      subnetTags: {

        Name: ‘gitlab-runner-eks-demo/gitlab-runner-eks-demo-vpc/PrivateSubnet*’

      },

      // Apply security groups that match the following tags to the launched instances

      securityGroupTags: {

        ‘kubernetes.io/cluster/gitlab-runner-eks-demo’: ‘owned’      

      }

    }),

    // Create a pair of a new GitLab runner deployments, one running on

    // arm64 (Graviton) instance, the other on an x86_64 instance.

    // We’ll show the definition of the GitLabRunner class below.

    new GitLabRunner({

      arch: CpuArch.ARM_64,

      // If you’re using an on-premise GitLab installation, you’ll want

      // to change the URL below.

      gitlabUrl: ‘https://gitlab.com’,

      // Kubernetes Secret containing the runner registration token

      // (discussed later)

      secretName: ‘gitlab-runner-secret’

    }),

    new GitLabRunner({

      arch: CpuArch.X86_64,

      gitlabUrl: ‘https://gitlab.com’,

      secretName: ‘gitlab-runner-secret’

    }),

  )

  .build(app, 

         // Stack name

         ‘gitlab-runner-eks-demo’);

Lớp GitLabRunner là một lớp con của HelmAddOn lấy một số tham số từ ứng dụng cấp cao:

// The location and name of the GitLab Runner Helm chart

const CHART_REPO = ‘https://charts.gitlab.io’;

const HELM_CHART = ‘gitlab-runner’;

// The default namespace for the runner

const DEFAULT_NAMESPACE = ‘gitlab’;

// The default Helm chart version

const DEFAULT_VERSION = ‘0.40.1’;

export enum CpuArch {

    ARM_64 = ‘arm64’,

    X86_64 = ‘amd64’

}

// Configuration parameters

interface GitLabRunnerProps {

    // The CPU architecture of the node on which the runner pod will reside

    arch: CpuArch

    // The GitLab API URL 

    gitlabUrl: string

    // Kubernetes Secret containing the runner registration token (discussed later)

    secretName: string

    // Optional tags for the runner. These will be added to the default list 

    // corresponding to the runner’s CPU architecture.

    tags?: string[]

    // Optional Kubernetes namespace in which the runner will be installed

    namespace?: string

    // Optional Helm chart version

    chartVersion?: string

}

export class GitLabRunner extends HelmAddOn {

    private arch: CpuArch;

    private gitlabUrl: string;

    private secretName: string;

    private tags: string[] = [];

    constructor(props: GitLabRunnerProps) {

        // Invoke the superclass (HelmAddOn) constructor

        super({

            name: `gitlab-runner-${props.arch}`,

            chart: HELM_CHART,

            repository: CHART_REPO,

            namespace: props.namespace || DEFAULT_NAMESPACE,

            version: props.chartVersion || DEFAULT_VERSION,

            release: `gitlab-runner-${props.arch}`,

        });

        this.arch = props.arch;

        this.gitlabUrl = props.gitlabUrl;

        this.secretName = props.secretName;

        // Set default runner tags

        switch (this.arch) {

            case CpuArch.X86_64:

                this.tags.push(‘amd64’, ‘x86’, ‘x86-64’, ‘x86_64’);

                break;

            case CpuArch.ARM_64:

                this.tags.push(‘arm64’);

                break;

        }

        this.tags.push(…props.tags || []); // Add any custom tags

    };

    // `deploy` method required by the abstract class definition. Our implementation

    // simply installs a Helm chart to the cluster with the proper values.

    deploy(clusterInfo: ClusterInfo): void | Promise<Construct> {

        const chart = this.addHelmChart(clusterInfo, this.getValues(), true);

        return Promise.resolve(chart);

    }

    // Returns the values for the GitLab Runner Helm chart

    private getValues(): Values {

        return {

            gitlabUrl: this.gitlabUrl,

            runners: {

                config: this.runnerConfig(), // runner config.toml file, from below

                name: `demo-runner-${this.arch}`, // name as seen in GitLab UI

                tags: uniq(this.tags).join(‘,’),

                secret: this.secretName, // see below

            },

            // Labels to constrain the nodes where this runner can be placed

            nodeSelector: {

                ‘kubernetes.io/arch’: this.arch,

                ‘karpenter.sh/capacity-type’: ‘on-demand’

            },

            // Default pod label

            podLabels: {

                ‘gitlab-role’: ‘manager’

            },

            // Create all the necessary RBAC resources including the ServiceAccount

            rbac: {

                create: true

            },

            // Required resources (memory/CPU) for the runner pod. The runner

            // is fairly lightweight as it’s a self-contained Golang app.

            resources: {

                requests: {

                    memory: ‘128Mi’,

                    cpu: ‘256m’

                }

            }

        };

    }

    // This string contains the runner’s `config.toml` file including the

    // Kubernetes executor’s configuration. Note the nodeSelector constraints 

    // (including the use of Spot capacity and the CPU architecture).

    private runnerConfig(): string {

        return `

  [[runners]]

    [runners.kubernetes]

      namespace = “{{.Release.Namespace}}”

      image = “ubuntu:16.04”

    [runners.kubernetes.node_selector]

      “kubernetes.io/arch” = “${this.arch}”

      “kubernetes.io/os” = “linux”

      “karpenter.sh/capacity-type” = “spot”

    [runners.kubernetes.pod_labels]

      gitlab-role = “runner”

      `.trim();

    }

}

Vì lý do bảo mật, chúng tôi lưu token đăng ký GitLab trong một Secret của Kubernetes – không bao giờ trong mã nguồn của chúng tôi. Để tăng thêm tính bảo mật, chúng tôi đề xuất mã hóa Secrets bằng cách sử dụng một khóa AWS Key Management Service (AWS KMS) mà bạn cung cấp bằng cách chỉ định cấu hình mã hóa khi bạn tạo cụm Amazon EKS của bạn. Điều này là một thực hành tốt để hạn chế truy cập vào Secret này thông qua các quy tắc RBAC của Kubernetes.

Để tạo Secret, chạy lệnh sau:

# These two values must match the parameters supplied to the GitLabRunner constructor

NAMESPACE=gitlab

SECRET_NAME=gitlab-runner-secret

# The value of the registration token.

TOKEN=GRxxxxxxxxxxxxxxxxxxxxxx

kubectl -n $NAMESPACE create secret generic $SECRET_NAME \

        –from-literal=”runner-registration-token=$TOKEN” \

        –from-literal=”runner-token=”

Xây dựng một hình ảnh container đa kiến trúc

Bây giờ sau khi chúng ta đã triển khai GitLab runners và cấu hình các executors, chúng ta có thể xây dựng và kiểm tra một hình ảnh container đa kiến trúc đơn giản. Nếu các bài kiểm tra thành công, chúng ta có thể tải lên nó lên kho lưu trữ container của dự án GitLab của chúng tôi. Ứng dụng của chúng tôi sẽ khá đơn giản: chúng tôi sẽ tạo một máy chủ web bằng Go đơn giản chỉ in ra “Xin chào thế giới” và in ra kiến trúc hiện tại.

Tìm mã nguồn của ứng dụng mẫu của chúng tôi trong kho lưu trữ GitLab của chúng tôi.

Trong GitLab, cấu hình CI/CD được lưu trong tệp .gitlab-ci.yml tại gốc của kho nguồn. Trong tệp này, chúng ta khai báo một danh sách các giai đoạn xây dựng được sắp xếp và sau đó chúng ta khai báo các công việc cụ thể được liên kết với mỗi giai đoạn.

Các giai đoạn của chúng tôi bao gồm:

  1. Giai đoạn xây dựng, trong đó chúng tôi biên dịch mã nguồn của mình, tạo các hình ảnh cụ thể cho từng kiến trúc và tải lên các hình ảnh này lên kho lưu trữ container của GitLab. Những hình ảnh đã tải lên này được đánh dấu bằng một hậu tố chỉ ra kiến trúc mà chúng được xây dựng. Công việc này sử dụng một biến ma trận để chạy nó song song trên hai runner khác nhau – một cho mỗi kiến trúc được hỗ trợ. Hơn nữa, thay vì sử dụng docker build để tạo ra các hình ảnh của chúng tôi, chúng tôi sử dụng Kaniko để xây dựng chúng. Điều này cho phép chúng tôi xây dựng hình ảnh trong một môi trường container không được ủy quyền và cải thiện đáng kể tư duy bảo mật.
  2. Giai đoạn kiểm tra, trong đó chúng tôi kiểm tra mã nguồn. Tương tự như giai đoạn xây dựng, chúng tôi sử dụng biến ma trận để chạy các bài kiểm tra song song trong các pod riêng biệt trên mỗi kiến trúc được hỗ trợ.

Giai đoạn lắp ráp, trong đó chúng tôi tạo một manifest hình ảnh đa kiến trúc từ hai hình ảnh cụ thể cho từng kiến trúc. Sau đó, chúng tôi đẩy manifest vào kho lưu trữ hình ảnh để chúng tôi có thể tham khảo nó trong các triển khai tương lai.

Hình 2. Ví dụ về ống dẫn CI/CD cho các hình ảnh đa kiến trúc.

Dưới đây là cái nhìn tổng quan về cấu hình cấp cao của chúng tôi:

variables:

  # These are used by the runner to configure the Kubernetes executor, and define

  # the values of spec.containers[].resources.limits.{memory,cpu} for the Pod(s).

  KUBERNETES_MEMORY_REQUEST: 1Gi

  KUBERNETES_CPU_REQUEST: 1

# List of stages for jobs, and their order of execution  

stages:    

  – build

  – test

  – create-multiarch-manifest

Here’s what our build stage job looks like. Note the matrix of variables which are set in BUILD_ARCH as the two jobs are run in parallel:

build-job:

  stage: build

  parallel:

    matrix:              # This job is run twice, once on amd64 (x86), once on arm64

    – BUILD_ARCH: amd64

    – BUILD_ARCH: arm64

  tags: [$BUILD_ARCH]    # Associate the job with the appropriate runner

  image:

    name: gcr.io/kaniko-project/executor:debug

    entrypoint: [“”]

  script:

    – mkdir -p /kaniko/.docker

    # Configure authentication data for Kaniko so it can push to the

    # GitLab container registry

    – echo “{\”auths\”:{\”${CI_REGISTRY}\”:{\”auth\”:\”$(printf “%s:%s” “${CI_REGISTRY_USER}” “${CI_REGISTRY_PASSWORD}” | base64 | tr -d ‘\n’)\”}}}” > /kaniko/.docker/config.json

    # Build the image and push to the registry. In this stage, we append the build

    # architecture as a tag suffix.

    – >-

      /kaniko/executor

      –context “${CI_PROJECT_DIR}”

      –dockerfile “${CI_PROJECT_DIR}/Dockerfile”

      –destination “${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}-${BUILD_ARCH}”

Dưới đây là công việc giai đoạn kiểm tra của chúng tôi. Lần này, chúng tôi sử dụng hình ảnh mà chúng tôi vừa sản xuất. Mã nguồn của chúng tôi được sao chép vào container ứng dụng. Sau đó, chúng tôi có thể chạy lệnh make test-api để thực hiện bộ kiểm tra máy chủ.

build-job:

  stage: build

  parallel:

    matrix:              # This job is run twice, once on amd64 (x86), once on arm64

    – BUILD_ARCH: amd64

    – BUILD_ARCH: arm64

  tags: [$BUILD_ARCH]    # Associate the job with the appropriate runner

  image:

    # Use the image we just built

    name: “${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}-${BUILD_ARCH}”

  script:

    – make test-container

Cuối cùng, dưới đây là giai đoạn tổng hợp của chúng tôi. Chúng tôi sử dụng Podman để xây dựng tài liệu đa kiến trúc và đẩy nó vào hệ thống lưu trữ hình ảnh. Thông thường, chúng tôi có thể đã sử dụng docker buildx để thực hiện việc này, nhưng việc sử dụng Podman cho phép chúng tôi thực hiện công việc này trong một môi trường container không đặc quyền để tăng cường bảo mật.

create-manifest-job:

  stage: create-multiarch-manifest

  tags: [arm64] 

  image: public.ecr.aws/docker/library/fedora:36

  script:

    – yum -y install podman

    – echo “${CI_REGISTRY_PASSWORD}” | podman login -u “${CI_REGISTRY_USER}” –password-stdin “${CI_REGISTRY}”

    – COMPOSITE_IMAGE=${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}

    – podman manifest create ${COMPOSITE_IMAGE}

    – >-

      for arch in arm64 amd64; do

        podman manifest add ${COMPOSITE_IMAGE} docker://${COMPOSITE_IMAGE}-${arch};

      done

    – podman manifest inspect ${COMPOSITE_IMAGE}

    # The composite image manifest omits the architecture from the tag suffix.

    – podman manifest push ${COMPOSITE_IMAGE} docker://${COMPOSITE_IMAGE}

Thử nghiệm

Tôi đã tạo một dự án GitLab thử nghiệm công cộng chứa mã nguồn ví dụ và đã đính kèm các runner vào dự án. Chúng ta có thể thấy chúng ở Cài đặt > CI/CD > Runners:

Hình 3. Cấu hình runner GitLab.

Ở đây, chúng ta cũng có thể thấy một số bản thực hiện pipeline, trong đó có một số đã thành công và một số khác thất bại.

Hình 4. Bản thực hiện pipeline ví dụ GitLab.

Chúng ta cũng có thể thấy các công việc cụ thể liên quan đến một bản thực hiện pipeline:

Hình 5. Bản thực hiện công việc ví dụ GitLab.

Cuối cùng, đây là các hình ảnh container của chúng tôi:

Hình 6. Hệ thống lưu trữ container ví dụ GitLab.

Kết luận

Trong bài viết này, chúng tôi đã minh họa cách bạn có thể nhanh chóng và dễ dàng xây dựng các hình ảnh container đa kiến trúc bằng GitLab, Amazon EKS, Karpenter và Amazon EC2, bằng cả gia đình máy chủ x86 và Graviton. Chúng tôi đã tập trung vào việc sử dụng càng nhiều dịch vụ quản lý càng tốt, tối đa hóa bảo mật và tối thiểu hóa sự phức tạp và TCO. Chúng tôi đã đào sâu vào nhiều khía cạnh của quá trình và đã thảo luận về cách tiết kiệm đến 90% chi phí của giải pháp bằng cách sử dụng Spot instances cho việc thực hiện CI/CD.

Tìm mã nguồn mẫu, bao gồm tất cả những gì được hiển thị ở đây hôm nay, trong kho lưu trữ GitLab của chúng tôi.

Xây dựng hình ảnh đa kiến trúc sẽ mở khóa giá trị và hiệu suất của việc chạy ứng dụng của bạn trên AWS Graviton và mang lại tính linh hoạt tăng cường về lựa chọn máy tính. Chúng tôi khuyến nghị bạn nên bắt đầu ngay hôm nay.