NGINX 튜토리얼: 컨테이너에서 보안 비밀 정보를 안전하게 관리하는 방법

NGINX | 2023년 3월 14일

이 게시물은 다음 자료의 개념을 실제로 적용하는 데 도움이 되는 네 가지 튜토리얼 중 하나입니다. Microservices March 2023: 마이크로서비스 제공 시작하기:

많은 마이크로서비스가 안전하게 작동하려면 보안 비밀이 필요합니다. 보안 비밀의 예로는 SSL/TLS 인증서의 개인 키, 다른 서비스에 인증하기 위한 API 키, 원격 로그인용 SSH 키 등이 있습니다. 보안 비밀을 올바르게 관리하려면 보안 비밀이 사용되는 범위를 필요한 곳으로만 엄격히 제한하고, 필요한 경우가 아니면 보안 비밀에 액세스하지 못하도록 해야 합니다. 하지만 애플리케이션 개발을 서두르다 보면 이러한 관행을 생략하는 경우가 많습니다. 그 결과, 부적절한 보안 비밀 관리는 정보 유출과 악용의 흔한 원인이 됩니다.

튜토리얼 개요

이 튜토리얼에서는 클라이언트 컨테이너가 서비스에 액세스할 때 사용하는 JSON 웹 토큰(JWT)을 안전하게 배포하고 사용하는 방법을 알아봅니다. 이 튜토리얼의 네 가지 과제에서는 시크릿을 관리하는 네 가지 방법을 각각 실습하면서, 컨테이너에서 시크릿을 올바르게 관리하는 방법뿐만 아니라 적절하지 않은 방법도 알아봅니다:

이 튜토리얼에서는 JWT를 비밀 정보의 예로 사용하지만, 여기서 소개하는 기법은 데이터베이스 자격 증명, SSL 개인 키, 기타 API 키 등 컨테이너에서 비밀로 유지해야 하는 모든 정보에 적용할 수 있습니다.

이 튜토리얼에서는 두 가지 주요 소프트웨어 구성 요소를 활용합니다:

  • API 서버 – NGINX Open Source와 기본적인 NGINX JavaScript 코드를 실행하는 컨테이너로, JWT에서 클레임을 추출하여 클레임 중 하나의 값을 반환하거나 클레임이 없는 경우 오류 메시지를 반환합니다.
  • API 클라이언트 – API 서버에 간단히 GET 요청을 보내는 매우 단순한 Python 코드를 실행하는 컨테이너

이 동영상에서 튜토리얼이 실제로 작동하는 모습을 확인해 보세요.

이 튜토리얼을 가장 쉽게 진행하려면 Microservices March에 등록하고 제공되는 브라우저 기반 실습 환경을 사용하세요. 이 게시물에서는 자체 환경에서 튜토리얼을 실행하는 방법을 안내합니다.

사전 요구 사항 및 설정

사전 요구 사항

자체 환경에서 튜토리얼을 완료하려면 다음이 필요합니다:

  • Linux/Unix 호환 환경
  • Linux 명령줄에 대한 기본적인 이해
  • 다음과 같은 텍스트 편집기: nano 또는 vim
  • Docker (포함 항목: Docker Compose, Docker Engine Swarm)
  • curl (대부분의 시스템에 이미 설치되어 있음)
  • git (대부분의 시스템에 이미 설치되어 있음)

참고:

  • 이 튜토리얼에서는 포트 80에서 수신 대기하는 테스트 서버를 사용합니다. 이미 포트 80을 사용 중이라면 ‑p 플래그를 사용하여 docker run 명령으로 테스트 서버를 시작할 때 다른 포트 값을 설정하세요. 그런 다음 localhost 뒤에 :<port_number> 접미사를 붙여 curl 명령을 실행하세요.
  • 이 튜토리얼에서는 명령을 터미널에 쉽게 복사하여 붙여넣을 수 있도록 Linux 명령줄 프롬프트를 생략합니다. 물결표(~)는 홈 디렉터리를 나타냅니다.

설정

이 섹션에서는 튜토리얼 리포지토리를 복제하고, 인증 서버를 시작한 다음, 테스트 요청을 전송합니다(토큰을 포함한 경우와 포함하지 않은 경우 모두).

튜토리얼 리포지토리 복제

  1. 홈 디렉터리에 microservices-march 디렉터리를 만들고 그 안에 GitHub 리포지토리를 복제합니다. (다른 디렉터리 이름을 사용하고 그에 맞게 지침을 조정해도 됩니다.) 이 리포지토리에는 구성 파일과 시크릿을 가져오는 방식이 서로 다른 API 클라이언트 애플리케이션의 개별 버전이 포함되어 있습니다.
mkdir ~/microservices-marchcd ~/microservices-march git clone https://github.com/microservices-march/auth.git
  1. 시크릿을 표시합니다. 이는 서명된 JWT로, 일반적으로 API 클라이언트가 서버에 인증할 때 사용됩니다.
cat ~/microservices-march/auth/apiclient/token1.jwt"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2Nz UyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-jEZdihy-H1glooSq_z162VKghA"

