Portainer, Gitea를 활용한 CI/CD 환경 구축

Portainer, Gitea를 활용한 CI/CD 환경 구축

필자는 Synology NAS 에 Portainer 를 설치해 블로그를 포함한 여러 웹 애플리케이션의 도커 컨테이너를 운영하고 있다. 예전부터 간간히 개발을 하며 필요로 하는 하는 간단한 웹 애플리케이션을 개발해 Portainer 에 올리는게 취미였는데 AI 성능이 좋아져 개발 속도가 폭발적으로 늘어나면서 개발/배포 빈도가 획기적으로 늘었다.

AI로 개발 문제가 해결되자 이제는 배포가 문제였다. 새로운 기능을 추가하거나 에러를 고칠 때마다 새로 도커 이미지를 생성, 업로드, 컨테이너를 생성해야 하는데 이 짓을 매번 수동으로 하려니 귀찮기 짝이 없다. 결국 소문으로만 듣던 CI/CD라는 것을 시도해보기로 했다. 다행히 평소 사용하는 Portainer 와 Gitea에서 CI/CD 기능을 잘 지원해 어렵지 않게 환경을 구성할 수 있었다.

이 글에서는 Gitea, Portainer 를 활용해 CI/CD 환경을 구축하고 개발 PC 에서 push 하면 자동으로 Portainer 에 변경 사항이 적용된 컨테이너를 배포하도록 설정하는 과정에 대해 설명한다.

1. 구성 환경

현재 구성 환경은 다음과 같다.

개발 PC - Sysnology NAS
          |- Portainer(Community Edition2.15.1)
             |- Gitea(1.26.2)
             |- 그 외 웹 애플리케이션

Gitea Actions, Gitea Container Registry, Portainer Community Edition(CE)을 이용해 git push만으로 Docker 이미지 빌드와 배포를 자동화할 거다.

기존 배포 과정은 다음과 같다.

1. 코드 수정
2. docker build
3. docker push
4. Portainer 접속
5. 최신 이미지 pull 및 Stack 재배포

자동화 후에는 다음처럼 바뀐다. 프로젝트 당 최초 한번만 Gitea, Portainer 에서 CI/CD 설정을 완료하면 git push 와 동시에 Gitea Actions 가 실행된다. Gitea Actions 는 설정에 따라 push 된 코드를 기반으로 Docker 이미지 빌드, push 하고 Portainer API를 사용해 Portainer 에 새로운 Stack 을 재배포한다.

1. git push
2. Gitea Actions
3. Docker 이미지 build/push
4. Portainer API
5. Stack 재배포

웹 애플리케이션 개발 후 git push 하면 다음과 같은 과정을 거쳐 새 코드가 적용된 도커 이미지를 빌드, 배포한다.

개발 PC
  │
  │ git push
  ▼
Gitea
  │
  │ Gitea Actions
  ▼
act_runner
  │
  ├─ Docker build
  ├─ Gitea Container Registry push
  │
  ▼
Portainer CE API
  │
  ▼
Stack 재배포

2. Gitea

2.1 Gitea Action 설정 활성화

Gitea Action 기능을 이용하기 위해선 1.19.0 이후 버전이어야 한다.

또한 1.21.0 이후로는 기본적으로 기능이 활성화되어 추가 설정이 필요 없지만 이보다 낮으면 Gitea 설정 값을 저정한 app.ini 파일에 다음과 같은 설정을 추가해야 한다.

[actions]
ENABLED=true

2.2 Gitea Runner 설치

Gitea 는 스스로 CI/CD job을 실행할 수 없기 때문에 개발 PC 에서 git push 할 때마다 도커 이미지를 빌드해 줄 Gitea Runner 가 필요하다.

Gitea Runner 가 Gitea에 접근해 기능을 사용하기 위해 Runner 토큰을 발급해야 한다. 등록 토큰의 레벨에 따라 다음과 같은 주소에 접속한다.

  • 인스턴스(Instance) 레벨 : <your_gitea.com>/-/admin/actions/runners
  • 조직(Organization) 레벨 : <your_gitea.com>/<org>/settings/actions/runners
  • 저장소(Repository) 레벨 : <your_gitea.com>/<owner>/<repo>/settings/actions/runners

