인프라 공방전 2주차 IaC - 코드로 CI/CD 파이프라인을 구축해보자

배경

모든 프로젝트에서 결과물은 코드로 남기면서, 왜 인프라는 손맛으로만 만들고 있었을까요?

인프라 공방전 스터디를 진행하며 모든 과정을 코드와 문서로 꼼꼼히 기록하던 중, 유독 인프라 영역만 빈 페이지로 남아 있다는 사실을 깨달았습니다. AWS 콘솔에서 마우스로 뚝딱뚝딱 만든 리소스들은, 스터디가 끝나거나 계정이 삭제되면 함께 사라지는 휘발성 작품이었거든요.

"이번엔 다르게 해보자." 그렇게 미뤄왔던 Terraform을 꺼내 들었습니다. 인프라를 코드로 적어두면 언제든 똑같이 재현할 수 있고, 누구나 읽고 리뷰할 수 있으니까요. 마침 Jenkins 파이프라인 구축을 앞둔 시점이라, 이보다 더 좋은 실험 무대는 없어 보였습니다.

테라폼이란?

테라폼 공식 사이트에서는 아래와 같이 테라폼을 설명하고 있습니다. 요약하자면, Terraform은 인프라를 코드로 관리할 수 있게 해주는 IaC(Infrastructure as Code) 도구입니다. 컴퓨팅 인스턴스, 스토리지, 네트워킹 같은 저수준 리소스부터 DNS 레코드, SaaS 기능 같은 고수준 리소스까지 폭넓게 다룰 수 있다는 점이 특징입니다.

Terraform is an infrastructure as code tool that lets you build, change, and version infrastructure safely and efficiently. This includes low-level components like compute instances, storage, and networking; and high-level components like DNS entries and SaaS features.

테라폼 다운로드 받기(macOS)

맥에서 가장 대중적으로 사용되는 패키지 매니저인 brew를 통해 쉽게 다운로드를 할 수 있습니다.

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

젠킨스도 코드로 구축해보자

젠킨스 JCasC

젠킨스를 사용하다보면 정말 많은 설정과 많은 플러그인들을 마주하게 됩니다. 여기서 만약 실수로 서버를 종료 시키거나 인프라 이전을 했을 때 기존의 젠킨스 설정이 전부 날아가버리게 됩니다. 그렇게 힘들게 설정한 젠킨스의 구성이 날아가지 않고 코드로 관리할 수 있도록 도와주는 오픈소스가 존재합니다. 그것이 바로 JCasC입니다.

Jenkins 초기 설정 (JCasC)

이 설정 파일은 JCasC(Jenkins Configuration as Code) 형식으로, Jenkins UI에서 클릭으로 설정해야 할 항목들을 YAML 코드로 정의해둔 것입니다. 컨테이너가 시작되면 이 파일을 읽어 자동으로 Jenkins를 구성하기 때문에, 매번 동일한 환경을 재현할 수 있습니다.

jenkins:
  systemMessage: "Managed by JCasC. Do not edit via UI."
  numExecutors: 2
  mode: NORMAL
  # 단일 컨트롤러 구성에서는 별도 inbound agent를 쓰지 않으므로 JNLP TCP 리스너 비활성.
  # -1 = disable (Jenkins core: Jenkins.java#setSlaveAgentPort 참조)
  slaveAgentPort: -1
  securityRealm:
    local:
      allowsSignup: false
      users:
        - id: "admin"
          password: "${JENKINS_ADMIN_PASSWORD}"
  authorizationStrategy:
    loggedInUsersCanDoAnything:
      allowAnonymousRead: false

credentials:
  system:
    domainCredentials:
      - credentials:
          - usernamePassword:
              scope: GLOBAL
              id: "github-pat"
              username: "x-access-token"
              password: "${GITHUB_PAT:-}"
              description: "GitHub PAT for SCM"
          - string:
              scope: GLOBAL
              id: "slack-token"
              secret: "${SLACK_TOKEN:-}"
              description: "Slack bot token"