이 토큰을 인증에 사용하는 방법은 몇 가지가 있지만, 이 튜토리얼에서는 API 클라이언트 앱이 OAuth 2.0 Bearer 토큰 인증 프레임워크를 사용하여 인증 서버에 토큰을 전달합니다. 이때 다음 예시와 같이 JWT 앞에 Authorization: Bearer를 붙입니다:

"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-jEZdihy-H1glooSq_z162VKghA"

인증 서버 빌드 및 시작

  1. 인증 서버 디렉터리로 이동합니다:
cd apiserver
  1. 인증 서버용 Docker 이미지를 빌드합니다(마지막 마침표에 유의하세요):
docker build -t apiserver .
  1. 인증 서버를 시작하고 실행 중인지 확인합니다(가독성을 위해 출력은 여러 줄로 나누어 표시했습니다):
docker run -d -p 80:80 apiserver docker ps
CONTAINER ID   IMAGE       COMMAND                  ...
2b001f77c5cb   apiserver   "nginx -g 'daemon of..." ...  


    ... CREATED         STATUS          ...                                    
    ... 26 seconds ago  Up 26 seconds   ... 


    ... PORTS                                      ...
    ... 0.0.0.0:80->80/tcp, :::80->80/tcp, 443/tcp ...


    ... NAMES
    ... relaxed_proskuriakova

인증 서버 테스트

  1. 인증 서버가 JWT가 포함되지 않은 요청을 거부하고 다음을 반환하는지 확인합니다: 401 Authorization Required:
curl -X GET http://localhost<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>nginx/1.23.3</center>
</body>
</html>
  1. Provide the JWT using the Authorization 헤더를 사용하여 JWT를 제공하세요. 200 OK 반환 코드는 API 클라이언트 앱이 성공적으로 인증되었음을 나타냅니다.
curl -i -X GET -H "Authorization: Bearer `cat $HOME/microservices-march/auth/apiclient/token1.jwt`" http://localhostHTTP/1.1 200 OK
Server: nginx/1.23.2
Date: Day, DD Mon YYYY hh:mm:ss TZ
Content-Type: text/html
Content-Length: 64
Last-Modified: Day, DD Mon YYYY hh:mm:ss TZ
Connection: keep-alive
ETag: "63dc0fcd-40"
X-MESSAGE: Success apiKey1
Accept-Ranges: bytes


{ "response": "success", "authorized": true, "value": "999" }

과제 1: 앱에 비밀 정보를 하드코딩하기(절대 금지!)

이 과제를 시작하기 전에 한 가지 분명히 해두겠습니다. 앱에 보안 비밀을 하드코딩하는 것은 절대 해서는 안 될 일입니다! 컨테이너 이미지에 접근할 수 있는 사람이라면 누구나 하드코딩된 자격 증명을 얼마나 쉽게 찾아내고 추출할 수 있는지 확인하게 될 것입니다.

이 챌린지에서는 API 클라이언트 앱의 코드를 복사하여 빌드 디렉터리에 넣고, 앱을 빌드하고 실행한 다음, 시크릿을 추출합니다.

API 클라이언트 앱 복사

The app_versions 하위 디렉터리에는 apiclient 디렉터리의 네 가지 챌린지에 사용할 수 있는 간단한 API 클라이언트 앱의 여러 버전이 포함되어 있으며, 각 버전은 이전 버전보다 보안이 조금씩 강화되어 있습니다(자세한 내용은 튜토리얼 개요 참조).

  1. API 클라이언트 디렉터리로 이동합니다:
cd ~/microservices-march/auth/apiclient
  1. 이 챌린지용 앱(비밀 값이 하드코딩된 앱)을 작업 디렉터리에 복사합니다:
cp ./app_versions/very_bad_hard_code.py ./app.py
  1. 앱을 살펴보세요:
cat app.pyimport urllib.request
import urllib.error

jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-jEZdihy-H1glooSq_z162VKghA"
authstring = "Bearer " + jwt
req = urllib.request.Request("http://host.docker.internal")
req.add_header("Authorization", authstring)
try:
    with urllib.request.urlopen(req) as response:
        the_page = response.read()
        message = response.getheader("X-MESSAGE")
        print("200  " + message)
except urllib.error.URLError as e:
    print(str(e.code) + " s " + e.msg)
  1. 이 코드는 단순히 로컬 호스트에 요청을 보내고 성공 메시지 또는 실패 코드를 출력합니다. 요청은 다음 줄에서 Authorization 헤더를 추가합니다:
req.add_header("Authorization", authstring)
  1. 그 밖에 눈에 띄는 것이 있나요? 하드코딩된 JWT가 보이나요? 잠시 후에 살펴보겠습니다. 먼저 앱을 빌드하고 실행해 보겠습니다.

API 클라이언트 앱 빌드 및 실행

여기서는 Docker Compose YAML 파일과 함께 docker compose 명령을 사용합니다. 이렇게 하면 어떤 작업이 이루어지는지 좀 더 쉽게 이해할 수 있습니다.

(이전 섹션의 2단계에서 챌린지 1용 API 클라이언트 앱의 Python 파일인 very_bad_hard_code.py의 이름을 app.py로 변경했다는 점에 유의하세요. 나머지 세 가지 챌린지에서도 동일하게 변경합니다. 매번 app.py를 사용하면 Dockerfile을 변경할 필요가 없어 작업이 간소화됩니다. 단, 매번 컨테이너를 강제로 다시 빌드하려면 ‑build 인수를 docker compose 명령에 포함해야 합니다.)

