DEV Community

kosei
kosei

Posted on • Updated on • Originally published at qiita.com

Implementing Continuous Delivery for Monorepo and Microservice with GitHub Actions

Introduction

In this article, we explore how to implement CD (Continuous Delivery) using GitHub Actions in a monorepo + microservices setup.

Prerequisites

This section outlines the language and directory structure used as prerequisites.

Language

The services within the repository are written in TypeScript and managed as workspaces with Yarn Workspaces. They are also assumed to be containerized. However, it is believed that the same mechanism could work even if services are written in different languages.

Directory Structure

The assumed directory structure is as follows: There is a services directory at the root, and each service is placed in its own directory within this.

Infrastructure management tools like AWS are also managed within the same repository, under services/infrastructure.

./
├── .github/
│   └── workflows/
├── package.json
└── serivces/
    ├── infrastructure/
    ├── service-a/
    └── service-b/
Enter fullscreen mode Exit fullscreen mode

Problem

When adopting such a configuration, the scope of deployment becomes a problem. Workflows used in the repository are managed as common resources in .github/workflows, and if you write the deployment workflows for service-a and service-b there, deploying only service-a will inadvertently cause service-b to be deployed as well. This can lead to increased workflow execution times and, depending on the configuration, unnecessary deployment actions.

Solution

Therefore, let's consider a method to deploy only the services that have been changed.

Solution 1 (Using paths Filters)

A possible solution involves utilizing the functionality of including and excluding paths.

https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore

You can define the relevant paths as follows and prepare a workflow for each service accordingly.

For example, to deploy only service-a, the configuration might look like this:

name: Deploy service-a

on:
  push:
    paths:
      - 'serivces/service-a/**'

jobs:
  # Steps to deploy service-a go here
Enter fullscreen mode Exit fullscreen mode

The disadvantages of this approach include the need to create a separate workflow file for each service, even when the deployment method is the same, which can make workflow file management cumbersome. Additionally, this method does not support dependencies between services.

Solution 2 (Using git diff)

Another method is to use Git's capabilities to deploy only the services that have been changed.

You can detect changes to the targeted services by using a command to get the differences from the last commit, and then deploy only the changed services.

Let's explore this approach further.

Architecture

The workflow will be documented for manual execution (workflow_dispatch), but it can also be set up to trigger automatically on push.

If there are changes to the overall infrastructure managed by IaC, you'll want to deploy those changes before deploying any services, considering the deployment dependencies. This setup should also accommodate dependencies between services, allowing them to be expressed in the same system.

Within the overall deployment workflow, implement the following jobs:

  1. Change detection job (prepare)
  2. Infrastructure deployment job
  3. Individual service deployment jobs

This architecture allows for a more flexible deployment process, accommodating both the infrastructure and individual services, and addressing the need to manage dependencies between services.

Flowchart of deployment steps

graph TD;
    prepare-->infrastructure;
    infrastructure-->service-a;
    infrastructure-->service-b;
Enter fullscreen mode Exit fullscreen mode

Indeed, having the ability to deploy services individually, in addition to deploying the entire system through one workflow, would offer greater flexibility and convenience. Thus, the solution should also enable workflows to deploy each service separately.

Detailed Workflow

Change Detection Job (prepare)

The change detection can be conducted as follows:

  1. Use a script within the prepare job to run a git diff command against the last commit to identify changed paths.
  2. Parse the output of the git diff command to determine which services have been modified. This could involve checking if the changes are within the directories specific to each service.
  3. Set the output of this job to indicate which services need to be deployed. This can be achieved by setting environment variables or producing a file that subsequent jobs can read to determine whether they should proceed with deployment.

By implementing this job at the beginning of the workflow, you can dynamically decide which subsequent jobs (e.g., infrastructure deployment or individual service deployments) need to run based on the changes detected. This approach minimizes unnecessary deployments, saves time, and ensures that resources are used efficiently.

For individual service deployment workflows, you can adopt a similar change detection mechanism but scoped to the specific service's directory. This way, each service can have its workflow triggered either manually or automatically upon changes to its relevant files, offering a seamless and flexible deployment process tailored to the needs of each service within the monorepo.