unclassified:
  location:
    url: "${JENKINS_URL:-http://localhost:8080/}"
    adminAddress: "dldydtjs8124@gmail.com"
  # slackNotifier:
  #   teamDomain: "<TBD>"
  #   tokenCredentialId: "slack-token"

tool:
  git:
    installations:
      - name: Default
        home: "/usr/bin/git"

보안 설정 (securityRealm & authorizationStrategy)

Jenkins에 로그인할 관리자 계정을 정의했습니다. `local` 방식으로 admin 계정을 생성하고, 비밀번호는 환경변수(`JENKINS_ADMIN_PASSWORD`)로 주입받도록 하여 코드에 평문으로 노출되지 않도록 했습니다.

또한 `loggedInUsersCanDoAnything` 전략을 사용해 로그인한 사용자만 Jenkins에 접근할 수 있도록 제한했고, 익명 읽기는 차단했습니다.

자격 증명 (credentials)

파이프라인에서 사용할 외부 서비스 인증 정보를 미리 등록해두었습니다. 두 값 모두 환경변수로 주입받아 민감 정보가 코드에 직접 노출되지 않도록 처리했습니다.

  • github-pat: GitHub Personal Access Token (SCM 연동용)
  • slack-token: Slack Bot Token (빌드 알림용)

시스템 설정

  • location.url: 젠킨스에서 외부로 접근하는 url
  • adminAddress: 시스템 알림이 발송되는 어드민 계정

Slack Notifier 설정은 워크스페이스 도메인이 아직 확정되지 않아 주석 처리해두었으며, 추후 활성화할 예정입니다.

도구 설정 (tool)

Git 실행 파일의 경로를 컨테이너 내부 경로(`/usr/bin/git`)로 명시했습니다. 베이스 이미지에 Git이 사전 설치되어 있다는 전제 하에, Jenkins가 SCM 작업을 수행할 때 이 경로를 사용합니다.

Plugin.txt

Jenkins에서 사용할 플러그인 목록을 선언한 파일입니다. `name:version` 형식으로 플러그인 이름과 버전을 명시하며, Docker 이미지를 빌드하는 시점에 `jenkins-plugin-cli` 도구가 이 파일을 읽어 모든 플러그인을 한꺼번에 설치합니다.

이렇게 이미지에 플러그인을 미리 포함시켜두면, 컨테이너가 실행될 때 별도의 다운로드 없이 곧바로 Jenkins가 기동됩니다.

# jenkins-plugin-cli manifest. Format: name:version (#은 주석)
# Jenkins 2.555.1-lts-jdk21 호환 검증됨. 업데이트: plugins.jenkins.io/api/plugin/<name>

configuration-as-code:2077.v41f1011a_5110
credentials:1502.v5c95e620ddfe
credentials-binding:719.v80e905ef14eb_
plain-credentials:199.v9f8e1f741799
ssh-credentials:372.va_250881b_08cd

workflow-aggregator:608.v67378e9d3db_1
pipeline-stage-view:2.41
pipeline-utility-steps:2.20.0

git:5.10.1
github:1.46.0
github-branch-source:1967.vdea_d580c1a_b_a_
workflow-multibranch:821.vc3b_4ea_780798

docker-workflow:634.vedc7242b_eda_7
ssh-agent:396.vcc7d84e622ec
timestamper:1.30
ws-cleanup:0.49
build-timeout:1.40
ansicolor:536.v13fa_b_860c267

slack:795.v4b_9705b_e6d47

Dockerfile

