Kubernetes – Spring Boot Hello World 와 정적 리소스 프로젝트 배포하기

By | 2026년 10월 5일
Table of Contents

Kubernetes – Spring Boot Hello World 와 정적 리소스 프로젝트 배포하기

이 글은 Kubernetes – ArgoCD + Helm 으로 Native Helm + GitOps Values 패턴 구성하기 의 후속편입니다. 2편에서 만든 단일 노드 클러스터(Debian 13 / Raspberry Pi, 192.168.20.206)와 k8s-gitops 저장소를 그대로 사용합니다.

0. 목표

이번 글에서는 직접 만든 애플리케이션 2개를 2편과 같은 Native Helm + GitOps Values 패턴으로 배포합니다.

  • spring-hello : Spring Boot 로 만든 Hello World 웹 애플리케이션 (Thymeleaf 페이지 1장 + JSON API 1개)
  • static-web : 이미지, CSS 같은 정적 리소스만 서빙하는 nginx 컨테이너

spring-hello 의 HTML 페이지는 CSS 와 이미지를 static-web 에서 불러옵니다. 실제 서비스에서 WAS 와 정적 리소스 서버(또는 CDN)를 분리하는 구조를 홈랩에서 그대로 연습하는 것이 목적입니다.

2편과 달라지는 점은 하나입니다. nginx 공식 이미지 대신 내가 빌드한 이미지를 써야 하므로, 이미지를 빌드해서 레지스트리에 올리는 단계(CI)가 추가됩니다.

1. 전체 구조

저장소는 3개를 사용합니다. 앱 저장소 2개(spring-hello, static-web)는 이미지를 만들고, 2편의 k8s-gitops 는 그 이미지의 어느 태그를 배포할지만 결정합니다.

file

윗줄은 앱 저장소에 push 할 때마다 자동으로 돌고, 아랫줄은 values 의 image.tag 를 바꿔 push 할 때만 돕니다. 브라우저는 spring-hello 에서 HTML 을, static-web 에서 CSS·이미지를 각각 받습니다.

2. Spring Boot Hello World 프로젝트 (spring-hello)

Spring Boot 는 2026년 6월 GA 된 4.1.x, Java 는 LTS 인 25 를 사용합니다. start.spring.io 에서 아래처럼 만들면 됩니다.

  • Project: Gradle – Groovy / Language: Java / Spring Boot: 4.1.x / Java: 25
  • Group: com.example / Artifact: spring-hello
  • Dependencies: Spring Web, Thymeleaf, Spring Boot Actuator
spring-hello/
├── .github/workflows/build.yml      # 4장
├── Dockerfile
├── build.gradle
└── src/main/
    ├── java/com/example/hello/
    │   ├── SpringHelloApplication.java
    │   └── HelloController.java
    └── resources/
        ├── application.yaml
        └── templates/index.html

2.1 build.gradle

start.spring.io 가 만들어 준 파일에서 jar 파일 이름 고정과 plain jar 비활성화 두 가지만 추가합니다. Dockerfile 에서 build/libs/app.jar 하나만 복사하기 위해서입니다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.0'   // start.spring.io 가 넣어 준 최신 패치 그대로
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

// ★ 추가
bootJar {
    archiveFileName = 'app.jar'
}
jar {
    enabled = false
}

Spring Boot 4 부터 spring-boot-starter-web 은 spring-boot-starter-webmvc 로 이름이 바뀌었습니다. 예전 이름도 동작하지만 deprecated 입니다.

2.2 HelloController.java

페이지 1장(/)과 JSON API 1개(/api/hello)를 만듭니다. 응답에 파드 이름(HOSTNAME) 을 넣어 두면 레플리카 2개에 요청이 나뉘는 것을 눈으로 확인할 수 있습니다.

package com.example.hello;