jobs:
  prepare:
    outputs:
      infrastructure-diff-count: ${{ steps.infrastructure_changes.outputs.diff-count }}
      service-a-diff-count: ${{ steps.service_a_changes.outputs.diff-count }}
      service-b-diff-count: ${{ steps.service_b_changes.outputs.diff-count }}

    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 2
      - id: infrastructure_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/infrastructure | wc -l` >> $GITHUB_OUTPUT
      - id: service_a_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/service-a | wc -l` >> $GITHUB_OUTPUT
      - id: service_b_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/service-b | wc -l` >> $GITHUB_OUTPUT
Enter fullscreen mode Exit fullscreen mode

Here are the key point:

Point
Set fetch-depth to 2 in actions/checkout@v3

To obtain the differences from the previous commit using git diff HEAD~, you need to set the fetch-depth to 2. If this is not set, the previous commit cannot be fetched, and you will not be able to perform a diff comparison.
This precaution is essential because the default behavior of actions/checkout@v3 is to fetch only the latest commit (i.e., with a fetch-depth of 1). This shallow fetch is faster as it downloads less history, but it's not suitable for workflows that need to compare changes between commits. Setting fetch-depth to 2 ensures that the action fetches the last two commits, allowing git diff to correctly identify any changes made since the last commit. This setup is crucial for the change detection mechanism in CI/CD workflows that rely on comparing different versions of the codebase to determine the scope of changes and the necessity for deployments.

Infrastructure Deployment Job

We won't detail the infrastructure deployment code, but we will mainly mention the parts related to the previous job, the change detection job.

We define a deployment workflow for the infrastructure itself. By defining workflow_dispatch, we can execute the workflow for the infrastructure itself. Also, to make it callable from the overall workflow, we define workflow_call as well.

on:
  workflow_dispatch:
    inputs:
      stage:
        required: true
        type: string
  workflow_call:
    inputs:
      stage:
        required: true
        type: string

jobs:
  deploy:
    # setup, install, deploy, etc.
    # If using AWS CDK, deploy with commands like yarn workspace infrastructure cdk deploy --all --require-approval=never
Enter fullscreen mode Exit fullscreen mode

Add the workflow to deploy the infrastructure itself to the overall deployment workflow.

jobs: # Written to align the indent, but it's the same job as prepare
  infrastructure_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.infrastructure-diff-count > 0 }}
    needs:
      - prepare
    uses: '.github/workflows/deploy-infrastructure.yaml'
    with:
      stage: ${{ inputs.stage }}
    secrets: inherit
Enter fullscreen mode Exit fullscreen mode

Here are the key points:

Point 1
Use if statements to determine the necessity of job execution

Use needs.prepare.outputs.infrastructure-diff-count > 0 to get the number of changed files obtained from the previous job and execute only if there are changes.

Point 2
Use needs to indicate dependencies

needs: 
  - prepare

shows that it depends on the prepare job. It is possible to specify multiple dependencies, which can represent waiting until the deployment of the infrastructure or another dependent service is completed.

Point 3
Call the individual infrastructure workflow with uses

You can call another workflow set up with workflow_call using uses: path to workflow file. If secrets are needed, they can be passed when calling. Specifying inherit allows passing them collectively, but it's also possible to specify them individually.

Deployment Jobs for Each Service

The deployment jobs for each service are almost the same as those for deploying infrastructure, so only the key points are described.

We define a deployment workflow for each individual service. Services that can be deployed with the same workflow will reuse that workflow by accepting the service name as input.

on:
  workflow_dispatch:
    inputs:
      stage:
        required: true
        type: string
      service:
       required: true
       type: choice
       options:
         - service-a
         - service-b
  workflow_call:
    inputs:
      stage:
        required: true
        type: string
      service:
       required: true
       type: string

jobs:
  deploy:
    # setup, install, deploy, etc.
    # The workflow is reused by switching the target service based on inputs.service
Enter fullscreen mode Exit fullscreen mode

Add the workflow for deploying individual infrastructure to the overall deployment workflow.

jobs: # Written to align the indentation, but it's the same job as prepare
  service_a_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.service-a-diff-count > 0 }}
    needs:
      - prepare
      - infrastructure_deploy
    uses: '.github/workflows/deploy-service.yaml'
    with:
      stage: ${{ inputs.stage }}
      service: service-a
    secrets: inherit

  service_b_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.service-b-diff-count > 0 }}
    needs:
      - prepare
      - infrastructure_deploy
      # - service_a_deploy
      # If dependent on service-a, specify as above to wait for the completion of service-a's deployment
    uses: '.github/workflows/deploy-service.yaml'
    with:
      stage: ${{ inputs.stage }}
      service: service-b
    secrets: inherit
Enter fullscreen mode Exit fullscreen mode

The key point is as follows:

Point
Add cancelled(), failure() to the if statement to determine whether to execute the job

Add !cancelled() && !failure() && to the conditions of the if statement. This is to ensure that the workflow stops if it is cancelled, and also to ensure that this job does not skip if the jobs it depends on are skipped.

Summary

With GitHub monorepo + microservices, it was possible to deploy only the services that have changes. The triggers for the workflow and the conditions for detecting changes can be adjusted according to each project.

Top comments (0)