Tuesday, 1 September 2026

Privelege port and docker user creation and permission

 biding to port < 1024 such as 443 need root permission


docker is always run by root so no issue 



Dockerfile:  adduser -D -u 10001 myprojectuser      (in-image account, name exists only inside)
             USER 10001                       (default if compose says nothing)

compose:     user: "${gID:-10001}:${uID:-10001}"   (OVERRIDES the image's USER -> myprojectuser)

kernel:      checks NUMBERS ONLY against mounted-file owners — no names cross the boundary
ScenarioContainer runs asHost logs/ written byjoey's access
.env has gID/uID = joey's ids700198504 (myprojectuser ignored)joeyfull — owner
.env unset → fallback 1000110001 (myprojectuser )nameless 10001read only (644/755) — can't create/delete/chown

Two wording refinements to your summary

  1. "mapped" isn't quite the mechanism — there's no mapping table. When the container (10001) writes to the mount, the number 10001 lands in the host inode's owner field directly; the host just displays it namelessly because no account has it. Nothing is translated in either direction.

  2. "myprojectuser is ignored" is about the account, not the permissions — when you set joeyids, the image's USER 10001 and the myprojectuser passwd entry are bypassed entirely, but the files in the image still work for joey because we chmod -R a+rX /app in the Dockerfile (world-readable). That chmod is why any uid can run the app; the user: line only decides whose numbers touch the mounts.

So the practical rule you've derived is exactly right: pick the number in .env, and make the mounted dirs owned by that same number — either naturally (you own them, set your ids) or once (chown 10001:10001 logs), and cleanup for the nameless case is docker run --rm -v ./logs:/w alpine chown ... since any daemon user has root on mounts.



Docker image buildign

 docker image automatically uses only 1 alpine (1 light weight OS)


many alpine like go alpine or python alpine all come with a light weight linux OS,

the way to achieve is u use go alpine to build and export the finished binary

use python alpine to build and export finished dir


then copy both to new runtime alpine such as python aline(cause python need an interprotr)




# --- Stage 1: Go builder (offline: deps are vendored) ----------------------

FROM myhub.com/golang:1.26.2-alpine3.22 AS go-builder

WORKDIR /build

COPY srv/ ./srv/

COPY vendor/ ./vendor/

COPY go.mod go.sum ./

# CGO_ENABLED=0 -> static binary; -mod=vendor -> no network needed

RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -o /edge ./srv/cmd/server


# --- Stage 2: Python builder — venv from lockfile, then strip source -------

FROM myhub.com/python:3.12-alpine AS py-builder

WORKDIR /app

COPY app/pyproject.toml app/uv.lock ./

RUN pip install --no-cache-dir uv \

 && uv sync --frozen --no-dev --no-install-project

COPY app/server.py ./

# Bytecode-only: compile server.py (and the whole venv) to .pyc next to the

# source, then delete every .py. Python imports .pyc seamlessly, so

# `uvicorn server:app` works unchanged; the app's source never ships.

RUN python -m compileall -b server.py && rm server.py \

 && python -m compileall -b -q .venv \

 && find .venv -name '*.py' -delete


# --- Runtime: same python image as the pyc builder -------------------------

# Using python:3.12-alpine as runtime (not bare alpine + apk python3) makes

# the venv's interpreter path (/usr/local/bin/python3) and the pyc bytecode

# format structurally identical to the builder — no version-skew bugs.

FROM myhub.com//python:3.12-alpine

# tzdata: logger pst_time needs America/Los_Angeles; ca-certificates: the

# edge and uvicorn call upstream Mantis over TLS.

RUN sed -i "s#dl-cdn.alpinelinux.org/alpine#jfrog.corp.fortinet.com/artifactory/apk_proxy#g" /etc/apk/repositories \

 && apk add --no-cache tzdata ca-certificates \

 && adduser -D -u 10001 mantis \

 && mkdir -p /app/logs /certs && chown -R mantis:mantis /app/logs /certs

COPY --from=go-builder /edge /edge

COPY --from=py-builder /app /app

# Files from the builders are root-owned; the runtime user (10001) needs

# read access to /app (server.pyc, venv). a+rX = readable, dirs traversable.

RUN chmod -R a+rX /app

USER 10001

WORKDIR /app

EXPOSE 8443

ENTRYPOINT ["/edge"]




---------------




ur image has three Alpines — and only one survives


FROM golang:1.26.2-alpine3.22   (stage 1: go-builder)  ──► discarded (only /edge extracted)

FROM python:3.12-alpine         (stage 2: py-builder)  ──► discarded (only /app copied out)

FROM python:3.12-alpine         (stage 3: runtime)     ──► THIS becomes the container

COPY --from=py-builder /app /app is the only thing crossing the boundary — it plucks the finished pyc-only venv out of that stage's filesystem. The go-builder's Go toolchain, its alpine userland, its whole filesystem: none of it exists in the final image. Not merged, not layered-on — discarded.


So "which alpine am I in" when you exec into the container

The last stage's one — verified just now by poking the running image:



/etc/os-release    → Alpine Linux

python3            → /usr/local/bin/python3   (the python:3.12-alpine layout — apk's would be /usr/bin/python3)

/bin/busybox       → present (alpine userland)

That path detail (/usr/local/bin vs /usr/bin) is actually the fingerprint distinguishing "python image's alpine" from "plain alpine + apk python" — and it was the exact bug from the first build: the venv symlinked to /usr/local/bin/python3, which the bare-alpine runtime didn't have. Choosing the same image for stage 2 and 3 made the builder/runtime OS boundary disappear entirely.


The rules that make this coherent

One runtime filesystem, period. A container = exactly one image's final layer stack. FROM stages before the last exist only at build time; docker exec / docker run can never see them.

COPY --from is the only door between stages. Files cross explicitly; nothing else carries over — not env vars, not users (hence the re-created mantis user in the runtime stage), not the OS's package database (the runtime stage doesn't "know" about the go-builder's apk installs).

They can even be different distros. A totally legal pattern: build in golang:alpine, run in debian:slim or ubuntu — or the reverse (build on glibc's debian, run on alpine musl = breakage). Nothing forces the bases to match. Ours deliberately match (alpine→alpine, same python image twice) to keep musl/pyc/symlinks consistent.

"Alpine" is not one monolithic thing. Each stage's FROM pins a different alpine snapshot: go-builder = alpine 3.22, python stages = whatever alpine 3.12-python image is built on (also 3.22-ish here). They coexist at build time, independent and unrelated at run time.

The practical checks when you're confused inside a container


cat /etc/os-release      # which distro userland this is

which python3            # /usr/local/bin → python image; /usr/bin → apk-installed

head -1 /app/.venv/bin/uvicorn   # shebang: which interpreter the venv expects

TL;DR: multiple alpines can coexist inside a Dockerfile (we have three), but the container is only the last one — verified above: the running container is the runtime-stage alpine with the python-image layout, and the other two alpines died when their build stages ended.





ENTRYPOINT vs CMD — the full picture

ENTRYPOINT CMD

Role The container's main process — what it is Default arguments to that process — how it's typically invoked

exec-form ENTRYPOINT ["/edge"] CMD ["--port", "8443"]

Shell-form ENTRYPOINT /edge (wraps in sh -c, breaks PID 1 signal semantics — avoid) CMD --port 8443 (same wrapping)

Overridden at runtime by docker run ... --entrypoint X (rare) docker run image <args> (common)

With both set runs as ENTRYPOINT + CMD concatenated

The four combinations, concretely for our image (ENTRYPOINT ["/edge"], no CMD):


What you run What actually executes

docker run mantis-mcp:latest /edge (entrypoint alone) — our case

docker run mantis-mcp:latest foo bar /edge foo bar — CLI args appended to the entrypoint

docker run --entrypoint /bin/sh mantis-mcp:latest /bin/sh — entrypoint replaced (our debugging trick all session: --entrypoint /bin/sh -c '...')

hypothetical ENTRYPOINT ["/edge"] + CMD ["--port","9000"] /edge --port 9000




Yes — it works, and you already have a perfect example on this box: the alpine image has no ENTRYPOINT, only CMD ["/bin/sh"] (verified above). docker run alpine starts /bin/sh as PID 1, which immediately hits EOF (no stdin attached, not interactive), exits 0, container ends.


CMD-only behavior

Without ENTRYPOINT, CMD alone defines the process:



CMD ["python", "server.py"]     # image with no ENTRYPOINT

What you run What executes as PID 1

docker run img python server.py (the CMD)

docker run img bash bash — CMD fully replaced

docker run img -c "echo hi" python -c "echo hi" — CMD's first word replaced, rest kept (the footgun)

So a CMD-only image is a perfectly normal container — certgen could have been written CMD-only and would run the same way.


Then why does ENTRYPOINT exist?

The distinction is override semantics — what survives when the user appends arguments:


user runs img args args become

CMD-only CMD replaced the whole new command — the program itself can be swapped

ENTRYPOINT+CMD ENTRYPOINT + args parameters to a fixed program — the program can't be swapped, only instructed




COPY --from=go-builder /edge /edge — both paths here are the binary file itself:



COPY --from=<stage> <SRC path in that stage> <DEST path in this stage>

             │                    │                      │

      py-builder/go-builder    /edge (the file         /edge (destination

      (named "go-builder"      that `go build          FILE in the runtime

      via `AS go-builder`)     -o /edge` made)         stage's filesystem)

The name is just what we chose: go build -o /edge ./srv/cmd/server in stage 1 wrote the compiled binary to a file called /edge; the COPY then plucks that one file into the runtime stage at the same path. If we'd written -o /edge/mcp-server, then /edge would be a directory containing a binary — but we didn't, so it's a single executable file at the filesystem root. (Compare: COPY --from=py-builder /app /app copies a directory — COPY copies whatever the source is, file or tree.)


Why the odd-looking syntax X X (same path twice)? The two /edges live in different filesystems: the first is a path inside the go-builder stage, the second is a path inside the runtime stage. They happen to be spelled the same; COPY --from=py-builder /app /app shows the identical pattern. A more descriptive spelling that means exactly the same thing:



COPY --from=go-builder /edge /usr/local/bin/edge   # dest is the file path, wherever you like

2. It's not run "with CMD" — it's run via ENTRYPOINT

Small but important, from the last discussion: the image has no CMD. What launches /edge is:



ENTRYPOINT ["/edge"]     ← names /edge as the container's main process (PID 1)

So the complete story across the three Dockerfile stages:



stage 1 (go-builder):   go build -o /edge ./srv/cmd/server    # COMPILE → /edge is created as a file

stage 3 (runtime):      COPY --from=go-builder /edge /edge    # TRANSPORT the file into the final image

                        ENTRYPOINT ["/edge"]                  # DECLARE: this file is what `docker run` execs

/edge is a file 




The chain you described, confirmed


/edge (PID 1) starts

  → config.Load() reads CHILD_CMD (among others)

  → childArgv = strings.Fields(CHILD_CMD)     ["…/uvicorn", "server:app", "--host", "127.0.0.1", …]

  → supervisor.Spawn(childArgv, CHILD_DIR)    fork + exec → uvicorn runs as child of /edge

So /app's Python stuff only exists on disk because of the COPY — it only runs because /edge reads CHILD_CMD and execs it. No env → no spawn → edge exits at startup (the fail-fast we mapped). And uvicorn's cwd is CHILD_DIR (default /app), which is why bare server:app resolves to /app/server.pyc — the module file sitting right there.


What happens with no CHILD_CMD — the default kicks in

config.go:



const defaultChildCmd = "/app/.venv/bin/uvicorn server:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips 127.0.0.1"


ChildCmd:  getEnv("CHILD_CMD", defaultChildCmd),   // ← .env value if present, else this

python bytecode python pyc vs py

 pyc advantage is faster on load, because its compiled, but it still requires a run time python interpretor, the main thing is others cant see ur source code directly and its a bit more diffcult to reverse engineer



server.py  →  server.pyc

A .pyc is the same Python interpreter executing a pre-parsed intermediate representation (bytecode for a stack VM), just skipping the compile-from-source step at import time. Nothing becomes an ELF binary:


server.py server.pyc Go binary (/edge)

What runs it python interpreter python interpreter (same) CPU directly

Form source text frozen bytecode + marshalled constants machine code

Readable as source? trivially no — only via decompiler, lossy no

This project dev only what the image ships yes

The venv's python process still interprets every line — exactly as before. The only mechanical differences: import finds server.pyc instead of server.py (both are equally importable), and startup is marginally faster (no parse/compile step). Zero behavior change.


The ladder, made concrete (since this was the source-visibility decision)


server.py     readable source            ← what a normal Python deploy ships

server.pyc    bytecode, decompilable     ← where we are (Dockerfile: compileall -b + delete .py)

Nuitka        transpiles to C → real ELF binary   ← the actual "make it a binary" option

Go rewrite    ELF binary                 ← the only *true* hiding, full rewrite of the app

.pyc protection is against casual reading — docker run ... cat /app/server.py fails because the file doesn't exist; strings server.pyc shows fragmented constants, not logic. A determined person with a decompiler (pycdc/uncompyle-style tools) can reconstruct most of it, though imperfectly — f-strings, comprehensions and some typing info come out mangled. That's the exact threat model you picked: "production shouldn't have readable Python," not "survive a hostile reverse-engineer."


If that bar ever rises, the escalation path (from the todo plan) is Nuitka — but that's a real bet: build complexity, fastmcp's decorator/introspection magic needs validation, and every dependency bump re-runs the compatibility test. The Go rewrite is the only total hiding, and we already chose it for the edge while keeping the app in Python deliberately.


One sharp edge worth knowing

.pyc files embed a magic number tied to the CPython minor version — server.pyc from 3.12 will refuse to run on 3.11/3.13. That's why the Dockerfile builds the pyc with the same image (python:3.12-alpine) that runs it, making the pairing structural rather than coincidental. It's also why you can't "just copy the venv" between hosts — irrelevant inside the immutable image, but remember it if you ever extract the venv for debugging on the prod host.


Bottom line: Dockerfile bytecode-only = source removed, bytecode kept — the interpreter still runs Python, just from a non-readable file. It's obfuscation-by-deletion: cheap, standard, fully reversible by you (git has the source), and sufficient for "the devops team can run it but shouldn't read it."




Exactly right on all three points — that's a precise summary. Expanding each slightly so the model is complete:


1. Interpreted at runtime — yes, same interpreter, same VM. The CPython execution model is two-stage: compile source → bytecode, then interpret bytecode on a stack machine. A .pyc just pre-computes stage one, shipping the frozen output of the compiler. At runtime, python server.pyc and python server.py execute the identical instruction stream — your decorators, async coroutines, the FastMCP tool dispatch — all still go through the interpreter's eval loop, instruction by instruction, just as before.


2. Slightly faster — but only at one specific moment: import time. The speedup is skipping lexing/parsing/codegen when the module loads. For this app that's milliseconds, once per process start. After import, zero runtime difference — same bytecode buffer, same eval loop, same speed. So it's a free side benefit, but if anyone asks "did we do this for performance?", the honest answer is no — it was for source-hiding; the perf delta is a rounding error.


3. Not readable without a decompiler — with a precise boundary on what "not readable" means:


Effort What they get

cat /app/server.pyc, strings fragmentary constants (strings, names), no logic — enough to spot a token or a URL, not to follow code

pip-installed decompiler (pycdc etc.) approximate source back, with real damage: f-strings, comprehensions, lambdas, and stripped type info come out garbled or missing

Determined reverse-engineering most business logic reconstructed

Also worth noting what doesn't hide at all: docstrings, comments... actually — docstrings survive into the .pyc verbatim (they're runtime objects), while comments do not exist in bytecode and vanish completely. So the bytecode hides your code but still leaks your prose (including anything sensitive accidentally left in a docstring, like the token example we saw in .env.example history). If you ever want prose scrubbed too, -OO at compile time strips docstrings/asserts:



python -m compileall -b -O2 ...

but you'd lose the helpful docstrings in tracebacks/debugging — not recommended for an app you still have to operate.


So the one-line mental model you've now got: .pyc = compiled for the interpreter, not to the machine — the hiding comes from the deleted source, the speed comes from the skipped compile, and the interpreter never stopped being the one doing the work. And in this image, all of Python — interpreter, stdlib, deps — is itself pyc-only too (find .venv -name '*.py' -delete), so the whole shipped surface is consistently bytecode, not just server.py.





FROM jfrog.corp.fortinet.com/docker-group/python:3.12-alpine AS py-builder

WORKDIR /app

COPY app/pyproject.toml app/uv.lock ./

RUN pip install --no-cache-dir uv \

 && uv sync --frozen --no-dev --no-install-project

COPY app/server.py ./

# Bytecode-only: compile server.py (and the whole venv) to .pyc next to the

# source, then delete every .py. Python imports .pyc seamlessly, so

# `uvicorn server:app` works unchanged; the app's source never ships.

RUN python -m compileall -b server.py && rm server.py \

 && python -m compileall -b -q .venv \

 && find .venv -name '*.py' -delete

docker entrypoint cmd

 entrypoint is in DockerFile of an image is what docker exec on start

CMD without entrypoint specified is same thing, docker exec CMD


if entrypoint and CMD both present, doccker exec entry point and treat CMD as input parameter to entrypoint

Friday, 28 August 2026

PKCE, started in authorize, posted in token

PKCE stands for Proof Key for Code Exchange. It protects the OAuth authorization code so that even if someone steals the code from the redirect, they still cannot exchange it for a token.

The client first creates a random secret:

code_verifier = random_secret_123

Then it hashes that secret:

code_challenge = SHA256(code_verifier)

The authorization request sends only the challenge:

/authorize?
  client_id=abc&
  redirect_uri=http://127.0.0.1:5000/callback&
  code_challenge=HASHED_VALUE&
  code_challenge_method=S256

After login, Mantis redirects back with:

code=AUTH_CODE_123

Then the CLI calls /token with both the authorization code and the original secret:

POST /token

code=AUTH_CODE_123
code_verifier=random_secret_123

Mantis checks:

SHA256(code_verifier)
        ==
stored code_challenge

If they match, it issues the token.

So if an attacker somehow steals only:

AUTH_CODE_123

they still cannot get the token because they don't have:

code_verifier

Tuesday, 25 August 2026

use any model in claude cli

 npm install claude cli

~/.claude/settings.json:

{
  "env": {
    "ANTHROPIC_AUTH_TOKEN": "<Your-AuthKey>",
    "ANTHROPIC_BASE_URL": "https://aiendpoint",
    "ANTHROPIC_MODEL": "ai-model-name",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "ai-model-name-ultra",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "ai-model-name-codefast",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "ai-model-name-fast",
"CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING": "1", "CLAUDE_CODE_ATTRIBUTION_HEADER": "0" }, "alwaysThinkingEnabled": false }

Tuesday, 18 August 2026

OS core, OS thread, process, go process, go routine

 

 Binary vs process

A Go binary sitting on disk is just a file:

certgen-service

When you run it:

./certgen-service

the OS creates a process.

Go binary
↓ run
Go process

A process is mainly a container for things like memory, files, sockets, and threads.


2. Every running process needs at least one OS thread

A generic process can be as simple as:

Process
└── OS Thread
Logical CPU
Physical CPU Core

The thread is the actual execution unit that the OS schedules.

The process itself is not what directly runs on a core.


3. Go adds another layer: goroutines

A Go process has the Go runtime inside it.

Go Process
├── Go Runtime
├── main goroutine
├── Goroutine A
├── Goroutine B
├── Goroutine C
├── runtime goroutines
└── OS Threads

Important point:

main() itself runs as the main goroutine.

Even if you never write:

go someFunction()

Go still has internal goroutines for runtime work such as GC and scheduling.


4. Goroutine is not an OS thread

A goroutine is a lightweight user-space execution unit.

You can casually think:

goroutine ≈ lightweight thread

But technically:

Goroutine != OS Thread

Many goroutines are scheduled onto a smaller number of OS threads:

Go Process

Goroutine main ─┐
Goroutine A ────┤
Goroutine B ────┤
Goroutine C ────┤
Goroutine D ────┤
Goroutine E ────┘
Go Scheduler
┌───────────────┐
│ OS Thread 1 │
│ OS Thread 2 │
│ OS Thread 3 │
└───────────────┘

Creating:

go foo()

does not mean:

new goroutine
=
new OS thread

Usually it just creates another goroutine for the Go scheduler to manage.


5. One OS thread executes one goroutine at a time

Suppose several goroutines can use Thread 1:

OS Thread 1
├── main goroutine
├── Goroutine A
├── Goroutine B
└── Goroutine C

That does not mean all four run simultaneously on Thread 1.

It means over time:

Thread 1
main runs
A runs
B runs
main runs again
C runs

Go switches which goroutine is running.

And goroutines are generally not permanently attached to a specific OS thread.

For example:

Time 1:

Thread 1 → Goroutine A
Thread 2 → Goroutine B


Time 2:

Thread 1 → Goroutine C
Thread 2 → Goroutine A

Goroutine A moved.


6. OS threads are tied to CPU execution

The OS schedules threads onto logical CPUs.

Goroutine
↓ Go scheduler
OS Thread
↓ OS scheduler
Logical CPU
Physical Core

At one instant:

Logical CPU 1 → Thread A
Logical CPU 2 → Thread B
Logical CPU 3 → Thread C
Logical CPU 4 → Thread D

So if you have 4 logical CPUs, roughly 4 OS threads can actually execute instructions simultaneously.


7. Physical core vs logical CPU

Without SMT / Hyper-Threading:

Physical Core 1
└── Logical CPU 1
one OS thread executing

With SMT / Hyper-Threading:

Physical Core 1
├── Logical CPU 1 → Thread A
└── Logical CPU 2 → Thread B

So a machine might say:

8 physical cores
16 logical CPUs

The OS generally schedules against those 16 logical CPUs.


Full Go picture

Putting everything together:

MACHINE
┌───────────┴───────────┐
│ │
Physical Core 1 Physical Core 2
├─ Logical CPU 1 ├─ Logical CPU 3
└─ Logical CPU 2 └─ Logical CPU 4
↑ ↑
│ OS Scheduler │
│ │
OS Thread 1 OS Thread 2
↑ ↑
│ Go Scheduler │
│ │
┌───────┴───────┐ ┌──────┴───────┐
│ │ │ │
main A B C
goroutine goroutine goroutine goroutine

all inside one Go process

The two schedulers are important:

Goroutines
Go scheduler
OS Threads
OS scheduler
Logical CPUs / cores

8. What happens when a goroutine launches OpenSSL

This is different because OpenSSL becomes a separate process.

Suppose you write:

go func() {
exec.Command("openssl", ...).Run()
}()

Conceptually:

Go Process
├── main goroutine
├── Goroutine A
│ │
│ └── exec.Command("openssl")
│ │
│ │ asks OS to launch
│ ↓
└── Go OS Threads


NEW PROCESS

OpenSSL Process
└── OS Thread
Logical CPU

The OpenSSL process has its own address space and own OS thread(s).

So:

Goroutine
starts
OpenSSL Process
contains
OpenSSL OS Thread
CPU

The OpenSSL process is not an OS thread itself.


9. Your certificate service example

Say you have:

8 logical CPUs
1 Go certgen process
100 goroutines
20 OpenSSL commands running

You could conceptually have:

Machine
├── Logical CPU 1
├── Logical CPU 2
├── Logical CPU 3
├── Logical CPU 4
├── Logical CPU 5
├── Logical CPU 6
├── Logical CPU 7
└── Logical CPU 8
│ OS schedules everything here
┌────────┴───────────────────────────────┐
│ │
│ Go Process │
│ ├── main goroutine │
│ ├── goroutine 1 │
│ ├── goroutine 2 │
│ ├── ... │
│ ├── goroutine 100 │
│ │ │
│ └── several Go OS threads │
│ │
├── OpenSSL Process #1 │
│ └── OS thread │
│ │
├── OpenSSL Process #2 │
│ └── OS thread │
│ │
├── OpenSSL Process #3 │
│ └── OS thread │
│ │
└── ... │
OpenSSL Process #20 │
└── OS thread │

All of those threads compete for the same CPU resources.

That's why creating:

1000 goroutines
+
1000 OpenSSL processes

does not mean you get 1000-way CPU parallelism.

If you only have 8 logical CPUs:

thousands of runnable threads/work
OS scheduler
8 logical CPUs

Only a limited amount can actually execute at once.


The shortest version to remember

Binary
↓ run
Process
↓ contains
OS Threads
↓ OS schedules onto
Logical CPUs
↓ provided by
Physical CPU cores

And for Go, insert one extra layer:

Go Process
↓ contains
Goroutines
↓ Go schedules onto
OS Threads
↓ OS schedules onto
Logical CPUs
Physical CPU cores

And for your OpenSSL case:

Go goroutine
exec.Command()
NEW OpenSSL process
OpenSSL OS thread(s)
CPU

That entire model is the foundation for understanding why goroutine count, GOMAXPROCS, CPU cores, semaphores, and spawning many OpenSSL processes affect the performance of your certificate service.