The docker compose 명령은 컨테이너를 빌드하고 애플리케이션을 시작한 다음, API 요청을 한 번 수행하고 그 결과를 콘솔에 표시한 후 컨테이너를 종료합니다.

출력의 끝에서 두 번째 줄에 있는 200 Success 코드는 인증에 성공했음을 나타냅니다. apiKey1 값은 인증 서버가 JWT에서 해당 이름의 클레임을 디코딩할 수 있었음을 보여 주므로 이를 한 번 더 확인해 줍니다:

docker compose -f docker-compose.hardcode.yml up -build
...
apiclient-apiclient-1  | 200 Success apiKey1
apiclient-apiclient-1 exited with code 0

예상대로 하드코딩된 자격 증명은 API 클라이언트 앱에서 제대로 작동했습니다. 하지만 보안상 안전할까요? 컨테이너가 종료되기 전에 이 스크립트를 한 번만 실행하고 셸도 없으니, 어쩌면 안전할 수도 있습니다.

사실 전혀 안전하지 않습니다.

컨테이너 이미지에서 시크릿 가져오기

자격 증명을 하드코딩하면 컨테이너 이미지에 액세스할 수 있는 누구나 이를 확인할 수 있게 됩니다. 컨테이너의 파일 시스템을 추출하는 것은 매우 간단하기 때문입니다.

  1. Create the extract 디렉터리를 생성하고 해당 디렉터리로 이동합니다:
mkdir extractcd extract
  1. 컨테이너 이미지에 대한 기본 정보를 나열합니다. --format 플래그를 사용하면 출력 내용을 더 쉽게 읽을 수 있습니다(같은 이유로 여기서는 출력 내용을 두 줄로 나누어 표시합니다).
docker ps -a --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"CONTAINER ID   NAMES                   IMAGE       ...
11b73106fdf8   apiclient-apiclient-1   apiclient   ...
ad9bdc05b07c   exciting_clarke         apiserver   ...


    ... CREATED          STATUS
    ... 6 minutes ago    Exited (0) 4 minutes ago
    ... 43 minutes ago   Up 43 minutes
  1. 최신 apiclient 이미지를 .tar 파일로 추출합니다. <container_ID>에는 위 출력의 CONTAINER ID 필드 값(이 튜토리얼에서는 11b73106fdf8)을 입력합니다:
docker export -o api.tar <container_ID>
  1. 컨테이너의 전체 파일 시스템이 포함된 api.tar 아카이브를 만드는 데는 몇 초 정도 걸립니다. 비밀 정보를 찾는 한 가지 방법은 전체 아카이브를 추출하여 분석하는 것이지만, 흥미로운 정보가 있을 만한 부분을 찾는 더 간단한 방법이 있습니다. 바로 docker history 명령으로 컨테이너의 기록을 표시하는 것입니다. (이 방법은 Docker Hub나 다른 컨테이너 레지스트리에서 찾은 컨테이너처럼 Dockerfile은 없고 컨테이너 이미지만 있는 경우에도 사용할 수 있어 특히 유용합니다.)
  2. 컨테이너의 기록을 표시합니다:
docker history apiclient
IMAGE         CREATED        ...
9396dde2aad0  8 minutes ago  ...                    
<missing>     8 minutes ago  ...   
<missing>     28 minutes ago ...  
               
    ... CREATED BY                          SIZE ... 
    ... CMD ["python" "./app.py"]           622B ...   
    ... COPY ./app.py ./app.py # buildkit   0B   ... 
    ... WORKDIR /usr/app/src                0B   ...   
             
    ... COMMENT
    ... buildkit.dockerfile.v0
    ... buildkit.dockerfile.v0
    ... buildkit.dockerfile.v0
  1. 출력 줄은 시간 역순으로 표시됩니다. 이를 통해 작업 디렉터리가 /usr/app/src로 설정된 후, 앱의 Python 코드 파일이 복사되어 실행되었음을 알 수 있습니다. 이 컨테이너의 핵심 코드베이스가 /usr/app/src/app.py에 있으며, 따라서 자격 증명이 있을 가능성이 높은 위치라는 점은 어렵지 않게 추론할 수 있습니다.
  2. 이제 알아낸 정보를 바탕으로 해당 파일만 추출합니다:
tar --extract --file=api.tar usr/app/src/app.py
  1. 파일의 내용을 표시하면, 이렇게 간단히 “보안” JWT에 접근할 수 있습니다:
cat usr/app/src/app.py
...
jwt="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA4MTMsImlzcyI6ImFwaUtleTEiLCJhdWQiOiJhcGlTZXJ2aWNlIiwic3ViIjoiYXBpS2V5MSJ9._6L_Ff29p9AWHLLZ-jEZdihy-H1glooSq_z162VKghA"
...

과제 2: 시크릿을 환경 변수로 전달하기(다시 말하지만, 안 됩니다!)

Microservices March 2023의 유닛 1(마이크로서비스 아키텍처에 12요소 앱 적용)을 완료했다면 환경 변수를 사용하여 컨테이너에 구성 데이터를 전달하는 방법에 익숙할 것입니다. 놓쳤더라도 걱정하지 마세요. 등록 후 온디맨드로 시청할 수 있습니다.

