본문 바로가기

프로젝트/Techfork

[26/02/21] 오늘의 개발 일지 - Apple 키 파일 보안 취약점 해소와 github actions 의존성 최신화

무중단 배포쪽에 담으면 글의 통일성이 너무 깨질것 같아서 따로 작성합니다.

오늘은 전체적인 배포 흐름을 쭉 검토해보았었는데,

 

Apple 키 파일 보안 취약점과 의존성들의 최신화가 미흡했다는 걸 발견했습니다.

 

1. Apple 키 파일 보안 취약점 해소

애플 소셜 로그인을 구현하면 .p8 형식의 Private Key 파일이 필요합니다. 처음에는 CD 과정에서 GitHub Secrets의 키 내용을 파일로 만들어 Docker 이미지에 포함시켰는데, 이 방식에서 문제가 생겨 볼륨 마운트 방식으로 변경했습니다.

어떤 문제가 있었고, 왜 볼륨 마운트가 맞는지 정리합니다.

 


Apple 로그인에 .p8 키 파일이 필요한 이유

Apple OAuth2 로그인은 다른 소셜 로그인과 다르게 client_secret이 고정 문자열이 아닙니다. Apple Developer에서 발급받은 .p8 Private Key로 JWT를 동적으로 생성해서 client_secret으로 사용합니다.

// AppleClientSecretGenerator.java
public String generateClientSecret() {
    return Jwts.builder()
            .setHeaderParam("kid", keyId)
            .setHeaderParam("alg", "ES256")
            .setIssuer(teamId)
            .setIssuedAt(new Date())
            .setExpiration(expirationDate)
            .setAudience("https://appleid.apple.com")
            .setSubject(clientId)
            .signWith(getPrivateKey(), SignatureAlgorithm.ES256)
            .compact();
}

private PrivateKey getPrivateKey() throws Exception {
    String privateKeyContent = new String(Files.readAllBytes(Paths.get(privateKeyPath)));
    // PEM 파싱 → PrivateKey 객체 변환
}

privateKeyPath에서 파일을 읽어서 JWT에 서명합니다. 즉 애플리케이션이 실행되는 시점에 이 파일이 파일 시스템 어딘가에 있어야 합니다. 문제는 "어딘가"를 어떻게 결정하느냐입니다.

 


처음 시도: CD에서 키 파일을 만들어 Docker 이미지에 포함

TechFork의 CI/CD는 CI에서 JAR를 빌드하고, CD에서 그 JAR를 받아 Docker 이미지를 만드는 구조입니다. 처음에는 CD 단계에서 키 파일을 생성한 뒤, Docker 빌드 context에 포함시켜서 이미지에 넣었습니다.

# cd.yml (이전 방식 — 문제 있음)
jobs:
  docker-build:
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Download JAR from CI
        uses: actions/download-artifact@v7
        with:
          name: tech-fork-jar
          path: build/libs/

      - name: Create Apple private key file
        run: |
          mkdir -p keys
          echo "${{ secrets.APPLE_PRIVATE_KEY }}" > keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .      # keys/ 디렉토리가 context에 포함됨
          push: true
          tags: ${{ env.DOCKER_IMAGE }}:${{ github.event.workflow_run.head_branch }}
# Dockerfile (이전 방식 — 문제 있음)
FROM eclipse-temurin:17-jre

RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
COPY keys/ /app/keys/

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

CD 러너에서 echo로 GitHub Secrets의 키 내용을 파일로 쓰고, 그 파일이 Docker 빌드 context에 포함되어 COPY keys/ /app/keys/로 이미지 안에 들어가는 구조입니다. 언뜻 보면 동작할 것 같지만, 여러 문제가 있었습니다.

 

문제 1: 이미지 레이어에 비밀 키가 남는다

Docker 이미지는 레이어 구조입니다. COPY keys/ /app/keys/를 실행하면 그 레이어에 키 내용이 영구적으로 기록됩니다. 나중에 별도 레이어에서 삭제해도 이전 레이어에 여전히 존재합니다.