이 글에서는 저장소 레벨을 기준으로 작성한다.

저장소 별 러너 토큰을 생성하려면 Gitea에 접속 - 러너를 생성할 저장소 페이지로 이동 - 우측의 설정을 클릭해 저장소 설정 페이지로 이동한다.

Gitea 저장소 화면

저장소 설정 페이지의 좌측의 액션 - 일반 버튼을 클릭해 저장소에 액션이 허용되어 있는지 확인한다.

저장소 액션 기능 활성화

이후 액션 - 러너 버튼을 클릭하면 러너를 생성하거나 등록된 러너를 확인할 수 있다. 우측의 새 러너 생성 버튼을 클릭하면 Registration Token 을 확인할 수 있다. 이 값을 복사해 기억하자.

러너 토큰 생성

Portainer에서 다음 Stack으로 Gitea Runner 컨테이너를 실행했다. 환경 변수 값과 볼륨 경로는 적절하게 설정하자.

services:
  runner:
    image: gitea/act_runner:latest
    container_name: test-runner
    restart: unless-stopped
    environment:
      GITEA_INSTANCE_URL: "https://gitea 주소/"
      GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
      GITEA_RUNNER_NAME: "test-runner"
    volumes:
      - "gitea runner 데이터 경로:/data"
      - "/var/run/docker.sock:/var/run/docker.sock"

Runner가 Docker 이미지를 빌드하려면 Docker 소켓 마운트가 필요하다. 다만 Docker 소켓 접근 권한은 NAS의 Docker 엔진을 제어할 수 있는 강력한 권한이다. 따라서 개인 또는 신뢰할 수 있는 private 저장소에서만 사용해야 한다.

/var/run/docker.sock:/var/run/docker.sock

Runner가 Gitea의 Actions 메뉴에서 Idle 또는 유휴 상태로 보이면 정상적으로 등록된 것이다.

생성된 러너 목록

2.3 Gitea 사용자 액세스 토큰 생성

Portainer 는 Gitea API를 사용해 저장소에 등록된 도커 이미지에 접근, pull할 수 있어야 한다. 이를 위해 사용자의 권한으로 Gitea API를 사용할 수 있도록 사용자 액세스 토큰을 발급받아야 한다.

Gitea 에서 우측 상단의 프로필 이미지 - 설정 버튼을 클릭해 사용자 설정 페이지로 이동한다.

좌측 사이드 메뉴에서 어플리케이션 - 액세스 토큰 관리 에서 새 토큰을 생성 버튼을 클릭한다. 토큰의 이름과 공개 범위, 부여할 권한을 설정해야 한다. 필요에 따라 공개 범위를 설정하고 package 에 읽기 쓰기 권한을 부여해 토큰을 생성한다.

Gitea 토큰 생성 화면

토큰 생성 버튼을 클릭하면 페이지 상단에 "새로운 토큰이 생성되었습니다. 이 토큰은 다시 보이지 않으니 지금 복사하십시오." 라 출력되며 바로 아래에 토큰 값이 나타난다. 단 한번만 보여주니 복사해서 잘 보관하고 결코 타인에게 노출돼선 안된다.

생성된 Gitea 토큰

2.4 Gitea Actions Secrets 등록

도메인, 토큰, 비밀번호를 workflow 파일에 직접 작성하지 않고 Actions Secrets로 저장한다. 이 값들은 이후 docker-compose.yml, deploy.yml 에서 참조해 사용한다.

REGISTRY_HOST
REGISTRY_USERNAME
REGISTRY_PASSWORD
PORTAINER_URL
PORTAINER_API_KEY

예를 들면 다음과 같다.

Secret 용도
REGISTRY_HOST Gitea Container Registry 도메인
REGISTRY_USERNAME Gitea 사용자명
REGISTRY_PASSWORD Gitea Personal Access Token
PORTAINER_URL Portainer 주소
PORTAINER_API_KEY Portainer API Access Token. 이 값은 하단의 Portainer 단락에서 생성 해야 함.