이 챌린지에서는 보안 정보를 환경 변수로 전달합니다. 챌린지 1에서 사용한 방법과 마찬가지로 이 방법도 권장하지 않습니다! 보안 정보를 하드코딩하는 것만큼 나쁘지는 않지만, 앞으로 살펴보겠듯이 몇 가지 취약점이 있습니다.

컨테이너에 환경 변수를 전달하는 방법은 네 가지입니다:

  • Use the ENV 문을 Dockerfile에서 사용하여 변수를 치환합니다(빌드되는 모든 이미지에 변수를 설정). 예:
ENV PORT $PORT
  • Use the ‑e 플래그를 docker run 명령어에 사용합니다. 예:
docker run -e PASSWORD=123 mycontainer
  • Docker Compose YAML 파일에서 environment 키를 사용합니다.
  • 변수가 포함된 .env 파일을 사용합니다.

이 챌린지에서는 환경 변수를 사용하여 JWT를 설정하고 컨테이너를 검사하여 JWT가 노출되는지 확인합니다.

환경 변수 전달

  1. API 클라이언트 디렉터리로 다시 이동합니다:
cd ~/microservices-march/auth/apiclient
  1. 이 챌린지에서 사용할 앱(환경 변수를 사용하는 앱)을 작업 디렉터리에 복사하여 챌린지 1의 app.py 파일을 덮어씁니다:
cp ./app_versions/medium_environment_variables.py ./app.py
  1. 앱을 살펴보세요. 관련 출력 줄에서 비밀 값(JWT)은 로컬 컨테이너의 환경 변수로 읽힙니다:
cat app.py...
jwt = ""
if "JWT" in os.environ:
    jwt = "Bearer " + os.environ.get("JWT")
...
  1. 위에서 설명한 것처럼 환경 변수를 컨테이너에 전달하는 방법은 여러 가지가 있습니다. 일관성을 위해 여기서는 Docker Compose를 사용하겠습니다. environment 키를 사용하여 JWT 환경 변수를 설정하는 Docker Compose YAML 파일의 내용을 표시합니다:
cat docker-compose.env.yml---
version: "3.9"
services:
  apiclient:
    build: .
    image: apiclient
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      - JWT
  1. 환경 변수를 설정하지 않고 앱을 실행합니다. 출력의 끝에서 두 번째 줄에 있는 401 Unauthorized 코드는 API 클라이언트 앱이 JWT를 전달하지 않아 인증에 실패했음을 나타냅니다:
docker compose -f docker-compose.env.yml up -build...
apiclient-apiclient-1  | 401 Unauthorized
apiclient-apiclient-1 exited with code 0
  1. 간단하게 진행하기 위해 환경 변수를 로컬에 설정합니다. 현재 우려하는 보안 문제와는 관련이 없으므로, 이 튜토리얼의 현 단계에서는 이렇게 해도 괜찮습니다:
export JWT=`cat token1.jwt`
  1. 컨테이너를 다시 실행합니다. 이제 테스트가 성공하며, 챌린지 1에서와 동일한 메시지가 표시됩니다:
docker compose -f docker-compose.env.yml up -build
... 
apiclient-apiclient-1  | 200 Success apiKey1
apiclient-apiclient-1 exited with code 0

이제 적어도 기본 이미지에는 비밀 정보가 포함되지 않으며, 실행 시점에 이를 전달할 수 있으므로 더 안전합니다. 하지만 여전히 문제가 하나 있습니다.

컨테이너 살펴보기

  1. 컨테이너 이미지에 대한 정보를 표시하여 API 클라이언트 앱의 컨테이너 ID를 확인합니다(가독성을 위해 출력은 두 줄로 나누어 표시됨):
docker ps -a --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"CONTAINER ID   NAMES                   IMAGE      ...
6b20c75830df   apiclient-apiclient-1   apiclient  ...
ad9bdc05b07c   exciting_clarke         apiserver  ...


    ... CREATED             STATUS
    ... 6 minutes ago       Exited (0) 6 minutes ago
    ... About an hour ago   Up About an hour
  1. API 클라이언트 앱의 컨테이너를 검사합니다. <container_ID>에는 위 출력의 CONTAINER ID 필드에 있는 값을 입력합니다(여기서는 6b20c75830df). docker inspect 명령을 사용하면 현재 실행 중인지 여부와 관계없이 시작된 모든 컨테이너를 검사할 수 있습니다. 바로 이것이 문제입니다. 컨테이너가 실행 중이 아닌데도 출력에서 컨테이너 구성에 안전하지 않게 저장된 JWT가 Env 배열에 노출됩니다.
docker inspect <container_ID>...
"Env": [
  "JWT=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24ifQ.eyJpYXQiOjE2NzUyMDA...",
  "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "LANG=C.UTF-8",
  "GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D",
  "PYTHON_VERSION=3.11.2",
  "PYTHON_PIP_VERSION=22.3.1",
  "PYTHON_SETUPTOOLS_VERSION=65.5.1",
  "PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1a96dc5acd0303c4700e026...",
  "PYTHON_GET_PIP_SHA256=d1d09b0f9e745610657a528689ba3ea44a73bd19c60f4c954271b790c..."
]