import java.util.Map;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class HelloController {

    @Value("${app.static-base-url}")
    private String staticBaseUrl;

    @Value("${app.version}")
    private String version;

    private final String hostname = System.getenv().getOrDefault("HOSTNAME", "local");

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("staticBaseUrl", staticBaseUrl);
        model.addAttribute("version", version);
        model.addAttribute("hostname", hostname);
        return "index";
    }

    @GetMapping("/api/hello")
    @ResponseBody
    public Map<String, String> hello() {
        return Map.of(
                "message", "Hello, World!",
                "version", version,
                "pod", hostname);
    }
}

2.3 application.yaml

정적 리소스 서버 주소는 코드에 하드코딩하지 않고 설정값으로 둡니다. 쿠버네티스에서는 환경변수 APP_STATIC_BASE_URL 로 덮어씁니다(Spring 의 relaxed binding).

app:
  static-base-url: http://localhost:8081   # 로컬 개발용 기본값
  version: local

management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      probes:
        enabled: true        # /actuator/health/liveness, /readiness 활성화

2.4 templates/index.html

CSS 와 이미지는 staticBaseUrl 을 앞에 붙여서 static-web 에서 가져옵니다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko">
<head>
    <meta charset="UTF-8">
    <title>Spring Hello</title>
    <link rel="stylesheet" th:href="${staticBaseUrl + '/css/style.css'}">
</head>
<body>
<main class="card">
    <img th:src="${staticBaseUrl + '/img/logo.png'}" alt="logo" class="logo">
    <h1>Hello, World!</h1>
    <p>version : <span th:text="${version}">local</span></p>
    <p>pod : <span th:text="${hostname}">local</span></p>
</main>
</body>
</html>

2.5 Dockerfile

jar 는 GitHub Actions 러너에서 미리 빌드하고(4장), Dockerfile 은 복사만 합니다. RUN 명령이 없으므로 arm64 이미지를 만들 때 QEMU 에뮬레이션이 필요 없고 빌드가 빠릅니다.

FROM eclipse-temurin:25-jre

WORKDIR /app
COPY build/libs/app.jar app.jar

# 컨테이너 메모리 limit 의 75% 를 힙으로 사용
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"

# root 로 실행하지 않음 (숫자 UID 는 RUN useradd 없이도 사용 가능)
USER 1000:1000

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

로컬 확인

./gradlew bootRun
curl http://localhost:8080/api/hello

3. 정적 리소스 프로젝트 (static-web)

정적 리소스는 별도 저장소로 분리하고, 파일을 nginx 이미지 안에 넣어서 배포합니다. 디자이너나 프론트엔드 담당이 CSS·이미지만 고쳐서 push 해도 WAS 를 다시 빌드할 필요가 없습니다.

static-web/
├── .github/workflows/build.yml      # 4장
├── Dockerfile
├── nginx/
│   └── default.conf
└── html/
    ├── index.html                   # 정적 서버 자체 확인용
    ├── css/
    │   └── style.css
    └── img/
        └── logo.jpg                 # 아무 PNG 파일

3.1 html/css/style.css

body {
    margin: 0;
    min-height: 100vh;
    display: grid;
    place-items: center;
    font-family: system-ui, sans-serif;
    background: #f2f4f7;
}

.card {
    padding: 40px 56px;
    border-radius: 12px;
    background: #fff;
    box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
    text-align: center;
}

.logo {
    width: 96px;
}

html/index.html 은 정적 서버만 단독으로 열었을 때 확인용입니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>static-web</title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="card">
    <img src="/img/logo.png" alt="logo" class="logo">
    <h1>static-web OK</h1>
</main>
</body>
</html>

3.2 nginx/default.conf

이미지 기본 설정을 덮어써서 정적 파일 캐시 헤더와 헬스체크 경로를 추가합니다.

server {
    listen       80;
    server_name  _;
    root         /usr/share/nginx/html;

    location / {
        try_files $uri $uri/ =404;
    }

    # 정적 파일: 브라우저 캐시 1시간
    location ~* \.(css|js|png|jpe?g|gif|svg|webp|ico|woff2?)$ {
        expires 1h;
        access_log off;
        # 다른 포트(=다른 origin)의 spring-hello 페이지에서 폰트·fetch 로 읽을 때 필요
        add_header Access-Control-Allow-Origin "*";
    }

    # k8s probe 용
    location = /healthz {
        access_log off;
        default_type text/plain;
        return 200 "ok";
    }
}