기본 `jenkins/jenkins:2.555.1-lts-jdk21` 이미지에는 Docker CLI와 AWS CLI가 포함되어 있지 않습니다. 하지만 이번 파이프라인에서는 컨테이너 내부에서 직접 Docker 명령과 AWS 리소스 조작이 필요하기 때문에, 두 도구를 추가로 설치하도록 Dockerfile을 작성했습니다. 전체 흐름은 다음과 같습니다.

  1. Docker CLI 설치: Docker 공식 apt 저장소를 추가하고 `docker-ce-cli` 패키지를 설치합니다.
  2. AWS CLI v2 설치: 공식 zip 인스톨러를 다운로드해 설치하며, IMDSv2를 통해 EC2 인스턴스 프로파일의 자격 증명을 자동으로
    획득하도록 구성됩니다.
  3. Docker 그룹 권한 부여: 호스트의 Docker 소켓을 사용할 수 있도록 `jenkins` 사용자를 `docker` 그룹에 추가합니다.
  4. 플러그인 설치: `plugins.txt` 파일을 이미지 안으로 복사한 뒤, `jenkins-plugin-cli`로 명시된 플러그인들을 일괄 설치합니다.

이 모든 작업이 이미지 빌드 시점에 완료되기 때문에, 컨테이너 실행 시에는 별도의 추가 설치 없이 바로 Jenkins가 기동됩니다.

FROM jenkins/jenkins:2.555.1-lts-jdk21

ARG DOCKER_GID=999

USER root