3. Portainer

3.1 Portainer API Key 생성

Gitea Action 이 Portainer 에서 Stack을 생성하기 위해서 Portainer API Key를 생성해야 한다.

Portainer 접속, 우측 상단의 사용자 프로필 - My account 버튼을 클릭해 계정 페이지로 이동한다.

Portainer 대시보드

계정 페이지에서 Access tokens 탭의 Add access Token 버튼을 클릭해 토큰 생성 페이지로 이동한다.

Portainer 계정 페이지

토큰 이름을 적당히 짓고 Add access token 버튼을 클릭하면 토큰 값이 생성된다. 이 토큰 값은 단 한번만 보여주니 복사 후 어딘가에 저장하자. 외부에 노출되어선 안되는 중요한 값이니 잘 관리해야 한다.

Portainer Access Token 생성 화면

3.2 Registry 등록

Portainer 가 Stack 재생성 시 Gitea 저장소의 이미지를 pull할 수 있도록 이미지 레지스트리를 등록해야 한다.

Portainer 접속, 좌측 메뉴의 Registries - Add registry 버튼 클릭해 레지스트리 추가 페이지로 이동한다.

Registry Provider 중 Custom registry 선택한다. 등록할 레지스트리의 이름, Gitea URL, Authenication 버튼을 On 해 Gitea 에 접속할 Username 과 Password 를 작성한다. Password 는 일반적인 사용자 패스워드가 아닌 앞서 생성한 Gitea Token을 입력해야 한다.

Portainer 레지스트리 추가 화면

4. 개발 PC

4.1 Portainer Stack 준비

웹 애플리케이션은 Portainer에서 단일 컨테이너가 아닌 Stack으로 관리할 예정이므로 적절한 docker-compose.yml 파일을 작성해야 한다.

다음은 예시 docker-compose.yml 파일이다. image 값에 gitea에 업로드한 도커 이미지 경로를 지정한다.

services:
  simple-test:
    image: ${REGISTRY_HOST}/gitea 사용자/simpletest:latest
    container_name: simple-test
    ports:
      - "490:5000"
    volumes:
      - "/volume/path/to/data:/app/data"
    restart: unless-stopped
    environment:
      TZ: Asia/Seoul

배포 과정을 확인할 수 있도록 간단한 정적 페이지를 제공하는 Dockerfile을 작성해야 한다. 아래 예시는 간단한 index.html 파일을 복사하고 파이썬으로 웹서버를 서비스하는 Dockerfile 이다.

FROM python:3.12-slim

WORKDIR /app
COPY index.html ./index.html

EXPOSE 5000
CMD ["python", "-m", "http.server", "5000", "--bind", "0.0.0.0"]

4.2 Gitea Actions Workflow 작성

이 글에서 작성한 deploy.yml 은 이미 존재하는 stack 에 대해 재빌드/배포하는 방식이므로 미리 stack을 하나 생성해야 한다.

저장소의 .gitea/workflows/deploy.yml에 Gitea Action 이 실행할 workflow 파일을 생성한다.

다음은 예시 deploy.yml 파일이다. IMAGE_NAME 과 PORTAINER_STACK_NAME 을 적절하게 편집해야 한다. 두 변수 이외의 것들은 Gitea Actions 에서 Secret 변수 값으로 지정한 값이 그대로 전달되어 사용된다.
예시의 steps 부분을 하나씩 읽어 Runner 에서 어떤 작업을 실행하는지 확인해보자. Gitea 레지스트리에 접속하고, 이미지를 빌드해 push 하고, Portainer API를 사용해 Gitea에 업로드된 이미지를 pull 하고 새로운 Stack을 생성한다.