<link rel="stylesheet"> 와 <img> 는 다른 origin 에서 불러와도 CORS 헤더가 필요 없습니다. 나중에 웹폰트(@font-face)나 JS 의 fetch() 로 읽는 파일이 생기면 그때 위 Access-Control-Allow-Origin 이 필요합니다.

3.3 Dockerfile

# 운영에서는 nginx:1.xx-alpine 처럼 버전을 고정하세요
FROM nginx:stable-alpine

COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY html/ /usr/share/nginx/html/

이 Dockerfile 도 RUN 이 없어서 arm64 이미지를 에뮬레이션 없이 바로 만들 수 있습니다.

로컬 확인 (2.3 의 기본값 http://localhost:8081 과 포트를 맞춥니다)

docker build -t static-web .
docker run --rm -p 8081:80 static-web

# 다른 터미널에서 spring-hello 를 bootRun 한 뒤
# http://localhost:8080/ 접속 → 스타일과 로고가 보이면 성공

4. GitHub Actions 로 이미지 빌드 → ghcr.io push

라즈베리 파이에서 직접 docker build 해도 되지만, 클러스터 노드에서 빌드하는 것은 GitOps 흐름과 맞지 않습니다. 이미지는 GitHub Actions 가 빌드해서 GitHub Container Registry(ghcr.io) 에 올리고, 클러스터는 받아 가기만 합니다.

  • linux/arm64 (라즈베리 파이)와 linux/amd64 (개발 PC) 이미지를 함께 만듭니다
  • 태그는 커밋 해시 기반 sha-xxxxxxx 와 latest 를 붙이고, 클러스터에서는 sha- 태그만 사용합니다 (7장)
  • 인증은 Actions 기본 제공 GITHUB_TOKEN 을 사용하므로 별도 토큰이 필요 없습니다

4.1 spring-hello/.github/workflows/build.yml

name: build

on:
  push:
    branches: [ main ]
  workflow_dispatch:

permissions:
  contents: read
  packages: write          # ghcr.io push 권한

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: '25'
          cache: gradle

      - name: Build jar
        run: ./gradlew bootJar --no-daemon

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository_owner }}/spring-hello
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest

      - uses: docker/build-push-action@v7
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

4.2 static-web/.github/workflows/build.yml

Java 빌드 단계만 빠지고 나머지는 같습니다.

name: build

on:
  push:
    branches: [ main ]
  workflow_dispatch:

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository_owner }}/static-web
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest
      - uses: docker/build-push-action@v7
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

액션의 메이저 버전(@v5, @v7 등)은 작성 시점 기준입니다. 각 액션 저장소의 Releases 에서 최신 메이저 버전을 확인하세요.

4.3 패키지를 Public 으로 변경

처음 push 된 ghcr.io 패키지는 Private 입니다. 홈랩에서는 Public 으로 바꾸는 것이 가장 간단합니다.

  1. GitHub 프로필 → Packages → spring-hello
  2. Package settings → Danger Zone → Change visibility → Public
  3. static-web 도 동일하게

클러스터 노드에서 받아지는지 확인합니다.

# sudo crictl pull ghcr.io/<YOUR_ID>/spring-hello:latest
# sudo crictl pull ghcr.io/<YOUR_ID>/static-web:latest

sudo ctr -n k8s.io images pull --platform linux/arm64 ghcr.io/<YOUR_ID>/spring-hello:latest
sudo ctr -n k8s.io images pull --platform linux/arm64 ghcr.io/<YOUR_ID>/static-web:latest

sudo ctr -n k8s.io images ls | grep <YOUR_ID>

ghcr.io 이미지 경로는 소문자만 허용됩니다. GitHub ID 에 대문자가 있다면 values 에서는 소문자로 적어야 합니다.