과제 3: 로컬 시크릿 사용

이제 비밀 정보를 코드에 하드코딩하거나 환경 변수를 사용하는 방식이 여러분(또는 보안 팀)이 요구하는 만큼 안전하지 않다는 사실을 알게 되셨을 것입니다.

보안을 강화하려면 로컬 Docker 시크릿을 사용해 민감한 정보를 저장해 볼 수 있습니다. 이 역시 가장 이상적인 방법은 아니지만, 작동 방식을 이해해 두는 것이 좋습니다. 프로덕션 환경에서 Docker를 사용하지 않더라도, 여기서 중요한 점은 컨테이너에서 시크릿을 추출하기 어렵게 만드는 방법을 익히는 것입니다.

Docker에서는 파일 시스템 마운트 /run/secrets/를 통해 컨테이너에 보안 비밀이 제공되며, 각 보안 비밀의 값은 별도의 파일에 저장됩니다.

이 챌린지에서는 Docker Compose를 사용하여 로컬에 저장된 보안 비밀을 컨테이너에 전달한 다음, 이 방법을 사용할 때 컨테이너에서 보안 비밀이 노출되지 않는지 확인합니다.

로컬에 저장된 시크릿을 컨테이너에 전달하기

  1. 이제 예상하셨겠지만, 먼저 apiclient 디렉터리로 이동합니다:
cd ~/microservices-march/auth/apiclient
  1. 이 챌린지용 앱(컨테이너 내부의 보안 비밀을 사용하는 앱)을 작업 디렉터리에 복사하여 챌린지 2의 app.py 파일을 덮어씁니다:
cp ./app_versions/better_secrets.py ./app.py
  1. 다음 Python 코드를 살펴보세요. 이 코드는 /run/secrets/jot 파일에서 JWT 값을 읽습니다. (그리고 네, 이 파일에 한 줄만 있는지 확인하는 편이 좋겠죠. 아마 Microservices March 2024에서 다루게 될까요?)
cat app.py...
jotfile = "/run/secrets/jot"
jwt = ""
if os.path.isfile(jotfile):
    with open(jotfile) as jwtfile:
        for line in jwtfile:
            jwt = "Bearer " + line
...
  1. 좋습니다. 그렇다면 이 시크릿은 어떻게 생성할까요? 답은 docker-compose.secrets.yml 파일에 있습니다.
  2. Docker Compose 파일을 살펴보면, 비밀 파일이 secrets 섹션에 정의된 후 apiclient 서비스에서 참조됩니다:
cat docker-compose.secrets.yml---
version: "3.9"
secrets:
  jot:
    file: token1.jwt
services:
  apiclient:
    build: .
    extra_hosts:
      - "host.docker.internal:host-gateway"
    secrets:
      - jot

컨테이너에서 시크릿이 노출되지 않는지 확인

  1. 앱을 실행합니다. 컨테이너 내에서 JWT에 액세스할 수 있도록 설정했으므로, 이제 익숙한 다음 메시지와 함께 인증에 성공합니다:
docker compose -f docker-compose.secrets.yml up -build...
apiclient-apiclient-1  | 200 Success apiKey1
apiclient-apiclient-1 exited with code 0
  1. 컨테이너 이미지에 대한 정보를 표시하고 API 클라이언트 앱의 컨테이너 ID를 확인합니다(출력 예시는 챌린지 2의 컨테이너 살펴보기에서 1단계 참조):
docker ps -a --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
  1. API 클라이언트 앱의 컨테이너를 검사합니다. <container_ID>에는 이전 단계의 출력에 있는 CONTAINER ID 필드의 값을 대입합니다. 컨테이너 검사의 2단계 출력과 달리 Env 섹션 시작 부분에 JWT= 줄이 없습니다:
docker inspect <container_ID>
"Env": [
  "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "LANG=C.UTF-8",
  "GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D",
  "PYTHON_VERSION=3.11.2",
  "PYTHON_PIP_VERSION=22.3.1",
  "PYTHON_SETUPTOOLS_VERSION=65.5.1",
  "PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1a96dc5acd0303c4700e026...",
  "PYTHON_GET_PIP_SHA256=d1d09b0f9e745610657a528689ba3ea44a73bd19c60f4c954271b790c..."
]
  1. 지금까지는 순조롭지만, 비밀은 컨테이너 파일 시스템의 /run/secrets/jot에 있습니다. 챌린지 1의 컨테이너 이미지에서 비밀 가져오기에서 사용한 것과 동일한 방법으로 그곳에서 비밀을 추출할 수 있을지도 모릅니다.
  2. Challenge 1에서 생성한 extract 디렉터리로 이동한 다음, 컨테이너를 tar 아카이브로 내보냅니다:
cd extractdocker export -o api2.tar <container_ID>
  1. 다음 tar 파일에서 secrets 파일을 찾습니다:
tar tvf api2.tar | grep jot-rwxr-xr-x  0 0      0           0 Mon DD hh:mm run/secrets/jot
  1. 이런, JWT가 들어 있는 파일이 보이네요. 컨테이너에 비밀 정보를 포함하는 것이 “안전하다”고 하지 않았나요? 챌린지 1에서만큼이나 상황이 심각한 걸까요?
  2. 확인해 보겠습니다. tar 파일에서 비밀 정보 파일을 추출한 후 그 내용을 살펴보세요:
tar --extract --file=api2.tar run/secrets/jotcat run/secrets/jot
  1. 좋은 소식입니다! cat 명령어의 출력이 없으므로 컨테이너 파일 시스템의 run/secrets/jot 파일이 비어 있다는 뜻입니다. 즉, 그 안에는 노출될 비밀 정보가 없습니다! 컨테이너에 시크릿 아티팩트가 있더라도 Docker는 민감한 데이터를 컨테이너에 저장하지 않습니다.

그렇지만 이 컨테이너 구성은 안전하더라도 한 가지 단점이 있습니다. 컨테이너를 실행할 때 로컬 파일 시스템에 token1.jwt이라는 파일이 있어야 합니다. 이 파일의 이름을 변경하면 컨테이너를 다시 시작할 수 없습니다. (직접 확인하려면 token1.jwt의 이름을 변경한 후[삭제하면 안 됩니다!] 1단계의 docker compose 명령을 다시 실행해 보세요.)

이제 절반은 해결했습니다. 컨테이너에서는 비밀이 쉽게 유출되지 않도록 보호하는 방식으로 비밀을 사용하지만, 호스트에 있는 비밀은 여전히 보호되지 않습니다. 비밀을 암호화하지 않은 채 일반 텍스트 파일에 저장해서는 안 됩니다. 이제 비밀 관리 도구를 도입할 차례입니다.

과제 4: 비밀 관리자 사용

시크릿 관리자는 시크릿의 전체 수명 주기에 걸쳐 시크릿을 관리하고, 검색하고, 교체할 수 있도록 도와줍니다. 선택할 수 있는 시크릿 관리자는 다양하지만, 모두 비슷한 목적을 수행합니다:

  • 보안 비밀을 안전하게 저장
  • 액세스 제어
  • 런타임에 배포
  • 비밀 정보 순환 사용

비밀 관리 옵션은 다음과 같습니다:

간단히 설명하기 위해 이 챌린지에서는 Docker Swarm을 사용하지만, 이러한 원칙은 여러 비밀 관리 도구에도 동일하게 적용됩니다.

이 챌린지에서는 Docker에서 시크릿을 생성하고, 시크릿과 API 클라이언트 코드를 복사한 다음, 컨테이너를 배포하고, 시크릿을 추출할 수 있는지 확인한 후, 시크릿을 교체합니다.

Docker Secret 구성

  1. 이제 늘 하던 대로 apiclient 디렉터리로 이동합니다:
cd ~/microservices-march/auth/apiclient
  1. Docker Swarm 초기화:
docker swarm init
Swarm initialized: current node (t0o4eix09qpxf4ma1rrs9omrm) is now a manager.
...
  1. 비밀 값을 생성하여 token1.jwt에 저장합니다:
docker secret create jot ./token1.jwtqe26h73nhb35bak5fr5east27
  1. 시크릿에 대한 정보를 표시합니다. 시크릿 값(JWT) 자체는 표시되지 않는다는 점에 유의하세요:
docker secret inspect jot[
  {
    "ID": "qe26h73nhb35bak5fr5east27",
    "Version": {
      "Index": 11
    },
    "CreatedAt": "YYYY-MM-DDThh:mm:ss.msZ",
    "UpdatedAt": "YYYY-MM-DDThh:mm:ss.msZ",
    "Spec": {
      "Name": "jot",
      "Labels": {}
    }
  }
]

Docker Secret 사용하기

API 클라이언트 애플리케이션 코드에서 Docker 시크릿을 사용하는 방법은 로컬에서 생성한 시크릿을 사용하는 방법과 완전히 동일합니다. /run/secrets/ 파일 시스템에서 읽을 수 있습니다. Docker Compose YAML 파일에서 시크릿 한정자만 변경하면 됩니다.

  1. Docker Compose YAML 파일을 살펴보세요. external 필드의 값이 true로 설정되어 있는데, 이는 Docker Swarm 시크릿을 사용하고 있음을 나타냅니다:
cat docker-compose.secretmgr.yml---
version: "3.9"
secrets:
  jot:
    external: true
services:
  apiclient:
    build: .
    image: apiclient
    extra_hosts:
      - "host.docker.internal:host-gateway"
    secrets:
      - jot
  1. 따라서 이 Compose 파일은 기존 API 클라이언트 애플리케이션 코드와 함께 작동할 것으로 예상할 수 있습니다. 정확히 말하면 거의 그렇습니다. Docker Swarm(또는 다른 컨테이너 오케스트레이션 플랫폼)은 많은 부가 가치를 제공하지만, 그만큼 복잡성도 다소 증가합니다. docker compose는 외부 시크릿을 지원하지 않으므로 몇 가지 Docker Swarm 명령어, 특히 docker stack deploy를 사용해야 합니다. Docker Stack은 콘솔 출력을 숨기므로 출력을 로그에 기록한 다음 해당 로그를 확인해야 합니다. 작업을 더 쉽게 하기 위해 연속적인 while True 루프를 사용하여 컨테이너가 계속 실행되도록 합니다.
  2. 이 챌린지에서 사용할 앱(비밀 관리자 사용 앱)을 작업 디렉터리에 복사하여 챌린지 3의 app.py 파일을 덮어씁니다. app.py의 내용을 표시해 보면 코드가 챌린지 3의 코드와 거의 동일하다는 것을 알 수 있습니다. 유일한 차이점은 while True 루프가 추가되었다는 것입니다:
cp ./app_versions/best_secretmgr.py ./app.pycat ./app.py
...
while True:
    time.sleep(5)
    try:
        with urllib.request.urlopen(req) as response:
            the_page = response.read()
            message = response.getheader("X-MESSAGE")
            print("200 " + message, file=sys.stderr)
    except urllib.error.URLError as e:
        print(str(e.code) + " " + e.msg, file=sys.stderr)

컨테이너를 배포하고 로그 확인하기

  1. 컨테이너를 빌드합니다(이전 과제에서는 Docker Compose가 이 작업을 처리했습니다):
docker build -t apiclient .
  1. 컨테이너를 배포합니다:
docker stack deploy --compose-file docker-compose.secretmgr.yml secretstack
Creating network secretstack_default
Creating service secretstack_apiclient
  1. 실행 중인 컨테이너를 나열하고 secretstack_apiclient의 컨테이너 ID를 확인합니다(앞서와 마찬가지로 가독성을 위해 출력은 여러 줄로 나누어 표시했습니다).
docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"CONTAINER ID  ...  
20d0c83a8b86  ... 
ad9bdc05b07c  ... 

    ... NAMES                                             ...  
    ... secretstack_apiclient.1.0e9s4mag5tadvxs6op6lk8vmo ...  
    ... exciting_clarke                                   ...                                 

    ... IMAGE              CREATED          STATUS
    ... apiclient:latest   31 seconds ago   Up 30 seconds
    ... apiserver          2 hours ago      Up 2 hours
  1. Docker 로그 파일을 표시합니다. <container_ID>에는 이전 단계의 출력에 있는 CONTAINER ID 필드의 값(여기서는 20d0c83a8b86)을 입력합니다. 애플리케이션 코드에 while True 루프를 추가했으므로 로그 파일에 일련의 성공 메시지가 표시됩니다. 명령을 종료하려면 Ctrl+c를 누릅니다.
docker logs -f <container_ID>200 Success apiKey1
200 Success apiKey1
200 Success apiKey1
200 Success apiKey1
200 Success apiKey1
200 Success apiKey1
...
^c

시크릿에 액세스해 보기

민감한 환경 변수가 설정되어 있지 않다는 것을 알고 있습니다(하지만 챌린지 2의 docker inspect 명령을 사용하여 컨테이너 살펴보기의 2단계에서처럼 언제든지 확인할 수 있습니다).

챌린지 3을 통해 /run/secrets/jot 파일이 비어 있다는 것도 알고 있지만, 다음과 같이 확인할 수 있습니다:

cd extractdocker export -o api3.tar  tar --extract --file=api3.tar run/secrets/jot cat run/secrets/jot

성공입니다! 컨테이너에서 시크릿을 가져올 수도 없고, Docker 시크릿에서 직접 읽을 수도 없습니다.

시크릿 교체

물론 적절한 권한이 있다면 서비스를 생성하고, 시크릿을 읽어 로그에 기록하거나 환경 변수로 설정하도록 구성할 수 있습니다. 또한 API 클라이언트와 서버 간의 통신이 암호화되지 않은 평문이라는 점을 눈치채셨을 수도 있습니다.

따라서 거의 모든 비밀 관리 시스템에서 비밀이 유출될 가능성은 여전히 존재합니다. 이로 인한 피해 가능성을 줄이는 한 가지 방법은 비밀을 정기적으로 교체하는 것입니다.

Docker Swarm에서는 시크릿을 삭제한 후 다시 생성하는 방식만 사용할 수 있습니다(Kubernetes에서는 시크릿을 동적으로 업데이트할 수 있습니다). 또한 실행 중인 서비스에 연결된 시크릿은 삭제할 수 없습니다.

  1. 실행 중인 서비스를 나열합니다:
docker service lsID             NAME                    MODE         ... 
sl4mvv48vgjz   secretstack_apiclient   replicated   ... 


    ... REPLICAS   IMAGE              PORTS
    ... 1/1        apiclient:latest
  1. Delete the secretstack_apiclient 서비스를 삭제합니다.
docker service rm secretstack_apiclient
  1. 시크릿을 삭제한 후 새 토큰으로 다시 생성합니다:
docker secret rm jot docker secret create jot ./token2.jwt
  1. 서비스를 다시 생성합니다:
docker stack deploy --compose-file docker-compose.secretmgr.yml secretstack
  1. Look up the container ID for apiclient의 컨테이너 ID를 조회합니다(출력 예시는 컨테이너 배포 및 로그 확인의 3단계 참조):
docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"
  1. 일련의 성공 메시지가 표시되는 Docker 로그 파일을 확인합니다. <container_ID>에는 이전 단계의 출력에 있는 CONTAINER ID 필드의 값을 입력합니다. 명령을 종료하려면 Ctrl+c를 누릅니다.
docker logs -f <container_ID>200 Success apiKey2
200 Success apiKey2
200 Success apiKey2
200 Success apiKey2
...
^c