name: Build and deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    env:
      # Synology Docker 엔진의 최대 API 버전에 맞춘다.
      DOCKER_API_VERSION: "1.41"
      IMAGE_NAME: user/simpletest:latest
      PORTAINER_STACK_NAME: simpletest

    steps:
      - name: Check out source
        uses: actions/checkout@v4

      - name: Install Docker CLI and deployment tools
        run: |
          apt-get update
          apt-get install -y --no-install-recommends ca-certificates curl docker.io jq

      - name: Log in to the private registry
        env:
          REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
          REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
          REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
        run: |
          printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" \
            --username "$REGISTRY_USERNAME" --password-stdin

      - name: Build and push image
        env:
          REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
        run: |
          image="$REGISTRY_HOST/$IMAGE_NAME"
          docker build --no-cache --tag "$image" .
          docker push "$image"

      - name: Re-pull image and redeploy Portainer stack
        env:
          PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
          PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
          REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
        run: |
          set -eu

          : "${PORTAINER_URL:?PORTAINER_URL secret is required (for example, https://portainer.example.com)}"
          : "${PORTAINER_API_KEY:?PORTAINER_API_KEY secret is required}"

          # Secrets pasted into the Gitea UI can contain a trailing CR/LF.
          # Remove only those line terminators and a trailing slash; spaces and
          # a missing scheme remain errors rather than becoming a different URL.
          portainer_url="$(printf '%s' "$PORTAINER_URL" | tr -d '\r\n')"
          portainer_url="${portainer_url%/}"
          case "$portainer_url" in
            http://*|https://*) ;;
            *)
              echo "PORTAINER_URL must start with http:// or https://" >&2
              exit 1
              ;;
          esac
          case "$portainer_url" in
            *[[:space:]]*)
              echo "PORTAINER_URL must not contain whitespace" >&2
              exit 1
              ;;
          esac

          echo "Fetching the Portainer stack list"
          stacks="$(curl --fail --silent --show-error \
            --header "X-API-Key: $PORTAINER_API_KEY" \
            "$portainer_url/api/stacks")"

          matching_stacks="$(printf '%s' "$stacks" | jq --compact-output \
            --arg name "$PORTAINER_STACK_NAME" \
            '[.[] | select(.Name == $name)]')"

          stack_count="$(printf '%s' "$matching_stacks" | jq 'length')"
          if [ "$stack_count" -ne 1 ]; then
            echo "Expected exactly one Portainer stack named '$PORTAINER_STACK_NAME'; found $stack_count" >&2
            exit 1
          fi

          stack_id="$(printf '%s' "$matching_stacks" | jq --raw-output '.[0].Id')"
          endpoint_id="$(printf '%s' "$matching_stacks" | jq --raw-output '.[0].EndpointId')"
          stack_id_encoded="$(printf '%s' "$stack_id" | jq --slurp --raw-input --raw-output '@uri')"
          endpoint_id_encoded="$(printf '%s' "$endpoint_id" | jq --slurp --raw-input --raw-output '@uri')"

          payload="$(jq --null-input --rawfile stack_file docker-compose.yml \
            --arg registry_host "$REGISTRY_HOST" \
            '{StackFileContent: $stack_file, Env: [{name: "REGISTRY_HOST", value: $registry_host}], PullImage: true, Prune: false}')"

          curl --fail --silent --show-error \
            --request PUT \
            --header "X-API-Key: $PORTAINER_API_KEY" \
            --header "Content-Type: application/json" \
            --data "$payload" \
            "$portainer_url/api/stacks/$stack_id_encoded?endpointId=$endpoint_id_encoded"

4.3 테스트

이제 배포는 다음 명령만으로 끝난다.

git add .
git commit -m "Update application"
git push origin main

Gitea Actions가 이미지를 빌드해 Gitea Container Registry에 push하고, Portainer API가 최신 이미지를 pull한 뒤 Stack을 재배포한다.

재배포 과정은 해당 Gitea 저장소의 액션 탭에서 확인할 수 있다.

러너에 의해 실행되는 job

Runner와 Registry, Portainer API 구성은 한 번만 하면 된다. 이후 다른 저장소에는 Compose 파일과 workflow를 복사한 뒤 이미지 이름, Stack 이름, 포트, 볼륨 경로만 서비스별로 바꾸면 같은 방식으로 확장할 수 있다.

5. Reference