10년 된 노트북으로 비서를 고용하기 (2) — SSH 보안 설정과 Docker 올리기
10년 된 노트북으로 비서를 고용하기
- 서버 만들기 — 오래된 노트북에 Ubuntu Server 올리기
- SSH 보안 설정과 Docker — SSH·방화벽 기본과 Docker ← 지금 글
- Ollama와 n8n 올리기 — 로컬 모델과 자동화 도구
- 비서 고용 — 외부 접속 경로와 도구를 쓰는 봇
서론
지난 편에서 10년 된 HP 노트북에 Ubuntu Server를 올렸습니다. 덮개를 닫아도 깨어 있고, SSH로 붙을 수 있는 상태까지 왔습니다.
다만 지난 편까지 이 서버는 비밀번호로 접속하고 있었습니다. 집 안에서만 닿으니 당장 문제는 없었지만, 마지막 편에서 외부 접속을 열 예정이라 그 전에 더 견고한 방식으로 바꿔두기로 했습니다.
이번 편에서 하는 일은 두 가지입니다. 서버 접속을 안전하게 제한하는 일과, 다음 편에서 서비스를 올릴 Docker를 준비하는 일입니다. 분량은 앞쪽에 크게 쏠려 있습니다.
그리고 그 과정에서 예상하지 못한 것을 하나 배웠습니다. 설정을 넣는 것보다 그게 실제로 적용됐는지 확인하는 게 어렵습니다. 틀렸을 때 아무도 에러를 내주지 않기 때문입니다.
본론
1. 보안 설정
무엇으로부터 방어하는가?
보안 설정을 하기에 앞서서 생각을 해봤습니다.
“무엇으로부터 방어를 해야하는가?”
이러한 기준이 없으면, 그냥 인터넷 정보나 LLM이 설정해준대로 따라가기만 할 것 같았습니다.
설정하려는 Ubuntu Server는 현재로서는 외부에서 들어올 경로가 없고, 집 안의 네트워크에 연결된 기기 뿐이었으며, 로그인 가능한 사람은 저 혼자였습니다.
물론, 앞으로 외부 네트워크 연결을 하여 비서가 좀 더 많은 일을 할 수 있도록 할 예정이긴 하지만, 초기 셋팅에서는 기본적인 보안 설정으로도 충분하다고 판단했습니다.
그래서 이번 편에서 잡을 것은 세 가지입니다. 전부 SSH로 들어오는 문에 대한 것입니다.
- 키로만 SSH에 들어가지게 만들기 — 비밀번호 인증을 닫습니다
- SSH 포트 옮기기 — 기본 포트를 노리는 자동 스캔을 줄입니다
- fail2ban — 인증 실패가 반복되는 IP를 자동으로 막습니다
방화벽(ufw)은 이번 편에서 다루지 않습니다. 설치는 되어 있지만 켜지 않은 상태 그대로 두었습니다. 무엇을 열어둘지는 무엇을 올릴지가 정해진 다음의 문제라, 외부 접속 경로까지 정해지는 마지막 편에서 함께 손보는 편이 낫다고 판단했습니다.
왜지금 방화벽을 켜지 않아도 되는 이유
현재 상태부터 확인합니다.
sudo ufw statusStatus: inactiveUbuntu Server에 ufw는 설치되어 있지만 기본값은 비활성입니다. 그런데도 지금 인터넷에서 이 서버로 들어올 수 있는 경로는 없습니다. 1편부터 공유기에 포트 포워딩을 하나도 걸지 않았기 때문입니다. 지금의 방어선은 ufw가 아니라 공유기입니다.
그래서 ufw를 지금 켜면 막을 대상이 집 안의 기기뿐입니다. 얻는 것에 비해 스스로를 잠글 위험이 큽니다 — 규칙을 빠뜨린 채 ufw enable을 치면 그 순간 SSH 연결이 끊기고, 화면 없는 서버에서는 복구가 번거롭습니다.
마지막 편에서 Cloudflare Tunnel로 외부 접속 경로를 만들고 나면 상황이 달라집니다. 그때는 외부 SSH가 터널로 들어오므로 ufw에 SSH 포트를 인터넷에 여는 규칙 자체가 필요 없어지고, 집 안 대역만 허용하는 형태로 켜게 됩니다.
참고이 글의 <꺾쇠> 표기에 대하여
아래 명령어와 설정 파일에서 <서버 고정 IP>, <SSH 포트>처럼 꺾쇠로 감싼 부분은 환경마다 다르고, 직접 정해야 하는 값입니다. 그대로 복사하면 동작하지 않습니다.
| 표기 | 정하는 방법 |
|---|---|
<SSH 포트> | SSH를 옮길 포트. 1024~65535 사이에서 임의로 고릅니다. 다른 서비스가 쓰는 번호는 피합니다 |
<서버 고정 IP> | 서버에 직접 부여할 주소. 공유기의 DHCP 할당 범위 밖에서 고릅니다 |
<DHCP로 받은 IP> | 지금 공유기가 서버에 빌려준 주소. ip -4 addr로 확인합니다 |
<접속하는 기기의 IP> | 서버에 접속하는 노트북이나 데스크탑의 주소 |
<사용자 이름> | 서버에 만들어둔 계정 이름 |
특히 포트 번호는 제 것을 그대로 쓰지 마세요. 공개된 글에 적힌 번호는 이미 스캔 대상이라, 기본 포트에서 옮기는 의미가 사라집니다.
지금 무엇이 열려 있는지부터
무엇을 잠글지 정하려면 지금 무엇이 열려 있는지를 먼저 알아야 합니다. 그래서 sshd가 실제로 어떤 설정으로 돌고 있는지부터 봤습니다.
여기서 봐야 할 것은 sshd_config 파일의 내용이 아니라 sshd가 그 파일들을 다 읽고 최종적으로 결정한 값입니다. 설정은 여러 파일에 흩어져 있고, 주석 처리되어 아무 효력이 없는 줄도 섞여 있기 때문입니다.
sudo sshd -T | grep -iE 'passwordauth|permitroot|pubkeyauth|maxauthtries'maxauthtries 6
permitrootlogin no
passwordauthentication yes
pubkeyauthentication yes용어파이프( | )와 grep 옵션 읽기
이 글에는 A | grep B 형태가 반복해서 나옵니다. 두 부분으로 나눠 읽으면 됩니다.
| (파이프) — 앞 명령의 출력을 화면에 뿌리는 대신 뒤 명령의 입력으로 넘깁니다. sshd -T는 설정 항목을 100줄 넘게 쏟아내므로, 그걸 그대로 보는 대신 grep에게 넘겨 필요한 줄만 걸러냅니다.
grep — 넘어온 줄 중에서 패턴에 맞는 것만 출력합니다. 이 글에 나오는 옵션은 넷입니다.
| 옵션 | 뜻 |
|---|---|
-i | 대소문자 무시(ignore case). sshd -T는 항목을 전부 소문자로 출력하는데, 설정 파일에는 PasswordAuthentication처럼 대문자가 섞여 있어 붙여둡니다 |
-E | 확장 정규식(Extended regex) 사용. 이걸 붙여야 ` |
-n | 찾은 줄의 번호(number)를 함께 출력 |
-c | 찾은 줄의 개수(count)만 출력 |
-E가 붙었을 때 따옴표 안의 |는 파이프가 아니라 **“또는”**입니다. 즉 'passwordauth|permitroot'는 “둘 중 아무거나 포함한 줄”이라는 뜻입니다. 같은 기호가 따옴표 밖에서는 파이프, 안에서는 또는으로 쓰이는 셈이라 헷갈리기 쉬운 지점입니다.
따옴표로 감싸는 이유도 여기 있습니다. 감싸지 않으면 셸이 |를 파이프로 먼저 해석해버립니다.
passwordauthentication yes. 비밀번호로 들어올 수 있는 상태입니다. 그리고 이건 제가 어딘가에 그렇게 적어둬서가 아니라, 손대지 않으면 그렇게 되어 있는 기본 상태입니다.
집 안에서만 닿는 서버라 당장 위험하진 않지만, 비밀번호는 추측할 수 있는 것입니다. 키는 추측할 수 없습니다. 그래서 첫 작업은 여기입니다.
용어sshd -T 와 sshd -t 는 다릅니다
한 글자 차이인데 하는 일이 완전히 다릅니다.
sshd -t— 문법 검사(test). 설정 파일에 오타가 있는지만 봅니다sshd -T— 최종 설정 출력. 여러 파일에 흩어진 설정을 실제 규칙대로 병합한 결과값을 보여줍니다
-T가 중요한 이유는 이것이 실행 중인 데몬에게 물어보는 것이 아니라 설정 파일을 다시 읽어 계산한다는 점입니다. 즉 재시작하기 전에 재시작 후의 값을 미리 볼 수 있습니다. 원격으로만 접근하는 서버에서는 이 순서가 안전장치가 됩니다.
그래서 설정을 고친 뒤에는 항상 두 개를 붙여서 실행했습니다.
sudo sshd -t && sudo sshd -T | grep -i passwordauth문법이 틀리면 && 뒤가 실행되지 않고, 문법이 맞으면 재시작 후에 적용될 값을 미리 보여줍니다.
1단계 — 키로 접속되게 만들기
키로 접속되게 만들고, 그다음에 비밀번호로 들어오는 것을 차단합니다. 순서를 바꾸면 안 됩니다. 비밀번호를 먼저 닫았는데 키가 제대로 등록되어 있지 않으면, 그 순간부터 들어갈 방법이 없는 서버가 됩니다.
키는 비대칭키 한 쌍으로 만듭니다. 개인키는 접속하는 쪽에 남고 공개키만 서버에 올라가므로, 키를 만드는 곳은 서버가 아니라 접속할 기기입니다. 저는 맥에서 접속하니 맥에서 만들었습니다.
ssh-keygen -t ed25519 -f ~/.ssh/home_ed25519 -C "home-server"용어ssh-keygen 의 옵션 읽기
| 옵션 | 뜻 | 안 쓰면 |
|---|---|---|
-t ed25519 | 키의 종류(type) | rsa로 만들어집니다 |
-f ~/.ssh/home_ed25519 | 저장할 파일 이름(file) | ~/.ssh/id_ed25519로 저장됩니다 |
-C "home-server" | 키 끝에 붙는 메모(comment) | 사용자@컴퓨터이름이 들어갑니다 |
-C는 인증에 아무 영향을 주지 않습니다. 서버의 authorized_keys를 열어봤을 때 이 줄이 어느 기기의 키인지 알아보기 위한 꼬리표입니다. 키를 여러 대에서 등록해두면 나중에 특정 기기만 골라 지울 때 이것만 보고 찾게 됩니다.
이 명령이 만드는 파일은 두 개입니다.
home_ed25519— 개인키. 절대 밖으로 나가면 안 됩니다. 권한이600이 아니면 SSH가 사용을 거부합니다home_ed25519.pub— 공개키. 서버에 올리는 쪽입니다
이름이 헷갈리기 쉬운데, 서버에 등록하는 것은 .pub가 붙은 쪽입니다.
파일 이름을 home_ed25519로 지은 것도 나중을 위한 것입니다. 기본값인 ~/.ssh/id_ed25519로 만들면 키가 여러 개가 됐을 때 어느 것이 어느 서버 것인지 알 수 없게 됩니다. 서버 이름을 파일명에 넣어두면 지우거나 교체할 때 헷갈리지 않습니다. 1편에서 정한 호스트명이 home-server라 home_ed25519로 지었습니다.
만든 공개키를 서버에 등록합니다.
ssh-copy-id -i ~/.ssh/home_ed25519.pub home-server이 명령이 하는 일은 단순합니다. 서버에 접속해서 ~/.ssh/authorized_keys 끝에 공개키 한 줄을 덧붙이는 것입니다. 이 시점에는 아직 비밀번호로 접속합니다.
용어ssh-copy-id 가 실제로 하는 일
-i 하나만 씁니다. identity file, 즉 올릴 공개키 파일을 지정합니다. 생략하면 ~/.ssh/id_rsa.pub나 id_ed25519.pub 같은 기본 이름을 찾는데, 앞에서 파일 이름을 직접 지었으니 명시해야 합니다.
반드시 .pub를 지정해야 합니다. 개인키 쪽을 적으면 ssh-copy-id가 알아서 짝이 되는 .pub를 찾아주긴 하지만, 습관적으로 개인키 경로를 넘기다 보면 언젠가 개인키를 다른 곳에 복사하게 됩니다.
이 명령이 없어도 손으로 할 수 있습니다. 하는 일이 이게 전부이기 때문입니다.
cat ~/.ssh/home_ed25519.pub | ssh <사용자 이름>@<서버 고정 IP> \
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"ssh-copy-id는 여기에 더해 이미 등록된 키인지 확인해 중복을 막고, 디렉터리 권한을 맞춰줍니다. SSH는 ~/.ssh가 700이 아니거나 authorized_keys가 600이 아니면 키를 무시합니다. 남이 읽을 수 있는 위치의 키는 신뢰하지 않는다는 정책인데, 이때도 에러 대신 그냥 “인증 실패”로 넘어가기 때문에 손으로 등록할 때 가장 자주 걸리는 곳입니다.
그리고 맥의 ~/.ssh/config에 접속 정보를 적어둡니다.
IgnoreUnknown UseKeychain
Host <접속할 서버의 별명(임의 설정 가능)>
HostName <접속 url 혹은 ip>
Port <설정한 포트>
User <사용자 이름>
IdentityFile ~/.ssh/home_ed25519
IdentitiesOnly yes
AddKeysToAgent yes
UseKeychain yesHost 뒤의 별명은 마음대로 정하면 됩니다. 저는 1편에서 정한 호스트명을 그대로 따서 home-server로 지었고, 이 글에서 앞으로 나오는 ssh home-server는 전부 이 별명을 부르는 것입니다. 4편에서 외부 접속 경로를 붙일 때도 이 블록을 고쳐 씁니다. 이 블록을 적어두면 ssh -p <SSH 포트> <사용자 이름>@<서버 고정 IP> 대신 ssh home-server로 끝납니다.
IdentitiesOnly yes가 중요합니다. 이게 없으면 SSH는 가지고 있는 키를 순서대로 전부 시도하는데, 서버의 MaxAuthTries에 먼저 걸려서 정작 맞는 키를 내밀기 전에 끊길 수 있습니다. 지정한 키 하나만 쓰게 못박아두는 옵션입니다.
이제 검증합니다.
ssh -o PreferredAuthentications=publickey home-server 'echo KEY_OK'KEY_OK여기까지 확인되기 전에는 다음 단계로 넘어가지 않습니다.
용어ssh 의 -o 옵션과 뒤에 붙는 명령
-o — ~/.ssh/config에 적을 수 있는 설정을 이번 접속에만 임시로 적용합니다(option). 파일을 고치지 않으므로 되돌릴 것이 없고, 그래서 검증용으로 쓰기 좋습니다. 여러 개가 필요하면 -o를 반복해서 붙입니다.
이 글에서 쓰는 것은 셋입니다.
| 옵션 | 뜻 |
|---|---|
PreferredAuthentications=publickey | 인증 수단을 키 하나로 제한합니다. 실패하면 비밀번호로 넘어가지 않고 그냥 끊깁니다 |
PreferredAuthentications=password | 반대로 비밀번호만 시도합니다 |
PubkeyAuthentication=no | 키 인증을 끕니다. 위와 함께 쓰면 “비밀번호로만 들어가보기”가 됩니다 |
이렇게 수단을 하나로 봉인해야 그 수단이 되는지 안 되는지를 알 수 있습니다. 아무 옵션 없이 ssh home-server를 하면 SSH가 알아서 여러 방법을 시도하기 때문에, 성공해도 어느 방법으로 성공했는지 알 수 없습니다.
뒤에 붙는 'echo KEY_OK' — 접속한 다음 서버에서 실행할 명령입니다. 이걸 적으면 SSH는 명령만 실행하고 바로 빠져나옵니다. 대화형 셸이 열리지 않으니 exit을 칠 필요가 없고, 화면에 KEY_OK가 찍혔다는 것 자체가 “접속에 성공했다”는 증거가 됩니다.
뒤에서는 true를 씁니다. 아무 일도 하지 않고 성공만 반환하는 명령이라, 출력 없이 접속 가능 여부만 보고 싶을 때 씁니다.
키를 만들면서 짚고 넘어가기
용어대칭키와 비대칭키
대칭키는 잠그는 열쇠와 여는 열쇠가 같습니다. 우리가 아는 비밀번호가 이쪽에 가깝습니다. 빠르지만 문제가 하나 있습니다. 그 열쇠를 상대에게 전달하는 순간이 가장 위험합니다. 서버에 비밀번호로 접속한다는 것은 매번 그 값을 서버로 보낸다는 뜻입니다.
비대칭키는 열쇠가 한 쌍입니다. 수학적으로 짝지어져 있지만 한쪽으로 다른 쪽을 계산해낼 수 없습니다.
- 개인키 — 내 컴퓨터에만 둡니다. 어디에도 보내지 않습니다
- 공개키 — 서버에 올려둡니다. 유출되어도 상관없습니다
접속할 때 벌어지는 일은 이렇습니다. 서버가 무작위 값을 하나 던지면, 클라이언트가 개인키로 서명해서 돌려주고, 서버는 가지고 있는 공개키로 그 서명이 맞는지 검증합니다. 이 과정에서 개인키는 한 번도 네트워크를 타지 않습니다. 오가는 것은 매번 달라지는 서명뿐이라, 중간에서 훔쳐봐도 다음 접속에 쓸 수 없습니다.
비밀번호를 없애는 이유가 여기 있습니다. 비밀번호는 추측할 수 있고, 맞히면 그대로 들어옵니다. 개인키는 256비트짜리 난수라 추측이라는 방법 자체가 성립하지 않습니다. 대신 파일로 존재하므로 훔쳐갈 수는 있고, 그래서 아래의 패스프레이즈가 필요해집니다.
왜ed25519를 고른 이유
SSH 키에는 여러 종류가 있고, 오래된 안내문에는 대부분 rsa가 적혀 있습니다.
ed25519는 타원곡선 기반 방식으로, RSA보다 키가 훨씬 짧으면서 보안 강도는 더 높습니다. RSA는 충분한 강도를 내려면 4096비트가 필요하고 키 파일이 길어지는데, ed25519는 고정 256비트에 공개키가 한 줄로 끝납니다. 서명과 검증도 빠릅니다.
강도를 직접 정할 수 없다는 점도 오히려 장점입니다. RSA는 -b 옵션으로 비트 수를 고를 수 있어서 모르고 약하게 만들 여지가 있는데, ed25519에는 그런 선택지 자체가 없습니다.
OpenSSH 6.5(2014년) 이상이면 어디서나 지원되므로, 새로 만드는 키라면 특별한 이유가 없는 한 이쪽이 기본입니다.
용어패스프레이즈를 걸어야 하나
ssh-keygen을 실행하면 Enter passphrase (empty for no passphrase):라고 묻습니다.
패스프레이즈는 개인키 파일 자체를 암호화하는 비밀번호입니다. 서버에 보내는 값이 아니라, 내 컴퓨터에 저장된 키 파일을 여는 열쇠입니다. 서버 비밀번호와는 아무 관계가 없습니다.
이게 막는 상황은 하나입니다. 누군가 내 컴퓨터의 키 파일을 통째로 훔쳐갔을 때. 패스프레이즈가 없으면 그 파일만으로 즉시 서버에 들어갈 수 있고, 있으면 파일을 가져가도 쓸 수 없습니다.
매번 입력하는 게 번거로울 것 같지만 그렇지 않습니다. macOS에서는 위 ~/.ssh/config의 두 줄이 이를 해결합니다.
AddKeysToAgent yes— 한 번 푼 키를ssh-agent가 메모리에 들고 있습니다UseKeychain yes— 패스프레이즈를 키체인에 저장해 재부팅 후에도 자동으로 꺼내 씁니다
결과적으로 처음 한 번만 입력하고 그 뒤로는 묻지 않습니다. 걸어두는 쪽이 맞습니다.
주의UseKeychain 이 Bad configuration option 으로 거부될 때
UseKeychain은 Apple이 자기네 OpenSSH에만 넣은 확장 옵션입니다. 표준 OpenSSH에는 없습니다.
맥에 Homebrew로 openssh를 설치해두면 /opt/homebrew/bin/ssh가 /usr/bin/ssh를 가리게 되는데, 이쪽은 표준 빌드라 UseKeychain을 모릅니다. 그러면 이렇게 거부합니다.
/Users/me/.ssh/config: line 20: Bad configuration option: usekeychain해결은 “모르는 옵션은 무시하라”고 알려주는 것입니다.
IgnoreUnknown UseKeychain이 줄을 파일 맨 위, 어떤 Host 블록에도 속하지 않는 위치에 두어야 합니다. Host home-server 안에 넣으면 다른 호스트에 접속할 때 다시 같은 에러가 납니다. SSH는 설정 파일을 파싱하는 시점에 모르는 옵션을 거부하기 때문에, 지금 접속하려는 호스트와 무관한 블록의 오류로도 전체가 죽습니다.
2단계 — 비밀번호 로그인 닫기
키가 되는 것을 확인했으니 이제 비밀번호를 닫습니다. sshd_config를 직접 고치지 않고 sshd_config.d/에 파일을 하나 추가했습니다.
sudo tee /etc/ssh/sshd_config.d/01-hardening.conf > /dev/null <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
PermitEmptyPasswords no
MaxAuthTries 3
EOF용어sudo tee ... <<'EOF' 뜯어보기
한 줄에 낯선 기호가 세 종류 들어 있습니다. 나눠 보면 각각 단순합니다.
sudo tee <파일> — 표준 입력으로 들어온 내용을 파일에 씁니다. > 리다이렉션 대신 이걸 쓰는 이유는 권한 때문입니다.
sudo echo "..." > /etc/ssh/sshd_config.d/01-hardening.conf # 실패합니다이건 파일에 쓰는 주체가 sudo가 아니라 셸이기 때문에 Permission denied가 납니다. sudo는 echo에만 걸리고, >는 그 바깥에서 내 권한으로 실행됩니다. tee는 자기 자신이 파일을 여는 프로그램이라 sudo tee로 감싸면 권한이 제대로 붙습니다.
> /dev/null — tee는 이름 그대로 T자 분기관이라 파일에 쓰면서 화면에도 같은 내용을 뿌립니다. 방금 적은 내용이 그대로 다시 출력되는 게 번거로우니 화면 쪽 출력을 버립니다. /dev/null은 뭘 넣어도 사라지는 특수 파일입니다.
<<'EOF' ... EOF — 히어독(here document)입니다. 두 EOF 사이의 줄들을 통째로 앞 명령의 입력으로 넘깁니다. 여러 줄을 한 번에 넣을 때 씁니다.
여기서 두 가지를 주의해야 합니다.
- 첫 줄을 입력하면 프롬프트가
>로 바뀝니다. 명령이 끝나지 않은 게 아니라, 닫는EOF를 기다리는 중입니다. 내용을 다 적고 마지막 줄에EOF만 입력하면 실행됩니다 'EOF'처럼 따옴표로 감싸야 합니다. 감싸지 않으면 셸이 내용 안의$나 백틱을 변수·명령으로 해석해버립니다. 설정 파일을 적을 때는 글자 그대로 들어가야 하므로 항상 감쌉니다
터미널에 붙여넣기가 잘 안 되는 환경이라면 한 줄씩 나눠 써도 결과는 같습니다.
printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' \
| sudo tee /etc/ssh/sshd_config.d/01-hardening.conf > /dev/null재시작 전에 문법과 병합 결과를 확인합니다.
sudo sshd -t && sudo sshd -T | grep -iE 'passwordauth|kbdinteractive|maxauthtries'maxauthtries 3
passwordauthentication no
kbdinteractiveauthentication no값이 의도대로 나오면 재시작합니다.
sudo systemctl restart ssh용어이 글에 나오는 systemctl 하위 명령들
systemctl은 systemd가 관리하는 서비스를 다루는 명령입니다. 이 글에서 쓰는 것만 모으면 이렇습니다.
| 명령 | 하는 일 |
|---|---|
restart <서비스> | 서비스를 껐다 켭니다. 바뀐 설정은 이때 적용됩니다 |
status <서비스> | 지금 돌고 있는지, 최근 로그가 무엇인지 보여줍니다 |
is-enabled <서비스> | 부팅 시 자동 시작되도록 설정돼 있는지만 답합니다. 지금 돌고 있는지와는 별개입니다 |
edit <서비스> | 패키지가 제공한 원본은 두고, 덮어쓸 내용만 담은 파일을 따로 만듭니다 |
daemon-reload | 유닛 파일이 바뀐 것을 systemd에게 다시 읽으라고 알립니다 |
cat <서비스> | 원본과 덮어쓴 내용을 합쳐서 보여줍니다 |
두 가지가 헷갈리기 쉽습니다.
restart와 daemon-reload는 다릅니다. daemon-reload는 systemd가 설정 파일을 다시 읽는 것이고, restart는 서비스를 다시 띄우는 것입니다. 유닛 파일(.service, .socket)을 고쳤다면 둘 다 필요합니다. 반대로 sshd_config처럼 서비스가 직접 읽는 파일만 고쳤다면 restart만 하면 됩니다.
systemctl restart ssh는 이미 붙어 있는 접속을 끊지 않습니다. 새로 들어오는 접속에만 새 설정이 적용됩니다. 원격으로 SSH 설정을 고칠 수 있는 것이 이 성질 덕분입니다.
그리고 검증인데, 두 가지를 따로 물어봐야 합니다.
ssh home-server 'echo STILL_OK' # 키가 되는가
ssh -o PreferredAuthentications=password \
-o PubkeyAuthentication=no home-server true # 비밀번호가 막히는가STILL_OK
Permission denied (publickey).괄호 안에 publickey만 남았습니다. 이전에는 (publickey,password)였습니다. 첫 번째 명령은 “키가 된다”만 증명하고, “비밀번호가 안 된다”는 두 번째만이 증명합니다.
비밀번호를 닫으면서 짚고 넘어가기
주의파일 이름을 01- 로 지은 이유 — sshd는 먼저 읽은 값이 이깁니다
sshd_config는 24행에서 sshd_config.d/*.conf를 사전순으로 불러옵니다.
grep -n "^Include" /etc/ssh/sshd_config24:Include /etc/ssh/sshd_config.d/*.conf문제는 sshd가 같은 항목을 여러 번 만나면 먼저 읽은 값을 채택하고 나중 값을 버린다는 것입니다. 나중 설정이 앞 설정을 덮어쓰는 대부분의 도구와 반대입니다.
그리고 Ubuntu에는 이미 50-cloud-init.conf가 들어 있고, 그 안에 PasswordAuthentication yes가 적혀 있습니다.
50-cloud-init.conf PasswordAuthentication yes ← 먼저 읽힘. 이김
99-hardening.conf PasswordAuthentication no ← 무시됨
01-hardening.conf PasswordAuthentication no ← 50보다 앞. 이김흔히 쓰는 99-로 만들었다면 파일은 멀쩡히 있고, 재시작도 성공하고, 아무 에러도 없는데, 비밀번호는 그대로 열려 있었을 것입니다.
주의cloud-init 이 재부팅 때 다시 써넣습니다
50-cloud-init.conf는 cloud-init이 관리하는 파일입니다. 손으로 지우거나 고쳐도 조건에 따라 다시 생성됩니다. 01-hardening.conf가 이기고 있으니 당장 문제는 없지만, 근본을 막아두는 편이 낫습니다.
echo 'ssh_pwauth: false' | sudo tee /etc/cloud/cloud.cfg.d/99-ssh-pwauth.cfg여기는 99-가 맞습니다. cloud-init은 sshd와 반대로 나중 파일이 이깁니다. 방금 01-을 쓴 직후라 같은 규칙일 거라고 넘겨짚기 딱 좋은 지점인데, 정반대입니다. 도구마다 다르니 그때그때 확인해야 합니다.
왜KbdInteractiveAuthentication 도 함께 꺼야 하는 이유
PasswordAuthentication no 한 줄로는 부족합니다. 이건 추측이 아니라 sshd_config 파일 안의 주석이 직접 경고하고 있는 내용입니다.
SSH에는 비밀번호를 받는 경로가 두 개 있습니다. 하나는 password 인증이고, 다른 하나는 keyboard-interactive 인증입니다. 후자는 원래 OTP 같은 대화형 절차를 위한 통로인데, PAM 설정에 따라 여기로 비밀번호가 그대로 들어올 수 있습니다.
주의되돌릴 길을 남겨두고 작업하기
SSH 설정을 바꾸다 잘못되면 원격 접속 경로 자체가 사라집니다. 헤드리스 서버라면 노트북을 직접 열어야 합니다.
두 가지를 지켰습니다.
- 기존 SSH 세션을 끊지 않습니다. 설정을 바꾸고 재시작한 뒤에도 그 창은 그대로 두고, 새 터미널을 열어서 접속을 시도합니다. 새 접속이 실패해도 살아있는 세션에서 되돌릴 수 있습니다
- 재시작 전에 항상
sudo sshd -t를 먼저 실행합니다. 오타가 있는 채로 재시작하면 sshd가 아예 뜨지 못합니다
systemctl restart ssh는 이미 연결된 세션을 끊지 않습니다. 새로 들어오는 접속에만 새 설정이 적용됩니다. 이 성질 덕분에 안전망이 성립합니다.
3단계 — SSH 포트 옮기기
기본 포트인 22번은 자동 스캔의 첫 번째 대상입니다. 포트를 옮긴다고 보안이 강해지는 것은 아니지만, 의미 없는 로그와 시도가 크게 줄어듭니다. fail2ban이 진짜 봐야 할 것을 보게 만드는 정리 작업에 가깝습니다.
Ubuntu 24.04부터 sshd는 소켓 활성화(socket activation) 방식으로 돕니다. 데몬이 포트를 잡고 기다리는 게 아니라, systemd가 포트를 열어두고 있다가 접속이 들어오면 그때 sshd 인스턴스를 띄웁니다. 그래서 포트를 결정하는 것은 sshd_config가 아니라 ssh.socket입니다.
sudo systemctl edit ssh.socket여기에 세 줄을 적습니다.
[Socket]
ListenStream=
ListenStream=<SSH 포트>중간의 빈 ListenStream=이 핵심입니다. 이 줄이 기본값 22를 지웁니다. 이게 없으면 22와 새 포트를 둘 다 듣게 됩니다.
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket확인은 설정이 아니라 실제로 듣고 있는 쪽을 봐야 합니다.
sudo ss -tulpn | grep <SSH 포트>LISTEN 0.0.0.0:<SSH 포트> users:(("sshd",pid=18364,fd=3),("systemd",pid=1,fd=256))systemd가 함께 잡혀 있는 것이 소켓 활성화의 흔적입니다.
용어ss -tulpn 의 글자 다섯 개
ss는 socket statistics, 지금 열려 있는 네트워크 연결을 보여주는 명령입니다. 예전에 쓰던 netstat의 후속입니다.
옵션은 한 글자씩 따로 읽으면 됩니다.
| 글자 | 뜻 |
|---|---|
-t | TCP 소켓 |
-u | UDP 소켓 |
-l | 듣고 있는(listening) 것만. 이미 연결된 세션은 빼고 봅니다 |
-p | 그 포트를 쓰는 프로세스(process) 이름과 PID를 함께 |
-n | 포트를 숫자(numeric) 그대로. 이게 없으면 22를 ssh로 바꿔 보여줘 원하는 숫자로 찾기 어려워집니다 |
-p로 프로세스를 보려면 권한이 필요해서 sudo를 붙입니다. 붙이지 않으면 목록은 나오지만 프로세스 칸이 비어 있습니다.
이 명령이 중요한 이유는 설정 파일이 아니라 실제 상태를 보여주기 때문입니다. sshd -T가 “sshd가 자기 설정을 어떻게 읽었는가”라면, ss는 “지금 실제로 누가 어느 포트를 잡고 있는가”입니다. 둘이 다를 수 있다는 것이 이 절의 요지입니다.
주의sshd_config 의 Port 를 고쳐도 포트는 바뀌지 않습니다
SSH 포트를 옮기는 방법으로 대부분의 안내문은 sshd_config의 Port를 고치라고 적습니다. 소켓 활성화가 켜져 있으면 그 줄은 아무 일도 하지 않습니다.
더 헷갈리는 것은 확인 명령까지 그럴듯하게 응답한다는 점입니다.
sudo sshd -T | grep '^port'port <SSH 포트>바뀐 것처럼 보입니다. 하지만 이건 sshd가 자기 설정 파일을 어떻게 읽었는지일 뿐, 실제로 누가 그 포트를 열고 있는지와는 다른 이야기입니다. 재시작도 성공하고 에러도 나지 않습니다. 그저 포트만 그대로입니다.
소켓 활성화가 켜져 있는지는 이걸로 확인합니다.
systemctl is-enabled ssh.socket # enabled 면 소켓 활성화enabled가 나오면 ssh.socket을 고쳐야 하고, disabled나 masked가 나오면 전통적인 방식이라 sshd_config의 Port가 실제로 동작합니다.
4단계 — fail2ban으로 반복 시도 막기
키만 받도록 잠갔지만, 그래도 누군가는 계속 두드릴 수 있습니다. fail2ban은 로그를 지켜보다가 인증 실패가 정해진 횟수를 넘긴 IP를 방화벽 수준에서 차단합니다.
sudo apt install -y fail2ban-y는 설치 중간에 나오는 “계속할까요? [Y/n]” 질문에 미리 예라고 답해두는 옵션입니다(yes). 없으면 엔터를 한 번 더 쳐야 합니다.
설정은 jail.d/에 파일을 만들어 넣습니다. jail.conf를 직접 고치면 패키지 업데이트 때 덮어쓰기 때문입니다.
sudo tee /etc/fail2ban/jail.d/sshd.local > /dev/null <<'EOF'
[sshd]
enabled = true
backend = systemd
mode = aggressive
maxretry = 3
findtime = 10m
bantime = 1h
EOF
sudo systemctl restart fail2ban용어jail 설정 항목 읽기
| 항목 | 뜻 |
|---|---|
[sshd] | jail 이름. fail2ban은 감시 대상을 jail 단위로 관리하고, sshd는 미리 정의된 jail입니다 |
enabled = true | 이 jail을 켭니다. 정의만 되어 있고 꺼져 있는 jail이 기본으로 여럿 들어 있습니다 |
backend = systemd | 로그를 어디서 읽을지. 파일(/var/log/auth.log) 대신 journald를 읽습니다 |
mode = aggressive | 어떤 로그 패턴까지 실패로 셀지. 기본값 normal보다 넓게 잡습니다 |
maxretry = 3 | 몇 번 실패하면 차단할지 |
findtime = 10m | 몇 분 안에 그만큼 실패해야 하는지. 이 시간이 지나면 카운트가 사라집니다 |
bantime = 1h | 차단을 얼마나 유지할지. -1을 주면 영구 차단입니다 |
maxretry와 findtime은 항상 함께 읽어야 합니다. maxretry = 3 하나만 보면 “3번 틀리면 차단”이지만, 실제로는 10분 안에 3번이어야 합니다. 하루에 한 번씩 세 번 틀린 경우는 차단되지 않습니다.
bantime을 처음부터 길게 잡는 것도 조심스럽습니다. 자기 자신이 차단되면 그 시간 동안 못 들어갑니다. 설정을 시험하는 동안에는 짧게 두었다가 확인이 끝난 뒤 늘리는 편이 안전하고, 되돌릴 창구를 남겨두는 것도 방법입니다.
sudo fail2ban-client set sshd unbanip <내 IP> # 실수로 자신을 막았을 때읽는 순서대로 옮기면 10분 안에 3번 실패한 IP를 1시간 차단입니다. backend = systemd는 로그 파일 대신 journald를 읽으라는 뜻이고, Ubuntu처럼 로그가 journal로 가는 환경에서는 이쪽이 맞습니다.
문제는 이 상태의 출력이 이렇게 나온다는 것입니다.
Status for the jail: sshd
|- Currently failed: 0 |- Total failed: 0
`- Currently banned: 0 `- Total banned: 0전부 0입니다. 그럴듯한 설명이 바로 떠오릅니다. 외부에서 들어올 경로가 없으니 두드릴 사람도 없다. 맞는 말처럼 들리지만, 설정이 아예 안 먹고 있어도 똑같이 0이 나옵니다. 그래서 직접 두드려보기로 했습니다.
두드리기 전에 현재 값을 한 번 찍어둡니다. 그런데 이미 0이 아닙니다.
sudo fail2ban-client status sshd|- Currently failed: 0 |- Total failed: 42단계에서 제가 쌓은 것입니다. 비밀번호가 막히는지 확인하려고 PubkeyAuthentication=no를 붙여 일부러 실패시킨 그 접속들입니다. 저에게는 검증이었지만 fail2ban 입장에서는 구분할 방법이 없는 인증 실패입니다. 로그에 남은 것만 보고 세는 도구라 의도까지는 알지 못합니다.
이 숫자를 미리 확인해두는 것이 검증의 절반입니다. 봐야 하는 것은 값 자체가 아니라 증가분이기 때문입니다. 이제 없는 계정으로 세 번 두드립니다.
for i in 1 2 3; do
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no \
-p <SSH 포트> nosuchuser@<서버 고정 IP> true
done
sudo fail2ban-client status sshd|- Currently failed: 3 |- Total failed: 7용어for 반복문과 fail2ban-client 하위 명령
for i in 1 2 3; do ... done — 1 2 3 목록을 하나씩 i에 넣으며 do와 done 사이를 반복합니다. 여기서는 i를 안 쓰므로 그냥 세 번 실행하라는 뜻입니다. 손으로 세 번 치는 것과 결과가 같습니다.
터미널에 여러 줄 붙여넣기가 잘 안 되면 한 줄로 이어 써도 됩니다.
for i in 1 2 3; do ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no -p <SSH 포트> nosuchuser@<서버 고정 IP> true; done여기서 nosuchuser는 존재하지 않는 계정 이름입니다. 실제 계정으로 시험하면 그 계정이 차단 대상이 되어 자기 발등을 찍게 됩니다.
fail2ban-client — 돌고 있는 fail2ban에게 물어보거나 시키는 명령입니다.
| 명령 | 하는 일 |
|---|---|
status | 켜져 있는 jail 목록 |
status <jail> | 그 jail의 실패·차단 집계 |
get <jail> <항목> | 실제로 적용된 설정값을 답합니다 |
set <jail> unbanip <IP> | 차단을 풉니다 |
get이 특히 중요합니다. 설정 파일에 적어둔 값과 실제 적용된 값이 다를 수 있기 때문입니다. 파일을 다시 읽어봐야 알 수 없고, 이렇게 물어봐야 알 수 있습니다.
sudo fail2ban-client get sshd maxretry
sudo fail2ban-client get sshd bantime4에서 7. 정확히 3이 올랐습니다. fail2ban 자체 로그에도 남았습니다.
fail2ban.filter [sshd] Found <접속하는 기기의 IP> - 2026-08-29 13:27:48
fail2ban.filter [sshd] Found <접속하는 기기의 IP> - 2026-08-29 13:27:48
fail2ban.filter [sshd] Found <접속하는 기기의 IP> - 2026-08-29 13:27:490이었던 이유는 정말로 두드릴 사람이 없어서였습니다. 짐작이 맞았지만, 맞았다는 걸 알게 된 건 확인했기 때문입니다.
fail2ban 을 설정하며 짚고 넘어가기
주의jail.d 는 *.conf 와 *.local 만 읽습니다
설정을 손보려고 파일을 만들었는데 이름을 sshd.local이 아니라 sshd.locl로 썼습니다.
fail2ban은 이 디렉터리에서 *.conf와 *.local만 읽습니다. 그 외의 이름은 그냥 없는 파일 취급입니다. 그런데 겉으로는 아무 문제가 없습니다. 파일은 멀쩡히 있고, 서비스는 정상 기동하고, systemctl status는 초록불이고, 에러 로그도 없습니다. 다만 그 파일의 내용이 어디에도 반영되지 않았을 뿐입니다.
알아챈 방법은 하나였습니다. 파일에 적어둔 값과 실제 적용된 값을 직접 비교하는 것입니다.
sudo fail2ban-client get sshd maxretry파일에는 3이라고 적혀 있는데 5가 나왔습니다. 기본값입니다. 설정 파일을 아무리 다시 읽어봐도 알 수 없는 종류의 오류라, 적용된 값을 물어보는 명령을 아는 것이 유일한 확인 수단입니다.
참고감지(Found)와 차단(Ban)은 별개의 단계입니다
위 검증에서 Total failed는 올랐지만 Total banned는 계속 0입니다. 실패한 건 확실한데 왜 차단이 안 될까요.
fail2ban은 두 단계로 동작합니다.
- 감지 — 로그에서 인증 실패를 찾아 셉니다. 로그에
Found로 남습니다 - 차단 — 센 횟수가
findtime안에서maxretry를 넘기면 그때nftables에 차단 규칙을 넣습니다
3번 실패는 maxretry = 3의 문턱에 닿았을 뿐 넘지는 않은 상태입니다. 즉 지금 검증된 것은 감지까지이고, 실제 차단 동작은 아직 한 번도 실행된 적이 없습니다.
집 안에서만 접근하는 지금은 여기까지로 충분하다고 판단했습니다. 차단이 실제로 도는지는 외부 접속을 여는 마지막 편에서 확인할 일로 남겨둡니다.
용어Total failed 는 누적값입니다
fail2ban-client status sshd의 숫자 네 개가 각각 다른 것을 셉니다.
| 항목 | 의미 |
|---|---|
Currently failed | 지금 findtime 창 안에 살아있는 실패 수. 시간이 지나면 줄어듭니다 |
Total failed | 서비스 시작 이후 누적 실패 수. 줄어들지 않습니다 |
Currently banned | 지금 차단 중인 IP 수 |
Total banned | 누적 차단 횟수 |
검증할 때 봐야 하는 것은 Total failed의 증가분입니다. 이 값은 되돌아가지 않으므로, 두드리기 전후로 한 번씩 찍어두고 차이를 보면 됩니다. Currently failed는 시간이 지나면 알아서 0으로 돌아가기 때문에 그것만 보면 “안 세고 있다”고 오해하기 쉽습니다.
fail2ban을 재시작하면 누적값도 초기화됩니다.
여기까지의 상태
| 항목 | 설정 | 확인 명령 |
|---|---|---|
| SSH 키 인증 | ed25519 키 등록 | ssh -o PreferredAuthentications=publickey home-server true |
| 비밀번호 로그인 | 차단 | ssh -o PubkeyAuthentication=no home-server true → Permission denied (publickey) |
| root 로그인 | 차단 | sudo sshd -T | grep permitrootlogin |
| SSH 포트 | 기본 22에서 이동 | sudo ss -tulpn | grep <SSH 포트> |
| fail2ban | 10분 3회 → 1시간 차단 | sudo fail2ban-client get sshd maxretry |
2. Docker 설치
공식 저장소로 설치하기
Docker를 설치하는 방법은 세 가지입니다. Ubuntu 기본 저장소의 docker.io, snap, 그리고 Docker가 직접 운영하는 공식 저장소입니다. 저는 공식 저장소를 등록하는 쪽을 골랐습니다.
이유는 이 서버에 931GB HDD가 /data에 붙어 있기 때문입니다. 언젠가 거기에 데이터를 둘 생각인데, snap으로 설치한 Docker는 파일시스템 접근이 격리되어 있어서 홈 디렉터리 밖의 경로를 볼륨으로 마운트할 때 막힙니다. 처음부터 막힐 길로 갈 이유가 없었습니다.
sudo apt update
sudo apt install ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo 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/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin설치된 버전입니다.
Docker 29.7.2
Docker Compose v5.5.0docker compose가 공백인 것에 주의해야 합니다. 예전에 쓰던 docker-compose(하이픈)와 다른 물건입니다.
설치 명령 뜯어보기
용어저장소를 직접 등록한다는 것
apt install은 아무 데서나 패키지를 받아오지 않습니다. 등록된 저장소 목록에서만 찾습니다. Docker 공식 버전을 쓰려면 그 목록에 Docker의 서버를 추가해야 하고, 위 명령이 하는 일이 그것입니다.
세 단계로 나뉩니다.
- 키 받기 — Docker가 패키지에 서명할 때 쓰는 공개키를 내려받아
/etc/apt/keyrings/에 둡니다 - 저장소 주소 등록 —
sources.list.d/docker.list에 “어느 주소에서, 어느 키로 검증해서 받을지”를 적습니다 apt update— 새로 등록한 곳의 패키지 목록을 읽어옵니다
키가 필요한 이유는 내려받은 파일이 진짜 Docker가 만든 것인지 검증하기 위해서입니다. 중간에서 누가 바꿔치기해도 서명이 맞지 않으면 apt가 설치를 거부합니다. 그래서 apt update를 먼저 하지 않으면 방금 등록한 저장소를 모르는 상태라 설치가 실패합니다.
signed-by=가 요즘 방식입니다. 예전에는 apt-key add로 키를 시스템 전체에 신뢰 등록했는데, 그러면 그 키로 서명된 아무 패키지나 어느 저장소에서든 통과됩니다. signed-by=는 이 저장소는 이 키로만 검증하라고 묶어두는 방식입니다.
용어명령에 붙은 옵션들
| 조각 | 하는 일 |
|---|---|
curl -fsSL <URL> | 파일을 받아 화면(표준 출력)으로 흘려보냅니다 |
gpg --dearmor | 사람이 읽을 수 있는 텍스트 형태의 키를 apt가 읽는 바이너리 형태로 변환합니다 |
install -m 0755 -d <경로> | 디렉터리를 권한까지 지정해서 만듭니다. mkdir + chmod를 한 번에 |
chmod a+r <파일> | 모든 사용자(all)에게 읽기 권한. apt가 일반 권한으로도 키를 읽어야 합니다 |
$(dpkg --print-architecture) | 이 컴퓨터의 아키텍처(amd64, arm64 등)로 치환됩니다 |
$(. /etc/os-release && echo "$VERSION_CODENAME") | Ubuntu 버전의 코드네임(noble 등)으로 치환됩니다 |
curl의 네 글자는 각각입니다.
-f— 서버가 에러를 반환하면 에러 페이지 내용을 저장하지 않고 실패시킵니다(fail). 이게 없으면 404 페이지가 키 파일로 저장됩니다-s— 진행률 표시를 숨깁니다(silent)-S— 그래도 진짜 에러는 보여줍니다(Show error).-s와 짝으로 씁니다-L— 주소가 옮겨졌으면 따라갑니다(Location)
$( )는 명령 치환입니다. 안의 명령을 먼저 실행하고 그 출력으로 자리를 바꿉니다. 덕분에 이 명령을 어느 버전, 어느 아키텍처에서 실행해도 그대로 동작합니다.
주의docker-compose(하이픈)와 docker compose(공백)
인터넷에서 찾은 명령이 동작하지 않는다면 대부분 이 차이입니다.
docker-compose— Python으로 만든 독립 도구. V1이고 지원이 끝났습니다docker compose— Docker CLI의 플러그인. V2이고 지금의 표준입니다
위 설치 명령의 docker-compose-plugin 패키지가 후자입니다. 패키지 이름에는 하이픈이 있지만 실행할 때는 공백을 씁니다.
설치되는 다섯 패키지도 나눠 보면 이렇습니다.
| 패키지 | 역할 |
|---|---|
docker-ce | Docker 데몬. 실제로 컨테이너를 돌리는 쪽 |
docker-ce-cli | docker 명령어. 데몬에게 지시하는 쪽 |
containerd.io | 컨테이너 실행 엔진. 데몬이 내부적으로 씁니다 |
docker-buildx-plugin | 이미지 빌드 확장 |
docker-compose-plugin | docker compose |
ce는 Community Edition, 무료판이라는 뜻입니다.
docker 그룹에 들어간다는 것의 의미
매번 sudo를 붙이기 번거로워서 사용자를 docker 그룹에 넣었습니다.
sudo usermod -aG docker $USER이 명령은 현재 세션에 바로 반영되지 않습니다. 로그아웃 후 다시 접속해야 합니다.
주의usermod -aG 에서 -a 를 빼면 벌어지는 일
-aG는 붙여 쓴 두 옵션입니다.
-G <그룹>— 이 사용자가 속할 보조 그룹을 지정합니다-a— 추가(append)
문제는 -a 없이 -G만 쓰면 지정한 그룹으로 통째로 교체된다는 것입니다. 기존에 속해 있던 그룹이 전부 사라집니다.
sudo usermod -G docker $USER # 위험합니다이걸 실행하면 그 계정은 docker 그룹 하나만 남습니다. sudo 그룹에서도 빠집니다. 즉 그 순간부터 관리자 권한을 잃고, 다른 관리자 계정이 없으면 되돌릴 방법이 마땅치 않습니다.
-a와 -G는 항상 붙여 쓴다고 외워두는 편이 낫습니다. 실행 전에 확인하려면 지금 속한 그룹을 먼저 보면 됩니다.
groups # 지금 내가 속한 그룹
id -nG <사용자> # 특정 사용자의 그룹$USER는 지금 로그인한 사용자 이름으로 치환되는 셸 변수입니다.
용어왜 로그아웃해야 반영되나
그룹 정보는 로그인하는 순간 세션에 새겨집니다. /etc/group 파일이 바뀌어도 이미 열려 있는 세션은 그때 받아둔 목록을 계속 씁니다.
그래서 usermod 직후에 docker ps를 하면 여전히 거부됩니다.
permission denied while trying to connect to the Docker daemon socket설정이 안 된 게 아니라 아직 반영되지 않은 것입니다. SSH를 끊고 다시 붙으면 됩니다. 확인은 이렇게 합니다.
groups | grep dockernewgrp docker로 현재 셸에만 즉시 적용할 수도 있지만, 그 셸을 벗어나면 다시 원래대로라 헷갈리기 쉽습니다. 재접속이 확실합니다.
그런데 이 한 줄에는 대부분의 설치 안내가 적지 않는 사실이 하나 있습니다. docker 그룹에 속한다는 것은 사실상 root 권한을 갖는 것과 같습니다.
# docker 그룹 사용자가 sudo 없이 실행 가능
docker run -it -v /:/host alpine chroot /host sh호스트의 루트 디렉터리를 컨테이너에 통째로 마운트하고 그 안으로 들어가는 명령입니다. 이 순간부터 호스트 파일시스템 전체를 root 권한으로 읽고 씁니다. Docker 데몬 자체가 root로 돌고 있고, 그 데몬에게 명령할 수 있는 권한이 곧 root 권한이기 때문입니다. 막을 방법은 없습니다. 취약점이 아니라 설계가 그렇습니다.
결론
Ubuntu Server에 보안 설정과 Docker 설치를 했습니다. 이제는 비밀번호로 접속하지 않고, 가지고 있는 키로 간편히 접속할 수 있고, SSH 연결 포트를 변경하여 기본 포트만 노리는 자동 스캔은 헛걸음하게 되었습니다. 그리고 fail2ban이 반복되는 인증 실패를 세고 있습니다.
다음 편에서는 지금까지 만들었던 Ubuntu Server에 Ollama와 n8n을 올려보려고 합니다. 로컬에서 도는 언어 모델과, 그 모델을 정해진 시각에 불러줄 자동화 도구입니다.