See the change from apiKey1에서 apiKey2(으)로 변경된 것이 보이시나요? 비밀 값을 교체한 것입니다.

이 튜토리얼에서는 API 서버가 여전히 두 JWT를 모두 허용하지만, 프로덕션 환경에서는 JWT의 클레임에 특정 값을 요구하거나 JWT의 만료일을 확인하여 이전 JWT를 더 이상 사용하지 않도록 할 수 있습니다.

또한 보안 비밀을 업데이트할 수 있는 보안 비밀 관리 시스템을 사용하는 경우, 새로운 보안 비밀 값을 반영할 수 있도록 코드에서 보안 비밀을 자주 다시 읽어야 합니다.

정리

이 튜토리얼에서 생성한 객체를 정리하려면 다음을 수행하세요:

  1. Delete the secretstack_apiclient 서비스를 삭제합니다.
docker service rm secretstack_apiclient
  1. 시크릿을 삭제합니다.
docker secret rm jot
  1. Swarm에서 나갑니다(이 튜토리얼을 위해 Swarm을 생성했다고 가정합니다).
docker swarm leave --force
  1. 실행 중인 apiserver 컨테이너를 종료합니다.
docker ps -a | grep "apiserver" | awk {'print $1'} |xargs docker kill
  1. 불필요한 컨테이너를 목록으로 확인한 다음 삭제합니다.
docker ps -a --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.RunningFor}}\t{{.Status}}"docker rm <container_ID>
  1. 불필요한 컨테이너 이미지를 나열한 후 삭제합니다.
docker image list docker image rm <image_ID>

다음 단계

이 블로그를 참고하여 자체 환경에서 튜토리얼을 구현하거나 브라우저 기반 실습 환경에서 직접 체험해 볼 수 있습니다(여기에서 등록). Kubernetes 서비스 노출에 대해 자세히 알아보려면 유닛 2: 마이크로서비스 시크릿 관리 입문의 다른 활동도 함께 진행해 보세요.

NGINX Plus를 활용한 프로덕션급 JWT 인증에 대해 자세히 알아보려면 설명서를 확인하고 블로그에서 Authenticating API Clients with JWT and NGINX Plus를 읽어보세요.

'마이크로서비스 2023년 3월: 무료로 신청하고 지금 등록하세요'라고 적힌 배너


공유

작성자 소개

관련 블로그 게시물

시청하기: KubeCon North America 2023에서의 NGINX Gateway Fabric
NGINX | 2023. 11. 30.

시청하기: KubeCon North America 2023에서의 NGINX Gateway Fabric

이 영상에서는 KubeCon의 NGINX 부스를 찾아가, 저희가 공개한 Kubernetes Gateway API 구현을 살펴봅니다.

F5 NGINX로 대규모 WAF 플릿을 확인하고 관리하는 2가지 방법
NGINX | 2023. 03. 23.

F5 NGINX로 대규모 WAF 플릿을 확인하고 관리하는 2가지 방법

앱 보안을 강화하는 두 가지 방법을 소개합니다. NGINX Management Suite를 통해 WAF 운영에 대한 가시성을 높이고, NGINX App Protect WAF를 활용해 코드형 보안을 구현하는 것입니다.

Ingress 컨트롤러 선택 가이드, 4부: NGINX Ingress Controller 옵션
NGINX | 2021. 09. 27.

Ingress 컨트롤러 선택 가이드, 4부: NGINX Ingress Controller 옵션

개발 주체, 개발 철학, 프로덕션 환경 준비도, 보안 및 지원을 기준으로 어떤 NGINX Ingress 컨트롤러가 가장 적합한지 알아보세요.

진화하는 공격 환경에 NGINX App Protect Denial of Service가 적응하는 방법
NGINX | 2021. 07. 06.

진화하는 공격 환경에 NGINX App Protect Denial of Service가 적응하는 방법

NGINX App Protect Denial of Service(DoS)가 계층 7 DoS 공격을 방어하기 위해 완화 방법을 실시간으로 조정하는 방식을 설명합니다. 정상적인 사용자 행동과 서버 상태에 대한 모델을 생성하여 공격을 나타내는 이상 징후를 감지하고 맞춤형 완화 시그니처를 적용할 수 있습니다.

NGINX Instance Manager를 활용한 대규모 구성 업데이트 및 인증서 관리
NGINX | 2021. 06. 30.

NGINX Instance Manager를 활용한 대규모 구성 업데이트 및 인증서 관리

NGINX Instance Manager 버전 1.0에는 두 가지 새로운 기능이 도입되었습니다. NGINX 인스턴스에 태그와 사용자 역할을 지정하면 전체 그룹에 구성을 한 번에 적용할 수 있습니다. 인증서 검사는 만료 예정인 인증서를 감지하여 서비스 중단을 방지할 수 있도록 인증서를 교체할 수 있게 해줍니다.

Netflix가 자사 CDN의 핵심으로 NGINX를 선택한 이유
NGINX | 2015. 03. 25.

Netflix가 자사 CDN의 핵심으로 NGINX를 선택한 이유

Netflix가 글로벌 콘텐츠 전송 네트워크인 Open Connect의 스트리밍 미디어 기술로 NGINX를 선택한 이유를 NGINX 개발자 Gleb Smirnoff에게서 알아보세요.