# 이미지를 pull 받은 누구든 이렇게 꺼낼 수 있음
docker save my-image:latest | tar -x
cat {layer-hash}/layer.tar | tar -x app/keys/AuthKey.p8

DockerHub에 push된 이미지를 통해 Private Key가 유출될 수 있는 심각한 보안 문제입니다.

문제 2: 키 교체 시 이미지를 다시 빌드해야 한다

Apple 키를 재발급하면, 코드 변경이 전혀 없는데도 이미지를 통째로 다시 빌드하고 다시 배포해야 합니다. CI/CD 파이프라인 전체를 태워야 하는 불필요한 작업입니다.

 


해결: 볼륨 마운트 방식

키 파일을 이미지에서 완전히 분리하고, 실행 시점에 볼륨으로 주입하는 방식으로 변경했습니다.

Step 1: CD에서 서버에 키 파일 생성

# cd.yml (현재 방식)
- name: Place Apple private key on server
  uses: appleboy/ssh-action@v1
  env:
    APPLE_PRIVATE_KEY: ${{ secrets.APPLE_PRIVATE_KEY }}
    APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
  with:
    host: ${{ secrets.EC2_HOST }}
    username: ${{ secrets.EC2_USERNAME }}
    key: ${{ secrets.EC2_SSH_KEY }}
    envs: APPLE_PRIVATE_KEY,APPLE_KEY_ID
    script: |
      mkdir -p ~/keys
      chmod 700 ~/keys
      printf '%s' "$APPLE_PRIVATE_KEY" > ~/keys/AuthKey_${APPLE_KEY_ID}.p8
      chmod 600 ~/keys/AuthKey_${APPLE_KEY_ID}.p8

이전과 달리 Docker 이미지를 빌드하는 CD 러너가 아니라, 실제 배포 서버에 SSH로 접속해서 키 파일을 생성합니다. 키가 이미지 빌드 과정에 전혀 관여하지 않습니다.

파일 권한도 신경 써야 합니다. ~/keys 디렉토리는 700(소유자만 접근), .p8 파일은 600(소유자만 읽기/쓰기)으로 설정합니다.

 

Step 2: Docker Compose에서 볼륨 마운트

# docker-compose.blue.yml
services:
  app-blue:
    image: ${DOCKER_IMAGE}:${BRANCH}
    container_name: techfork-app-blue
    environment:
      - APPLE_PRIVATE_KEY_PATH=/app/keys/AuthKey_${APPLE_KEY_ID}.p8
    volumes:
      - ~/keys:/app/keys:ro

:ro(read-only)가 핵심입니다. 컨테이너 안에서 키 파일을 수정하거나 삭제할 수 없도록 읽기 전용으로 마운트합니다. 혹시 컨테이너가 침해되더라도 키 파일을 변조할 수 없습니다.

 

호스트의 ~/keys 디렉토리가 컨테이너의 /app/keys에 연결되므로, Blue든 Green이든 Dev든 같은 키 파일을 공유합니다. Blue-Green 배포 시 키 파일을 따로 관리할 필요가 없습니다.

 

Step 3: Spring Boot에서 경로 참조

# application-dev.yml
apple:
  private-key-path: /app/keys/AuthKey_${APPLE_KEY_ID}.p8

컨테이너 내부 경로인 /app/keys/...를 참조합니다. 볼륨 마운트가 호스트의 ~/keys를 컨테이너의 /app/keys에 연결해주므로, 애플리케이션 입장에서는 그냥 로컬 파일을 읽는 것과 동일합니다.

 

Dockerfile은 키와 무관해진다

# Dockerfile (현재 — 키 관련 로직 없음)
FROM eclipse-temurin:17-jre

RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Dockerfile에 키 관련 코드가 전혀 없습니다. 이미지는 순수하게 JAR만 담고, 비밀 정보는 실행 시점에 외부에서 주입됩니다.

 


전체 흐름 정리

[이전]
GitHub Secrets → CD 러너에서 echo로 파일 생성 → Docker build context에 포함 → COPY로 이미지에 포함 → DockerHub Push
                                                                                     ↑ 이미지 레이어에 키 영구 기록

[현재]
GitHub Secrets → SSH로 배포 서버 접속 → printf로 ~/keys/AuthKey.p8 생성 (chmod 600)
                                              ↓
Docker 이미지 (키 없음)    →    volumes: ~/keys:/app/keys:ro    →    Spring Boot가 파일 읽기

두 방식 비교

  이미지에 포함 (이전)  볼륨 마운트 (현재)
보안 이미지 레이어에 키가 영구 기록 이미지에 키 흔적 없음
키 교체 이미지 재빌드 + 재배포 필요 파일만 교체하고 컨테이너 재시작
Blue-Green 배포 Blue/Green 이미지 모두 재빌드 같은 호스트 경로를 마운트하므로 자동 적용
로컬 개발 이미지에 종속 로컬 경로만 바꾸면 됨

 


이 패턴이 적용되는 다른 경우들

Apple .p8 키 외에도 같은 패턴으로 관리해야 하는 것들이 있습니다. 예를 들어 Firebase serviceAccountKey.json, GCP 서비스 계정 키, TLS 인증서(fullchain.pem, privkey.pem), SSH 키 등이 모두 해당됩니다. 공통점은 "파일 형태의 비밀 정보"라는 것이고, 원칙은 동일합니다. Docker 이미지에 절대 포함하지 말고, 실행 시점에 볼륨이나 시크릿으로 주입할 것.

 

Kubernetes 환경이라면 볼륨 마운트 대신 Secret 리소스를 사용하고, Docker Swarm이라면 docker secret을 사용하는 게 더 적절합니다. TechFork는 단일 서버에 Docker Compose를 쓰고 있으므로 호스트 볼륨 마운트가 가장 심플한 선택이었습니다.

 

 

2. Github Actions 의존성 최신화

배포 최적화를 위해 도커 공식 문서를 읽다가 docker/build-push-action v6를 써야 docker checks란걸 적용할 수 있음을 파악했습니다. docker checks는 Dockerfile의 스크립트에 문제가 있으면 자동으로 확인해주는 기능인데, 중요한 건 현재 의존성들이 다 과거 버전이라는 것이었습니다.

 

https://docs.docker.com/build/checks/

 

Build checks

Learn how to use build checks to validate your build configuration.

docs.docker.com

 

이를 해결할 필요가 있음을 깨닫고 의존성을 최신화하였습니다.

 

기존에 작성된 코드의 uses: 뒤에 있는 주소가 곧 GitHub 저장소 주소임을 파악했습니다.

예를 들어 uses: docker/build-push-action@v6라면, 브라우저 주소창에 https://github.com/docker/build-push-action을 치고 들어가서 우측의 Releases 탭을 보면 현재 최신 태그(예: v6.6.0)를 확인할 수 있습니다.

 

액션 변경 전 변경 후
actions/checkout v4 v6
actions/setup-java v4 v5
gradle/actions/setup-gradle v3 v5
actions/upload-artifact v4 v6
actions/download-artifact v4 v7
docker/build-push-action v5 v6
appleboy/scp-action v0.1.7 v1
appleboy/ssh-action v1.0.3 v1

appleboy 계열 액션은 major 버전 태그(v1)를 사용하여 이후 패치 버전이 자동으로 반영됩니다.

 

또한 이걸 계속해서 체크하긴 번거로우므로 자동으로 의존성을 업데이트하는 dependabot도 도입하였습니다.

 

version: 2

updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

.github 패키지 아래에 dependabot.yml로 작성하면 dependabot을 활용할 수 있습니다.