push 가 끝나면 Actions 로그 또는 패키지 페이지에서 sha- 로 시작하는 태그(예: sha-1a2b3c4)를 확인해 둡니다. 다음 장에서 values 에 적을 값입니다.

5. k8s-gitops 저장소에 추가

2편의 규칙(apps/ = 무엇을·어느 버전으로, values/ = 어떻게)을 그대로 따릅니다. 추가되는 파일은 다음과 같습니다.

k8s-gitops/
├── apps/
│   ├── spring-hello.yaml            # ★ 추가
│   └── static-web.yaml              # ★ 추가
├── values/
│   ├── spring-hello/values.yaml     # ★ 추가
│   └── static-web/values.yaml       # ★ 추가
└── charts/
    ├── nginx/                       # 2편 차트 → static-web 에 재사용
    └── spring-boot/                 # ★ 추가 (Spring Boot 앱 공용 차트)
        ├── Chart.yaml
        ├── values.yaml
        └── templates/
            ├── deployment.yaml
            └── service.yaml
  • static-web 은 결국 nginx 이미지이므로 2편의 charts/nginx 를 그대로 재사용하고, values 에서 이미지만 바꿉니다.
  • spring-hello 는 포트(8080), 헬스체크(actuator), JVM 메모리 설정이 필요하므로 charts/spring-boot 를 새로 만듭니다. 앱 이름을 Release 이름으로 쓰기 때문에, 이후 만드는 Spring Boot 앱도 values 파일 하나만 추가하면 됩니다.

5.1 charts/spring-boot

charts/spring-boot/Chart.yaml

apiVersion: v2
name: spring-boot
description: Spring Boot 앱 공용 차트
type: application
version: 0.1.0

charts/spring-boot/values.yaml (차트 기본값)

replicaCount: 1

image:
  repository: ""
  tag: ""

imagePullSecrets: []        # private 레지스트리일 때만 사용 (8장)

containerPort: 8080

# 환경변수 (KEY: value)
env: {}

service:
  type: ClusterIP
  port: 80
  nodePort: null

resources:
  requests: { cpu: 100m, memory: 256Mi }
  limits:   { memory: 512Mi }

charts/spring-boot/templates/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
  labels:
    app: {{ .Release.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app: {{ .Release.Name }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - name: http
              containerPort: {{ .Values.containerPort }}
          env:
            - name: APP_VERSION                 # 화면에 표시할 버전 = 이미지 태그
              value: {{ .Values.image.tag | quote }}
            {{- range $key, $value := .Values.env }}
            - name: {{ $key }}
              value: {{ $value | quote }}
            {{- end }}
          # 라즈베리 파이에서는 기동에 수십 초 걸림 → 최대 180초 기다림
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: http }
            periodSeconds: 5
            failureThreshold: 36
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: http }
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: http }
            periodSeconds: 5
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