# Docker CLI: Debian 기본 저장소에 없어 공식 apt repo 추가 필요. unzip은 awscli v2 zip installer용.
RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates curl gnupg lsb-release unzip && \
    install -m 0755 -d /etc/apt/keyrings && \
    curl -fsSL https://download.docker.com/linux/debian/gpg \
      | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
    chmod a+r /etc/apt/keyrings/docker.gpg && \
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
      > /etc/apt/sources.list.d/docker.list && \
    apt-get update && \
    apt-get install -y --no-install-recommends docker-ce-cli && \
    rm -rf /var/lib/apt/lists/*

# awscli v2: 파이프라인이 SSM Run Command / ELBv2 / ECR API를 컨테이너에서 직접 호출.
# 컨트롤러 EC2의 instance profile credentials를 IMDSv2 경유로 자동 획득.
# 플러그인 install 레이어보다 위에 두어 plugins.txt 변경 시 awscli 레이어 재사용.
RUN ARCH=$(uname -m) && \
    curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscliv2.zip && \
    unzip -q /tmp/awscliv2.zip -d /tmp && \
    /tmp/aws/install && \
    rm -rf /tmp/aws /tmp/awscliv2.zip && \
    aws --version

RUN (getent group docker && groupmod -g ${DOCKER_GID} docker) \
      || groupadd -g ${DOCKER_GID} docker && \
    usermod -aG docker jenkins

USER jenkins

COPY plugins.txt /usr/share/jenkins/ref/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt --verbose

DockerCompose.yml

지금까지 작성한 파일들을 정리해보면 다음과 같이 설명할 수 있습니다.

  • Dockerfile: Jenkins + Docker CLI + AWS CLI가 포함된 이미지 정의
  • plugins.txt: 이미지에 설치할 플러그인 목록
  • jcasc 설정: Jenkins의 보안·자격 증명·시스템 설정

이제 이 파일들을 하나로 묶어 실제로 동작하는 컨테이너로 실행하는 단계가 남았습니다. 그 역할을 하는 것이 바로
docker-compose.yml입니다. 이 파일에서는 다음과 같은 항목들을 정의했습니다.

항목 역할
build Dockerfile을 기반으로 이미지를 빌드하고 DOCKER_GID를 주입
ports 8080 포트로 Jenkins 웹 UI 노출
volumes 데이터 영속화(jenkins_home), Docker 소켓 공유, JCasC 설정 마운트
environment Setup Wizard 비활성화, JCasC 경로, 자격 증명 환경변수 주입
healthcheck /login 엔드포인트로 컨테이너 상태 주기적 점검
logging 로그 파일 크기·개수 제한으로 디스크 보호

특히 볼륨 설정에서 세 가지 마운트가 각각 다른 목적을 가진다는 점입니다. 요약하여 설명하면 다음과 같습니다.

  1. jenkins_home (Named Volume) — Jenkins 데이터 영속성
  2. /var/run/docker.sock (Bind Mount) — 호스트 Docker 데몬 공유
  3. ./jcasc (Bind Mount, 읽기 전용) — JCasC 설정 주입

이 모든 설정이 코드로 관리되기 때문에, 누구든 저장소를 클론한 뒤docker compose up -d 한 줄이면 동일한 Jenkins 환경을 띄울 수 있습니다. 이제 이 코드로 정의된 젠킨스를 테라폼을 통해 인터페이스를 사용하지 않고 코드로 인프라를 띄울 것입니다.

services:
  jenkins:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        DOCKER_GID: "${DOCKER_GID:-999}"
    image: pms-order-jenkins:2.555.1
    container_name: pms-order-jenkins
    ports:
      - "8080:8080"
      # 단일 컨트롤러 구성에서는 별도의 JNLP TCP 포트(50000)를 열지 않는다.
    volumes:
      - jenkins_home:/var/jenkins_home
      - /var/run/docker.sock:/var/run/docker.sock
      - ./jcasc:/var/jenkins_home/casc_configs:ro
    environment:
      - JAVA_OPTS=-Djenkins.install.runSetupWizard=false -Xmx1536m
      - CASC_JENKINS_CONFIG=/var/jenkins_home/casc_configs
      - TZ=Asia/Seoul
      - JENKINS_ADMIN_PASSWORD=${JENKINS_ADMIN_PASSWORD}
      - GITHUB_PAT=${GITHUB_PAT:-}
      - SLACK_TOKEN=${SLACK_TOKEN:-}
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/login"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 120s
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

volumes:
  jenkins_home:
    driver: local

 

테라폼으로 젠킨스 서버 띄우기

VPC와 ALB는 다른 팀원들이 직접 구축하기로 역할이 분담되었기 때문에, 기존에 구성된 인프라 위에 Terraform을 활용해 Jenkins 
서버를 어떻게 올릴 수 있는지에 초점을 맞춰 정리해보겠습니다.

저는 이번 스터디에서 단일 컨트롤러 노드 한 대로 구성했지만, 실제 운영 환경에서는 하나의 컨트롤러 노드와 여러 에이전트 노드를 두는 분산 구조도 많이 사용됩니다. 이렇게 분리하면 빌드(CPU·메모리 집약적)와 배포(네트워크 I/O 집약적) 작업을 각각의 워크로드에 특화된 인스턴스에 할당할 수 있어, 자원을 훨씬 효율적으로 사용할 수 있습니다.

서버 컴퓨팅 자원 할당

이 compute.tf는 Jenkins 컨트롤러를 실행할 단일 EC2 인스턴스를 정의하고 있습니다. 내용을 요약하자면 최신 Amazon Linux 2023 AMI를 SSM Parameter Store에서 조회하고, 지정된 서브넷과 보안 그룹 안에 EC2를 생성합니다.

인스턴스에는 IAM Instance Profile, 보안 메타데이터 설정, 암호화된 gp3 루트 볼륨, 그리고 Jenkins 초기 설치과 세팅을 위한 user data를 적용 하고 있습니다. Jenkins 데이터는 별도 EBS 볼륨을 참조하도록 구성되어 있어, EC2 자체와 Jenkins 데이터 저장소의 역활을 분리함으로써 새로운 EC2로 이전하더라도 이전 젠킨스 세팅을 그대로 운용가능하도록 구성할 수 있었습니다.

# Jenkins 컨트롤러 단일 EC2. 학습 목적에서는 ASG/Spot/별도 agent 없이 이 구성이
# 가장 이해하기 쉽고 운영 포인트가 적다.
data "aws_ssm_parameter" "al2023_ami" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}

resource "aws_instance" "controller" {
  ami                         = data.aws_ssm_parameter.al2023_ami.value
  instance_type               = var.controller_instance_type
  subnet_id                   = var.controller_subnet_id
  associate_public_ip_address = var.associate_public_ip_address
  vpc_security_group_ids      = [aws_security_group.controller.id]

  iam_instance_profile = aws_iam_instance_profile.controller.name

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 2
  }

  root_block_device {
    volume_size           = 20
    volume_type           = "gp3"
    delete_on_termination = true
    encrypted             = true
  }

  tags = {
    Name = "jenkins-controller"
    Role = "controller"
  }

  user_data = templatefile("${path.module}/templates/userdata-controller.sh.tftpl", {
    jenkins_repo_url     = var.jenkins_repo_url
    jenkins_repo_ref     = var.jenkins_repo_ref
    jenkins_repo_raw_url = var.jenkins_repo_raw_url
    jenkins_data_volume  = aws_ebs_volume.jenkins_data.id
    aws_region           = var.aws_region
  })

  user_data_replace_on_change = true
}

EBS 설정

EBS는 EC2 인스턴스에 연결해서 사용하는 네트워크 기반 블록 스토리지입니다. 쉽게 말하면 서버에 붙이는 디스크라고 볼 수 있습니다. EC2의 루트 디스크와 별도로 EBS 볼륨을 만들어 Jenkins 데이터를 저장하면, EC2 인스턴스가 교체되더라도 해당 볼륨을 다시 연결해 데이터를 유지할 수 있습니다.

또한 스냅샷을 통해 백업하거나 복구할 수 있습니다. 즉, 물리적인 하드디스크를 직접 다루는 대신 AWS에서 추상화된 디스크 자원을 생성하고 EC2에 붙여 사용하는 구조입니다. 

# ─────────────────────────────────────────────────────────────────────────────
# 영속 EBS - controller subnet과 같은 AZ에 생성. EC2 user-data에서 attach.
# prevent_destroy로 실수로 인한 데이터 손실 방지 (변경하려면 lifecycle 블록 수정).
# ─────────────────────────────────────────────────────────────────────────────

resource "aws_ebs_volume" "jenkins_data" {
  availability_zone = data.aws_subnet.controller.availability_zone
  size              = var.data_volume_size_gb
  type              = "gp3"
  encrypted         = true

  tags = {
    Name     = "jenkins-data"
    Snapshot = "true"
  }

  lifecycle {
    prevent_destroy = true
  }
}

 

IAM 권한 설정

AWS의 인프라 뿐만 아니라 코드를 통해 IAM 권한 또한 다음과 같이 정의할 수 있습니다. 아래의 스크립트는 제가 젠킨스를 SSM을 활용하여 서버를 제어하기 위해 설정을 하였고 ELB를 사용하기 위한 설정과 실제 ECR을 통해 이미지를 올리고 서버 인스턴스에 배포를 하기위한 권한들을 지정해두었습니다.

data "aws_caller_identity" "current" {}

data "aws_iam_policy_document" "ec2_assume_role" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "controller" {
  name               = "jenkins-controller-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
}

resource "aws_iam_role_policy_attachment" "controller_ssm_core" {
  role       = aws_iam_role.controller.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

data "aws_iam_policy_document" "controller_inline" {
  statement {
    effect    = "Allow"
    actions   = ["ssm:GetParameter", "ssm:GetParameters"]
    resources = ["arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/jenkins/*"]
  }

  statement {
    effect    = "Allow"
    actions   = ["ec2:AttachVolume", "ec2:DetachVolume", "ec2:DescribeVolumes"]
    resources = ["*"]
  }

  # ── pms-order 롤링 배포 파이프라인용 ────────────────────────────────────────
  # 파이프라인이 컨트롤러 IAM으로 다음 작업을 수행:
  #   - SSM Parameter Store에서 앱 메타데이터(INSTANCE_IDS, TG_ARN, ECR_REPO 등) 조회
  #   - SSM Run Command로 앱 EC2에서 deploy.sh / stop-old-color.sh / nginx 롤백 실행
  #   - ALB 타겟 deregister/register + healthy 폴링
  #   - ECR에 새 이미지 push
  #   - SSM 명령 출력 → CloudWatch Logs

  statement {
    effect = "Allow"
    actions = [
      "ssm:GetParameter",
      "ssm:GetParameters",
      "ssm:GetParametersByPath",
    ]
    resources = ["arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/pms-order/*"]
  }

  # SendCommand: instance와 document를 분리한다.
  # condition은 statement 전체에 적용되므로, AWS 관리 문서(AWS-RunShellScript)에까지
  # ssm:resourceTag/Project 태그를 요구하면 condition fail로 전체가 deny된다.
  # → instance 리소스에만 Project=pms-order 태그 조건을 걸고, 문서는 무조건 허용.
  statement {
    effect    = "Allow"
    actions   = ["ssm:SendCommand"]
    resources = ["arn:aws:ssm:${var.aws_region}::document/AWS-RunShellScript"]
  }

  statement {
    effect    = "Allow"
    actions   = ["ssm:SendCommand"]
    resources = ["arn:aws:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*"]
    # 인스턴스는 태그 Project=pms-order 가 붙은 것에만 명령 가능. 운영 안전장치.
    condition {
      test     = "StringEquals"
      variable = "ssm:resourceTag/Project"
      values   = ["pms-order"]
    }
  }

  statement {
    effect = "Allow"
    # 리소스 레벨 스코프 미지원 → wildcard 강제
    actions = [
      "ssm:GetCommandInvocation",
      "ssm:ListCommandInvocations",
      "ssm:DescribeInstanceInformation",
    ]
    resources = ["*"]
  }

  statement {
    effect = "Allow"
    # TG ARN은 SSM에서 런타임 조회 → 학습용 wildcard.
    # 운영 시 SSM 키가 안정화되면 특정 TG ARN 으로 좁힐 것.
    actions = [
      "elasticloadbalancing:DescribeTargetHealth",
      "elasticloadbalancing:DescribeTargetGroups",
      "elasticloadbalancing:RegisterTargets",
      "elasticloadbalancing:DeregisterTargets",
    ]
    resources = ["*"]
  }

  statement {
    effect    = "Allow"
    actions   = ["ec2:DescribeInstances"]
    resources = ["*"]
  }

  statement {
    effect    = "Allow"
    actions   = ["ecr:GetAuthorizationToken"]
    resources = ["*"]
  }

  statement {
    effect = "Allow"
    actions = [
      "ecr:BatchCheckLayerAvailability",
      "ecr:PutImage",
      "ecr:InitiateLayerUpload",
      "ecr:UploadLayerPart",
      "ecr:CompleteLayerUpload",
      "ecr:BatchGetImage",
      "ecr:DescribeRepositories",
    ]
    # 실제 ECR repo 명: b-team/pms-order. b-team 네임스페이스 하위 레포 전체 허용.
    resources = ["arn:aws:ecr:${var.aws_region}:${data.aws_caller_identity.current.account_id}:repository/b-team/*"]
  }

  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
      "logs:DescribeLogStreams",
    ]
    resources = ["arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/ssm/pms-order-deploy*"]
  }
}

resource "aws_iam_role_policy" "controller_inline" {
  name   = "jenkins-controller-inline"
  role   = aws_iam_role.controller.id
  policy = data.aws_iam_policy_document.controller_inline.json
}

resource "aws_iam_instance_profile" "controller" {
  name = "jenkins-controller-profile"
  role = aws_iam_role.controller.name
}

 

Network

젠킨스 서버를 띄우기 위한 권한, 저장 공간, 인스턴스 스펙을 정의하였으니 이제는 네트워크를 설정을 해야합니다. 저는 이미 구축되어있는 가상 네트워크 망과 서브넷이 있어서 기존 VPC를 테라폼 변수로 지정하여 주입하도록 구현하였습니다. 그리고 젠킨스가 뜨는 8080 포트를 인바운드 규칙을 지정해줌으로써 이제 젠킨스를 코드로 띄울 준비가 완료되었습니다.

이제 terraform apply를 입력하면 테라폼이 내부적으로 AWS의 인프라를 구성하여 생성 및 수정해주고 AWS 콘솔을 통해 확인하면 Jenkins-Controller라는 인스턴스가 떠있는 것을 확인할 수 있었습니다.

# 기존 VPC 안에 Jenkins 컨트롤러 1대만 배치한다.
data "aws_vpc" "selected" {
  id = var.vpc_id
}

data "aws_subnet" "controller" {
  id = var.controller_subnet_id
}

resource "aws_security_group" "controller" {
  name        = "jenkins-controller"
  description = "Jenkins controller - 8080 from existing VPC only"
  vpc_id      = data.aws_vpc.selected.id
}

resource "aws_security_group_rule" "controller_8080_from_vpc" {
  type              = "ingress"
  security_group_id = aws_security_group.controller.id
  from_port         = 8080
  to_port           = 8080
  protocol          = "tcp"
  cidr_blocks       = [data.aws_vpc.selected.cidr_block]
  description       = "HTTP from existing ALB or VPC clients"
}

resource "aws_security_group_rule" "controller_egress_all" {
  type              = "egress"
  security_group_id = aws_security_group.controller.id
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  description       = "All egress"
}
# 예시 변수값. 실제 사용 시 terraform.tfvars(gitignore됨)로 복사 후 수정.

aws_region  = "ap-northeast-2"
aws_profile = "my-profile"

# 기존 AWS 인프라
vpc_id               = "vpc-xxxxxxxxxxxxxxxxx"
controller_subnet_id = "subnet-xxxxxxxxxxxxxxxxx"

# ALB는 별도로 연결한다. ALB DNS가 정해졌다면 여기에 넣는다.
# 비워두면 Jenkins 내부 location.url은 http://localhost:8080/ 이다.
# jenkins_url = "http://existing-alb-123456789.ap-northeast-2.elb.amazonaws.com/"

# 리포 URL - user-data가 git clone / curl 로 받아옴
jenkins_repo_url     = "https://github.com/protect-my-service/bteam-jenkins.git"
jenkins_repo_ref     = "main"
jenkins_repo_raw_url = "https://raw.githubusercontent.com/protect-my-service/bteam-jenkins/main"

# 단일 컨트롤러 EC2
# controller_instance_type    = "t3.medium"
# associate_public_ip_address = true

# Jenkins home EBS
# data_volume_size_gb = 30

관련 코드

https://github.com/protect-my-service/bteam-jenkins

 

GitHub - protect-my-service/bteam-jenkins: pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포

pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포. Contribute to protect-my-service/bteam-jenkins development by creating an account on GitHub.

github.com

 

CI/CD 파이프라인 구성하기

이번 CI/CD 파이프라인은 컨테이너 단위의 Blue-Green 배포와 인스턴스 단위의 Rolling 배포를 조합한 하이브리드 방식으로 구성했습니다.
각 서버 앞단에는 Nginx 리버스 프록시를 두고, 하나의 인스턴스 내부에서는 기존 컨테이너와 신규 컨테이너를 분리하여 실행합니다. 신규 컨테이너가 정상적으로 기동되고 헬스 체크를 통과하면 Nginx의 upstream을 신규 컨테이너로 전환합니다. 이처럼 인스턴스 내부에서는 컨테이너 단위의 Blue-Green 배포가 이루어집니다.
전체 서버 관점에서는 두 대의 인스턴스를 동시에 교체하지 않고 한 대씩 순차적으로 배포합니다. 첫 번째 인스턴스의 신규 컨테이너가 정상적으로 배포되고 트래픽 전환까지 완료되면, 그 다음 두 번째 인스턴스에 동일한 과정을 수행합니다. 이 부분은 Rolling 배포 방식으로 구현이 되어있습니다.
이러한 구조를 선택한 이유는 배포 시간과 롤백 시간을 줄이기 위해서 선택하였습니다. 매번 새로운 서버를 프로비저닝하는 방식은 안정적일 수 있지만, 인스턴스 생성과 초기화에 시간이 필요합니다. 반면 기존 인스턴스 안에서 Docker 이미지를 기반으로 컨테이너만 교체하면 배포 속도를 높일 수 있습니다.
또한 신규 버전에 문제가 발생하더라도 이전 버전의 Docker 이미지를 다시 실행하고 Nginx 라우팅을 되돌리면 되기 때문에, 별도의 인프라 재구성 없이 빠르게 롤백할 수 있습니다.
결과적으로 이러한 구조를 설계하여 제한된 인프라 환경에서 무중단에 가까운 배포, 빠른 롤백, 점진적인 배포 전파를 모두 확보할 수 있는 형태로 배포를 진행할 수 있게 되었습니다.

젠킨스 파일

https://github.com/protect-my-service/bteam-jenkins/blob/main/Jenkinsfile

 

bteam-jenkins/Jenkinsfile at main · protect-my-service/bteam-jenkins

pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포. Contribute to protect-my-service/bteam-jenkins development by creating an account on GitHub.

github.com

 

후기

이번 Jenkins 인프라 구축 과정에서는 Claude Code와 같은 AI 도구를 함께 활용했습니다. 예전 같았다면 Terraform 리소스를 하나씩 찾아보고, Jenkins 설정 파일을 직접 비교하며, Dockerfile과 IAM 정책을 반복해서 수정하느라 훨씬 더 많은 시간이 걸렸을 것입니다. 하지만 이제는 어느 정도의 아키텍처 방향과 요구사항만 명확하다면, 인프라 코드와 설정 파일의 초안을 빠르게 만들어낼 수 있다는 것을 체감했습니다.
이 경험을 통해 앞으로 개발자의 역할이 단순히 코드를 직접 작성하는 사람에서, 문제를 구조화하고 결과물을 검증하는 오케스트레이터에 가까워질 수 있겠다는 생각이 들었습니다. AI가 많은 부분을 빠르게 생성해줄 수는 있지만, 왜 이런 구조가 필요한지, 이 권한이 과도하지는 않은지, 장애가 났을 때 복구 가능한 구조인지 판단하는 일은 여전히 개발자의 몫이기 때문입니다.
특히 인프라 영역에서는 겉으로 보기에는 동작하는 코드처럼 보여도, 작은 설정 하나가 보안 문제나 운영 장애로 이어질 수 있을 것입니다. IAM 권한 범위, EBS 영속성, User Data 실행 방식, Nginx 라우팅 전환, 롤백 전략 같은 부분은 단순히 코드가 생성되었다고 끝나는 것이 아니라 실제 운영 관점에서 생각하고 고려해야됩니다.
이번 스터디를 통해 구현 속도는 분명히 과거보다 훨씬 빨라졌다는 것을 느꼈습니다. 동시에 그만큼 내부 동작을 깊게 이해하지 못한 채 넘어갈 위험도 커졌다고 생각합니다. 결국 중요한 것은 AI를 통해 빠르게 만드는 것이 아니라, 빠르게 만든 결과물을 내가 설명할 수 있고, 운영 중 문제가 발생했을 때 책임지고 수정할 수 있느냐? 가 중요해질 것 같다는 생각이 들었습니다.