Skip to main content

Command Palette

Search for a command to run...

DevOps with Terraform, ArgoCD, Jenkins and AWS

Updated
13 min read
DevOps with Terraform, ArgoCD, Jenkins and AWS

Mô hình

AWS Configure

aws configure

Install Jenkins using Terraform

Clone code từ repo trên github:

git clone https://github.com/HoangPhan10/Teris.git
cd Teris
cd jenkins-terraform

Danh sách file:

Tạo resource Security group cho server jenkins, mở các port 22, 88, 443, 8080, 9000, 3000:

Tạo resource EC2 và cấp Role AdministratorAccess cho máy chủ jenkins:

💡
Lưu ý dùng AMI của Ubuntu.

Tệp Terraform để triển khai AWS EC2 với Jenkins được cài đặt, cùng với container SonarQube, Trivy, AWS CLI, Kubectl và Terraform.

#!/bin/bash
sudo apt update -y
wget -O - https://packages.adoptium.net/artifactory/api/gpg/key/public | tee /etc/apt/keyrings/adoptium.asc
echo "deb [signed-by=/etc/apt/keyrings/adoptium.asc] https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | tee /etc/apt/sources.list.d/adoptium.list
sudo apt update -y
sudo apt install temurin-17-jdk -y
/usr/bin/java --version
curl -fsSL https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key | sudo tee /usr/share/keyrings/jenkins-keyring.asc > /dev/null
echo deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc] https://pkg.jenkins.io/debian-stable binary/ | sudo tee /etc/apt/sources.list.d/jenkins.list > /dev/null
sudo apt-get update -y
sudo apt-get install jenkins -y
sudo systemctl start jenkins
sudo systemctl status jenkins

#install docker
sudo apt-get update
sudo apt-get install docker.io -y
sudo usermod -aG docker ubuntu  
newgrp docker
sudo chmod 777 /var/run/docker.sock
docker run -d --name sonar -p 9000:9000 sonarqube:lts-community

# install trivy
sudo apt-get install wget apt-transport-https gnupg lsb-release -y
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update
sudo apt-get install trivy -y

#install terraform
sudo apt install wget -y
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform

#install Kubectl on Jenkins
sudo apt update
sudo apt install curl -y
curl -LO https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
kubectl version --client

#install Aws cli 
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
sudo apt-get install unzip -y
unzip awscliv2.zip
sudo ./aws/install

Mở terminal và chạy các lệnh sau:

terraform init

Validate các file terraform:

$ terraform validate

Success! The configuration is valid.

Plan tạo các resources:

terraform plan

Sau đó, apply các resources và nhập yes ở bước confirm:

terraform apply --auto-approve

Các state của terraform sẽ được lưu trữ tại bucket s3 s3-teris-v1

Quá trình terraform apply đã hoàn tất. Bây giờ, bạn có thể vào AWS Console để kiểm tra EC2 instance đã được tạo thành công:

Truy cập và cấu hình Jenkins

Trong file install_jenkins.sh thì chúng ta đã thực hiện chạy jenkins, để truy cập vào sử dụng public_ip_ec2 với port 8080:

Sử dụng keypair của riêng bạn lúc tạo ec2 để truy cập vào server:

ssh -i ./keypair/keypair ubuntu@3.92.21.20

Để lấy được mật khẩu administrator , thực hiện đọc tệp file /var/lib/jenkins/secrets/initialAdminPassword:

sudo cat /var/lib/jenkins/secrets/initialAdminPassword

Tiếp đến, Install suggested plugins

Jenkins sẽ thực hiện cài đặt một số plugin cần thiết:

Bây giờ thực hiện tạo user Admin:

Giao diện Dashboard Jenkins:

Dashboard Jenkins → Manage Jenkins → Plugins

Chọn đến Available plugins:

Tìm kiếm và tải xuống Terraform:

💡
Ở đây, chúng ta sẽ tạo 1 job trên jenkins để tạo resource eks nên cần phải sử dụng tool terraform

Tiếp đến, truy cập vào server jenkins để lấy path của terraform với lệnh:

which terraform

Dashboard Jenkins → Manage Jenkins → Tools

Thêm terraform vào tools:

Go to DashBoard Jenkins → Manage Jenkins → Plugins → Available Plugins

Trước tiên, chúng ta cần cài đặt một số plugin:

  • Eclipse Temurin installer: Thao tác với JDK

  • Sonarqube Scanner: Phân tích mã nguồn để phát hiện các lỗi và lỗ hổng.

  • NodeJs: Thao tác với Node

  • Owasp Dependency-Check: Phân tích các thư viện được sử dụng với các CVE.

  • Docker, Docker Commons, Docker Pipeline, Docker API, Docker-build-step: Thao tác với docker

Goto Manage Jenkins → Tools
Cấu hình các Tools cho Jenkins:
Thêm JDK jdk17:

Thêm NodeJS node16:

Thêm Sonar Scanner sonar-scanner:

Thêm Dependency-Check DP-Check:

Thêm Docker docker:

Truy cập vào SonarQube với port 9000:

Login: admin
Password: admin

Goto Administration → Security → Users:

Chọn Update Tokens:

Tạo token mới và copy token này:

Goto Jenkins Dashboard → Manage Jenkins → Credentials → System → Global credentials

Tạo 1 credentials mới sử dụng secret sonarqube vừa rồi:

Tạo thêm 1 credentials cho docker:

Goto Dashboard → Manage Jenkins → System
Thêm Sonar Server sonar-server sử dụng credentials vừa tạo:

Cấu hình webhooks ở SonarQube:

Tạo webhook sử dụng url của server jenkins và thêm /sonarqube-webhook/:

Tạo job để tạo EKS bằng Terraform

Tạo job với type Pipeline:

Tại đây thì khi tạo eks, chúng ta cần thêm 1 số action như apply, destroy để phù hợp. Nếu muốn xóa bỏ resource eks thì chỉ cần chạy job với action destroy.

Sau đó, thêm script pipeline sau:

pipeline{
    agent any
    stages {
        stage('Checkout from Git'){
            steps{
                git branch: 'master', url: 'https://github.com/HoangPhan10/Teris.git'
            }
        }
        stage('Terraform version'){
             steps{
                 sh 'terraform --version'
             }
        }
        stage('Terraform init'){
             steps{
                 dir('eks-terraform') {
                      sh 'terraform init'
                   }
             }
        }
        stage('Terraform validate'){
             steps{
                 dir('eks-terraform') {
                      sh 'terraform validate'
                   }
             }
        }
        stage('Terraform plan'){
             steps{
                 dir('eks-terraform') {
                      sh 'terraform plan'
                   }
             }
        }
        stage('Terraform apply/destroy'){
             steps{
                 dir('eks-terraform') {
                      sh 'terraform ${action} --auto-approve'
                   }
             }
        }
    }
}

Thực hiện Build job với param apply:

Mất khoảng hơn 10 phút để chạy xong job này, vì tạo eks sẽ hơi mất thời gian:

Kiểm tra AWS Console của resource EKS:

EKS tạo thêm 2 máy chủ EC2 để làm worker node:

Tạo job để build, push image lên DockerHub

Tạo job với type Pipeline:

Sau đó, thêm script pipeline sau:

pipeline {
    agent any
    tools {
        jdk 'jdk17'
        nodejs 'node16'
    }
    environment {
        SCANNER_HOME = tool 'sonar-scanner'
    }
    stages {
        stage('clean workspace') {
      steps {
        cleanWs()
      }
        }
        stage('Checkout from Git') {
      steps {
        git branch: 'master', url: 'https://github.com/HoangPhan10/Tetris.git'
      }
        }
        stage('Sonarqube Analysis') {
      steps {
        dir('code') {
          withSonarQubeEnv('sonar-server') {
            sh ''' $SCANNER_HOME/bin/sonar-scanner -Dsonar.projectName=Tetris \
                    -Dsonar.projectKey=Tetris '''
        }}
        }
      }
        stage('quality gate') {
      steps {
        dir('code') {
          script {
            waitForQualityGate abortPipeline: false, credentialsId: 'Sonar-token'
        }}
        }
      }
        stage('Install Dependencies') {
      steps {
        dir('code') {
        sh 'npm install'}
      }
        }
        stage('OWASP FS SCAN') {
        steps {
        dir('code') {
          dependencyCheck additionalArguments: '--scan ./ --disableYarnAudit --disableNodeAudit', odcInstallation: 'DP-Check'
          dependencyCheckPublisher pattern: '**/dependency-check-report.xml'
      }}
        }
        stage('TRIVY FS SCAN') {
      steps {
        sh 'trivy fs . > trivyfs.txt'
      }
        }
        stage('Docker Build & Push') {
      steps {
        dir('code') {
          script {
            withDockerRegistry(credentialsId: 'Docker', toolName: 'docker') {
              sh 'docker build -t tetris .'
              sh 'docker tag tetris phanhoang102/tetris:latest '
              sh 'docker push phanhoang102/tetris:latest '
            }
        }}
        }
      }
        stage('TRIVY') {
      steps {
        sh 'trivy image phanhoang102/tetris:latest > trivyimage.txt'
      }
        }
   }
 }

Sau đó Apply và Build job:

SonarQube, nó đã thực hiện quét 1.1k dòng và phát hiện được 2 bug:

Owasp Dependency Check:

Đẩy thành công image lên DockerHub:

Cập nhật image lên argocd

Goto Github → Setting, tạo token account github.

Chọn Developer settings:

Chọn Tokens (classic):

Tạo token mới:

Đặt tên và chọn tất cả các checkbox:

Copy token:

Thêm credential của github vào jenkins:

Thêm stage vào script pipeline:

#add inside environment
 environment {
    GIT_REPO_NAME = "Tetris-argocd"
    GIT_USER_NAME = "HoangPhan10"
  }
# add these stages after trivy image scan
        stage('Checkout Code') {
            steps {
                git branch: "master", url: "https://github.com/HoangPhan10/Tetris-argocd.git"
            }
        }
        stage('Update Deployment File') {
            steps {
                script {
                   withCredentials([string(credentialsId: "Github", variable: "GITHUB_TOKEN")]) {
                    NEW_IMAGE_NAME = "phanhoang102/tetris:${env.VERSION_ID}"
                    sh "sed -i 's|image: .*|image: $NEW_IMAGE_NAME|' deployment.yaml"
                    sh 'git add deployment.yaml'
                    sh "git commit -m 'Update deployment image to $NEW_IMAGE_NAME'"
                    sh "git push https://${GITHUB_TOKEN}@github.com/${GIT_USER_NAME}/${GIT_REPO_NAME} HEAD:master"
                  }
                }
            }
        }

Tiếp đến cập nhật vào với script đã có:

pipeline {
    agent any
    tools {
        jdk "jdk17"
        nodejs "node16"
    }
    environment {
        SCANNER_HOME = tool "sonar-scanner"
        GIT_REPO_NAME = "Tetris-argocd"
        GIT_USER_NAME = "HoangPhan10"
    }
    stages {
        stage("Clean workspace") {
      steps {
        cleanWs()
      }
        }
        stage("Initialize") {
      steps {
        script {
          env.VERSION_ID = "v1.${env.BUILD_NUMBER}"
          echo "Version ID: ${env.VERSION_ID}"
        }
      }
        }
        stage("Checkout from Git") {
      steps {
        git branch: "master", url: "https://github.com/HoangPhan10/Tetris.git"
      }
        }
        stage("Sonarqube Analysis") {
      steps {
        dir("code") {
          withSonarQubeEnv("sonar-server") {
            sh ''' $SCANNER_HOME/bin/sonar-scanner -Dsonar.projectName=Tetris \
                    -Dsonar.projectKey=Tetris '''
        }}
        }
      }
        stage("Quality gate") {
      steps {
        dir("code") {
          script {
            waitForQualityGate abortPipeline: false, credentialsId: "Sonar-token"
        }}
        }
      }
        stage("Install Dependencies") {
      steps {
        dir("code") {
        sh "npm install"}
      }
        }
        stage("OWASP FS SCAN") {
        steps {
        dir("code") {
          dependencyCheck additionalArguments: "--scan ./ --disableYarnAudit --disableNodeAudit", odcInstallation: "DP-Check"
          dependencyCheckPublisher pattern: "**/dependency-check-report.xml"
      }}
        }
        stage("TRIVY FS SCAN") {
      steps {
        sh "trivy fs . > trivyfs.txt"
      }
        }
        stage("Build Docker Image") {
      steps {
        dir("code") {
          script {
            sh "docker build -t tetris:${env.VERSION_ID} ."
          }
        }
      }
        }
        stage("Push Docker Image") {
      steps {
          script {
            withDockerRegistry(credentialsId: "Docker") {
              sh "docker tag tetris:${env.VERSION_ID} phanhoang102/tetris:${env.VERSION_ID}"
              sh "docker push phanhoang102/tetris:${env.VERSION_ID}"
              sh "docker rmi tetris:${env.VERSION_ID} phanhoang102/tetris:${env.VERSION_ID}"
            }
          }
      }
        }
        stage("TRIVY") {
      steps {
        sh "trivy image phanhoang102/tetris:${env.VERSION_ID} > trivyimage.txt"
      }
        }

        stage("Checkout Code") {
      steps {
        git branch: "master", url: "https://github.com/HoangPhan10/Tetris-argocd.git"
      }
        }
        stage("Update Deployment File") {
      steps {
        script {
          withCredentials([string(credentialsId: "Github", variable: "GITHUB_TOKEN")]) {
            NEW_IMAGE_NAME = "phanhoang102/tetris:${env.VERSION_ID}"
            sh "sed -i 's|image: .*|image: $NEW_IMAGE_NAME|' deployment.yaml"
            sh 'git add deployment.yaml'
            sh "git commit -m 'Update deployment image to $NEW_IMAGE_NAME'"
            sh "git push https://${GITHUB_TOKEN}@github.com/${GIT_USER_NAME}/${GIT_REPO_NAME} HEAD:master"
          }
        }
      }
     }
   }
 }

Apply và Build job:

Tạo commit change image lên github dùng để chạy argocd:

Setup ArgoCD

Cập nhật file kubeconfig trên server jenkins:

aws eks update-kubeconfig --name <CLUSTER NAME> --region <CLUSTER REGION>
aws eks update-kubeconfig --name EKS_CLOUD --region ap-south-1

Xem các nodes:

kubectl get nodes

Cài đặt ArgoCD:

kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/v2.4.7/manifests/install.yaml

Theo mặc định thì server argocd không public internet, chúng ta cần cập nhật sang dạng loadbalancer:

kubectl patch svc argocd-server -n argocd -p '{"spec": {"type": "LoadBalancer"}}'

Lấy EXTERNAL-IP của service:

kubectl get svc argocd-server -n argocd

Truy cập vào server argocd:

Lấy mật khẩu argocd:

kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d && printf "\n"

Giao diện màn hình argocd:

Tiếp đến, chọn Setting ở thanh navbar bên trái:

Chọn tiếp vào Repositories:

Chọn Connect repo using HTTPS:

Chọn type git, project default và nhập đường dẫn github vào:

Sau đó click Connect:

Bây giờ quay về trang chủ, chọn Create Application:

Nhập các thông tin cần thiết để tạo app:

Sử dụng ./ để sử dụng thư mục gốc:

Khai báo chạy trên cluster và namespace nào:

Sau đó chọn Create, tạo thành công:

Argocd đã thực hiện tạo ra các resource k8s mà chúng ta đã khởi tạo trong file trên github:

Chọn Details của svc để lấy link truy cập dịch vụ:

Triển khai thành công:

Tạo thêm job chạy app version 2

Tạo job với type pipeline:

Với script pipeline tương tự như version1, thay đổi 1 số thông tin:

pipeline {
    agent any
    tools {
        jdk "jdk17"
        nodejs "node16"
    }
    environment {
        SCANNER_HOME = tool "sonar-scanner"
        GIT_REPO_NAME = "Tetris-argocd"
        GIT_USER_NAME = "HoangPhan10"
    }
    stages {
        stage("Clean workspace") {
      steps {
        cleanWs()
      }
        }
        stage("Initialize") {
      steps {
        script {
          env.VERSION_ID = "v1.${env.BUILD_NUMBER}"
          echo "Version ID: ${env.VERSION_ID}"
        }
      }
        }
        stage("Checkout from Git") {
      steps {
        git branch: "master", url: "https://github.com/HoangPhan10/Tetris-v2.git"
      }
        }
        stage("Sonarqube Analysis") {
      steps {
          withSonarQubeEnv("sonar-server") {
            sh ''' $SCANNER_HOME/bin/sonar-scanner -Dsonar.projectName=Tetrisv2 \
                    -Dsonar.projectKey=Tetrisv2 '''
        }
        }
      }
        stage("Quality gate") {
      steps {
          script {
            waitForQualityGate abortPipeline: false, credentialsId: "Sonar-token"
        }
        }
      }
        stage("Install Dependencies") {
      steps {
        sh "npm install"
      }
        }
        stage("OWASP FS SCAN") {
        steps {
          dependencyCheck additionalArguments: "--scan ./ --disableYarnAudit --disableNodeAudit", odcInstallation: "DP-Check"
          dependencyCheckPublisher pattern: "**/dependency-check-report.xml"
      }
        }
        stage("TRIVY FS SCAN") {
      steps {
        sh "trivy fs . > trivyfs.txt"
      }
        }
        stage("Build Docker Image") {
      steps {
          script {
            sh "docker build -t tetrisv2:${env.VERSION_ID} ."
          }
      }
        }
        stage("Push Docker Image") {
      steps {
          script {
            withDockerRegistry(credentialsId: "Docker") {
              sh "docker tag tetrisv2:${env.VERSION_ID} phanhoang102/tetrisv2:${env.VERSION_ID}"
              sh "docker push phanhoang102/tetrisv2:${env.VERSION_ID}"
              sh "docker rmi tetrisv2:${env.VERSION_ID} phanhoang102/tetrisv2:${env.VERSION_ID}"
            }
          }
      }
        }
        stage("TRIVY") {
      steps {
        sh "trivy image phanhoang102/tetrisv2:${env.VERSION_ID} > trivyimage.txt"
      }
        }
        stage("Checkout Code") {
      steps {
        git branch: "master", url: "https://github.com/HoangPhan10/Tetris-argocd.git"
      }
        }
        stage("Update Deployment File") {
      steps {
        script {
          withCredentials([string(credentialsId: "Github", variable: "GITHUB_TOKEN")]) {
            NEW_IMAGE_NAME = "phanhoang102/tetrisv2:${env.VERSION_ID}"
            sh "sed -i 's|image: .*|image: $NEW_IMAGE_NAME|' deployment.yaml"
            sh 'git add deployment.yaml'
            sh "git commit -m 'Update deployment image to $NEW_IMAGE_NAME'"
            sh "git push https://${GITHUB_TOKEN}@github.com/${GIT_USER_NAME}/${GIT_REPO_NAME} HEAD:master"
          }
        }
      }
    }
  }
}

Lúc này thì sẽ tạo ra một repo image mới trên DockerHub:

Sau đó, cập nhật lên github chứa file chạy k8s:

Argocd server sẽ tự động cập nhật lại khi có commit change trên git:

Đã được cập nhật sang image version 2:

Truy cập lại vào app thì lúc này đã được chuyển sang version 2:

Thông báo kết quả build về mail

Goto Dashboard → Manage Jenkins → Plugins

Chọn Available plugins, tìm kiếm Email Extension Template và tải xuống:

Goto Gmail → Quản lý tài khoản

Tìm kiếm và chọn mật khẩu ứng dụng:

Tạo một ứng dụng mới và copy mật khẩu:

Goto Dashboard → Manage Jenkins → Credentials

Thêm credential cho mail bằng mật khẩu vừa tạo:

Goto Dashboard → Manage Jenkins → System

Cấu hình Extended E-mail Notification:

Cấu hình E-mail Notification:

Sau đó, thêm script sau vào:

post {
     always {
        emailext attachLog: true,
            subject: "'${currentBuild.result}'",
            body: "Project: ${env.JOB_NAME}<br/>" +
                "Build Number: ${env.BUILD_NUMBER}<br/>" +
                "URL: ${env.BUILD_URL}<br/>",
            to: 'phanhoang1022002@gmail.com',
            attachmentsPattern: 'trivyfs.txt,trivyimage.txt'
        }
    }

Sau khi build nhận được kết quả gửi về mail:

Tạo job để trigger

💡
Ta thấy thì cả app version 1 và version 2 đều có phần update code lên github argocd tương tự nhau, thay đổi mỗi image. Tại đây chúng ta tạo 1 trigger dùng chung cho cả job version 1 và 2.

Tạo job với type pipeline:

Cấu hình parameter:

Chạy script sau:

pipeline{
    agent any
    environment {
        GIT_REPO_NAME = "Tetris-argocd"
        GIT_USER_NAME = "HoangPhan10"
    }
    stages {
        stage("Checkout Code") {
      steps {
        git branch: "master", url: "https://github.com/HoangPhan10/Tetris-argocd.git"
      }
        }
        stage("Update Deployment File") {
      steps {
        script {
          withCredentials([string(credentialsId: "Github", variable: "GITHUB_TOKEN")]) {
            NEW_IMAGE_NAME = "${image}"
            sh "sed -i 's|image: .*|image: $NEW_IMAGE_NAME|' deployment.yaml"
            sh 'git add deployment.yaml'
            sh "git commit -m 'Update deployment image to $NEW_IMAGE_NAME'"
            sh "git push https://${GITHUB_TOKEN}@github.com/${GIT_USER_NAME}/${GIT_REPO_NAME} HEAD:master"
          }
        }
      }
        }
    }
}

Thực hiện thay thế vào job chạy app version 1 và version 2:

      //   stage("Checkout Code") {
      // steps {
      //   git branch: "master", url: "https://github.com/HoangPhan10/Tetris-argocd.git"
      // }
      //   }
      //   stage("Update Deployment File") {
      // steps {
      //   script {
      //     withCredentials([string(credentialsId: "Github", variable: "GITHUB_TOKEN")]) {
      //       NEW_IMAGE_NAME = "phanhoang102/tetris:${env.VERSION_ID}"
      //       sh "sed -i 's|image: .*|image: $NEW_IMAGE_NAME|' deployment.yaml"
      //       sh 'git add deployment.yaml'
      //       sh "git commit -m 'Update deployment image to $NEW_IMAGE_NAME'"
      //       sh "git push https://${GITHUB_TOKEN}@github.com/${GIT_USER_NAME}/${GIT_REPO_NAME} HEAD:master"
      //     }
      //   }
      // }
      //   }

 stage('Trigger manifest') {
    steps {
        build job: 'Manifest',
              wait: true,
              parameters: [
                  string(name: 'image', value: "phanhoang102/tetris:${env.VERSION_ID}")
              ]
    }
}

Kết quả sau khi chạy:

Xóa các resource

Đầu tiên, xóa app ở trên argocd.

Goto ArgoCD, chọn DELETE:

Xác nhận xóa app:

Tiếp đến thực hiện xóa server argocd.

Thực hiện truy cập SSH vào server jenkins để tương tác với cụm k8s:

kubectl delete svc argocd-server -n argocd

Bước 3, thực hiện xóa eks bằng cách chạy job với parameter destroy:

Cuối cùng, thực hiện xóa jenkins bằng terraform:

terraform destroy --auto-approve

Cảm ơn bạn đã đọc.

More from this blog

Learn DevOps

21 posts