CI/CD + Terraform – czyli jak wdrożyć swoją aplikację w AWS? – cz. 2

Autor Autor:
Zespół Innokrea
Data publikacji: 2025-02-18
Kategorie: Administracja Programowanie

Hej, dzisiaj jako Innokrea rozwiniemy dla Was ostatni temat dotyczący używania GitHub Actions. Spróbujemy wdrożyć naszą prostą aplikację i pokazać jak działają zaawansowane opcje dostępne w naszych workflows. Koniecznie przeczytajcie nasze poprzednie artykuły w temacie Actions, Terraform oraz DevSecOps. Spróbujemy pokazać Wam także, jak połączyć wiedzę z Terraforma, aby stworzyć środowisko chmurowe na które następnie trafi nasza, przygotowana w poprzednim artykule aplikacja. Wszystkie pliki są dostępne w naszym repozytorium GitLab.

 

Dodanie testów do projektu

Zacznijmy od utworzenia prostego testu do naszej aplikacji. Ten test będzie sprawdzał czy wiadomość zwracana na głównym endpoint’cie jest poprawna. Test będzie utworzony w pliku index.test.js w folderze app.

const request = require('supertest');
const { app } = require('.');  
let server;
beforeAll(() => {
  server = app.listen(0);
});
afterAll(() => {
  server.close();
});

describe('Main Endpoint', () => {
  it('should return "Hello, World!" if RESPONSE_MESSAGE is not set', async () => {
    const response = await request(server).get('/');
    expect(response.status).toBe(200);
    expect(response.text).toBe('Hello, World!');  // Check that the response body is "Hello, World!"
  });

  it('should return custom message if RESPONSE_MESSAGE is set', async () => {
    process.env.RESPONSE_MESSAGE = 'Custom Message';  
    const response = await request(server).get('/');
    expect(response.status).toBe(200);  
    expect(response.text).toBe('Custom Message');
  });
});

 

Następnie zmodyfikujmy przygotowane ostatnio workflow i dodamy krok wywołujący testy w naszym kodzie, a także zmienimy nazwę workflow oraz naszego job’a.

name: Test & Deploy
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install dependencies
        run: npm install
      - name: Test the code
        run: npm test

 

Workflow uruchomione z sukcesem

Rysunek 1 – Workflow uruchomione z sukcesem

 

Testy powinny przejść bez problemu, a całe workflow pokazać w GitHubie na zielono.

 

Użycie Terraform do stworzenia serwera EC2

Spróbujmy zacząć od stworzenia pojemnika S3, w chmurze publicznej AWS, na przechowywanie stanu .tfstate do Terraforma. Podejście to sprawia, że możemy współdzielić stan między naszym GitHub Actions i lokalnym środowiskiem, a także wraz z innymi deweloperami. Stan nie powinien być przechowywany w ramach współdzielonego repozytorium, a raczej pojedynczy plik powinien być na współdzielonej instancji S3. Po utworzeniu S3 napiszemy skrypt w Terraformie, który w ramach workflow GitHub Actions utworzy serwer EC2, na który później wdrożymy naszą aplikację express.js.

Zacznijmy od utworzenia pojemnika S3 w AWS, które zostało przedstawione na poniższych zrzutach ekranu.

 

Konsola AWS w której tworzymy S3

Rysunek 2 – Konsola AWS w której tworzymy S3

 

Proces tworzenia pojemnika S3 w AWS

Rysunek 3 – Proces tworzenia pojemnika S3, nazywamy go express-app-bucket

 

Zostawiamy wszystko na domyślnych ustawieniach i klikamy utwórz na dole konsoli. Następnie, aby mieć dostęp do tego pojemnika S3 utworzymy także konto serwisowe w module IAM. Jest to moduł do zarządzania kontami, rolami oraz grupami w AWS.

 

Proces tworzenia konta serwisowego w AWS

Rysunek 4 – Proces tworzenia konta serwisowego, wybranie nazwy

 

AWS

Rysunek 5 – Wybór policy, które będzie bezpośrednio podłączone do tworzonego konta

 

AWS

Rysunek 6 – Stworzenie nowego policy z granulowanym dostępem tylko do zasobu S3. Aby zastosować, będziesz musiał podać nazwę własnego pojemnika S3. Kod tej policy znajduje się w pliku infrastructure/s3_policy.json

 

AWS

Rysunek 7 – powrót do tworzenia konta serwisowego. Powinniśmy załączyć stworzoną policy do konta, a także dodać AmazonEC2FullAccess do konta, aby móc utworzyć nową instancję serwera EC2, gdzie nasza aplikacja będzie wdrożona.

 

AWS