charts/spring-boot/templates/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}
spec:
  type: {{ .Values.service.type }}
  selector:
    app: {{ .Release.Name }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
      {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
      nodePort: {{ .Values.service.nodePort }}
      {{- end }}

5.2 values (이 클러스터의 실제 설정)

포트는 2편 nginx(31405) 다음 번호를 사용합니다.

앱 NodePort 접속 주소
spring-hello 31406 http://192.168.20.206:31406/
static-web 31407 http://192.168.20.206:31407/

values/spring-hello/values.yaml

replicaCount: 2

image:
  repository: ghcr.io/<YOUR_ID>/spring-hello
  tag: sha-1a2b3c4               # 4장에서 확인한 태그

env:
  # 브라우저가 CSS·이미지를 받아 갈 주소 → 노드 IP + static-web NodePort
  APP_STATIC_BASE_URL: http://192.168.20.206:31407

service:
  type: NodePort
  nodePort: 31406

values/static-web/values.yaml (2편 charts/nginx 의 값 구조 그대로)

replicaCount: 1

image:
  repository: ghcr.io/<YOUR_ID>/static-web
  tag: sha-5d6e7f8               # 4장에서 확인한 태그

service:
  type: NodePort
  nodePort: 31407

APP_STATIC_BASE_URL 에 쿠버네티스 내부 주소(http://static-web)를 쓰면 안 됩니다. CSS·이미지를 요청하는 것은 파드가 아니라 사용자의 브라우저이기 때문에, 브라우저에서 접근 가능한 주소여야 합니다.

5.3 apps (Application 정의)

2편 3.3 의 nginx 와 같은 형태입니다. 차트도 values 도 같은 저장소에 있지만 multi-source 형태를 유지합니다.

apps/spring-hello.yaml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: spring-hello
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  sources:
    - repoURL: https://github.com/<YOUR_ID>/k8s-gitops.git
      targetRevision: main
      path: charts/spring-boot
      helm:
        releaseName: spring-hello
        valueFiles:
          - $values/values/spring-hello/values.yaml
    - repoURL: https://github.com/<YOUR_ID>/k8s-gitops.git
      targetRevision: main
      ref: values
  destination:
    server: https://kubernetes.default.svc
    namespace: hello
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions:
      - CreateNamespace=true

apps/static-web.yaml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: static-web
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  sources:
    - repoURL: https://github.com/<YOUR_ID>/k8s-gitops.git
      targetRevision: main
      path: charts/nginx                 # 2편 차트 재사용
      helm:
        releaseName: static-web
        valueFiles:
          - $values/values/static-web/values.yaml
    - repoURL: https://github.com/<YOUR_ID>/k8s-gitops.git
      targetRevision: main
      ref: values
  destination:
    server: https://kubernetes.default.svc
    namespace: hello
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions:
      - CreateNamespace=true

두 앱은 hello 네임스페이스에 함께 배포합니다. 2편의 nginx(default 네임스페이스)와 섞이지 않게 하기 위해서입니다.

push 전에 렌더링 결과를 미리 확인합니다. (2편 5.3 과 같은 방법)

helm template spring-hello charts/spring-boot -n hello \
  -f values/spring-hello/values.yaml | less

helm template static-web charts/nginx -n hello \
  -f values/static-web/values.yaml | less

6. 적용 및 확인

2편에서 root Application 이 apps/ 디렉터리를 보고 있으므로, push 만 하면 새 Application 2개가 자동으로 생성됩니다. kubectl apply 는 필요 없습니다.

git add .
git commit -m "add spring-hello, static-web"
git push origin main

# 바로 반영하려면 (기다리면 최대 3분)
argocd login 192.168.20.206:30443 --username admin --insecure
argocd app get root --refresh
kubectl -n argocd get applications
------------------------------------
NAME             SYNC STATUS   HEALTH STATUS
argocd           Synced        Healthy
headlamp         Synced        Healthy
metrics-server   Synced        Healthy
nginx            Synced        Healthy
root             Synced        Healthy
spring-hello     Synced        Healthy
static-web       Synced        Healthy
kubectl -n hello get pods,svc
------------------------------------
NAME                                READY   STATUS    RESTARTS   AGE
pod/spring-hello-7c9d8b6f5d-4kq2x   1/1     Running   0          2m
pod/spring-hello-7c9d8b6f5d-p8m7z   1/1     Running   0          2m
pod/static-web-6b8f7d9c4-x2n5w      1/1     Running   0          2m

NAME                   TYPE       CLUSTER-IP      PORT(S)        AGE
service/spring-hello   NodePort   10.96.112.45    80:31406/TCP   2m
service/static-web     NodePort   10.96.201.13    80:31407/TCP   2m

spring-hello 파드는 라즈베리 파이에서 READY 1/1 이 되기까지 30초\~1분 정도 걸립니다. 그동안은 startupProbe 가 기다려 주므로 재시작되지 않습니다.

6.1 정적 리소스 확인

curl -I http://192.168.20.206:31407/css/style.css
# HTTP/1.1 200 OK
# Content-Type: text/css
# Expires: ...
# Cache-Control: max-age=3600
# Access-Control-Allow-Origin: *

6.2 Spring Boot 확인

# 여러 번 호출하면 pod 값이 두 파드 사이에서 바뀜
for i in $(seq 1 6); do curl -s http://192.168.20.206:31406/api/hello; echo; done
# {"message":"Hello, World!","version":"sha-1a2b3c4","pod":"spring-hello-7c9d8b6f5d-4kq2x"}
# {"message":"Hello, World!","version":"sha-1a2b3c4","pod":"spring-hello-7c9d8b6f5d-p8m7z"}

브라우저에서 http://192.168.20.206:31406/ 에 접속해 카드 스타일과 로고 이미지가 보이면 두 앱이 연결된 것입니다. 개발자 도구 Network 탭에서 style.css, logo.png 가 :31407 에서 받아진 것을 확인할 수 있습니다.

  • spring-hello : http://192.168.20.206:31406/
  • static-web : http://192.168.20.206:31407/
  • ArgoCD : https://192.168.20.206:30443/ (Application 2개 추가 확인)

7. 새 버전 배포: 앱 push → 태그 1줄 변경

코드를 고친 뒤의 배포는 두 번의 push 입니다. 앱 저장소 push 로 이미지가 만들어지고, k8s-gitops 저장소 push 로 배포됩니다.

  1. 앱 저장소(spring-hello 또는 static-web)에서 코드를 수정하고 push
  2. GitHub Actions 가 새 이미지 sha-9f8e7d6 을 ghcr.io 에 push
  3. k8s-gitops 에서 values 의 image.tag 한 줄만 변경 후 push
cd k8s-gitops
vi values/spring-hello/values.yaml     # tag: sha-9f8e7d6
git commit -am "spring-hello sha-9f8e7d6"
git push

ArgoCD 가 Deployment 의 image 가 바뀐 것을 감지해 롤링 업데이트합니다. readinessProbe 가 통과한 새 파드만 트래픽을 받으므로, 6.2 의 curl 반복 호출을 켜 둔 채로 배포해도 응답이 끊기지 않습니다.

kubectl -n hello rollout status deployment/spring-hello

7.1 왜 latest 를 쓰지 않는가

values 에 tag: latest 를 적으면 새 이미지가 올라와도 Git 에는 아무 변화가 없으므로 ArgoCD 가 배포할 이유가 없습니다. 또 지금 어떤 코드가 돌고 있는지 Git 만 보고는 알 수 없게 됩니다.

sha- 태그를 쓰면 다음이 모두 Git 으로 해결됩니다.

  • 현재 배포 버전 = values 의 태그 = 앱 저장소의 커밋
  • 롤백 = git revert 후 push (2편 6.1 과 동일)
  • 배포 이력 = k8s-gitops 의 커밋 로그

7.2 태그 변경을 자동화하려면

태그 수정이 번거로워지면 다음 두 가지 방법이 있습니다.

  • 앱 저장소 Actions 에서 k8s-gitops 에 커밋 : 빌드 마지막 단계에서 values 의 태그를 sed 로 바꾸고 push. k8s-gitops 쓰기 권한이 있는 토큰을 앱 저장소 Secret 으로 등록해야 합니다.
  • Argo CD Image Updater : 레지스트리를 감시하다가 새 태그가 생기면 Git 에 자동 커밋. 별도 컴포넌트를 설치해야 합니다.

두 방법 모두 결국 Git 의 태그가 바뀌어서 배포된다는 원칙은 같습니다. 이 글에서는 흐름이 잘 보이도록 수동으로 둡니다.

8. 주의사항과 문제 해결

8.1 exec format error 로 파드가 바로 죽을 때

이미지에 arm64 가 없는 경우입니다. 4장 workflow 의 platforms 에 linux/arm64 가 있는지, 그리고 이미지에 두 아키텍처가 모두 들어갔는지 확인합니다.

docker buildx imagetools inspect ghcr.io/<YOUR_ID>/spring-hello:sha-1a2b3c4
# Platform: linux/amd64
# Platform: linux/arm64   ← 이 줄이 있어야 함

8.2 ImagePullBackOff

대부분 패키지가 아직 Private 이거나, 이미지 경로에 대문자가 섞인 경우입니다. Private 으로 유지하고 싶다면 read:packages 권한 토큰으로 pull Secret 을 만들고 values 에서 참조합니다.

kubectl create namespace hello
kubectl -n hello create secret docker-registry ghcr-pull \
  --docker-server=ghcr.io \
  --docker-username=<YOUR_ID> \
  --docker-password=<GITHUB_TOKEN>
# values/spring-hello/values.yaml 에 추가
imagePullSecrets:
  - name: ghcr-pull

2편 6.3 과 마찬가지로 이 Secret 은 Git 에 커밋하지 않습니다. 또 2편의 charts/nginx 에는 imagePullSecrets 가 없으므로, static-web 도 Private 으로 쓰려면 5.1 의 deployment.yaml 처럼 with .Values.imagePullSecrets 블록을 추가해야 합니다.

8.3 메모리: OOMKilled 와 느린 기동

  • JVM 은 컨테이너 limit 를 기준으로 힙을 잡습니다(MaxRAMPercentage=75 → limit 512Mi 중 약 384Mi). OOMKilled 가 보이면 values/spring-hello/values.yaml 에서 resources.limits.memory 를 올립니다.
  • 라즈베리 파이 4GB 에서는 2편의 ArgoCD + Headlamp + 이번 spring-hello 2개(최대 1Gi)가 함께 올라갑니다. kubectl top pods -A 로 여유를 확인하고, 부족하면 replicaCount: 1 로 줄입니다.
  • 기동이 180초를 넘겨 재시작이 반복되면 차트의 startupProbe.failureThreshold 를 늘립니다.

8.4 static-web 헬스체크

3.2 에서 만든 /healthz 는 2편 charts/nginx 에 probe 가 없어서 아직 쓰이지 않습니다. 필요하면 charts/nginx/templates/deployment.yaml 의 컨테이너에 다음을 추가합니다. (2편 nginx 공식 이미지에는 /healthz 가 없으므로 path: / 를 쓰는 편이 두 앱 모두에 안전합니다.)

          readinessProbe:
            httpGet: { path: /, port: 80 }
            periodSeconds: 5

8.5 CSS 를 고쳤는데 화면이 그대로일 때

static-web 은 1시간 브라우저 캐시를 주기 때문에, 파일 이름이 같으면 이전 CSS 가 보일 수 있습니다. 강력 새로고침(Ctrl+Shift+R)으로 확인하고, 운영에서는 style.css?v=버전 처럼 쿼리 문자열이나 파일명에 버전을 붙여 캐시를 무효화합니다.

8.6 HTTPS 로 바꿀 때

나중에 spring-hello 를 HTTPS 로 서비스하면서 APP_STATIC_BASE_URL 이 http:// 로 남아 있으면, 브라우저가 혼합 콘텐츠(Mixed Content) 로 CSS·이미지를 차단합니다. 두 앱을 Ingress 뒤에 같은 도메인으로 묶을 때 함께 바꿔야 합니다.

9. 정리

2편 → 3편 변경 요약

항목 2편 3편
배포 대상 공식 이미지(nginx, metrics-server 등) 직접 빌드한 이미지 2개
이미지 빌드 없음 GitHub Actions (amd64 + arm64)
레지스트리 Docker Hub 등 공개 레지스트리 ghcr.io (sha- 태그)
차트 charts/nginx charts/spring-boot 추가, charts/nginx 는 static-web 에 재사용
앱 구성 단일 nginx WAS(spring-hello) + 정적 리소스 서버(static-web) 분리
배포 트리거 values / targetRevision 변경 values 의 image.tag 변경
수동 작업 최초 1회 앱 push 후 태그 1줄 수정 (7.2 로 자동화 가능)

저장소는 세 개가 되었지만 역할은 명확합니다. 앱 저장소는 이미지를 만들고, k8s-gitops 는 무엇을 배포할지 결정하고, ArgoCD 는 그대로 반영합니다.

참고

답글 남기기