Rysunek 8 – Podsumowanie ustawień stworzonego konta. Teraz musimy stworzyć dane do logowania (security credentials), które będziemy wykorzystywać w GitHub Actions. Klikamy utwórz nowe dane.

 

AWS

Rysunek 9 – wybieramy other i klikamy next, a następnie pobieramy je na komputer

 

Dane do logowania w AWS

Rysunek 10 – utworzone dane do logowania widoczne w konsoli AWS

 

Rysunek 11 – Jeśli chcemy korzystać z terraforma lokalnie możemy skonfigurować konto w terminalu z użyciem polecenia aws configure i podając pobrane wcześniej dane. Warto to zrobić, aby przetestować naszą konfigurację.

 

Utworzenie pliku Terraform

Mamy już skonfigurowane konto serwisowe AWS, pojemnik S3 na stan Terraform. Musimy jeszcze stworzyć parę kluczy SSH, które przy pomocy Terraforma zostaną wgrane na przy tworzeniu zasobu EC2 w AWS. Skorzystamy w tym celu z programu ssh-keygen.

ssh-keygen -t rsa -b 4096 -f ./id_rsa

 

Generowanie klucza SSH

Rysunek 12 – Generowanie klucza SSH, widok z poziomu folderu infrastructure w naszym utworzonym repozytorium na GitHub

 

Skrypt Terraform i utworzenie zasobów chmurowych

Korzystając z dokumentacji providera Terraform od AWS tworzymy w folderze infrastructure plik main.tf w którym umieścimy całą naszą konfigurację. Utworzymy z poziomu kodu Terraform zasób EC2 z tzw. Security Group pozwalającym na ruch HTTP i SSH. Jako miejsce przechowywania pliku stanu Terraform .tfstate użyjemy utworzonego uprzednio S3.

# Define AWS provider
provider "aws" {
  region = "eu-north-1" 
}

terraform {
  backend "s3" {
    bucket = "express-app-bucket"
    key    = "terraform.tfstate"  # The location within the bucket
    region = "eu-north-1"                  # Your desired AWS region
    encrypt = true      
  }
}


# EC2 Key Pair
resource "aws_key_pair" "deployer_key" {
  key_name   = "github-deploy-key"
  public_key = file("./id_rsa.pub") 
}


# Security Group
resource "aws_security_group" "express_sg" {
  name        = "express-app-sg"
  description = "Allow HTTP and SSH"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}


# EC2 Instance
resource "aws_instance" "express_instance" {
  ami           = "ami-05edb7c94b324f73c"
  instance_type = "t3.micro"
  key_name      = aws_key_pair.deployer_key.key_name

  security_groups = [
    aws_security_group.express_sg.name,
  ]

  tags = {
    Name = "express-app-instance"
  }
}

output "instance_public_ip" {
  value = aws_instance.express_instance.public_ip
}

 

Jeśli uprzednio skonfigurowaliśmy naszą chmurę przy użyciu aws configure, będziemy mogli wykonać poniższą komendę.

 

Rysunek 13 – wykonanie pliku Terraform z poziomu terminala komputera. Terraform informuje, że zostaną utworzone zasoby w chmurze. Powinniśmy być uwierzytelnieni, jeśli tylko wcześniej wykonaliśmy komendę AWS configure

 

Rysunek 14 – po utworzeniu zasobów powinniśmy otrzymać adres ip na ekranie naszego serwera EC2

 

Rysunek 15 – logowanie na serwer przy pomocy naszego klucza prywatnego SSH

 

Udało nam się stworzyć infrastrukturę w chmurze publicznej AWS za pomocą kodu Terraform. Teraz zniszczmy zasoby z użyciem polecenia terraform destroy, aby później skorzystać z workflow do stworzenia całej infrastruktury. W tym celu dodamy pobrane wcześniej sekrety AWS do GitHuba w ramach funkcjonalności GitHub Secrets. Jest to funkcjonalność służąca do przechowywania sekretów w ramach GitHuba. Raz wrzucony sekret nie może być oczytany z poziomu UI, ale za to może być użyty w ramach runnera GitHub Actions.

 

Dodanie sekretów AWS do GitHub secrets

Rysunek 16 – Dodanie sekretów AWS do GitHub secrets

 

Musimy także dodać klucz prywatny do secrets, aby móc zalogować się do naszego serwera podczas wdrożenia aplikacji. Klucz publiczny jest przechowywany w ramach repozytorium i będzie instalowany przez Terraforma podczas tworzenia zasobów w chmurze AWS.

 

Dodanie klucza prywatnego w AWS

Rysunek 17 – Dodanie klucza prywatnego, który będzie używany do logowania do instancji EC2 i postawienia naszej aplikacji w ramach chmury AWS

 

Teraz przygotujmy workflow używający Terraforma oraz korzystający z sekretów zapisanych w naszym menedżerze sekretów. Workflow będzie wykonywane manualnie i będzie wykorzystywać obiekt sekret na poziomie pojedynczego joba. Wykorzystamy komendy terraform, aby pobrać Terraform provider, wygenerować plan a następnie wdrożyć go na chmurę AWS.

name: Terraform Deployment
on:
  workflow_dispatch:  

jobs:
  terraform-deploy:
    runs-on: ubuntu-24.04
    env:  # Declare environment variables at the job level
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

    steps:
    - name: Checkout Code
      uses: actions/checkout@v3

    - name: Setup Terraform
      uses: hashicorp/setup-terraform@v2
      with:
        terraform_version: 1.5.0

    - name: Terraform Init
      run: terraform -chdir=infrastructure init
     
    - name: Terraform Plan
      run: terraform -chdir=infrastructure plan -out tfplan
       
    - name: Terraform Apply
      run: |
        terraform -chdir=infrastructure apply -auto-approve tfplan | tee tf_output.txt
        INSTANCE_PUBLIC_IP=$(grep 'instance_public_ip =' tf_output.txt | awk -F ' = ' '{print $2}' | tr -d '"' | head -n 1 | tr -d '\n')
        echo $INSTANCE_PUBLIC_IP > tf_output.txt
        echo "INSTANCE_PUBLIC_IP=$INSTANCE_PUBLIC_IP" >> $GITHUB_ENV

    - name: Upload Terraform Output as Artifact
      uses: actions/upload-artifact@v4
      with:
        name: tf_output
        path: tf_output.txt

 

Dodatkowo, użyjemy komend Linuxa do poznania adresu IP, które AWS nam przyzna. Ten adres będzie można odczytać zarówno w logach runnera (dzięki obiektowi output Terraform) oraz w artefakcie który wygenerujemy. Artefakt to plik, generowany na poziomie pojedynczego workflow, który możemy pobrać po jego poprawnym wykonaniu.

Po dodaniu workflow w .github/workflows możemy zacommitować, wypchnąć zmiany do repozytorium i następnie uruchomić workflow.

 

Rysunek 18 – uruchomione workflow, które utworzyło naszą instancję EC2 i wgrało klucz publiczny do serwera. Dzięki temu możemy się zalogować na podany adres za pomocą komendy ssh -i klucz ec2-user@adres-ip

 

Umieszczenie stanu w pojemniku S3 sprawia, że możemy posługiwać się Terraformem zarówno z lokalnego systemu jak i z poziomu runnera. Ustawienie workflow z Terraformem pozwala na wdrożenie infrastruktury jednym kliknięciem, a także jeśli zastosowalibyśmy samą komendę ‘terraform plan’ na kontrolę konfiguracji. Dzięki temu wiemy, czy ktoś np. nie zmodyfikował ustawień naszego serwera z poziomu UI (configuration drift) i czy nasza konfiguracja jest zgodna ze stanem.

 

Wdrożenie naszej aplikacji z pomocą GitHub Actions

Aby wdrożyć automatycznie na nasz serwer naszą prostą aplikację do naszego workflow z testami dopiszemy kolejnego job’a, który będzie miał za zadanie wykonywać wdrożenie, jeśli testy się udały. Jako input od użytkownika będziemy brali adres ip serwera na który mamy wdrożyć (uzyskany z artefaktu pierwszego workflow) oraz port na którym ma działać aplikacja. Utworzymy w tym celu kolejny plik YML, który będzie wyglądać nastepująco:

name: Test & Deploy
on:
  workflow_dispatch:
    inputs:
      instance_ip:
        description: 'EC2 Instance Public IP'
        required: true
        type: string
      port_number:
        description: 'Port number'
        required: true
        type: int
jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18

      - name: Cache node_modules
        uses: actions/cache@v3
        with:
          path: node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install dependencies
        run: npm ci

      - name: Test the code
        run: npm test

  deploy:
    runs-on: ubuntu-24.04
    needs: test
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup SSH private key for deployment
        run: |
          echo "${{ secrets.AWS_SSH_EXPRESS_PRIV_KEY }}" > private_key.pem
          chmod 600 private_key.pem
          ls -la

      - name: Copy files to EC2 instance using SCP
        run: |
          # Export EC2 instance IP from user input
          INSTANCE_IP=${{ github.event.inputs.instance_ip }}
         
          # Copy the code to the EC2 instance using SCP
          scp -o StrictHostKeyChecking=no -i private_key.pem -r . ec2-user@$INSTANCE_IP:/home/ec2-user/app

      - name: Deploy on EC2 instance
        run: |
          INSTANCE_IP=${{ github.event.inputs.instance_ip }}
          PORT=${{ github.event.inputs.port_number }}  # Port passed as input

          # SSH into EC2 with the environment variable
          ssh -t -o StrictHostKeyChecking=no -i private_key.pem ec2-user@$INSTANCE_IP << EOF
            set -e  # Fail on any error

            # Update the system and install Node.js (Amazon Linux 2023)
            sudo yum update -y
            curl -sL https://rpm.nodesource.com/setup_18.x | sudo bash -
            sudo yum install -y nodejs

            # Navigate to the app directory
            cd /home/ec2-user/app

            # Install dependencies
            npm install

            # Stop any existing application
            sudo pkill -f "node app/index.js" || true  

            # Export the environment variable
            export PORT=$PORT

            # Start the application with sudo and fully detach
            echo "Starting application on port \$PORT"
            nohup sudo PORT=\$PORT npm start > app.log 2>&1 & disown

            # Wait briefly to ensure the application starts
            sleep 5

            # Verify the application is running
            if ! sudo lsof -i :\$PORT; then
              echo "Application failed to start on port \$PORT"
              exit 1
            else
              echo "Application is running successfully on port \$PORT"
            fi
            # Cleanly exit the SSH session
            exit 0
          EOF

      - name: Clean up SSH key
        run: rm -f private_key.pem

 

Do skryptu dodaliśmy komentarze, tak aby można było zrozumieć co robią poszczególne komendy. Wykorzystujemy tutaj komendy do pobrania klucza prywatnego z obiektu secrets, a następnie zalogowania się do serwera i postawienia działającej aplikacji z wykorzystaniem SSH, oraz kilku innych komend. Zwróćmy uwagę, że wykorzystujemy dyrektywę ‘ needs: test’, co oznacza, że aby wdrożenie mogło się rozpocząć nasz kod musi przejść najpierw wszystkie testy. Jest ona wymagana, ponieważ domyślnie osobne joby wykonują się współbieżnie tzn. w tym samym momencie. Po dodaniu pliku YML, zcommitowaniu oraz zpushowaniu uruchamiamy workflow. Dodatkowo korzystamy z mechanizmu cache’owania pozwalającego na przyśpieszenie wykonania workflow jeśli nasze zależności w package.json się nie zmieniły.

Jeśli wszystko zrobiliśmy poprawnie to wszystko powinno wyglądać w sposób widoczny na poniższym zrzucie ekranu.

 

Rysunek 19 – Uruchomione workflow, dwa joby są od siebie zależny co widać na rysunku. Pod naszym adresem ip, powinna być dostępna aplikacja.

 

Podsumowanie

Dzisiaj pokazaliśmy Wam jak wdrożyć aplikację z użyciem GitHub Actions i jak wykorzystać do tego technologie AWS oraz Terraform. Powyższy przykład jest czysto dydaktyczny. W środowisku produkcyjnym takie wdrożenie mogłoby zostać poszerzone o zastosowanie kontenerów, konsoli aws-cli, klastra, odpowiedniego branchingu w repozytorium, skanowania kodu (SAST, SCA) czy wreszcie odpowiednio ustawionych ruleset’ów ograniczających możliwości wdrożenia niezweryfikowanego kodu. Cały kod i konfigurację udostępniamy na naszym repozytorium. Dzięki i do usłyszenia za tydzień w kolejnym wpisie!

Zobacz więcej na naszym blogu:

CI/CD – jak wykorzystać GitHub Actions do zbudowania pipelineów? – cz. 1

CI/CD – jak wykorzystać GitHub Actions do zbudowania pipelineów? – cz. 1

Czym jest CI/CD oraz jak wykorzystać natywne rozwiązanie do CI/CD od GitHub – GitHub Actions? Czym są pipeline’y i jak można je wykorzystać w celu automatyzacji wdrożenia Waszej aplikacji?

AdministracjaProgramowanie

DevSecOps – czyli jak zadbać o bezpieczeństwo aplikacji w ramach procesu DevOps

DevSecOps – czyli jak zadbać o bezpieczeństwo aplikacji w ramach procesu DevOps

Jak dbać o bezpieczeństwo produktu w ramach procesu DevOps? Czym są SASTy, DASTy i SCA i jak to wszystko może wpłynąć na poprawę bezpieczeństwa?

AdministracjaBezpieczeństwo

Zarządzanie tożsamością i dostępem użytkownika, czyli o co chodzi z IDP?

Zarządzanie tożsamością i dostępem użytkownika, czyli o co chodzi z IDP?

Czym jest tożsamość użytkownika? Z czego wynika potrzeba zarządzania dostępem w firmie? Jak działa tzw. IDP? Odpowiedź na te pytania znajdziesz w artykule.

Bezpieczeństwo